Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31e8f46e03 | ||
|
|
e2ebc079f1 | ||
|
|
19096b36e3 | ||
|
|
2658efd89d | ||
|
|
f66c3e2b7f | ||
|
|
de120f08a9 | ||
|
|
5361c3a1c1 | ||
|
|
094c47c15d | ||
|
|
0f7affc498 | ||
|
|
a2888810ad | ||
|
|
8fcb50e16a | ||
|
|
b72dbf3e55 | ||
|
|
6f6960ce42 | ||
|
|
fd46731a53 | ||
|
|
bf25260d4c | ||
|
|
973d9e3a14 | ||
|
|
848dd49cce | ||
|
|
ad62aab07d | ||
|
|
fadbf0084b | ||
|
|
8a312485a7 | ||
|
|
280a8ac9a6 | ||
|
|
1e3067989a | ||
|
|
7db4cb81a3 | ||
|
|
310e91ed97 | ||
|
|
f8e6e86619 | ||
|
|
b49822b553 | ||
|
|
b98ad7568d | ||
|
|
7a5b488882 | ||
|
|
e28c9debea | ||
|
|
861b8f6549 | ||
|
|
19db67138b | ||
|
|
7bb4ca1066 | ||
|
|
d68cc1d664 | ||
|
|
a78e6b766a | ||
|
|
fa791cd3cb | ||
|
|
34d4563a5f | ||
|
|
b34e5a34d8 | ||
|
|
bc7f2bdb09 | ||
|
|
8231058f79 | ||
|
|
fe7682fa45 | ||
|
|
7d77dd1424 | ||
|
|
eef690b809 | ||
|
|
c8b1335ff4 | ||
|
|
c906bca459 | ||
|
|
205db53d7e | ||
| 64e321a350 | |||
| 55f8368214 | |||
|
|
0ca85544aa | ||
|
|
f6ab9bbfa7 | ||
|
|
1cca67668e | ||
|
|
b612d5d232 | ||
|
|
5be6582c39 | ||
|
|
6e2bf3fe93 | ||
|
|
2b35ee3f99 | ||
|
|
adc3437bd5 | ||
|
|
4003149046 | ||
|
|
1c40e6a0e8 | ||
|
|
95fa69e968 | ||
|
|
d24cabf818 | ||
|
|
2a29739dc3 |
17
.env.gowa.example
Normal file
@@ -0,0 +1,17 @@
|
||||
# Salin ke .env.gowa lalu isi nilainya. File .env.gowa tidak di-commit.
|
||||
#
|
||||
# Login dashboard GOWA (Basic Auth): user:password
|
||||
GOWA_BASIC_AUTH=admin:ganti-password-kuat
|
||||
|
||||
# Port dan interface dashboard/API GOWA
|
||||
GOWA_PORT=3000
|
||||
GOWA_BIND=0.0.0.0
|
||||
|
||||
# URL webhook OPEN-ISP dari dalam container.
|
||||
# App di host yang sama : http://host.containers.internal:3001/api/webhook/gowa
|
||||
# App di compose network : http://open-isp:3001/api/webhook/gowa
|
||||
GOWA_WEBHOOK_URL=http://host.containers.internal:3001/api/webhook/gowa
|
||||
|
||||
# Samakan dengan setting gowa_webhook_secret di OPEN-ISP.
|
||||
# Buat dengan: openssl rand -hex 32
|
||||
GOWA_WEBHOOK_SECRET=ganti-dengan-hasil-openssl-rand-hex-32
|
||||
33
.forgejo/workflows/ci.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: linux-amd64
|
||||
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
|
||||
45
.forgejo/workflows/release.yml
Normal file
@@ -0,0 +1,45 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: linux-amd64
|
||||
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
@@ -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
|
||||
45
.gitea/workflows/release.yml
Normal 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"
|
||||
4
.gitignore
vendored
@@ -3,6 +3,7 @@ node_modules/
|
||||
|
||||
# Environment and Secrets
|
||||
.env
|
||||
.env.gowa
|
||||
*.env
|
||||
*.jks
|
||||
*.keystore
|
||||
@@ -85,3 +86,6 @@ public/downloads/logo-*
|
||||
|
||||
# penanda restore tertunda (runtime)
|
||||
database/.restore-pending
|
||||
|
||||
# penanda update menunggu restart
|
||||
.restart-required
|
||||
|
||||
237
CHANGELOG.md
@@ -2,7 +2,242 @@
|
||||
|
||||
Format mengikuti [Keep a Changelog](https://keepachangelog.com/) dan [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased] — setelah v0.5.1
|
||||
## [0.6.14] — 2026-11-06 — "portal-custom"
|
||||
|
||||
### Ditambahkan
|
||||
- **Kustomisasi Beranda & Portal Pelanggan** dari Pengaturan:
|
||||
- banner pengumuman (info/warning/danger/success),
|
||||
- status jaringan + keterangan,
|
||||
- badge, judul, dan deskripsi hero,
|
||||
- link Google Maps, Instagram, Facebook, TikTok di footer.
|
||||
|
||||
## [0.6.13] — 2026-11-06 — "webhook-shopeepay"
|
||||
|
||||
### Diperbaiki
|
||||
- Parser notifikasi pembayaran mendukung variasi **ShopeePay / Mitra Shopee** yang memakai kata “ditambahkan” / “telah ditambahkan”, tanpa mengubah aplikasi Android.
|
||||
|
||||
## [0.6.12] — 2026-11-06 — "acs-virtual"
|
||||
|
||||
### Diperbaiki
|
||||
- Ekstraksi **GenieACS Pro** memakai virtual parameters: `pppoeIP`, `pppIP`, `WanIP`, `IPTR069`, `pppoeUsername2`, `PPPoEUser`, `WlanSSID`.
|
||||
- Lookup perangkat via PPPoE mencakup variabel virtual tersebut, sehingga IP/username lebih cepat dan akurat tampil di tabel ACS.
|
||||
|
||||
## [0.6.11] — 2026-11-06 — "acs-wifi-modal"
|
||||
|
||||
### Ditambahkan
|
||||
- Tombol **Atur WiFi (SSID / Sandi)** di daftar perangkat ACS.
|
||||
- Modal menampilkan SSID 2.4G/5G saat ini, simpan SSID dan sandi terpisah.
|
||||
- Endpoint `POST /admin/acs/api/device/:id/ssid` dan `/password`.
|
||||
|
||||
## [0.6.10] — 2026-11-06 — "acs-lookup"
|
||||
|
||||
### Diperbaiki
|
||||
- Lookup perangkat GenieACS kini punya fallback lewat data pelanggan (ID, nomor HP, PPPoE, tag ACS, MAC), sehingga perangkat lebih cepat ditemukan dan dashboard tidak menggantung saat tag/ID tidak cocok.
|
||||
|
||||
## [0.6.9] — 2026-11-06 — "acs-wifi-rx"
|
||||
|
||||
### Diperbaiki
|
||||
- **ACS/GenieACS WiFi**: SSID hanya mengubah SSID1 (2.4G) dan SSID5 (5G bila tersedia); tidak lagi menyentuh SSID2/Guest.
|
||||
- `setParameterValues` dipecah per parameter agar tidak kena TR-069 fault 9007.
|
||||
- Sinkronisasi nama WiFi baru ke data pelanggan setelah update.
|
||||
- **RX Power**: dukung nilai negatif, konversi raw CTC EPON/GPON, dan tampilkan **LOS** saat sinyal putus.
|
||||
|
||||
## [0.6.8] — 2026-11-06 — "isolir-url-setting"
|
||||
|
||||
### Ditambahkan
|
||||
- Pengaturan **URL Halaman Isolir** (`isolir_portal_url`) dan **Port Redirector Isolir** (`isolir_redirect_port`) di Info Aplikasi.
|
||||
- Redirector isolir membaca URL dari pengaturan **setiap request**, jadi perubahan URL langsung berlaku tanpa restart.
|
||||
- Generator script cloud memakai URL halaman isolir dari pengaturan tersebut.
|
||||
|
||||
## [0.6.7] — 2026-11-06 — "isolir-cloud"
|
||||
|
||||
### Ditambahkan
|
||||
- Tab **Cloud (Sederhana)** pada modal Script Isolir (Manajemen Router): generate script RouterOS siap tempel untuk billing yang berjalan di cloud.
|
||||
- Input domain/IP publik billing, port redirector, dan profil PPPoE isolir.
|
||||
- Berisi: address-list BILLING (semua hasil resolve A), hook profil PPPoE, redirect DNS, NAT HTTP ke redirector, serta filter allow/reject/drop.
|
||||
- Deteksi Cloudflare/CDN + peringatan bila port redirector tidak ikut diproxy.
|
||||
- Endpoint `GET /admin/api/isolir-cloud-script`.
|
||||
|
||||
## [0.6.6] — 2026-11-06 — "qr-custom-bulk"
|
||||
|
||||
### Ditambahkan
|
||||
- **Filter tanggal** (dari/sampai) pada histori QR-Custom Payment.
|
||||
- **Pilih semua / per baris** + tombol **Hapus Terpilih** (batch remove) dengan password admin.
|
||||
- **Reset Histori** tetap tersedia untuk menghapus semua data.
|
||||
|
||||
## [0.6.5] — 2026-11-06 — "qr-custom-delete"
|
||||
|
||||
### Ditambahkan
|
||||
- **Hapus satu QR custom** dari detail (wajib password admin).
|
||||
- **Reset seluruh histori QR custom** dari header tabel (wajib password admin, konfirmasi).
|
||||
|
||||
## [0.6.4] — 2026-11-06 — "qr-custom-ux"
|
||||
|
||||
### Diperbaiki
|
||||
- QR pada link publik kini **center**.
|
||||
- Timer hanya tampil saat QR masih aktif; berhenti/disembunyikan ketika LUNAS, ditolak, atau kedaluwarsa.
|
||||
- **Reject / Refund** kini tersedia juga untuk pembayaran yang sudah LUNAS.
|
||||
- QR kedaluwarsa: **nominal dan gambar QR disembunyikan** di link publik maupun detail dashboard.
|
||||
- Tombol **Regenerate** untuk membuat QR baru + kode unik baru dari QR yang kedaluwarsa (juga tersedia di tabel histori).
|
||||
|
||||
## [0.6.3] — 2026-11-06 — "webhook-secret-auto"
|
||||
|
||||
### Diperbaiki
|
||||
- **Secret Token webhook pembayaran** dibuat otomatis (64 hex) bila kosong/default, ditampilkan di Pengaturan dan langsung dipakai pada URL webhook.
|
||||
- URL webhook memakai **Public Base URL**; bila kosong memakai host saat ini (tidak lagi menghasilkan URL relatif).
|
||||
- Default lama `billing-rtrw-secret-key` tidak lagi diterima — forwarder harus memakai secret dari Pengaturan.
|
||||
|
||||
## [0.6.2] — 2026-11-06 — "webhook-diag"
|
||||
|
||||
### Diperbaiki
|
||||
- Respons webhook pembayaran kini menyertakan `matched_custom_qr_id` untuk memudahkan diagnosis QR-Custom Payment.
|
||||
|
||||
## [0.6.1] — 2026-11-06 — "qr-custom-gate"
|
||||
|
||||
### Diperbaiki
|
||||
- Ikon menu **QR-Custom Payment** memakai ikon yang tersedia (`bi-qr-code`).
|
||||
- Fitur QR-Custom **dinonaktifkan otomatis** bila QRIS Statis (Semi-Otomatis) belum aktif atau `qris_static_payload` kosong:
|
||||
- menu disembunyikan,
|
||||
- form pembuatan dinonaktifkan,
|
||||
- request pembuatan ditolak (tidak ada data/list dibuat tanpa QR).
|
||||
|
||||
## [0.6.0] — 2026-11-06 — "qr-custom"
|
||||
|
||||
### Ditambahkan
|
||||
- **QR-Custom Payment** (menu baru di sidebar UTAMA): membuat QRIS nominal bebas dengan kode unik untuk kebutuhan di luar billing (sewa, donasi perusahaan, dll).
|
||||
- Durasi 20 menit per QR, link share publik, QR dinamis, timer, dan halaman status.
|
||||
- Webhook pembayaran mencocokkan nominal unik; pembayaran dalam 20 menit → LUNAS + notifikasi admin.
|
||||
- Pembayaran dengan nominal sama **setelah 20 menit** → status `warning` + notifikasi admin, dapat diselesaikan atau ditolak/refund.
|
||||
- Panel histori, statistik masuk/pending/warning/rejected, manual pay bila sinkron webhook gagal.
|
||||
- **Mode Webhook Secret GOWA**: pilihan **Auto Generate** (kolom terkunci, tombol simpan nonaktif) atau **Manual** (isi secret → Simpan → terkunci, tombol Ubah untuk mengganti), plus tombol salin.
|
||||
|
||||
## [0.5.20] — 2026-11-06 — "copy-secret"
|
||||
|
||||
### Ditambahkan
|
||||
- Tombol **salin** di samping **Webhook Secret** GOWA (sama seperti tombol salin Webhook URL), dengan umpan balik ikon centang.
|
||||
|
||||
## [0.5.19] — 2026-11-06 — "restart-notice"
|
||||
|
||||
### Ditambahkan
|
||||
- Setelah update sukses, aplikasi menulis penanda `.restart-required`.
|
||||
- **Floating notification** di semua halaman admin: "Billing perlu restart" berisi versi yang sudah diterapkan dan tautan cara restart.
|
||||
- Banner di halaman Update menjelaskan bahwa versi sudah terbaru tetapi kode lama masih berjalan sampai restart.
|
||||
- Penanda otomatis dihapus saat proses aplikasi baru start, jadi notifikasi hilang setelah restart.
|
||||
|
||||
## [0.5.18] — 2026-11-06 — "gowa-autodevice"
|
||||
|
||||
### Diperbaiki
|
||||
- **Device ID kosong** = otomatis mengikuti device yang terhubung lewat QR dashboard (tidak lagi dipin/disimpan otomatis). Isi Device ID hanya untuk menunjuk device GOWA tertentu.
|
||||
- **Webhook secret GOWA** dibuat otomatis (32 byte hex) bila kosong atau masih default `secret`, lalu ditampilkan di pengaturan untuk disamakan dengan `WHATSAPP_WEBHOOK_SECRET` di GOWA.
|
||||
|
||||
## [0.5.17] — 2026-11-06 — "gowa-connected"
|
||||
|
||||
### Diperbaiki
|
||||
- Status GOWA membaca array `/devices` (banyak device) sehingga tidak lagi selalu menampilkan **Belum Terhubung** ketika Device ID belum diisi.
|
||||
- Device yang terhubung disimpan otomatis sebagai `gowa_device_id` — tidak perlu menyalin ID manual dari UI GOWA.
|
||||
- Setelah scan QR, halaman memantau status tiap 3 detik; QR otomatis disembunyikan dan status berubah **Terhubung** saat berhasil.
|
||||
- Pengiriman memilih device yang benar-benar terhubung bila ada lebih dari satu device.
|
||||
|
||||
## [0.5.16] — 2026-11-05 — "restart-help"
|
||||
|
||||
### Ditambahkan
|
||||
- Kotak panduan **Cara Restart Aplikasi** di halaman Update: self-hosted (PM2, manual, quadlet/systemd, Docker/Podman Compose) dan cloud/managed (restart otomatis pada update berikutnya atau hubungi admin server).
|
||||
|
||||
## [0.5.15] — 2026-11-05 — "update-keep-env"
|
||||
|
||||
### Diperbaiki
|
||||
- Update aplikasi tidak lagi menghapus `.env.gowa` (kredensial/webhook GOWA) saat `git clean`.
|
||||
- Log update tidak lagi menampilkan error `pathspec 'settings.json'/'database'` — file yang tidak dilacak git tidak di-checkout.
|
||||
- `.env.gowa` ditambahkan ke `.gitignore`.
|
||||
|
||||
## [0.5.14] — 2026-11-05 — "ai-test"
|
||||
|
||||
### Ditambahkan
|
||||
- Tombol **Test API** di Pengaturan → AI: memvalidasi provider, API key, base URL, dan model yang sedang diisi **tanpa harus menyimpan**. Menampilkan status valid/tidak, HTTP status, dan latensi.
|
||||
|
||||
## [0.5.13] — 2026-11-05 — "lunas-notify"
|
||||
|
||||
### Diperbaiki
|
||||
- Notifikasi WA **LUNAS** memakai gateway aktif (GOWA/Fonnte/Meta/Baileys), bukan hanya Baileys.
|
||||
- Deteksi koneksi GOWA membaca status device dari `/devices` (array) — sebelumnya dianggap tidak terhubung bila Device ID kosong, sehingga notifikasi lunas dilewati.
|
||||
- Approve pembayaran kolektor dari APK admin dan pencocokan notifikasi pembayaran manual kini mengirim WA lunas ke pelanggan.
|
||||
|
||||
## [0.5.12] — 2026-11-05 — "gowa-test-unsaved"
|
||||
|
||||
### Diperbaiki
|
||||
- **Test Koneksi GOWA** kini memakai nilai yang sedang diisi di form (base URL, username, password), jadi tidak perlu simpan pengaturan dulu.
|
||||
|
||||
## [0.5.11] — 2026-11-05 — "gowa-test"
|
||||
|
||||
### Ditambahkan
|
||||
- Tombol **Test Koneksi** di Admin → WhatsApp → GOWA: menampilkan apakah GOWA terjangkau, status `/health`, kecocokan username/password Basic Auth, dan jumlah device terdaftar.
|
||||
|
||||
## [0.5.10] — 2026-11-05 — "gowa-mode"
|
||||
|
||||
### Ditambahkan
|
||||
- Pilihan **Mode Akses GOWA** di Admin → WhatsApp: **Cloud (HTTPS)** atau **Local (HTTP)**. Mode Local menampilkan peringatan wajib polyfill `crypto.randomUUID` beserta tautan panduan wiki.
|
||||
- Halaman wiki **GOWA**: setup Compose, perbedaan Cloud/Local, perintah polyfill, setting OPEN-ISP, dan troubleshooting QR/webhook.
|
||||
|
||||
## [0.5.9] — 2026-11-05 — "gowa-qr-image"
|
||||
|
||||
### Diperbaiki
|
||||
- QR GOWA dikirim sebagai **data URI** sehingga gambar tetap tampil walau app berjalan multi-proses atau request gambar terpisah gagal.
|
||||
- `qr_link` dari GOWA yang menunjuk host internal (`127.0.0.1`/container) ditulis ulang memakai base URL publik yang dikonfigurasi.
|
||||
- Log peringatan `[GOWA] Gagal ambil gambar QR` bila pengambilan gambar gagal.
|
||||
|
||||
## [0.5.8] — 2026-11-05 — "gowa-qr-error"
|
||||
|
||||
### Diperbaiki
|
||||
- QR GOWA: pesan gagal kini menampilkan status HTTP dan respons GOWA (mis. `HTTP 401 — Unauthorized`) sehingga penyebabnya langsung terlihat, bukan lagi "pastikan GOWA berjalan".
|
||||
|
||||
## [0.5.7] — 2026-11-05 — "gowa-podman"
|
||||
|
||||
### Ditambahkan
|
||||
- `docker-compose.gowa.yml` + `.env.gowa.example`: menjalankan **GOWA** (WhatsApp gateway + dashboard/UI) dengan Docker atau Podman Compose, termasuk webhook ke OPEN-ISP dan Basic Auth.
|
||||
|
||||
## [0.5.6] — 2026-11-05 — "qris-hangus"
|
||||
|
||||
### Diperbaiki
|
||||
- **QRIS mengikuti skema hangus**: nominal unik hanya untuk **satu tagihan** (tagihan terbaru). Tagihan lama otomatis `void` saat tagihan baru terbit, jadi tidak ada tunggakan menumpuk.
|
||||
- Kirim tagihan QRIS manual memakai tagihan **terbaru** (sebelumnya bisa mengambil tagihan lama).
|
||||
|
||||
### Catatan
|
||||
- Nonaktif otomatis setelah **3 bulan** menunggak dan hapus akun pada bulan berikutnya tetap berjalan sesuai pengaturan `auto_deactivate_months` / `auto_delete_overdue_enabled`.
|
||||
|
||||
## [0.5.5] — 2026-11-05 — "qris-fix"
|
||||
|
||||
### Diperbaiki
|
||||
- **Kode unik bentrok**: pengecekan nominal unik sekarang mencakup invoice, voucher, dan donasi sekaligus — mencegah pembayaran dianggap ambigu oleh webhook (dulu bisa tidak diproses sama sekali).
|
||||
- **Kirim tagihan QRIS manual**: gambar QR benar-benar terlampir (path `qrcode` tidak lagi hardcoded ke `/opt/billing-rtrw`), nominal di pesan memakai nominal unik, dan placeholder `{{qris_kode}}` didukung.
|
||||
- **Kirim tagihan manual** memilih tagihan paling lama lebih dulu (sebelumnya tagihan terbaru).
|
||||
- Pesan **pengingat manual** kini jujur saat tidak dikirim (WA/notifikasi OFF, gateway belum siap, atau tidak ada target hari ini), bukan “dijalankan: target 0”.
|
||||
|
||||
## [0.5.4] — 2026-11-05 — "wa-notice"
|
||||
|
||||
### Ditambahkan
|
||||
- **WA ke pelanggan saat tiket gangguan dibuat** (web & aplikasi): konfirmasi laporan diterima beserta nomor tiket `#TCK-…`.
|
||||
- **Notifikasi WA pada Status Layanan**: centang "Kirim notifikasi WhatsApp" saat mode berubah ke **Gangguan Masal**/**Perbaikan**, dengan target semua pelanggan aktif, per area, atau per router.
|
||||
- **WA "Layanan Kembali Normal"** otomatis saat mode dikembalikan ke NORMAL.
|
||||
- Pengiriman broadcast berjalan di latar belakang dengan jeda acak mengikuti `whatsapp_broadcast_delay`, nomor duplikat digabung, dan hasilnya dicatat di log (`[NoticeWA]`).
|
||||
|
||||
## [0.5.3] — 2026-11-05 — "forge-lime"
|
||||
|
||||
### Ditambahkan
|
||||
- **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).
|
||||
|
||||
90
README.md
@@ -1,28 +1,49 @@
|
||||
> 🪞 **Mirror.** Repo asli & pengembangan ada di **Gitea**: https://gitea.oxpoe.qzz.io/oxpoe/open-isp
|
||||
> 🪞 **Repositori utama:** [Gitea](https://gitea.oxpoe.xyz/oxpoe/open-isp) — rilis & unduhan ada di sana. **Forgejo** (forgejo.oxpoe.xyz) hanya **mirror read-only**.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/banner.png" alt="OPEN-ISP" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<b>Sistem billing & manajemen ISP (fiber optic) — open source, self-hosted.</b><br>
|
||||
<b>Sistem billing & manajemen ISP (fiber optic) — open source, self-hosted builtin AI.</b><br>
|
||||
PWA pelanggan · Panel admin · Portal teknisi & kolektor · Multi-OLT · RADIUS · WhatsApp/Telegram · QRIS
|
||||
</p>
|
||||
|
||||
<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.6.14_portal--custom-ADFF2F" alt="Versi 0.6.14 (portal-custom)">
|
||||
<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/badge/dynamic/json?url=https%3A%2F%2Fgitea.oxpoe.qzz.io%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.qzz.io%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.qzz.io%2Fapi%2Fv1%2Frepos%2Foxpoe%2Fopen-isp&query=%24.open_issues_count&label=issues&style=flat&color=orange&logo=gitea" alt="Issues">
|
||||
<a href="https://demo-openisp.oxpoe.xyz"><img src="https://img.shields.io/badge/demo-live-ADFF2F?logo=googlechrome&logoColor=black" alt="Demo"></a>
|
||||
<a href="https://oxpoe.xyz"><img src="https://img.shields.io/badge/donasi-oxpoe.xyz-e5484d?logo=buymeacoffee&logoColor=white" alt="Donasi"></a>
|
||||
<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>
|
||||
|
||||
---
|
||||
|
||||
**[demo](https://demo-openisp.oxpoe.xyz) · [donate](https://oxpoe.xyz) · [main repo](https://gitea.oxpoe.xyz/oxpoe/open-isp)**
|
||||
|
||||
---
|
||||
**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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
| Inventaris & Stok Keluar | Pengaturan AI & Self-Healing |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
| Login Pelanggan | Dashboard Pelanggan |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
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.
|
||||
@@ -37,29 +58,37 @@
|
||||
- **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
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><b>Dashboard Admin</b><br><img src="docs/screenshots/admin-dashboard.jpg" width="420"></td>
|
||||
<td align="center"><b>Data Pelanggan</b><br><img src="docs/screenshots/admin-customers.jpg" width="420"></td>
|
||||
<td align="center"><b>Dashboard Admin</b><br><img src="docs/screenshots/admin-dashboard.webp" width="420"></td>
|
||||
<td align="center"><b>Data Pelanggan</b><br><img src="docs/screenshots/admin-customers.webp" width="420"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>Tagihan & Pembayaran</b><br><img src="docs/screenshots/admin-billing.jpg" width="420"></td>
|
||||
<td align="center"><b>OLT (PON)</b><br><img src="docs/screenshots/admin-olts.jpg" width="420"></td>
|
||||
<td align="center"><b>Tagihan & Pembayaran</b><br><img src="docs/screenshots/admin-billing.webp" width="420"></td>
|
||||
<td align="center"><b>OLT (PON)</b><br><img src="docs/screenshots/admin-olts.webp" width="420"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>Portal Pelanggan (PWA)</b><br><img src="docs/screenshots/customer-dashboard.jpg" width="250"></td>
|
||||
<td align="center"><b>Tagihan Pelanggan</b><br><img src="docs/screenshots/customer-billing.jpg" width="250"></td>
|
||||
<td align="center"><b>Portal Pelanggan (PWA)</b><br><img src="docs/screenshots/customer-dashboard.webp" width="250"></td>
|
||||
<td align="center"><b>Tagihan Pelanggan</b><br><img src="docs/screenshots/customer-billing.webp" width="250"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
Selengkapnya: **[Wiki OPEN-ISP](https://gitea.oxpoe.qzz.io/oxpoe/open-isp/wiki)**.
|
||||
Selengkapnya: **[Wiki OPEN-ISP](https://gitea.oxpoe.xyz/oxpoe/open-isp/wiki)**.
|
||||
|
||||
## Mulai Cepat
|
||||
|
||||
### Demo online
|
||||
|
||||
Coba tanpa instal: **[demo-openisp.oxpoe.xyz](https://demo-openisp.oxpoe.xyz)** — login `admin` / `demo-isp123`.
|
||||
|
||||
[](https://codespaces.new/oxpoe/open-isp)
|
||||
|
||||
### GitHub Codespaces (demo instan)
|
||||
@@ -69,7 +98,7 @@ Klik badge di atas untuk membuka **Codespaces** — dependensi & data demo disia
|
||||
### Docker (disarankan)
|
||||
|
||||
```bash
|
||||
git clone https://gitea.oxpoe.qzz.io/oxpoe/open-isp.git
|
||||
git clone https://gitea.oxpoe.xyz/oxpoe/open-isp.git
|
||||
cd open-isp
|
||||
docker compose up -d
|
||||
```
|
||||
@@ -77,6 +106,25 @@ docker compose up -d
|
||||
Buka **http://localhost:3001** — login awal `admin` / `demo-isp123` (**segera ganti password**).
|
||||
Data tersimpan di volume `docker-data/` (settings, database, logs, uploads, sesi WhatsApp).
|
||||
|
||||
### WhatsApp gateway GOWA + UI (Podman / Docker Compose)
|
||||
|
||||
GOWA dijalankan sebagai container terpisah, lengkap dengan dashboard/scan QR bawaan:
|
||||
|
||||
```bash
|
||||
cp .env.gowa.example .env.gowa # isi GOWA_BASIC_AUTH + GOWA_WEBHOOK_SECRET
|
||||
podman compose --env-file .env.gowa -f docker-compose.gowa.yml up -d
|
||||
```
|
||||
|
||||
Buka dashboard di **http://<ip-host>:3000** (login Basic Auth), lalu scan QR.
|
||||
Di OPEN-ISP → **Admin → WhatsApp**: pilih gateway **GOWA**, isi base URL `http://127.0.0.1:3000`
|
||||
(app di host yang sama), username/password, dan **webhook secret** yang sama dengan `.env.gowa`.
|
||||
|
||||
> **UI hanya loading saat diakses lewat `http://IP:3000`?** gowa-ui memakai `crypto.randomUUID`
|
||||
> yang hanya tersedia di secure context (HTTPS atau `localhost`). Pilih salah satu:
|
||||
> pakai HTTPS/reverse proxy, buka lewat SSH tunnel (`ssh -L 3000:127.0.0.1:3000 user@server`
|
||||
> lalu `http://localhost:3000`), atau tambahkan polyfill `crypto.randomUUID` ke
|
||||
> `storages/ui/index.html` dan set `GOWA_UI_AUTO_UPDATE=false`.
|
||||
|
||||
### Manual (Node.js ≥ 20, disarankan 22 LTS)
|
||||
|
||||
```bash
|
||||
@@ -105,8 +153,8 @@ 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://gitea.oxpoe.qzz.io/oxpoe/open-isp/wiki) — panduan instalasi, konfigurasi, tutorial, FAQ (sedang disusun).
|
||||
- [Issues](https://gitea.oxpoe.qzz.io/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
|
||||
|
||||
@@ -118,7 +166,7 @@ Dirilis dengan lisensi **ISC** — bebas digunakan, dimodifikasi, dan didistribu
|
||||
|
||||
- **Base kode & 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://gitea.oxpoe.qzz.io/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 & Donasi
|
||||
@@ -126,8 +174,10 @@ Dirilis dengan lisensi **ISC** — bebas digunakan, dimodifikasi, dan didistribu
|
||||
OPEN-ISP tumbuh dari karya **Alijaya Net** — ayo dukung juga pengembang aslinya:
|
||||
|
||||
- **Donasi Alijaya Net**: [app.alijaya.com/donasi](https://app.alijaya.com/donasi) · WhatsApp [0819-4721-5703](https://wa.me/6281947215703)
|
||||
- **Demo online**: [demo-openisp.oxpoe.xyz](https://demo-openisp.oxpoe.xyz)
|
||||
- **Donasi OPEN-ISP**: [oxpoe.xyz](https://oxpoe.xyz) — sukarela, untuk pengembangan fitur.
|
||||
|
||||
Dukungan untuk **pengembangan OPEN-ISP** (agar makin canggih): link donasi **menyusul** — sementara ini bisa lewat menu **Dukung OPEN-ISP** di aplikasi.
|
||||
Dukungan juga bisa lewat menu **Dukung OPEN-ISP** di aplikasi.
|
||||
|
||||
---
|
||||
|
||||
@@ -135,5 +185,7 @@ Dukungan untuk **pengembangan OPEN-ISP** (agar makin canggih): link donasi **men
|
||||
|
||||
**OPEN-ISP** is an open-source ISP billing & management system: customer PWA, admin/technician/collector portals, MikroTik PPPoE & hotspot, multi-vendor OLT (SNMP/CLI), GenieACS TR-069, RADIUS, WhatsApp & Telegram notifications, QRIS payments, and a bilingual (ID/EN) UI.
|
||||
|
||||
Live demo: **https://demo-openisp.oxpoe.xyz** (`admin` / `demo-isp123`). Donate: **https://oxpoe.xyz**.
|
||||
|
||||
Quick start: `docker compose up -d` → http://localhost:3001 (default login `admin` / `demo-isp123`).
|
||||
Advanced menus are unlocked with a free activation code — not published in this repository (set via `activation_code` in `settings.json`). License: **ISC**. Based on the base code & repo by **Alijaya Net** ([alijayanet/billing-rtrw](https://github.com/alijayanet/billing-rtrw)). Code by DeepSeek V4.1 Flash — max, on opencode.
|
||||
|
||||
167
app-customer.js
@@ -60,6 +60,10 @@ const { SUPPORTED_LANGS, FALLBACK_LANG, normalizeLang, t } = require('./config/i
|
||||
// Pastikan semua default settings ada (untuk migrasi/update dari Gitea)
|
||||
if (typeof ensureDefaultSettings === 'function') {
|
||||
ensureDefaultSettings();
|
||||
try { require('./config/settingsManager').ensureGowaWebhookSecret(); } catch (e) {}
|
||||
try { require('./config/settingsManager').ensureWebhookSecret(); } catch (e) {}
|
||||
// Proses baru = kode terbaru sudah dimuat; hapus penanda restart.
|
||||
try { fs.rmSync(path.join(__dirname, '.restart-required'), { force: true }); } catch (e) {}
|
||||
}
|
||||
|
||||
// Inisialisasi aplikasi Express
|
||||
@@ -177,7 +181,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://gitea.oxpoe.qzz.io/oxpoe/open-isp') || '').replace(/\/+$/, '');
|
||||
res.locals.projectUrl = String(getSetting('project_url', 'https://gitea.oxpoe.xyz/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');
|
||||
@@ -330,6 +334,17 @@ const updateWebhookPaymentNotifMatchDonation = db.prepare(`
|
||||
SET matched_donation_order_id=?
|
||||
WHERE id=?
|
||||
`);
|
||||
const selectCustomQrByUniqueAmount = db.prepare(`
|
||||
SELECT id, status, title, amount_unique, expires_at
|
||||
FROM custom_qr_payments
|
||||
WHERE status IN ('pending','expired') AND amount_unique = ?
|
||||
ORDER BY id DESC LIMIT 2
|
||||
`);
|
||||
const updateWebhookPaymentNotifMatchCustomQr = db.prepare(`
|
||||
UPDATE webhook_payment_notifs
|
||||
SET matched_custom_qr_id=?
|
||||
WHERE id=?
|
||||
`);
|
||||
|
||||
const markInvoicePaidAppendNote = db.prepare(`
|
||||
UPDATE invoices
|
||||
@@ -372,7 +387,7 @@ function parseRupiahAmountFromNotification(content) {
|
||||
'dana masuk', 'saldo masuk', 'saldo bertambah', 'berhasil top up',
|
||||
'top up berhasil', 'topup berhasil', 'terima uang', 'telah diterima dari',
|
||||
'diterima dari', 'qris berhasil', 'qris sukses', 'qr berhasil', 'qr sukses',
|
||||
'payment received', 'kamu menerima', 'berhasil menerima', 'menambahkan',
|
||||
'payment received', 'kamu menerima', 'berhasil menerima', 'menambahkan', 'ditambahkan', 'telah ditambahkan', 'berhasil ditambahkan',
|
||||
'telah diterima sebesar', 'uang diterima', 'menerima dana', 'dana diterima', 'menerima sebesar', 'baru saja menerima', 'telah menerima',
|
||||
'just received', 'you received', 'you have received', 'funds received', 'has been received', 'incoming transfer', 'credited'
|
||||
];
|
||||
@@ -395,7 +410,8 @@ function parseRupiahAmountFromNotification(content) {
|
||||
'uang masuk', 'telah diterima', 'top up berhasil', 'topup berhasil', 'berhasil top up',
|
||||
'menerima pembayaran', 'qris statis diterima', 'qris diterima', 'pembayaran qris',
|
||||
'menerima dana', 'dana diterima', 'menerima sebesar', 'telah menerima',
|
||||
'just received', 'you received', 'you have received', 'funds received', 'has been received'
|
||||
'just received', 'you received', 'you have received', 'funds received', 'has been received',
|
||||
'ditambahkan', 'telah ditambahkan', 'berhasil ditambahkan'
|
||||
];
|
||||
const hasStrongIncoming = strongIncoming.some((hint) => lower.includes(hint));
|
||||
|
||||
@@ -412,7 +428,7 @@ function parseRupiahAmountFromNotification(content) {
|
||||
|
||||
const candidates = [
|
||||
/(?:\bRp\.?\s*|IDR\s*)([0-9][0-9\.\,\s]*)/i,
|
||||
/(?:sebesar|senilai|nominal|masuk|transfer|top\s*up|topup|saldo\s+masuk)\s*(?:saldo\s*)?(?:\bRp\.?\s*)?([0-9][0-9\.\,\s]*)/i,
|
||||
/(?:sebesar|senilai|nominal|masuk|transfer|top\s*up|topup|saldo\s+masuk|ditambahkan)\s*(?:saldo\s*)?(?:\bRp\.?\s*)?([0-9][0-9\.\,\s]*)/i,
|
||||
];
|
||||
|
||||
let raw = null;
|
||||
@@ -628,8 +644,8 @@ async function trySendWaPaymentSuccess(settings, invoiceId, methodLabel) {
|
||||
if (!inv) return;
|
||||
const phone = String(inv.customer_phone || '').trim();
|
||||
if (!phone) return;
|
||||
const { sendWA, whatsappStatus } = await import('./services/whatsappBot.mjs');
|
||||
if (whatsappStatus.connection !== 'open') throw new Error('Bot WhatsApp belum terhubung');
|
||||
// Pakai gateway aktif (GOWA/Fonnte/Meta/Baileys), bukan hanya Baileys.
|
||||
const waNotify = require('./services/waNotifyService');
|
||||
|
||||
const appUrl = (settings.public_base_url || '').replace(/\/$/, '');
|
||||
const portalUrl = appUrl ? `${appUrl}/customer` : '';
|
||||
@@ -654,7 +670,7 @@ async function trySendWaPaymentSuccess(settings, invoiceId, methodLabel) {
|
||||
});
|
||||
|
||||
logger.info(`[WEBHOOK][payment-notif] Sending WA success notif to ${phone} inv=${invoiceId} method=${metode}`);
|
||||
await sendWA(phone, msg);
|
||||
await waNotify.sendText(phone, msg);
|
||||
} catch (e) {
|
||||
logger.error(`[WEBHOOK][payment-notif] WA success notif failed: ${e?.message || e}`);
|
||||
}
|
||||
@@ -887,7 +903,7 @@ app.post('/api/webhook/v1/payment-notif', multer().any(), async (req, res) => {
|
||||
gotTrim = gotTrim.replace(/^[\"'\s]+|[\"'\s]+$/g, '');
|
||||
if (/^bearer\s+/i.test(gotTrim)) gotTrim = gotTrim.replace(/^bearer\s+/i, '').trim();
|
||||
|
||||
const isSecretValid = (gotTrim === expectedTrim) || (gotTrim === 'billing-rtrw-secret-key');
|
||||
const isSecretValid = gotTrim === expectedTrim;
|
||||
|
||||
if (!isSecretValid) {
|
||||
logger.warn(`[WEBHOOK][payment-notif] Forbidden: secret_key mismatch. service=${String(service || '-')} (gotLen=${gotTrim.length}${gotTrim ? ', ***' + gotTrim.slice(-3) : ', empty'}, expectedLen=${expectedTrim.length}, ***${expectedTrim.slice(-3)})`);
|
||||
@@ -995,14 +1011,17 @@ app.post('/api/webhook/v1/payment-notif', multer().any(), async (req, res) => {
|
||||
let matchedInvoiceId = null;
|
||||
let matchedVoucherOrderId = null;
|
||||
let matchedDonationOrderId = null;
|
||||
let matchedCustomQrId = null;
|
||||
if (amount != null) {
|
||||
try {
|
||||
const invCandidates = selectInvoiceByUniqueAmount.all(amount);
|
||||
const vCandidates = selectVoucherOrderByUniqueAmount.all(amount);
|
||||
const dCandidates = selectDonationOrderByUniqueAmount.all(amount);
|
||||
const cqCandidates = selectCustomQrByUniqueAmount.all(amount);
|
||||
const totalCandidates = (Array.isArray(invCandidates) ? invCandidates.length : 0) +
|
||||
(Array.isArray(vCandidates) ? vCandidates.length : 0) +
|
||||
(Array.isArray(dCandidates) ? dCandidates.length : 0);
|
||||
(Array.isArray(dCandidates) ? dCandidates.length : 0) +
|
||||
(Array.isArray(cqCandidates) ? cqCandidates.length : 0);
|
||||
|
||||
if (totalCandidates === 1) {
|
||||
if (Array.isArray(invCandidates) && invCandidates.length === 1) {
|
||||
@@ -1066,17 +1085,36 @@ app.post('/api/webhook/v1/payment-notif', multer().any(), async (req, res) => {
|
||||
logger.error(`[WEBHOOK][payment-notif] Donation fulfill error: ${e?.message || e}`);
|
||||
}
|
||||
}
|
||||
} else if (Array.isArray(cqCandidates) && cqCandidates.length === 1) {
|
||||
const cq = cqCandidates[0];
|
||||
const cqId = Number(cq.id || 0);
|
||||
if (cqId > 0) {
|
||||
const svc = require('./services/customQrService');
|
||||
const expired = svc.isExpired(cq);
|
||||
if (String(cq.status) === 'pending' && !expired) {
|
||||
svc.markPaidFromWebhook(cqId, notifId || null);
|
||||
logger.info(`[WEBHOOK][payment-notif] MATCH custom_qr=${cqId} amount=${amount}`);
|
||||
} else {
|
||||
svc.markWarningFromWebhook(cqId, notifId || null);
|
||||
logger.warn(`[WEBHOOK][payment-notif] MATCH custom_qr=${cqId} amount=${amount} SETELAH 20 MENIT (warning)`);
|
||||
}
|
||||
matchedCustomQrId = cqId;
|
||||
if (notifId) {
|
||||
try { updateWebhookPaymentNotifMatchCustomQr.run(cqId, notifId); } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (totalCandidates > 1) {
|
||||
const invIds = Array.isArray(invCandidates) ? invCandidates.map(x => x.id).join(',') : '';
|
||||
const vIds = Array.isArray(vCandidates) ? vCandidates.map(x => x.id).join(',') : '';
|
||||
const dIds = Array.isArray(dCandidates) ? dCandidates.map(x => x.id).join(',') : '';
|
||||
logger.error(`[WEBHOOK][payment-notif] MATCH ambiguous: amount=${amount} invoices=[${invIds}] vouchers=[${vIds}] donations=[${dIds}]`);
|
||||
const cqIds = Array.isArray(cqCandidates) ? cqCandidates.map(x => x.id).join(',') : '';
|
||||
logger.error(`[WEBHOOK][payment-notif] MATCH ambiguous: amount=${amount} invoices=[${invIds}] vouchers=[${vIds}] donations=[${dIds}] custom_qr=[${cqIds}]`);
|
||||
}
|
||||
|
||||
// Tidak ada tagihan UNPAID dgn nominal itu → kemungkinan notifikasi ke-2 dari app lain
|
||||
// (mis. GoPay & GoPay Merchant) untuk pembayaran yang SUDAH LUNAS. Hubungkan ke invoice lunas.
|
||||
if (!matchedInvoiceId && !matchedVoucherOrderId && !matchedDonationOrderId) {
|
||||
if (!matchedInvoiceId && !matchedVoucherOrderId && !matchedDonationOrderId && !matchedCustomQrId) {
|
||||
const paidInv = selectPaidInvoiceByUniqueAmount.all(amount);
|
||||
if (Array.isArray(paidInv) && paidInv.length === 1) {
|
||||
matchedInvoiceId = Number(paidInv[0].id || 0) || null;
|
||||
@@ -1094,7 +1132,7 @@ app.post('/api/webhook/v1/payment-notif', multer().any(), async (req, res) => {
|
||||
|
||||
if (amount != null) {
|
||||
logger.info(`[WEBHOOK][payment-notif] PARSED service=${String(service || '-')} amount=${amount}`);
|
||||
return res.status(200).json({ status: 'processed', parsed: true, amount, matched_invoice_id: matchedInvoiceId, matched_voucher_order_id: matchedVoucherOrderId, matched_donation_order_id: matchedDonationOrderId });
|
||||
return res.status(200).json({ status: 'processed', parsed: true, amount, matched_invoice_id: matchedInvoiceId, matched_voucher_order_id: matchedVoucherOrderId, matched_donation_order_id: matchedDonationOrderId, matched_custom_qr_id: matchedCustomQrId });
|
||||
}
|
||||
|
||||
logger.error(`[WEBHOOK][payment-notif] FAILED parse: "${rawText.replace(/\r?\n/g, ' ').slice(0, 500)}"`);
|
||||
@@ -1973,13 +2011,8 @@ function ensureDonationOrderQrisUnique(baseAmount, donorPhone, donorName, notes)
|
||||
|
||||
// Halaman Publik Donasi QRIS Statis
|
||||
app.get(['/donasi', '/donate', '/public/donasi'], (req, res) => {
|
||||
const settings = getSettingsWithCache();
|
||||
res.render('donate', {
|
||||
settings,
|
||||
company: settings.company_header || 'OPEN-ISP',
|
||||
lang: req.session?.lang || 'id',
|
||||
t: res.locals.t || ((k, d) => d)
|
||||
});
|
||||
// Halaman dukungan dialihkan ke situs eksternal agar bisa dikustomisasi.
|
||||
return res.redirect(302, 'https://oxpoe.xyz');
|
||||
});
|
||||
|
||||
// Endpoint Buat Pesanan Donasi dengan Kode Unik
|
||||
@@ -2069,6 +2102,102 @@ app.post(['/donasi/confirm', '/api/donasi/confirm'], async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ─── QR CUSTOM PAYMENT (publik) ─────────────────────────────────────────────
|
||||
function cqEscape(v) {
|
||||
return String(v == null ? '' : v).replace(/[&<>"']/g, function (c) {
|
||||
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
||||
});
|
||||
}
|
||||
|
||||
app.get('/custom-qr/:token', (req, res) => {
|
||||
let row = null;
|
||||
try { row = require('./services/customQrService').getByToken(req.params.token); } catch (e) {}
|
||||
if (!row) return res.status(404).send('QR tidak ditemukan atau sudah dihapus.');
|
||||
const settings = getSettingsWithCache();
|
||||
const company = settings.company_header || 'OPEN-ISP';
|
||||
const statusLabels = { pending: 'Menunggu Pembayaran', paid: 'LUNAS', expired: 'Kedaluwarsa', warning: 'Perlu Verifikasi Admin', rejected: 'Ditolak / Refund' };
|
||||
const status = String(row.status || 'pending');
|
||||
const expiresMs = row.expires_at ? new Date(row.expires_at).getTime() : 0;
|
||||
const expiredNow = status === 'expired' || (status === 'pending' && expiresMs && expiresMs < Date.now());
|
||||
const showAmount = !expiredNow;
|
||||
const showQr = !expiredNow && status !== 'paid' && status !== 'rejected';
|
||||
const showTimer = status === 'pending' && !expiredNow;
|
||||
const amountText = 'Rp ' + Number(row.amount_unique || 0).toLocaleString('id-ID');
|
||||
const baseAmountText = 'Rp ' + Number(row.amount || 0).toLocaleString('id-ID');
|
||||
const html = `<!doctype html><html lang="id"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${cqEscape(row.title)} — ${cqEscape(company)}</title>
|
||||
<style>
|
||||
body{margin:0;background:#0b1220;color:#e5e7eb;font-family:system-ui,Segoe UI,Arial;display:grid;place-items:center;min-height:100vh;padding:20px}
|
||||
.card{max-width:460px;width:100%;background:#0f172a;border:1px solid rgba(148,163,184,.2);border-radius:16px;padding:22px;text-align:center}
|
||||
h1{font-size:19px;margin:0 0 4px}p{margin:6px 0;color:#94a3b8;font-size:13px}
|
||||
.amt{font-size:26px;font-weight:800;color:#ADFF2F;margin:12px 0 2px}.base{font-size:12px;color:#64748b}
|
||||
.qr{background:#fff;padding:12px;border-radius:14px;display:block;width:max-content;margin:14px auto}.qr img{display:block;width:240px;height:240px}
|
||||
.badge{display:inline-block;padding:5px 10px;border-radius:999px;font-size:12px;font-weight:700;margin-top:6px}
|
||||
.pending{background:rgba(245,158,11,.15);color:#fbbf24}.paid{background:rgba(34,197,94,.15);color:#4ade80}
|
||||
.expired{background:rgba(239,68,68,.15);color:#f87171}.warning{background:rgba(249,115,22,.15);color:#fb923c}.rejected{background:rgba(148,163,184,.15);color:#cbd5e1}
|
||||
.timer{font-size:14px;color:#fbbf24;margin-top:8px}.note{font-size:12px;color:#64748b;margin-top:12px}
|
||||
</style></head><body><div class="card">
|
||||
<div style="font-size:12px;letter-spacing:1px;color:#64748b;text-transform:uppercase">${cqEscape(row.category || 'Pembayaran')}</div>
|
||||
<h1>${cqEscape(row.title)}</h1>
|
||||
${showAmount
|
||||
? `<div class="amt">${amountText}</div><div class="base">Nominal dasar ${baseAmountText} + kode unik ${Number(row.unique_code || 0)}</div>`
|
||||
: `<div class="amt" style="font-size:18px;color:#f87171">QR Kedaluwarsa</div><div class="base">Nominal dan QR disembunyikan. Minta admin membuat QR baru.</div>`}
|
||||
<div class="badge ${cqEscape(expiredNow ? 'expired' : status)}" id="statusBadge">${cqEscape(expiredNow ? 'Kedaluwarsa' : (statusLabels[status] || status))}</div>
|
||||
${showQr ? `<div class="qr"><img src="/custom-qr/${cqEscape(row.share_token)}/qr.png?amount=${Number(row.amount_unique || 0)}" alt="QRIS"></div>` : ''}
|
||||
${showTimer ? `<div class="timer" id="timer" data-expires="${cqEscape(row.expires_at || '')}"></div>` : ''}
|
||||
${row.note ? `<div class="note">${cqEscape(row.note)}</div>` : ''}
|
||||
${showAmount ? `<div class="note">Bayar dengan nominal <b>persis</b> seperti di atas agar pembayaran otomatis tercocokkan. QR berlaku ${require('./services/customQrService').DURATION_MINUTES} menit.</div>` : ''}
|
||||
<div class="note">${cqEscape(company)}</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var el=document.getElementById('timer'), exp=el&&el.getAttribute('data-expires');
|
||||
function tick(){
|
||||
if(!el) return;
|
||||
if(!exp) return;
|
||||
var d=new Date(exp).getTime()-Date.now();
|
||||
if(d<=0){ el.textContent='Batas 20 menit telah lewat — pembayaran tetap dicek admin.'; return; }
|
||||
var m=Math.floor(d/60000), s=Math.floor((d%60000)/1000);
|
||||
el.textContent='Sisa waktu: '+m+' menit '+s+' detik';
|
||||
}
|
||||
tick(); setInterval(tick,1000);
|
||||
var last=${JSON.stringify(status)};
|
||||
setInterval(function(){
|
||||
fetch('/custom-qr/${cqEscape(row.share_token)}/status',{cache:'no-store'}).then(function(r){return r.json()}).then(function(d){
|
||||
if(d && d.status && d.status!==last){ location.reload(); }
|
||||
}).catch(function(){});
|
||||
},5000);
|
||||
})();
|
||||
</script></body></html>`;
|
||||
res.set('Cache-Control', 'no-store');
|
||||
return res.send(html);
|
||||
});
|
||||
|
||||
app.get('/custom-qr/:token/qr.png', async (req, res) => {
|
||||
let row = null;
|
||||
try { row = require('./services/customQrService').getByToken(req.params.token); } catch (e) {}
|
||||
if (!row) return res.status(404).send('QR tidak ditemukan');
|
||||
const settings = getSettingsWithCache();
|
||||
const payload = String(settings.qris_static_payload || '').trim();
|
||||
if (!payload) return res.status(404).send('QRIS statis belum diatur admin.');
|
||||
try {
|
||||
const jpg = await qrisUtil.buildDynamicQrisJpgBuffer(payload, Number(row.amount_unique || row.amount || 0));
|
||||
res.set('Content-Type', 'image/jpeg');
|
||||
res.set('Cache-Control', 'no-store');
|
||||
return res.send(jpg);
|
||||
} catch (e) {
|
||||
logger.error('[CustomQR] Gagal membuat QR: ' + e.message);
|
||||
return res.status(500).send('Gagal membuat QR');
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/custom-qr/:token/status', (req, res) => {
|
||||
let row = null;
|
||||
try { row = require('./services/customQrService').getByToken(req.params.token); } catch (e) {}
|
||||
if (!row) return res.status(404).json({ status: 'not_found' });
|
||||
return res.json({ status: row.status, paid_at: row.paid_at || null });
|
||||
});
|
||||
|
||||
// Mount built-in ACS server endpoint (TR-069)
|
||||
const acsServerService = require('./services/acsServerService');
|
||||
app.post('/acs', express.raw({ type: ['text/xml', 'application/soap+xml', 'application/xml', 'text/plain'], limit: '2mb' }), acsServerService.handleCwmpRequest);
|
||||
|
||||
@@ -1069,6 +1069,42 @@ try {
|
||||
console.error('Failed to migrate public_donation_orders:', e);
|
||||
}
|
||||
|
||||
// Safe migration: QR Custom Payment (nominal bebas di luar billing)
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS custom_qr_payments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
category TEXT DEFAULT '',
|
||||
amount INTEGER NOT NULL DEFAULT 0,
|
||||
unique_code INTEGER,
|
||||
amount_unique INTEGER,
|
||||
status TEXT DEFAULT 'pending',
|
||||
share_token TEXT NOT NULL,
|
||||
note TEXT DEFAULT '',
|
||||
created_by TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT (NOW_LOCAL()),
|
||||
expires_at TEXT,
|
||||
paid_at DATETIME,
|
||||
paid_by TEXT DEFAULT '',
|
||||
notif_id INTEGER,
|
||||
warning_at DATETIME,
|
||||
resolved_by TEXT DEFAULT '',
|
||||
resolved_at DATETIME,
|
||||
resolution_note TEXT DEFAULT ''
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_qr_token ON custom_qr_payments(share_token);
|
||||
CREATE INDEX IF NOT EXISTS idx_custom_qr_status ON custom_qr_payments(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_custom_qr_amount ON custom_qr_payments(amount_unique, status);
|
||||
`);
|
||||
const cols = db.prepare("PRAGMA table_info(webhook_payment_notifs)").all();
|
||||
if (cols.length > 0 && !cols.some(c => c.name === 'matched_custom_qr_id')) {
|
||||
db.exec("ALTER TABLE webhook_payment_notifs ADD COLUMN matched_custom_qr_id INTEGER");
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('Failed to migrate custom_qr_payments:', e);
|
||||
}
|
||||
|
||||
// Safe migration: automation_runs (riwayat otomatisasi pengiriman tagihan)
|
||||
try {
|
||||
db.exec(`
|
||||
@@ -1107,6 +1143,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;
|
||||
|
||||
@@ -278,6 +278,26 @@ function formatTimeLocal(date) {
|
||||
* Memastikan semua setting wajib ada di settings.json dengan default value.
|
||||
* Dijalankan saat startup aplikasi untuk migrasi/inisialisasi setting baru.
|
||||
*/
|
||||
/**
|
||||
* Pastikan gowa_webhook_secret tidak memakai default 'secret'.
|
||||
* Dibuat otomatis (32 byte hex) bila kosong/default/terlalu pendek.
|
||||
*/
|
||||
function ensureWebhookSecret(force) {
|
||||
const current = String(getSetting('webhook_secret', '') || '').trim();
|
||||
if (!force && current && current !== 'billing-rtrw-secret-key' && current.length >= 24) return current;
|
||||
const secret = crypto.randomBytes(32).toString('hex');
|
||||
saveSettings({ webhook_secret: secret });
|
||||
return secret;
|
||||
}
|
||||
|
||||
function ensureGowaWebhookSecret(force) {
|
||||
const current = String(getSetting('gowa_webhook_secret', '') || '').trim();
|
||||
if (!force && current && current !== 'secret' && current.length >= 24) return current;
|
||||
const secret = crypto.randomBytes(32).toString('hex');
|
||||
saveSettings({ gowa_webhook_secret: secret });
|
||||
return secret;
|
||||
}
|
||||
|
||||
function ensureDefaultSettings() {
|
||||
try {
|
||||
const currentSettings = getSettings();
|
||||
@@ -285,6 +305,17 @@ function ensureDefaultSettings() {
|
||||
|
||||
// Default settings yang wajib ada
|
||||
const defaultSettings = {
|
||||
// AI (OpenAI-compatible) & self-healing
|
||||
ai_provider: 'deepseek',
|
||||
deepseek_model: 'deepseek-chat',
|
||||
openai_model: 'gpt-4o-mini',
|
||||
groq_model: 'llama-3.3-70b-versatile',
|
||||
custom_ai_base_url: '',
|
||||
custom_ai_model: 'llama3',
|
||||
ai_self_healing_enabled: false,
|
||||
company_subheader: '',
|
||||
update_base_url: 'https://gitea.oxpoe.xyz',
|
||||
update_repo: 'oxpoe/open-isp',
|
||||
// RADIUS Server settings (ditambahkan untuk update dari Gitea)
|
||||
radius_enabled: '0',
|
||||
radius_secret: 'secret123',
|
||||
@@ -350,5 +381,7 @@ module.exports = {
|
||||
getNowLocalISO,
|
||||
parseDateInTimezone,
|
||||
startSettingsWatcher,
|
||||
ensureDefaultSettings
|
||||
ensureDefaultSettings,
|
||||
ensureGowaWebhookSecret,
|
||||
ensureWebhookSecret
|
||||
};
|
||||
|
||||
54
docker-compose.gowa.yml
Normal file
@@ -0,0 +1,54 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# GOWA — WhatsApp gateway + dashboard (UI) untuk OPEN-ISP
|
||||
#
|
||||
# Kompatibel dengan docker compose maupun podman compose.
|
||||
#
|
||||
# 1. Siapkan kredensial:
|
||||
# cp .env.gowa.example .env.gowa
|
||||
# # isi GOWA_BASIC_AUTH dan GOWA_WEBHOOK_SECRET (openssl rand -hex 32)
|
||||
#
|
||||
# 2. Jalankan:
|
||||
# podman compose --env-file .env.gowa -f docker-compose.gowa.yml up -d
|
||||
# # atau: docker compose --env-file .env.gowa -f docker-compose.gowa.yml up -d
|
||||
#
|
||||
# 3. Buka dashboard: http://<ip-host>:3000 (login Basic Auth di atas)
|
||||
# Scan QR di sana, atau dari OPEN-ISP → WhatsApp.
|
||||
#
|
||||
# 4. Di OPEN-ISP (Admin → WhatsApp → GOWA):
|
||||
# base URL : http://127.0.0.1:3000 (app di host yang sama)
|
||||
# username/pass : sesuai GOWA_BASIC_AUTH
|
||||
# webhook secret: sama dengan GOWA_WEBHOOK_SECRET
|
||||
# gateway : GOWA
|
||||
#
|
||||
# Catatan:
|
||||
# - UI (gowa-ui) diunduh otomatis oleh GOWA saat start; butuh internet.
|
||||
# Server tanpa internet: set APP_UI_AUTO_UPDATE=false lalu taruh
|
||||
# gowa-ui.html di volume gowa_storages (folder /app/storages/ui).
|
||||
# - host.containers.internal = IP host dari dalam container (Podman/Docker).
|
||||
# Bila tidak resolve, ganti GOWA_WEBHOOK_URL dengan IP LAN host,
|
||||
# mis. http://10.44.33.51:3001/api/webhook/gowa
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
services:
|
||||
gowa:
|
||||
image: docker.io/aldinokemal2104/go-whatsapp-web-multidevice:v9.8.0
|
||||
container_name: gowa
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${GOWA_BIND:-0.0.0.0}:${GOWA_PORT:-3000}:3000"
|
||||
volumes:
|
||||
- gowa_storages:/app/storages
|
||||
- gowa_statics:/app/statics
|
||||
environment:
|
||||
APP_PORT: "3000"
|
||||
APP_OS: "OPEN-ISP"
|
||||
APP_BASIC_AUTH: "${GOWA_BASIC_AUTH:?isi GOWA_BASIC_AUTH=user:password}"
|
||||
APP_UI_ENABLED: "${GOWA_UI_ENABLED:-true}"
|
||||
APP_UI_AUTO_UPDATE: "${GOWA_UI_AUTO_UPDATE:-true}"
|
||||
WHATSAPP_ACCOUNT_VALIDATION: "false"
|
||||
WHATSAPP_WEBHOOK: "${GOWA_WEBHOOK_URL:?isi GOWA_WEBHOOK_URL}"
|
||||
WHATSAPP_WEBHOOK_SECRET: "${GOWA_WEBHOOK_SECRET:?isi GOWA_WEBHOOK_SECRET}"
|
||||
WHATSAPP_WEBHOOK_EVENTS: "message"
|
||||
|
||||
volumes:
|
||||
gowa_storages:
|
||||
gowa_statics:
|
||||
66
docs/RELEASE_NOTES_v0.5.3.md
Normal 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/` (format WebP, ringan).
|
||||
|
||||
## 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)
|
||||
BIN
docs/screenshots/01-landing.webp
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
docs/screenshots/02-admin-login.webp
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
docs/screenshots/03-admin-dashboard.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
docs/screenshots/04-admin-customers.webp
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
docs/screenshots/05-admin-inventory.webp
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
docs/screenshots/06-inventory-stok-keluar.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
docs/screenshots/07-admin-settings-ai.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
docs/screenshots/08-customer-login.webp
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
docs/screenshots/09-customer-dashboard.webp
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
docs/screenshots/10-mobile-customer-login.webp
Normal file
|
After Width: | Height: | Size: 49 KiB |
22
docs/screenshots/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Screenshot Aplikasi (v0.5.2)
|
||||
|
||||
Format **WebP** (kualitas 78, lebar maks 1280px) — total ±0,36 MB (turun ~84% dari PNG).
|
||||
|
||||
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.webp` | Landing page publik |
|
||||
| `02-admin-login.webp` | Halaman login admin |
|
||||
| `03-admin-dashboard.webp` | Dashboard admin (ringkasan pelanggan, billing, ONU) |
|
||||
| `04-admin-customers.webp` | Daftar pelanggan |
|
||||
| `05-admin-inventory.webp` | Inventaris stok |
|
||||
| `06-inventory-stok-keluar.webp` | Modal Stok Keluar (FIFO / serial number) |
|
||||
| `07-admin-settings-ai.webp` | Pengaturan → AI & Self-Healing (key disamarkan) |
|
||||
| `08-customer-login.webp` | Login pelanggan + tab Status mengikuti setting `uptime_url` |
|
||||
| `09-customer-dashboard.webp` | Dashboard pelanggan |
|
||||
| `10-mobile-customer-login.webp` | Login pelanggan (tampilan mobile) |
|
||||
|
||||
Catatan: field API key di halaman Pengaturan memakai `type="password"`
|
||||
sehingga tidak tampil di screenshot maupun saat layar dibagikan.
|
||||
|
Before Width: | Height: | Size: 86 KiB |
BIN
docs/screenshots/admin-billing.webp
Normal file
|
After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 87 KiB |
BIN
docs/screenshots/admin-customers.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 80 KiB |
BIN
docs/screenshots/admin-dashboard.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 34 KiB |
BIN
docs/screenshots/admin-login.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 55 KiB |
BIN
docs/screenshots/admin-olts.webp
Normal file
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 37 KiB |
BIN
docs/screenshots/customer-billing.webp
Normal file
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 99 KiB |
BIN
docs/screenshots/customer-dashboard.webp
Normal file
|
After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 66 KiB |
BIN
docs/screenshots/customer-login.webp
Normal file
|
After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 83 KiB |
BIN
docs/screenshots/landing.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
@@ -10,18 +10,19 @@
|
||||
const http = require('http');
|
||||
|
||||
const PORT = parseInt(process.env.ISOLIR_REDIRECT_PORT || '8099', 10);
|
||||
let TARGET = process.env.ISOLIR_REDIRECT_URL || '';
|
||||
|
||||
// Coba ambil target dari settings.json bila tersedia
|
||||
try {
|
||||
const { getSetting } = require('./config/settingsManager');
|
||||
const s = getSetting('isolir_portal_url', '') || '';
|
||||
if (s) TARGET = s;
|
||||
} catch (_) { /* abaikan */ }
|
||||
|
||||
if (!TARGET) TARGET = 'http://localhost:3001/isolated';
|
||||
// Target dibaca ulang dari settings (bisa diubah dari dashboard tanpa restart).
|
||||
function currentTarget() {
|
||||
try {
|
||||
const { getSetting } = require('./config/settingsManager');
|
||||
const s = String(getSetting('isolir_portal_url', '') || '').trim();
|
||||
if (s) return s;
|
||||
} catch (_) {}
|
||||
return String(process.env.ISOLIR_REDIRECT_URL || '').trim() || 'http://localhost:3001/isolated';
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const TARGET = currentTarget();
|
||||
// Jangan cache supaya pelanggan selalu diarahkan ke portal terbaru
|
||||
res.writeHead(302, {
|
||||
Location: TARGET,
|
||||
@@ -40,7 +41,7 @@ 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}`);
|
||||
console.log(`[isolir-redirect] listening on 0.0.0.0:${PORT} -> ${currentTarget()}`);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => { server.close(() => process.exit(0)); });
|
||||
|
||||
@@ -1178,7 +1178,7 @@
|
||||
"deactivate_months": "Arrears Limit (months)",
|
||||
"deactivate_months_hint": "Customer is deactivated (isolated) after this many unpaid months (3 = 3 unpaid new invoices).",
|
||||
"auto_delete": "Auto-Delete Customer (over limit)",
|
||||
"auto_delete_hint": "If Active: customers overdue beyond the limit (e.g. 4th month) are deleted automatically — must re-register.",
|
||||
"auto_delete_hint": "If Active: customers overdue one month past the limit are deleted automatically — must re-register.",
|
||||
"arrears_notice": "Auto Arrears Warning",
|
||||
"arrears_notice_hint": "Send a WhatsApp warning when arrears approach the limit. Template in the Templates & Preview tab.",
|
||||
"manual_action": "Manual Action",
|
||||
@@ -1283,20 +1283,20 @@
|
||||
"st_visible": "Visible",
|
||||
"st_hidden": "Hide",
|
||||
"st_locked": "Lock",
|
||||
"pass_label": "Activation Code (Free)",
|
||||
"pass_ph": "Enter the activation password before saving",
|
||||
"pass_hint": "Sidebar menu status changes are saved only if the activation password is correct.",
|
||||
"no_pass": "No activation code yet?",
|
||||
"get_donation": "Get a FREE activation code on the OPEN-ISP Support page",
|
||||
"or_contact": "or contact",
|
||||
"pass_label": "Menu activation",
|
||||
"pass_ph": "Not required",
|
||||
"pass_hint": "Not required.",
|
||||
"no_pass": "Menus can be shown/hidden from this page.",
|
||||
"get_donation": "Support OPEN-ISP development",
|
||||
"or_contact": "",
|
||||
"donation_page": "Free Activation Code (OPEN-ISP Support)",
|
||||
"save": "Save Sidebar Settings",
|
||||
"only_admin": "Only the main admin can change sidebar menu status.",
|
||||
"notes": "Notes",
|
||||
"note_lock": "If a Locked menu is clicked from the sidebar, the system shows how to get a FREE activation code or contact",
|
||||
"note_lock": "A locked menu stays closed until enabled from this page.",
|
||||
"note1": "Hidden status removes the menu from the sidebar and denies direct URL access.",
|
||||
"note2": "Locked status keeps the menu visible but cannot be opened until changed on this page.",
|
||||
"note3": "Menu status changes can only be saved after entering the correct activation password."
|
||||
"note3": ""
|
||||
},
|
||||
"acsdev": {
|
||||
"title": "Device Detail",
|
||||
|
||||
@@ -1178,7 +1178,7 @@
|
||||
"deactivate_months": "Batas Tunggakan (bulan)",
|
||||
"deactivate_months_hint": "Pelanggan dinonaktif (isolir) bila menunggak sebanyak ini (3 = 3x tagihan baru belum lunas).",
|
||||
"auto_delete": "Auto-Hapus Pelanggan (lewat batas)",
|
||||
"auto_delete_hint": "Jika Aktif: pelanggan menunggak lebih dari batas (mis. bulan ke-4) dihapus otomatis — harus daftar ulang.",
|
||||
"auto_delete_hint": "Jika Aktif: pelanggan menunggak satu bulan setelah batas dihapus otomatis — harus daftar ulang.",
|
||||
"arrears_notice": "Auto-Peringatan Tunggakan",
|
||||
"arrears_notice_hint": "Kirim WA peringatan saat menunggak mendekati batas. Template di tab Template & Preview.",
|
||||
"manual_action": "Aksi Manual",
|
||||
@@ -1283,20 +1283,20 @@
|
||||
"st_visible": "Tampil",
|
||||
"st_hidden": "Sembunyikan",
|
||||
"st_locked": "Kunci",
|
||||
"pass_label": "Kode Aktivasi (Gratis)",
|
||||
"pass_ph": "Masukkan password aktivasi sebelum menyimpan",
|
||||
"pass_hint": "Perubahan status menu sidebar hanya akan disimpan jika password aktivasi benar.",
|
||||
"no_pass": "Belum punya kode aktivasi?",
|
||||
"get_donation": "Dapatkan kode aktivasi GRATIS di halaman Dukungan OPEN-ISP",
|
||||
"or_contact": "atau hubungi",
|
||||
"pass_label": "Aktivasi menu",
|
||||
"pass_ph": "Tidak diperlukan",
|
||||
"pass_hint": "Tidak diperlukan.",
|
||||
"no_pass": "Menu bisa ditampilkan/disembunyikan dari halaman ini.",
|
||||
"get_donation": "Dukung pengembangan OPEN-ISP",
|
||||
"or_contact": "",
|
||||
"donation_page": "Kode Aktivasi Gratis (Dukungan OPEN-ISP)",
|
||||
"save": "Simpan Pengaturan Sidebar",
|
||||
"only_admin": "Hanya admin utama yang dapat mengubah status menu sidebar.",
|
||||
"notes": "Catatan",
|
||||
"note_lock": "Jika menu berstatus Kunci diklik dari sidebar, sistem menampilkan cara mengambil kode aktivasi GRATIS atau menghubungi",
|
||||
"note_lock": "Menu berstatus Kunci tidak dapat dibuka sampai diaktifkan.",
|
||||
"note1": "Status Sembunyikan akan menghilangkan menu dari sidebar dan menolak akses URL langsung.",
|
||||
"note2": "Status Kunci tetap menampilkan menu, tetapi tidak bisa dibuka sampai statusnya diubah di halaman ini.",
|
||||
"note3": "Perubahan status menu hanya dapat disimpan setelah memasukkan password aktivasi yang benar."
|
||||
"note3": ""
|
||||
},
|
||||
"acsdev": {
|
||||
"title": "Detail Perangkat",
|
||||
|
||||
10
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "open-isp",
|
||||
"version": "0.5.2",
|
||||
"description": "OPEN-ISP — sistem billing & manajemen ISP open-source (PWA pelanggan, multi-OLT, RADIUS, WhatsApp, QRIS)",
|
||||
"version": "0.6.14",
|
||||
"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://gitea.oxpoe.qzz.io/oxpoe/open-isp#readme",
|
||||
"homepage": "https://gitea.oxpoe.xyz/oxpoe/open-isp#readme",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://gitea.oxpoe.qzz.io/oxpoe/open-isp.git"
|
||||
"url": "git+https://gitea.oxpoe.xyz/oxpoe/open-isp.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://gitea.oxpoe.qzz.io/oxpoe/open-isp/issues"
|
||||
"url": "https://gitea.oxpoe.xyz/oxpoe/open-isp/issues"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,6 +492,11 @@ function getDeviceParameterValue(device, keys, filterFn) {
|
||||
}
|
||||
|
||||
function extractPppoeIp(d) {
|
||||
// Virtual parameters GenieACS (lebih cepat & akurat bila tersedia)
|
||||
for (const k of ['VirtualParameters.pppoeIP', 'VirtualParameters.pppIP', 'VirtualParameters.WanIP', 'VirtualParameters.IPTR069', 'VirtualParameters.pppoe_ip']) {
|
||||
const val = getNestedValue(d, k);
|
||||
if (val && typeof val === 'string' && val.trim() && val !== '-' && val !== '0.0.0.0' && val !== 'N/A') return val.trim();
|
||||
}
|
||||
const ip = getDeviceParameterValue(d, PPPOE_IP_KEYS, (matchedPath, value, device) => {
|
||||
if (!value || value === '0.0.0.0' || value === '-') return false;
|
||||
|
||||
@@ -511,6 +516,10 @@ function extractPppoeIp(d) {
|
||||
}
|
||||
|
||||
function extractPppoeUser(d) {
|
||||
for (const k of ['VirtualParameters.pppoeUsername2', 'VirtualParameters.pppoeUsername', 'VirtualParameters.pppUsername', 'VirtualParameters.PPPoEUser', 'VirtualParameters.pppoe_username', 'VirtualParameters.username']) {
|
||||
const val = getNestedValue(d, k);
|
||||
if (val && typeof val === 'string' && val.trim() && val !== '-' && val !== 'N/A') return val.trim();
|
||||
}
|
||||
const user = getDeviceParameterValue(d, PPPOE_USER_KEYS, (matchedPath, value, device) => {
|
||||
if (!value || value === '-') return false;
|
||||
// Abaikan nilai generik yang bukan user PPPoE pelanggan
|
||||
@@ -705,7 +714,7 @@ function requireSidebarMenuAccess(menuKey) {
|
||||
}
|
||||
|
||||
if (access.reason === 'locked') {
|
||||
req.session._msg = { type: 'error', text: `Menu "${access.menu.labelDefault}" terkunci. Hubungi ${sidebarMenuSvc.getFeatureContactPhone()} untuk mendapatkan password.` };
|
||||
req.session._msg = { type: 'error', text: `Menu "${access.menu.labelDefault}" terkunci. Buka dari Pengaturan Sidebar.` };
|
||||
return res.redirect('/admin/sidebar-settings');
|
||||
}
|
||||
|
||||
@@ -1601,6 +1610,36 @@ router.get('/api/wifi-settings/:deviceId', requireAdmin, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api/device/:deviceId/ssid', requireAdmin, express.json(), async (req, res) => {
|
||||
try {
|
||||
const deviceId = req.params.deviceId;
|
||||
const ssid = String(req.body.ssid || '').trim();
|
||||
if (!ssid || ssid.length < 2) {
|
||||
return res.status(400).json({ success: false, message: 'Nama WiFi (SSID) minimal 2 karakter' });
|
||||
}
|
||||
const ok = await customerDevice.updateSSID(deviceId, ssid);
|
||||
if (!ok) return res.status(500).json({ success: false, message: 'Gagal mengubah SSID. Pastikan perangkat aktif.' });
|
||||
return res.json({ success: true, message: `SSID diubah menjadi "${ssid}"` });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api/device/:deviceId/password', requireAdmin, express.json(), async (req, res) => {
|
||||
try {
|
||||
const deviceId = req.params.deviceId;
|
||||
const password = String(req.body.password || '').trim();
|
||||
if (!password || password.length < 8) {
|
||||
return res.status(400).json({ success: false, message: 'Sandi WiFi minimal 8 karakter' });
|
||||
}
|
||||
const ok = await customerDevice.updatePassword(deviceId, password);
|
||||
if (!ok) return res.status(500).json({ success: false, message: 'Gagal mengubah sandi WiFi. Pastikan perangkat aktif.' });
|
||||
return res.json({ success: true, message: 'Sandi WiFi berhasil diperbarui' });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/api/add-wan-status/:deviceId', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { deviceId } = req.params;
|
||||
|
||||
@@ -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');
|
||||
@@ -70,6 +72,7 @@ const _jimpMod = require('jimp');
|
||||
const Jimp = _jimpMod.Jimp || _jimpMod;
|
||||
const jsQR = require('jsqr');
|
||||
const qrisUtil = require('../utils/qrisUtil');
|
||||
const qrisUniq = require('../services/invoiceQrisService');
|
||||
const { MultiFormatReader, BarcodeFormat, DecodeHintType, BinaryBitmap, HybridBinarizer, RGBLuminanceSource } = require('@zxing/library');
|
||||
const QRCode = require('qrcode');
|
||||
const acsPortal = require('./acsPortal');
|
||||
@@ -315,7 +318,7 @@ function requireSidebarMenuAccess(menuKey) {
|
||||
}
|
||||
|
||||
if (access.reason === 'locked') {
|
||||
req.session._msg = { type: 'error', text: `Menu "${access.menu.labelDefault}" terkunci. Hubungi ${sidebarMenuSvc.FEATURE_CONTACT_PHONE} untuk mendapatkan password.` };
|
||||
req.session._msg = { type: 'error', text: `Menu "${access.menu.labelDefault}" terkunci. Buka dari Pengaturan Sidebar.` };
|
||||
return res.redirect('/admin/sidebar-settings');
|
||||
}
|
||||
|
||||
@@ -338,9 +341,9 @@ function readTextFileSafe(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
function runCmd(cmd, args, cwd) {
|
||||
function runCmd(cmd, args, cwd, timeoutMs = 0) {
|
||||
try {
|
||||
const r = spawnSync(cmd, args, { cwd, encoding: 'utf8' });
|
||||
const r = spawnSync(cmd, args, { cwd, encoding: 'utf8', timeout: timeoutMs });
|
||||
return { ok: r.status === 0, code: r.status, stdout: r.stdout || '', stderr: r.stderr || '' };
|
||||
} catch (e) {
|
||||
return { ok: false, code: -1, stdout: '', stderr: String(e?.message || e) };
|
||||
@@ -394,9 +397,11 @@ function getUpdateInfo(repoRoot) {
|
||||
info.branch = branch;
|
||||
|
||||
const _repo = String(getSetting('update_repo', 'oxpoe/open-isp') || '').trim();
|
||||
const _base = String(getSetting('update_base_url', 'https://gitea.oxpoe.xyz') || '').trim().replace(/\/+$/, '');
|
||||
info.repo = _repo || 'origin';
|
||||
info.baseUrl = _base;
|
||||
const fetch = _repo
|
||||
? runCmd('git', ['fetch', '--prune', 'https://gitea.oxpoe.qzz.io/' + _repo + '.git', branch], repoRoot)
|
||||
? runCmd('git', ['fetch', '--prune', _base + '/' + _repo + '.git', branch], repoRoot, 120000)
|
||||
: runCmd('git', ['fetch', '--prune'], repoRoot);
|
||||
if (!fetch.ok) {
|
||||
info.error = 'Gagal git fetch: ' + (fetch.stderr || fetch.stdout || '').trim();
|
||||
@@ -554,17 +559,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;
|
||||
@@ -572,10 +598,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) => {
|
||||
@@ -3069,7 +3106,6 @@ router.post('/billing/:id/qris-assign', requireAdminSession, (req, res) => {
|
||||
const baseAmount = Number(inv.amount || 0);
|
||||
if (!Number.isFinite(baseAmount) || baseAmount <= 0) throw new Error('Nominal tagihan tidak valid');
|
||||
|
||||
const exists = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1');
|
||||
const update = db.prepare(`
|
||||
UPDATE invoices
|
||||
SET qris_unique_code=?, qris_amount_unique=?, qris_assigned_at=(NOW_LOCAL())
|
||||
@@ -3082,7 +3118,7 @@ router.post('/billing/:id/qris-assign', requireAdminSession, (req, res) => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const code = 1 + Math.floor(Math.random() * 999);
|
||||
const amount = baseAmount + code;
|
||||
if (!exists.get('unpaid', amount, invId)) {
|
||||
if (!qrisUniq.isQrisAmountTaken(amount, { excludeInvoiceId: invId })) {
|
||||
chosenCode = code;
|
||||
chosenAmount = amount;
|
||||
break;
|
||||
@@ -3092,7 +3128,7 @@ router.post('/billing/:id/qris-assign', requireAdminSession, (req, res) => {
|
||||
if (!chosenAmount) {
|
||||
for (let code = 1; code <= 999; code++) {
|
||||
const amount = baseAmount + code;
|
||||
if (!exists.get('unpaid', amount, invId)) {
|
||||
if (!qrisUniq.isQrisAmountTaken(amount, { excludeInvoiceId: invId })) {
|
||||
chosenCode = code;
|
||||
chosenAmount = amount;
|
||||
break;
|
||||
@@ -3166,7 +3202,6 @@ router.post('/billing/:id/whatsapp', requireAdminSession, async (req, res) => {
|
||||
if (hasStaticQris && String(inv.status) === 'unpaid' && (!qrisAmountUnique || !qrisCode || (qrisAmountUnique - qrisCode !== baseAmount))) {
|
||||
const invId = Number(inv.id);
|
||||
if (Number.isFinite(invId) && invId > 0 && Number.isFinite(baseAmount) && baseAmount > 0) {
|
||||
const exists = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1');
|
||||
const update = db.prepare(`
|
||||
UPDATE invoices
|
||||
SET qris_unique_code=?, qris_amount_unique=?, qris_assigned_at=(NOW_LOCAL())
|
||||
@@ -3178,7 +3213,7 @@ router.post('/billing/:id/whatsapp', requireAdminSession, async (req, res) => {
|
||||
|
||||
if (chosenCode > 0) {
|
||||
const pAmt = baseAmount + chosenCode;
|
||||
if (!exists.get('unpaid', pAmt, invId)) {
|
||||
if (!qrisUniq.isQrisAmountTaken(pAmt, { excludeInvoiceId: invId })) {
|
||||
chosenAmount = pAmt;
|
||||
}
|
||||
}
|
||||
@@ -3187,7 +3222,7 @@ router.post('/billing/:id/whatsapp', requireAdminSession, async (req, res) => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const code = 1 + Math.floor(Math.random() * 999);
|
||||
const amount = baseAmount + code;
|
||||
if (!exists.get('unpaid', amount, invId)) {
|
||||
if (!qrisUniq.isQrisAmountTaken(amount, { excludeInvoiceId: invId })) {
|
||||
chosenCode = code;
|
||||
chosenAmount = amount;
|
||||
break;
|
||||
@@ -3197,7 +3232,7 @@ router.post('/billing/:id/whatsapp', requireAdminSession, async (req, res) => {
|
||||
if (!chosenAmount) {
|
||||
for (let code = 1; code <= 999; code++) {
|
||||
const amount = baseAmount + code;
|
||||
if (!exists.get('unpaid', amount, invId)) {
|
||||
if (!qrisUniq.isQrisAmountTaken(amount, { excludeInvoiceId: invId })) {
|
||||
chosenCode = code;
|
||||
chosenAmount = amount;
|
||||
break;
|
||||
@@ -3953,11 +3988,6 @@ router.get('/sidebar-settings', requireAdminSession, (req, res) => {
|
||||
|
||||
router.post('/sidebar-settings', requireAdminSession, restrictToAdmin, express.urlencoded({ extended: true }), (req, res) => {
|
||||
try {
|
||||
const featurePassword = String(req.body.feature_password || '').trim();
|
||||
if (!sidebarMenuSvc.isFeaturePasswordValid(featurePassword)) {
|
||||
throw new Error(`Password aktivasi salah. Hubungi ${sidebarMenuSvc.getFeatureContactPhone()} untuk mendapatkan password yang benar.`);
|
||||
}
|
||||
|
||||
const menuStates = sidebarMenuSvc.sanitizeMenuStates(req.body.menu_state || {});
|
||||
const success = sidebarMenuSvc.saveMenuStates(menuStates);
|
||||
if (!success) throw new Error('Gagal menyimpan pengaturan sidebar');
|
||||
@@ -3983,14 +4013,133 @@ router.post('/sidebar-settings', requireAdminSession, restrictToAdmin, express.u
|
||||
res.redirect('/admin/sidebar-settings');
|
||||
});
|
||||
|
||||
router.get('/service-notice', requireAdminSession, requireSidebarMenuAccess('service_notice'), (req, res) => {
|
||||
res.render('admin/service_notice', {
|
||||
title: 'Status Layanan & Pengumuman', company: company(), activePage: 'service_notice',
|
||||
settings: getSettings(), msg: flashMsg(req)
|
||||
// ─── QR CUSTOM PAYMENT (nominal bebas, kode unik 20 menit) ─────────────────
|
||||
router.get('/custom-qr', requireAdminSession, requireSidebarMenuAccess('custom_qr'), (req, res) => {
|
||||
const svc = require('../services/customQrService');
|
||||
let detail = null;
|
||||
const detailId = Number(req.query.id) || 0;
|
||||
if (detailId > 0) { try { detail = svc.getById(detailId); } catch (e) {} }
|
||||
const filterFrom = String(req.query.from || '').trim();
|
||||
const filterTo = String(req.query.to || '').trim();
|
||||
const settings = getSettings();
|
||||
const baseUrl = String(settings.public_base_url || ((req.headers['x-forwarded-proto'] || req.protocol) + '://' + req.get('host'))).replace(/\/+$/, '');
|
||||
res.render('admin/custom_qr', {
|
||||
title: 'QR-Custom Payment',
|
||||
company: company(),
|
||||
activePage: 'custom_qr',
|
||||
msg: flashMsg(req),
|
||||
rows: svc.list(200, { from: filterFrom, to: filterTo }),
|
||||
filterFrom, filterTo,
|
||||
stats: svc.stats(),
|
||||
detail,
|
||||
baseUrl,
|
||||
durationMinutes: svc.DURATION_MINUTES,
|
||||
hasQrisPayload: !!String(settings.qris_static_payload || '').trim(),
|
||||
ready: svc.isReady()
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/service-notice', requireAdminSession, requireSidebarMenuAccess('service_notice'), express.urlencoded({ extended: true }), (req, res) => {
|
||||
router.post('/custom-qr', requireAdminSession, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
|
||||
try {
|
||||
const svc = require('../services/customQrService');
|
||||
const row = svc.create({
|
||||
title: req.body.title,
|
||||
category: req.body.category,
|
||||
amount: req.body.amount,
|
||||
note: req.body.note,
|
||||
createdBy: resolvePaidByName(req, 'Admin')
|
||||
});
|
||||
req.session._msg = { type: 'success', text: `QR custom dibuat: Rp ${Number(row.amount_unique).toLocaleString('id-ID')} — berlaku ${svc.DURATION_MINUTES} menit.` };
|
||||
return res.redirect('/admin/custom-qr?id=' + row.id);
|
||||
} catch (e) {
|
||||
req.session._msg = { type: 'error', text: 'Gagal membuat QR custom: ' + e.message };
|
||||
return res.redirect('/admin/custom-qr');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/custom-qr/:id/manual-paid', requireAdminSession, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
|
||||
try {
|
||||
require('../services/customQrService').markManualPaid(req.params.id, resolvePaidByName(req, 'Admin'), req.body.note);
|
||||
req.session._msg = { type: 'success', text: 'Pembayaran custom ditandai LUNAS (manual).' };
|
||||
} catch (e) { req.session._msg = { type: 'error', text: e.message }; }
|
||||
res.redirect('/admin/custom-qr?id=' + Number(req.params.id));
|
||||
});
|
||||
|
||||
function _checkAdminPassword(req) {
|
||||
const pass = String((req.body && req.body.password) || '');
|
||||
const expected = String(getSetting('admin_password', 'demo-isp123') || 'demo-isp123');
|
||||
return !!expected && pass === expected;
|
||||
}
|
||||
|
||||
router.post('/custom-qr/:id/delete', requireAdminSession, restrictToAdmin, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
|
||||
try {
|
||||
if (!_checkAdminPassword(req)) throw new Error('Password admin salah.');
|
||||
const n = require('../services/customQrService').remove(req.params.id);
|
||||
req.session._msg = n ? { type: 'success', text: 'QR custom dihapus.' } : { type: 'error', text: 'Data tidak ditemukan.' };
|
||||
} catch (e) { req.session._msg = { type: 'error', text: e.message }; }
|
||||
res.redirect('/admin/custom-qr');
|
||||
});
|
||||
|
||||
router.post('/custom-qr/reset', requireAdminSession, restrictToAdmin, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
|
||||
try {
|
||||
if (!_checkAdminPassword(req)) throw new Error('Password admin salah.');
|
||||
const n = require('../services/customQrService').clearAll();
|
||||
req.session._msg = { type: 'success', text: `Histori QR custom direset (${n} data dihapus).` };
|
||||
} catch (e) { req.session._msg = { type: 'error', text: e.message }; }
|
||||
res.redirect('/admin/custom-qr');
|
||||
});
|
||||
|
||||
router.post('/custom-qr/delete-bulk', requireAdminSession, restrictToAdmin, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
|
||||
try {
|
||||
if (!_checkAdminPassword(req)) throw new Error('Password admin salah.');
|
||||
const ids = Array.isArray(req.body.ids) ? req.body.ids : [req.body.ids];
|
||||
const n = require('../services/customQrService').removeMany(ids);
|
||||
req.session._msg = { type: 'success', text: `${n} QR custom dihapus.` };
|
||||
} catch (e) { req.session._msg = { type: 'error', text: e.message }; }
|
||||
const q = [];
|
||||
if (req.body.from) q.push('from=' + encodeURIComponent(req.body.from));
|
||||
if (req.body.to) q.push('to=' + encodeURIComponent(req.body.to));
|
||||
res.redirect('/admin/custom-qr' + (q.length ? ('?' + q.join('&')) : ''));
|
||||
});
|
||||
|
||||
router.post('/custom-qr/:id/regenerate', requireAdminSession, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
|
||||
try {
|
||||
const row = require('../services/customQrService').regenerate(req.params.id, resolvePaidByName(req, 'Admin'));
|
||||
req.session._msg = { type: 'success', text: `QR baru dibuat: Rp ${Number(row.amount_unique).toLocaleString('id-ID')} (kode ${row.unique_code}).` };
|
||||
return res.redirect('/admin/custom-qr?id=' + row.id);
|
||||
} catch (e) {
|
||||
req.session._msg = { type: 'error', text: e.message };
|
||||
return res.redirect('/admin/custom-qr?id=' + Number(req.params.id));
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/custom-qr/:id/accept', requireAdminSession, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
|
||||
try {
|
||||
require('../services/customQrService').acceptWarning(req.params.id, resolvePaidByName(req, 'Admin'), req.body.note);
|
||||
req.session._msg = { type: 'success', text: 'Pembayaran setelah 20 menit diterima.' };
|
||||
} catch (e) { req.session._msg = { type: 'error', text: e.message }; }
|
||||
res.redirect('/admin/custom-qr?id=' + Number(req.params.id));
|
||||
});
|
||||
|
||||
router.post('/custom-qr/:id/reject', requireAdminSession, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
|
||||
try {
|
||||
require('../services/customQrService').reject(req.params.id, resolvePaidByName(req, 'Admin'), req.body.note);
|
||||
req.session._msg = { type: 'success', text: 'Pembayaran ditolak / refund dicatat.' };
|
||||
} catch (e) { req.session._msg = { type: 'error', text: e.message }; }
|
||||
res.redirect('/admin/custom-qr?id=' + Number(req.params.id));
|
||||
});
|
||||
|
||||
router.get('/service-notice', requireAdminSession, requireSidebarMenuAccess('service_notice'), (req, res) => {
|
||||
let routers = [];
|
||||
try { routers = db.prepare('SELECT id, name FROM routers WHERE is_active = 1 ORDER BY name ASC').all(); } catch (_) {}
|
||||
res.render('admin/service_notice', {
|
||||
title: 'Status Layanan & Pengumuman', company: company(), activePage: 'service_notice',
|
||||
settings: getSettings(), msg: flashMsg(req),
|
||||
areas: customerSvc.getAllCustomerAreas(), routers
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/service-notice', requireAdminSession, requireSidebarMenuAccess('service_notice'), express.urlencoded({ extended: true }), async (req, res) => {
|
||||
try {
|
||||
const allowed = ['NORMAL', 'GANGGUAN_MASAL', 'PERBAIKAN'];
|
||||
let mode = String(req.body.notice_mode || 'NORMAL').trim().toUpperCase();
|
||||
@@ -3998,6 +4147,7 @@ router.post('/service-notice', requireAdminSession, requireSidebarMenuAccess('se
|
||||
let interval = parseInt(req.body.notice_interval_hours, 10);
|
||||
if (!interval || interval < 1) interval = 5;
|
||||
if (interval > 168) interval = 168;
|
||||
const prevMode = String(getSetting('notice_mode', 'NORMAL') || 'NORMAL').toUpperCase();
|
||||
saveSettings({
|
||||
notice_mode: mode,
|
||||
notice_start: String(req.body.notice_start || '').trim(),
|
||||
@@ -4007,7 +4157,27 @@ router.post('/service-notice', requireAdminSession, requireSidebarMenuAccess('se
|
||||
const noticeSvc = require('../services/serviceNoticeService');
|
||||
const n = noticeSvc.computeNotice();
|
||||
const detail = n.active ? (n.label + (n.estimateText ? ' — estimasi ' + n.estimateText : '')) : 'Normal (tanpa pengumuman)';
|
||||
req.session._msg = { type: 'success', text: 'Status layanan diperbarui: ' + detail };
|
||||
let waInfo = '';
|
||||
if (req.body.notice_wa === '1') {
|
||||
if (mode === prevMode) {
|
||||
waInfo = ' Notifikasi WA dilewati (mode tidak berubah).';
|
||||
} else {
|
||||
try {
|
||||
const r = await require('../services/serviceNoticeNotifyService').broadcastNotice({
|
||||
mode,
|
||||
target: String(req.body.notice_wa_target || 'all'),
|
||||
area: String(req.body.notice_wa_area || ''),
|
||||
routerId: parseInt(req.body.notice_wa_router, 10) || 0,
|
||||
notice: n
|
||||
});
|
||||
if (r.ok) waInfo = ` Notifikasi WA dikirim ke ${r.total} pelanggan (latar belakang).`;
|
||||
else waInfo = ` Notifikasi WA tidak dikirim: ${r.reason === 'empty' ? 'tidak ada nomor valid' : r.reason === 'busy' ? 'proses lain masih berjalan' : 'WhatsApp belum aktif'}.`;
|
||||
} catch (waErr) {
|
||||
waInfo = ' Notifikasi WA gagal: ' + waErr.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
req.session._msg = { type: 'success', text: 'Status layanan diperbarui: ' + detail + '.' + waInfo };
|
||||
} catch (e) {
|
||||
req.session._msg = { type: 'error', text: 'Gagal menyimpan status layanan: ' + e.message };
|
||||
}
|
||||
@@ -4371,15 +4541,17 @@ router.post('/automation/send-now', requireAdminSession, express.urlencoded({ ex
|
||||
});
|
||||
|
||||
router.get('/settings', requireAdminSession, requireSidebarMenuAccess('settings'), (req, res) => {
|
||||
const settings = getSettings();
|
||||
let settings = getSettings();
|
||||
try { require('../config/settingsManager').ensureWebhookSecret(); settings = getSettings(); } catch (e) {}
|
||||
const protocol = req.headers['x-forwarded-proto'] || req.protocol;
|
||||
const host = req.get('host');
|
||||
const baseUrl = (settings && settings.app_url ? String(settings.app_url) : `${protocol}://${host}`).replace(/\/+$/, '');
|
||||
const baseUrl = (settings.public_base_url || (settings && settings.app_url) || `${protocol}://${host}`).toString().replace(/\/+$/, '');
|
||||
const paymentWebhookUrl = `${baseUrl}/customer/payment/callback`;
|
||||
res.render('admin/settings', {
|
||||
title: 'Pengaturan Sistem', company: company(), activePage: 'settings',
|
||||
settings, msg: flashMsg(req),
|
||||
paymentWebhookUrl
|
||||
paymentWebhookUrl,
|
||||
webhookBaseUrl: baseUrl
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4544,16 +4716,34 @@ router.post('/settings/regenerate-icons', requireAdminSession, async (req, res)
|
||||
router.get('/update', requireAdminSession, requireSidebarMenuAccess('update'), restrictToAdmin, (req, res) => {
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const info = getUpdateInfo(repoRoot);
|
||||
let restartPending = null;
|
||||
try {
|
||||
const markerPath = path.join(repoRoot, '.restart-required');
|
||||
if (fs.existsSync(markerPath)) restartPending = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
|
||||
} catch (e) { restartPending = null; }
|
||||
res.render('admin/update', {
|
||||
title: 'Update Aplikasi',
|
||||
company: company(),
|
||||
activePage: 'update',
|
||||
msg: flashMsg(req),
|
||||
info,
|
||||
restartPending,
|
||||
log: popUpdateLog(req)
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/api/restart-required', requireAdminSession, (req, res) => {
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
try {
|
||||
const markerPath = path.join(repoRoot, '.restart-required');
|
||||
if (fs.existsSync(markerPath)) {
|
||||
const d = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
|
||||
return res.json({ pending: true, version: d.version || '', from: d.from || '', at: d.at || '' });
|
||||
}
|
||||
} catch (e) {}
|
||||
return res.json({ pending: false });
|
||||
});
|
||||
|
||||
router.post('/update/run', requireAdminSession, restrictToAdmin, (req, res) => {
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
if (!runCmd('git', ['rev-parse', '--is-inside-work-tree'], repoRoot).ok) {
|
||||
@@ -4575,6 +4765,7 @@ router.post('/update/run', requireAdminSession, restrictToAdmin, (req, res) => {
|
||||
const preservedEntries = [
|
||||
{ label: 'settings.json', relativePath: 'settings.json', cleanExclude: 'settings.json' },
|
||||
{ label: '.env', relativePath: '.env', cleanExclude: '.env' },
|
||||
{ label: '.env.gowa', relativePath: '.env.gowa', cleanExclude: '.env.gowa' },
|
||||
{ label: 'database', relativePath: 'database', cleanExclude: 'database' },
|
||||
{ label: 'public/uploads', relativePath: 'public/uploads', cleanExclude: 'public/uploads' },
|
||||
{ label: 'public/img', relativePath: 'public/img', cleanExclude: 'public/img' },
|
||||
@@ -4632,9 +4823,9 @@ router.post('/update/run', requireAdminSession, restrictToAdmin, (req, res) => {
|
||||
if (!inside.ok) throw new Error('Folder ini belum menjadi git repository.');
|
||||
|
||||
const _repo = String(getSetting('update_repo', 'oxpoe/open-isp') || '').trim();
|
||||
info.repo = _repo || 'origin';
|
||||
const fetch = _repo
|
||||
? runCmd('git', ['fetch', '--prune', 'https://gitea.oxpoe.qzz.io/' + _repo + '.git', branch], repoRoot)
|
||||
const _base = String(getSetting('update_base_url', 'https://gitea.oxpoe.xyz') || '').trim().replace(/\/+$/, '');
|
||||
const fetch = _repo
|
||||
? runCmd('git', ['fetch', '--prune', _base + '/' + _repo + '.git', branch], repoRoot, 120000)
|
||||
: runCmd('git', ['fetch', '--prune'], repoRoot);
|
||||
pushCmd('git fetch --prune', fetch);
|
||||
if (!fetch.ok) throw new Error('Gagal git fetch.');
|
||||
@@ -4655,11 +4846,7 @@ router.post('/update/run', requireAdminSession, restrictToAdmin, (req, res) => {
|
||||
fs.mkdirSync(backupRoot, { recursive: true });
|
||||
backupPreservedEntries();
|
||||
|
||||
const resetSettings = runCmd('git', ['checkout', '--', 'settings.json'], repoRoot);
|
||||
pushCmd('git checkout -- settings.json', resetSettings);
|
||||
const resetDb = runCmd('git', ['checkout', '--', 'database'], repoRoot);
|
||||
pushCmd('git checkout -- database', resetDb);
|
||||
|
||||
// settings.json / database tidak dilacak git; jangan checkout (menghasilkan error pathspec).
|
||||
const resetHard = runCmd('git', ['reset', '--hard', _repo ? 'FETCH_HEAD' : `origin/${branch}`], repoRoot);
|
||||
pushCmd(`git reset --hard origin/${branch}`, resetHard);
|
||||
if (!resetHard.ok) throw new Error('Gagal reset ke origin/' + branch);
|
||||
@@ -4702,7 +4889,17 @@ router.post('/update/run', requireAdminSession, restrictToAdmin, (req, res) => {
|
||||
}
|
||||
|
||||
const localAfter = readTextFileSafe(versionPath) || '-';
|
||||
req.session._msg = { type: 'success', text: `Update selesai. Versi: ${localBefore} → ${localAfter}. Silakan restart aplikasi.` };
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(repoRoot, '.restart-required'),
|
||||
JSON.stringify({ version: localAfter, from: localBefore, at: new Date().toISOString() }),
|
||||
'utf8'
|
||||
);
|
||||
log.push('$ restart-required marker ditulis');
|
||||
} catch (markerErr) {
|
||||
log.push('$ restart-required marker gagal: ' + String(markerErr?.message || markerErr));
|
||||
}
|
||||
req.session._msg = { type: 'success', text: `Update selesai. Versi: ${localBefore} → ${localAfter}. Restart aplikasi diperlukan agar perubahan aktif.` };
|
||||
req.session._updateLog = log.join('\n');
|
||||
} catch (e) {
|
||||
if (repoChanged) {
|
||||
@@ -4712,6 +4909,7 @@ router.post('/update/run', requireAdminSession, restrictToAdmin, (req, res) => {
|
||||
log.push(`$ restore preserved files failed: ${String(restoreErr?.message || restoreErr)}`);
|
||||
}
|
||||
}
|
||||
log.push(`$ ERROR: ${e?.message || e}`);
|
||||
req.session._msg = { type: 'error', text: 'Gagal update: ' + (e?.message || e) };
|
||||
req.session._updateLog = log.join('\n');
|
||||
} finally {
|
||||
@@ -4733,6 +4931,76 @@ router.post('/api/telegram/sync', requireAdminSession, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/settings/test-ai', requireAdminSession, express.json(), async (req, res) => {
|
||||
const presets = {
|
||||
deepseek: { baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat' },
|
||||
openai: { baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o-mini' },
|
||||
groq: { baseUrl: 'https://api.groq.com/openai/v1', model: 'llama-3.3-70b-versatile' },
|
||||
openrouter: { baseUrl: 'https://openrouter.ai/api/v1', model: 'meta-llama/llama-3.3-70b-instruct' },
|
||||
custom: { baseUrl: '', model: '' }
|
||||
};
|
||||
try {
|
||||
const b = req.body || {};
|
||||
const provider = String(b.provider || 'deepseek').trim().toLowerCase();
|
||||
const p = presets[provider] || presets.deepseek;
|
||||
const apiKey = String(b.apiKey || '').trim();
|
||||
const baseUrl = String(b.baseUrl || p.baseUrl || '').trim().replace(/\/+$/, '');
|
||||
const model = String(b.model || p.model || '').trim();
|
||||
|
||||
if (!baseUrl) return res.status(400).json({ ok: false, message: 'Base URL AI kosong.' });
|
||||
if (!model) return res.status(400).json({ ok: false, message: 'Model AI kosong.' });
|
||||
if (!apiKey && provider !== 'custom') return res.status(400).json({ ok: false, message: 'API key kosong.' });
|
||||
|
||||
const started = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(function () { controller.abort(); }, 15000);
|
||||
let r;
|
||||
let text = '';
|
||||
try {
|
||||
r = await fetch(baseUrl + '/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: Object.assign(
|
||||
{ 'Content-Type': 'application/json' },
|
||||
apiKey ? { Authorization: 'Bearer ' + apiKey } : {}
|
||||
),
|
||||
body: JSON.stringify({
|
||||
model: model,
|
||||
messages: [{ role: 'user', content: 'ping' }],
|
||||
max_tokens: 5,
|
||||
stream: false
|
||||
}),
|
||||
signal: controller.signal
|
||||
});
|
||||
text = await r.text();
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
const latencyMs = Date.now() - started;
|
||||
let json = null;
|
||||
try { json = JSON.parse(text); } catch (e) {}
|
||||
|
||||
if (!r.ok) {
|
||||
const detail = (json && json.error && (json.error.message || json.error.code)) || text || ('HTTP ' + r.status);
|
||||
const message = (r.status === 401 || r.status === 403)
|
||||
? 'API key tidak valid / tidak punya akses (HTTP ' + r.status + ').'
|
||||
: 'Gagal (HTTP ' + r.status + '): ' + String(detail).replace(/\s+/g, ' ').slice(0, 200);
|
||||
return res.json({ ok: false, provider, model, baseUrl, status: r.status, latencyMs, message });
|
||||
}
|
||||
|
||||
const reply = (json && json.choices && json.choices[0] &&
|
||||
((json.choices[0].message && json.choices[0].message.content) || json.choices[0].text)) || '';
|
||||
return res.json({
|
||||
ok: true, provider, model, baseUrl, status: r.status, latencyMs,
|
||||
message: 'API valid — ' + (reply ? ('balasan: ' + String(reply).trim().slice(0, 80)) : 'respon OK') + ' (' + latencyMs + ' ms)'
|
||||
});
|
||||
} catch (e) {
|
||||
const message = e && e.name === 'AbortError'
|
||||
? 'Timeout 15 detik — server AI tidak merespons.'
|
||||
: ('Tidak bisa menghubungi API: ' + (e && e.message ? e.message : String(e)));
|
||||
return res.status(500).json({ ok: false, message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/settings', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
|
||||
try {
|
||||
const newSettings = { ...req.body };
|
||||
@@ -4741,6 +5009,18 @@ 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;
|
||||
|
||||
if (newSettings.update_base_url !== undefined) {
|
||||
newSettings.update_base_url = String(newSettings.update_base_url).trim().replace(/\/+$/, '');
|
||||
}
|
||||
if (newSettings.update_repo !== undefined) {
|
||||
newSettings.update_repo = String(newSettings.update_repo).trim().replace(/^\/+|\/+$/g, '');
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -4789,6 +5069,7 @@ router.post('/settings', requireAdminSession, express.urlencoded({ extended: tru
|
||||
if (newSettings.auto_deactivate_enabled !== undefined) newSettings.auto_deactivate_enabled = (newSettings.auto_deactivate_enabled === 'true');
|
||||
if (newSettings.auto_deactivate_months !== undefined) newSettings.auto_deactivate_months = parseInt(newSettings.auto_deactivate_months) || 3;
|
||||
newSettings.use_builtin_acs = (newSettings.use_builtin_acs === 'true' || newSettings.use_builtin_acs === true);
|
||||
newSettings.portal_banner_enabled = (newSettings.portal_banner_enabled === 'true' || newSettings.portal_banner_enabled === true);
|
||||
|
||||
// Multi-Router Mode settings
|
||||
if (!newSettings.multi_router_mode) newSettings.multi_router_mode = 'disabled';
|
||||
@@ -4858,6 +5139,29 @@ router.post('/payment-unmatched/:id/match', requireAdminSession, express.urlenco
|
||||
if (!unpaid) await customerSvc.activateCustomer(inv.customer_id);
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// Notifikasi WA lunas ke pelanggan
|
||||
try {
|
||||
const cust = customerSvc.getCustomerById(inv.customer_id);
|
||||
if (cust && cust.phone) {
|
||||
const pkg = cust.package_id ? customerSvc.getPackageById(cust.package_id) : null;
|
||||
await sendPaymentSuccessWA(
|
||||
cust.phone,
|
||||
cust.name,
|
||||
`${inv.period_month}/${inv.period_year}`,
|
||||
Number(inv.amount || 0).toLocaleString('id-ID'),
|
||||
'Manual (Webhook)',
|
||||
{
|
||||
customerId: cust.id,
|
||||
invoiceId: inv.id,
|
||||
username: cust.pppoe_username || cust.id,
|
||||
customerCode: cust.customer_code || '',
|
||||
packageName: pkg?.name || cust.package_name || '-',
|
||||
paidAt: new Date(), method: 'Manual (Webhook)'
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (e) {}
|
||||
req.session._msg = { type: 'success', text: 'Invoice #' + invId + ' ditandai LUNAS & notif dicocokkan.' };
|
||||
} catch (e) {
|
||||
req.session._msg = { type: 'error', text: 'Gagal cocokkan: ' + e.message };
|
||||
@@ -5096,7 +5400,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',
|
||||
@@ -5105,7 +5410,8 @@ router.get('/inventory', requireAdminSession, requireSidebarMenuAccess('inventor
|
||||
msg: flashMsg(req),
|
||||
items,
|
||||
categories,
|
||||
logs
|
||||
logs,
|
||||
stats
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5159,6 +5465,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,
|
||||
@@ -6484,10 +6885,22 @@ router.get('/whatsapp/gowa/qr', requireAdminSession, async (req, res) => {
|
||||
try {
|
||||
const gowa = require('../services/gowaWhatsappService');
|
||||
const dev = await gowa.ensureDevice();
|
||||
if (dev && dev !== getSetting('gowa_device_id', '')) saveSettings({ gowa_device_id: dev });
|
||||
// Jangan simpan otomatis: device id kosong = ikuti device yang terhubung via QR.
|
||||
const r = await gowa.getLoginQr(dev);
|
||||
if (!r.ok) return res.json({ ok: false, message: 'Gagal membuat QR (' + (r.code || 'pastikan GOWA berjalan') + ').' });
|
||||
return res.json({ ok: true, image: '/admin/whatsapp/gowa/qr.png?v=' + Date.now(), device_id: r.device_id, duration: r.duration });
|
||||
if (!r.ok) {
|
||||
const detail = r.message || r.code || 'pastikan GOWA berjalan & username/password Basic Auth benar';
|
||||
return res.json({ ok: false, status: r.status || 0, message: 'Gagal membuat QR: ' + (r.status ? 'HTTP ' + r.status + ' — ' : '') + detail + '.' });
|
||||
}
|
||||
// Kirim gambar langsung sebagai data URI: tidak bergantung pada request
|
||||
// kedua /qr.png (aman bila app berjalan multi-proses) dan tidak kena cache.
|
||||
let image = '/admin/whatsapp/gowa/qr.png?v=' + Date.now();
|
||||
try {
|
||||
const img = await gowa.getLastQrImage();
|
||||
if (img && img.buffer && img.buffer.length) {
|
||||
image = 'data:' + (img.contentType || 'image/png') + ';base64,' + img.buffer.toString('base64');
|
||||
}
|
||||
} catch (e) {}
|
||||
return res.json({ ok: true, image, device_id: r.device_id, duration: r.duration });
|
||||
} catch (e) { return res.json({ ok: false, message: e.message }); }
|
||||
});
|
||||
|
||||
@@ -6515,12 +6928,17 @@ router.get('/whatsapp', requireAdminSession, requireSidebarMenuAccess('whatsapp'
|
||||
fonnte_api_url: getSetting('fonnte_api_url', 'https://api.fonnte.com/send'),
|
||||
fonnte_country_code: getSetting('fonnte_country_code', '62')
|
||||
};
|
||||
const gowaBaseUrl = getSetting('gowa_base_url', 'http://127.0.0.1:3000');
|
||||
let gowaWebhookSecret = getSetting('gowa_webhook_secret', '');
|
||||
try { gowaWebhookSecret = require('../config/settingsManager').ensureGowaWebhookSecret(); } catch (e) {}
|
||||
const gowaSettings = {
|
||||
gowa_base_url: getSetting('gowa_base_url', 'http://127.0.0.1:3000'),
|
||||
gowa_mode: getSetting('gowa_mode', /^https:/i.test(String(gowaBaseUrl)) ? 'cloud' : 'local'),
|
||||
gowa_secret_mode: getSetting('gowa_secret_mode', 'auto') === 'manual' ? 'manual' : 'auto',
|
||||
gowa_base_url: gowaBaseUrl,
|
||||
gowa_username: getSetting('gowa_username', ''),
|
||||
gowa_password: getSetting('gowa_password', ''),
|
||||
gowa_device_id: getSetting('gowa_device_id', ''),
|
||||
gowa_webhook_secret: getSetting('gowa_webhook_secret', 'secret')
|
||||
gowa_webhook_secret: gowaWebhookSecret
|
||||
};
|
||||
const gowaWebhookUrl = String(getSetting('app_url', '') || ((req.headers['x-forwarded-proto'] || req.protocol) + '://' + req.get('host'))).replace(/\/+$/, '') + '/api/webhook/gowa';
|
||||
const gowaUiHost = String(req.get('host') || '').split(':')[0];
|
||||
@@ -6553,6 +6971,7 @@ router.post('/whatsapp/gateway-settings', requireAdminSession, express.urlencode
|
||||
fonnte_token,
|
||||
fonnte_api_url,
|
||||
fonnte_country_code,
|
||||
gowa_mode,
|
||||
gowa_base_url,
|
||||
gowa_username,
|
||||
gowa_password,
|
||||
@@ -6570,11 +6989,15 @@ router.post('/whatsapp/gateway-settings', requireAdminSession, express.urlencode
|
||||
fonnte_token: String(fonnte_token || '').trim(),
|
||||
fonnte_api_url: String(fonnte_api_url || 'https://api.fonnte.com/send').trim(),
|
||||
fonnte_country_code: String(fonnte_country_code || '62').trim(),
|
||||
gowa_mode: String(gowa_mode || 'cloud').trim() === 'local' ? 'local' : 'cloud',
|
||||
gowa_base_url: String(gowa_base_url || 'http://127.0.0.1:3000').trim(),
|
||||
gowa_username: String(gowa_username || '').trim(),
|
||||
gowa_password: String(gowa_password || '').trim(),
|
||||
gowa_device_id: String(gowa_device_id || '').trim(),
|
||||
gowa_webhook_secret: String(gowa_webhook_secret || 'secret').trim()
|
||||
gowa_webhook_secret: (function () {
|
||||
const v = String(gowa_webhook_secret || '').trim();
|
||||
return (v && v !== 'secret' && v.length >= 24) ? v : crypto.randomBytes(32).toString('hex');
|
||||
})()
|
||||
});
|
||||
|
||||
if ((wa_gateway_type || 'baileys') === 'baileys') {
|
||||
@@ -6590,6 +7013,46 @@ router.post('/whatsapp/gateway-settings', requireAdminSession, express.urlencode
|
||||
res.redirect('/admin/whatsapp');
|
||||
});
|
||||
|
||||
router.post('/whatsapp/test-gowa-connection', requireAdminSession, express.json(), async (req, res) => {
|
||||
try {
|
||||
const gowaSvc = require('../services/gowaWhatsappService');
|
||||
const b = req.body || {};
|
||||
const r = await gowaSvc.testReachability({
|
||||
baseUrl: b.gowa_base_url,
|
||||
username: b.gowa_username,
|
||||
password: b.gowa_password
|
||||
});
|
||||
return res.json(Object.assign({ ok: r.reachable, success: r.reachable }, r));
|
||||
} catch (e) {
|
||||
return res.status(500).json({ ok: false, success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/whatsapp/gowa-secret', requireAdminSession, express.json(), async (req, res) => {
|
||||
try {
|
||||
const sm = require('../config/settingsManager');
|
||||
const b = req.body || {};
|
||||
const mode = String(b.mode || '').toLowerCase() === 'manual' ? 'manual' : 'auto';
|
||||
if (mode === 'auto') {
|
||||
const secret = sm.ensureGowaWebhookSecret(true);
|
||||
saveSettings({ gowa_secret_mode: 'auto' });
|
||||
return res.json({ ok: true, mode: 'auto', secret });
|
||||
}
|
||||
const secret = String(b.secret || '').trim();
|
||||
if (secret) {
|
||||
if (secret === 'secret' || secret.length < 16) {
|
||||
return res.status(400).json({ ok: false, message: 'Secret minimal 16 karakter dan tidak boleh "secret".' });
|
||||
}
|
||||
saveSettings({ gowa_webhook_secret: secret, gowa_secret_mode: 'manual' });
|
||||
return res.json({ ok: true, mode: 'manual', saved: true, secret });
|
||||
}
|
||||
saveSettings({ gowa_secret_mode: 'manual' });
|
||||
return res.json({ ok: true, mode: 'manual', saved: false, secret: getSetting('gowa_webhook_secret', '') });
|
||||
} catch (e) {
|
||||
return res.status(500).json({ ok: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/whatsapp/test-gowa', requireAdminSession, express.json(), async (req, res) => {
|
||||
try {
|
||||
const { phone, message } = req.body;
|
||||
@@ -7070,8 +7533,14 @@ router.post('/whatsapp/auto-billing', requireAdminSession, express.urlencoded({
|
||||
router.post('/whatsapp/run-reminder-now', requireAdminSession, async (req, res) => {
|
||||
try {
|
||||
const r = await require('../services/cronService').runAutoBillingReminder();
|
||||
const skipLabels = {
|
||||
disabled: 'pengingat otomatis sedang OFF',
|
||||
wa_disabled: 'WhatsApp atau notifikasi tagihan sedang OFF',
|
||||
gateway: 'gateway WhatsApp belum siap',
|
||||
no_targets: 'tidak ada pelanggan pada jadwal pengingat hari ini'
|
||||
};
|
||||
const msg = (r && r.skipped)
|
||||
? ('Pengingat dilewati (' + r.skipped + '). Pastikan opsi otomatis & WA aktif.')
|
||||
? ('Pengingat tidak dikirim: ' + (skipLabels[r.skipped] || r.skipped) + '.')
|
||||
: ('Pengingat dijalankan: target ' + ((r && r.target) || 0) + ', terkirim ' + ((r && r.sent) || 0) + ', gagal ' + ((r && r.failed) || 0) + '.');
|
||||
return res.json({ ok: true, message: msg, data: r || null });
|
||||
} catch (e) {
|
||||
@@ -7097,11 +7566,24 @@ router.get('/api/whatsapp/status', requireAdmin, async (req, res) => {
|
||||
} else if (gatewayType === 'gowa') {
|
||||
let st = null;
|
||||
try { st = await require('../services/gowaWhatsappService').gowaStatus(); } catch (e) { st = { ok: false, error: e.message }; }
|
||||
const rr = (st && st.data && st.data.results) || {};
|
||||
const loggedIn = !!(rr.is_logged_in || rr.isConnected || rr.connected);
|
||||
const isConn = !!(rr.is_connected || rr.connected);
|
||||
// /devices mengembalikan array (banyak device); /devices/:id/status mengembalikan objek.
|
||||
const raw = st && st.data && st.data.results;
|
||||
const list = Array.isArray(raw) ? raw : ((raw && typeof raw === 'object') ? [raw] : []);
|
||||
const pick = list.find(function (d) {
|
||||
return d && (d.is_connected || d.is_logged_in || d.connected ||
|
||||
d.state === 'connected' || d.state === 'logged_in');
|
||||
}) || list[0] || {};
|
||||
const loggedIn = !!(pick.is_logged_in || pick.is_connected || pick.connected ||
|
||||
pick.state === 'connected' || pick.state === 'logged_in');
|
||||
const isConn = !!(pick.is_connected || pick.connected || pick.state === 'connected');
|
||||
const conn = isConn ? 'open' : (loggedIn ? 'connecting' : 'close');
|
||||
res.json({ connection: conn, gateway: 'gowa', loggedIn: loggedIn, user: { id: 'gowa_gateway', name: loggedIn ? 'GOWA (WhatsApp)' : 'GOWA Gateway' }, lastUpdate: new Date().toISOString() });
|
||||
const deviceId = String(pick.device_id || pick.id || '').trim();
|
||||
// device id kosong = selalu ikuti device yang terhubung (tidak dipin otomatis)
|
||||
res.json({
|
||||
connection: conn, gateway: 'gowa', loggedIn: loggedIn, deviceId: deviceId,
|
||||
user: { id: deviceId || 'gowa_gateway', name: loggedIn ? 'GOWA (WhatsApp)' : 'GOWA Gateway' },
|
||||
lastUpdate: new Date().toISOString()
|
||||
});
|
||||
} else if (gatewayType === 'baileys') {
|
||||
const { whatsappStatus, startWhatsAppBot, isBotRunning } = await import('../services/whatsappBot.mjs');
|
||||
if (getSetting('whatsapp_enabled', true) && !isBotRunning() && whatsappStatus.connection !== 'open' && whatsappStatus.connection !== 'qr') {
|
||||
@@ -7192,9 +7674,12 @@ router.post('/whatsapp/repair-keys', requireAdminSession, async (req, res) => {
|
||||
|
||||
// ─── ROUTERS (MULTI-ROUTER) ──────────────────────────────────────────────────
|
||||
router.get('/routers', requireAdminSession, requireSidebarMenuAccess('mikrotik'), (req, res) => {
|
||||
const _settings = getSettings();
|
||||
const defaultPublicBase = String(_settings.public_base_url || _settings.app_url || ((req.headers['x-forwarded-proto'] || req.protocol) + '://' + req.get('host'))).replace(/\/+$/, '');
|
||||
res.render('admin/routers', {
|
||||
title: 'Manajemen Router', company: company(), activePage: 'mikrotik',
|
||||
routers: mikrotikService.getAllRouters(), msg: flashMsg(req)
|
||||
routers: mikrotikService.getAllRouters(), msg: flashMsg(req),
|
||||
defaultPublicBase
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7506,6 +7991,19 @@ router.post('/api/routers/:id/setup-firewall', requireAdmin, async (req, res) =>
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/api/isolir-cloud-script', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const data = await mikrotikService.generateIsolirCloudScript({
|
||||
host: req.query.host,
|
||||
port: req.query.port,
|
||||
profile: req.query.profile
|
||||
});
|
||||
res.json({ success: true, ...data });
|
||||
} catch (e) {
|
||||
res.json({ success: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/api/isolir-portal-script', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const data = await mikrotikService.generateIsolirPortalScript();
|
||||
@@ -7790,6 +8288,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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -18,6 +18,7 @@ const adminSvc = require('../services/adminService');
|
||||
const techSvc = require('../services/techService');
|
||||
const mikrotikService = require('../services/mikrotikService');
|
||||
const qrisUtil = require('../utils/qrisUtil');
|
||||
const qrisUniq = require('../services/invoiceQrisService');
|
||||
const QRCode = require('qrcode');
|
||||
|
||||
// Helper Token JWT / HMAC
|
||||
@@ -740,6 +741,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 +760,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 +783,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 +793,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 +834,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;
|
||||
|
||||
@@ -2334,6 +2359,37 @@ router.post('/app/admin/collector-payments/approve', requireAdminApiAuth, async
|
||||
if (unpaidCount === 0) {
|
||||
try { await customerSvc.activateCustomer(pay.customer_id); } catch (_) {}
|
||||
}
|
||||
|
||||
// Notifikasi WA lunas ke pelanggan (gateway aktif)
|
||||
try {
|
||||
const customer = customerSvc.getCustomerById(pay.customer_id);
|
||||
const inv = billingSvc.getInvoiceById(pay.invoice_id);
|
||||
if (customer && customer.phone && inv) {
|
||||
const settings = getSettingsWithCache();
|
||||
const waNotify = require('../services/waNotifyService');
|
||||
const whatsappService = require('../services/whatsappService');
|
||||
const pkg = customer.package_id ? customerSvc.getPackageById(customer.package_id) : null;
|
||||
const appUrl = String(settings.public_base_url || '').replace(/\/$/, '');
|
||||
const template = db.getAppSetting('whatsapp_payment_success_message', '');
|
||||
const msg = whatsappService.formatPaymentSuccessMessage({
|
||||
customerName: customer.name,
|
||||
invoiceId: inv.id,
|
||||
customerUsername: customer.pppoe_username || customer.id || '-',
|
||||
customerCode: customer.customer_code || '',
|
||||
packageName: pkg?.name || customer.package_name || '-',
|
||||
periodMonth: inv.period_month,
|
||||
periodYear: inv.period_year,
|
||||
amount: inv.amount,
|
||||
paymentMethod: collectorLabel,
|
||||
paidAt: new Date(),
|
||||
companyName: settings.company_header || 'OPEN-ISP',
|
||||
companyPhone: settings.company_phone || '',
|
||||
portalUrl: appUrl ? appUrl + '/customer' : '',
|
||||
customTemplate: template
|
||||
});
|
||||
await waNotify.sendText(customer.phone, msg);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
res.json({ success: true, message: 'Pembayaran berhasil di-approve dan tagihan ditandai LUNAS' });
|
||||
} catch (e) { res.status(500).json({ success: false, message: e.message }); }
|
||||
@@ -3485,6 +3541,11 @@ router.post('/tickets/create', requireCustomerApiAuth, async (req, res) => {
|
||||
for (const digits of recipients) {
|
||||
try { await waNotify.sendText(digits, waMsg); } catch (_) {}
|
||||
}
|
||||
|
||||
// WA konfirmasi ke pelanggan yang membuat tiket
|
||||
try {
|
||||
await require('../services/serviceNoticeNotifyService').notifyTicketCreated(customer, ticketId, subject);
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (waErr) {
|
||||
logger.error(`[CustomerAPI] Ticket WA Notification Error: ${waErr.message}`);
|
||||
@@ -3646,7 +3707,6 @@ function ensureCustomerApiInvoiceQrisUnique(inv) {
|
||||
return { uniqueCode: currentCode, amountUnique: currentAmount };
|
||||
}
|
||||
|
||||
const exists = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1');
|
||||
let chosenCode = 0;
|
||||
let chosenAmount = 0;
|
||||
|
||||
@@ -3654,7 +3714,7 @@ function ensureCustomerApiInvoiceQrisUnique(inv) {
|
||||
if (custId > 0) {
|
||||
const prefCode = (custId % 499 === 0) ? 499 : (custId % 499);
|
||||
const prefAmount = baseAmount + prefCode;
|
||||
if (!exists.get('unpaid', prefAmount, invId)) {
|
||||
if (!qrisUniq.isQrisAmountTaken(prefAmount, { excludeInvoiceId: invId })) {
|
||||
chosenCode = prefCode;
|
||||
chosenAmount = prefAmount;
|
||||
}
|
||||
@@ -3664,7 +3724,7 @@ function ensureCustomerApiInvoiceQrisUnique(inv) {
|
||||
if (!chosenAmount) {
|
||||
for (let code = 1; code <= 499; code++) {
|
||||
const amount = baseAmount + code;
|
||||
if (!exists.get('unpaid', amount, invId)) {
|
||||
if (!qrisUniq.isQrisAmountTaken(amount, { excludeInvoiceId: invId })) {
|
||||
chosenCode = code;
|
||||
chosenAmount = amount;
|
||||
break;
|
||||
@@ -3676,7 +3736,7 @@ function ensureCustomerApiInvoiceQrisUnique(inv) {
|
||||
if (!chosenAmount) {
|
||||
for (let code = 500; code <= 999; code++) {
|
||||
const amount = baseAmount + code;
|
||||
if (!exists.get('unpaid', amount, invId)) {
|
||||
if (!qrisUniq.isQrisAmountTaken(amount, { excludeInvoiceId: invId })) {
|
||||
chosenCode = code;
|
||||
chosenAmount = amount;
|
||||
break;
|
||||
|
||||
@@ -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');
|
||||
@@ -25,6 +26,7 @@ const QRCode = require('qrcode');
|
||||
const _jimpMod = require('jimp');
|
||||
const Jimp = _jimpMod.Jimp || _jimpMod;
|
||||
const qrisUtil = require('../utils/qrisUtil');
|
||||
const qrisUniq = require('../services/invoiceQrisService');
|
||||
const whatsappService = require('../services/whatsappService');
|
||||
const { BinaryBitmap, HybridBinarizer, RGBLuminanceSource, MultiFormatReader, BarcodeFormat, DecodeHintType } = require('@zxing/library');
|
||||
let loginRateLimiter = (req, res, next) => next();
|
||||
@@ -436,16 +438,10 @@ function getBaseUrl(req, settings) {
|
||||
function isQrisAmountAvailable(amount, opts = {}) {
|
||||
const amt = Number(amount || 0);
|
||||
if (!Number.isFinite(amt) || amt <= 0) return false;
|
||||
const excludeInvoiceId = Number(opts.excludeInvoiceId || 0);
|
||||
const excludeVoucherOrderId = Number(opts.excludeVoucherOrderId || 0);
|
||||
|
||||
const inv = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1').get('unpaid', amt, excludeInvoiceId);
|
||||
if (inv && inv.id) return false;
|
||||
|
||||
const ord = db.prepare('SELECT id FROM public_voucher_orders WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1').get('pending', amt, excludeVoucherOrderId);
|
||||
if (ord && ord.id) return false;
|
||||
|
||||
return true;
|
||||
return !qrisUniq.isQrisAmountTaken(amt, {
|
||||
excludeInvoiceId: opts.excludeInvoiceId,
|
||||
excludeVoucherOrderId: opts.excludeVoucherOrderId
|
||||
});
|
||||
}
|
||||
|
||||
router.get('/qris/static.jpg', async (req, res) => {
|
||||
@@ -1791,6 +1787,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 +1809,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 +1828,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 +2604,7 @@ router.post('/change-tag', async (req, res) => {
|
||||
});
|
||||
|
||||
router.post('/logout', (req, res) => {
|
||||
sessionLimit.removeLoginSession(req);
|
||||
req.session.destroy(() => {
|
||||
res.redirect('/customer/login');
|
||||
});
|
||||
@@ -2983,6 +2997,11 @@ router.post('/tickets/create', uploadCustomer.array('photos', 5), async (req, re
|
||||
if (!shouldSendWa(key)) continue;
|
||||
await waNotify.sendText(digits, waMsg);
|
||||
}
|
||||
|
||||
// WA konfirmasi ke pelanggan yang membuat tiket
|
||||
try {
|
||||
await require('../services/serviceNoticeNotifyService').notifyTicketCreated(customer, ticketId, subject);
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (waErr) {
|
||||
logger.error(`[Ticket] WA Notification Error: ${waErr.message}`);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ function testIsAdminNumber(number) {
|
||||
// Test beberapa nomor
|
||||
const testNumbers = [
|
||||
'6281947215703',
|
||||
'081947215703',
|
||||
|
||||
'083807665697',
|
||||
'082218094778',
|
||||
'081234567890' // Nomor test yang bukan admin
|
||||
|
||||
587
services/adminAiTools.js
Normal 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
|
||||
};
|
||||
@@ -40,15 +40,19 @@ function spintax(t) {
|
||||
});
|
||||
}
|
||||
|
||||
function buildMessage(tplRaw, c, inv) {
|
||||
function buildMessage(tplRaw, c, inv, qr) {
|
||||
const amount = Number(inv.amount || 0);
|
||||
const qrisAmount = Number((qr && qr.amount) || 0) || amount;
|
||||
const qrisCode = Number((qr && qr.uniqueCode) || 0) || 0;
|
||||
const qrisAttached = !!(qr && qr.payload);
|
||||
const period = inv.period_month + '/' + inv.period_year;
|
||||
return spintax(String(tplRaw || '')
|
||||
.replace(/{{id_pelanggan}}/gi, c.customer_code || ('ID:' + c.id))
|
||||
.replace(/{{nama}}/gi, c.name || 'Pelanggan')
|
||||
.replace(/{{paket}}/gi, inv.package_name || '-')
|
||||
.replace(/{{tagihan}}/gi, amount.toLocaleString('id-ID'))
|
||||
.replace(/{{qris_nominal}}/gi, amount.toLocaleString('id-ID'))
|
||||
.replace(/{{qris_nominal}}/gi, qrisAmount.toLocaleString('id-ID'))
|
||||
.replace(/{{qris_kode}}/gi, String(qrisCode).padStart(3, '0'))
|
||||
.replace(/{{rincian}}/gi, period)
|
||||
.replace(/{{periode}}/gi, period)
|
||||
.replace(/{{bulan_nunggak}}/gi, String(c.unpaid_streak || 0))
|
||||
@@ -57,7 +61,7 @@ function buildMessage(tplRaw, c, inv) {
|
||||
.replace(/{{link_portal}}/gi, portalLink())
|
||||
.replace(/{{company}}/gi, getSetting('company_header', '') || '-')
|
||||
.replace(/{{company_phone}}/gi, getSetting('company_phone', '') || '-')
|
||||
.replace(/{{qris_qr}}/gi, 'QRIS terlampir'));
|
||||
.replace(/{{qris_qr}}/gi, qrisAttached ? 'QRIS terlampir' : ('Portal Pelanggan: ' + portalLink())));
|
||||
}
|
||||
|
||||
function buildTargets(isolateDay) {
|
||||
@@ -72,6 +76,7 @@ function buildTargets(isolateDay) {
|
||||
try { invs = billingSvc.getUnpaidInvoicesByCustomerId(c.id) || []; } catch (e) {}
|
||||
if (!invs.length) continue;
|
||||
if (!c.phone || String(c.phone).replace(/\D/g, '').length < 9) continue;
|
||||
// skema hangus: hanya tagihan terbaru yang valid; tagihan lama otomatis void saat tagihan baru terbit
|
||||
targets.push({ c, inv: invs[invs.length - 1] });
|
||||
}
|
||||
return targets;
|
||||
@@ -108,23 +113,19 @@ async function start(opts = {}) {
|
||||
|
||||
const waNotify = require('./waNotifyService');
|
||||
let QRCode = null, ensureInvoiceQris = null;
|
||||
if (isQris) { try { QRCode = require('/opt/billing-rtrw/node_modules/qrcode'); } catch (e) {} try { ensureInvoiceQris = require('./invoiceQrisService').ensureInvoiceQris; } catch (e) {} }
|
||||
if (isQris) { try { QRCode = require('qrcode'); } catch (e) {} try { ensureInvoiceQris = require('./invoiceQrisService').ensureInvoiceQris; } catch (e) {} }
|
||||
|
||||
(async () => {
|
||||
for (let i = 0; i < targets.length; i++) {
|
||||
if (STATUS.stopped) break;
|
||||
const { c, inv } = targets[i];
|
||||
try {
|
||||
const caption = buildMessage(tplRaw, c, inv);
|
||||
if (isQris && QRCode && ensureInvoiceQris) {
|
||||
const qr = ensureInvoiceQris(inv) || {};
|
||||
if (qr.payload) {
|
||||
const url = await QRCode.toDataURL(qr.payload, { errorCorrectionLevel: 'M', margin: 1, width: 520 });
|
||||
const buf = Buffer.from(String(url).split(',')[1], 'base64');
|
||||
await waNotify.sendImage(c.phone, buf, caption);
|
||||
} else {
|
||||
await waNotify.sendText(c.phone, caption);
|
||||
}
|
||||
const qr = (isQris && ensureInvoiceQris) ? (ensureInvoiceQris(inv) || {}) : null;
|
||||
const caption = buildMessage(tplRaw, c, inv, qr);
|
||||
if (isQris && QRCode && qr && qr.payload) {
|
||||
const url = await QRCode.toDataURL(qr.payload, { errorCorrectionLevel: 'M', margin: 1, width: 520 });
|
||||
const buf = Buffer.from(String(url).split(',')[1], 'base64');
|
||||
await waNotify.sendImage(c.phone, buf, caption);
|
||||
} else {
|
||||
await waNotify.sendText(c.phone, caption);
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -11,6 +11,7 @@ const usageSvc = require('./usageService');
|
||||
const { getSetting } = require('../config/settingsManager');
|
||||
const db = require('../config/database');
|
||||
const qrisUtil = require('../utils/qrisUtil');
|
||||
const qrisUniq = require('./invoiceQrisService');
|
||||
const { formatUnpaidInvoicesSummary } = require('./whatsappService');
|
||||
|
||||
// Helper: Random delay generator untuk smart rate limiting
|
||||
@@ -60,7 +61,7 @@ async function runAutoBillingReminder() {
|
||||
const enabled = getSetting('whatsapp_auto_billing_enabled', false);
|
||||
const waEnabled = getSetting('whatsapp_enabled', false);
|
||||
const billingEnabled = getSetting('whatsapp_billing_to_customer_enabled', true);
|
||||
if (!enabled || !waEnabled || !billingEnabled) return { target: 0, sent: 0, failed: 0, skipped: 'disabled' };
|
||||
if (!enabled || !waEnabled || !billingEnabled) return { target: 0, sent: 0, failed: 0, skipped: (!waEnabled || !billingEnabled) ? 'wa_disabled' : 'disabled' };
|
||||
|
||||
const gatewayType = getSetting('wa_gateway_type', 'gowa');
|
||||
const waSvc = require('./whatsappService');
|
||||
@@ -70,13 +71,13 @@ async function runAutoBillingReminder() {
|
||||
const token = getSetting('meta_access_token', '');
|
||||
if (!phoneId || !token) {
|
||||
logger.warn('[CRON] Kredensial Meta API belum diisi, pengingat tagihan otomatis dilewati.');
|
||||
return;
|
||||
return { target: 0, sent: 0, failed: 0, skipped: 'gateway' };
|
||||
}
|
||||
} else if (gatewayType === 'fonnte') {
|
||||
const token = getSetting('fonnte_token', '');
|
||||
if (!token) {
|
||||
logger.warn('[CRON] Token Fonnte belum diisi, pengingat tagihan otomatis dilewati.');
|
||||
return;
|
||||
return { target: 0, sent: 0, failed: 0, skipped: 'gateway' };
|
||||
}
|
||||
} else {
|
||||
// Gateway gowa/baileys/lainnya → cek koneksi gateway aktif (gateway-aware)
|
||||
@@ -84,7 +85,7 @@ async function runAutoBillingReminder() {
|
||||
const connected = await waNotify.isConnected().catch(() => false);
|
||||
if (!connected) {
|
||||
logger.warn('[CRON] WhatsApp gateway belum terhubung, pengingat tagihan otomatis dilewati.');
|
||||
return;
|
||||
return { target: 0, sent: 0, failed: 0, skipped: 'gateway' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,14 +214,13 @@ async function runAutoBillingReminder() {
|
||||
|
||||
// Jika amt tidak ada atau amt tidak cocok dengan akumulasi total tagihan + kode, buat/perbarui nominal unik
|
||||
if ((!code || !amt || (amt - code !== baseAmount)) && invId > 0 && baseAmount > 0) {
|
||||
const exists = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1');
|
||||
const custId = Number(c?.id || inv?.customer_id || 0);
|
||||
let tryCode = 0;
|
||||
let tryAmt = 0;
|
||||
|
||||
if (code > 0) {
|
||||
const pAmt = baseAmount + code;
|
||||
if (!exists.get('unpaid', pAmt, invId)) {
|
||||
if (!qrisUniq.isQrisAmountTaken(pAmt, { excludeInvoiceId: invId })) {
|
||||
tryCode = code;
|
||||
tryAmt = pAmt;
|
||||
}
|
||||
@@ -229,7 +229,7 @@ async function runAutoBillingReminder() {
|
||||
if (!tryAmt && custId > 0) {
|
||||
const prefCode = (custId % 499 === 0) ? 499 : (custId % 499);
|
||||
const pAmt = baseAmount + prefCode;
|
||||
if (!exists.get('unpaid', pAmt, invId)) {
|
||||
if (!qrisUniq.isQrisAmountTaken(pAmt, { excludeInvoiceId: invId })) {
|
||||
tryCode = prefCode;
|
||||
tryAmt = pAmt;
|
||||
}
|
||||
@@ -238,7 +238,7 @@ async function runAutoBillingReminder() {
|
||||
if (!tryAmt) {
|
||||
for (let randCode = 1; randCode <= 499; randCode++) {
|
||||
const pAmt = baseAmount + randCode;
|
||||
if (!exists.get('unpaid', pAmt, invId)) {
|
||||
if (!qrisUniq.isQrisAmountTaken(pAmt, { excludeInvoiceId: invId })) {
|
||||
tryCode = randCode;
|
||||
tryAmt = pAmt;
|
||||
break;
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
235
services/customQrService.js
Normal file
@@ -0,0 +1,235 @@
|
||||
'use strict';
|
||||
/**
|
||||
* customQrService.js — QR nominal bebas (di luar billing) dengan kode unik 20 menit.
|
||||
* Status: pending -> paid | expired -> warning | rejected
|
||||
*/
|
||||
const crypto = require('crypto');
|
||||
const db = require('../config/database');
|
||||
const { isQrisAmountTaken } = require('./invoiceQrisService');
|
||||
const { logger } = require('../config/logger');
|
||||
const { getSetting } = require('../config/settingsManager');
|
||||
|
||||
const DURATION_MINUTES = 20;
|
||||
|
||||
function isReady() {
|
||||
try {
|
||||
const enabled = getSetting('qris_static_enabled', true);
|
||||
const off = enabled === false || enabled === 'false' || enabled === 0 || enabled === '0';
|
||||
const payload = String(getSetting('qris_static_payload', '') || '').trim();
|
||||
return !off && payload.length > 0;
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
function nowIso() { return new Date().toISOString(); }
|
||||
|
||||
function sweepExpired() {
|
||||
try {
|
||||
db.prepare("UPDATE custom_qr_payments SET status='expired' WHERE status='pending' AND expires_at IS NOT NULL AND expires_at < ?").run(nowIso());
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function isTaken(amount, excludeId) {
|
||||
if (isQrisAmountTaken(amount, { excludeCustomQrId: excludeId || 0 })) return true;
|
||||
try {
|
||||
const row = db.prepare("SELECT id FROM custom_qr_payments WHERE status IN ('pending','expired') AND amount_unique=? AND id!=? LIMIT 1").get(amount, Number(excludeId) || 0);
|
||||
return !!(row && row.id);
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
function create({ title, category, amount, note, createdBy }) {
|
||||
if (!isReady()) throw new Error('QRIS Statis (Semi-Otomatis) belum aktif/berisi payload. Atur dulu di Pengaturan → QRIS.');
|
||||
const base = Math.max(1, Math.floor(Number(amount) || 0));
|
||||
if (!title || !String(title).trim()) throw new Error('Judul wajib diisi.');
|
||||
if (!base) throw new Error('Nominal wajib diisi.');
|
||||
|
||||
let code = 0;
|
||||
let amountUnique = 0;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const c = 1 + Math.floor(Math.random() * 999);
|
||||
if (!isTaken(base + c)) { code = c; amountUnique = base + c; break; }
|
||||
}
|
||||
if (!amountUnique) {
|
||||
for (let c = 1; c <= 999; c++) {
|
||||
if (!isTaken(base + c)) { code = c; amountUnique = base + c; break; }
|
||||
}
|
||||
}
|
||||
if (!amountUnique) throw new Error('Gagal membuat kode unik (slot 1-999 penuh).');
|
||||
|
||||
const token = crypto.randomBytes(12).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + DURATION_MINUTES * 60 * 1000).toISOString();
|
||||
const r = db.prepare(`
|
||||
INSERT INTO custom_qr_payments (title, category, amount, unique_code, amount_unique, status, share_token, note, created_by, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?)
|
||||
`).run(String(title).trim(), String(category || '').trim(), base, code, amountUnique, token, String(note || '').trim(), String(createdBy || ''), expiresAt);
|
||||
|
||||
return getById(Number(r.lastInsertRowid));
|
||||
}
|
||||
|
||||
function getById(id) {
|
||||
sweepExpired();
|
||||
return db.prepare('SELECT * FROM custom_qr_payments WHERE id=?').get(Number(id) || 0);
|
||||
}
|
||||
|
||||
function getByToken(token) {
|
||||
sweepExpired();
|
||||
return db.prepare('SELECT * FROM custom_qr_payments WHERE share_token=?').get(String(token || ''));
|
||||
}
|
||||
|
||||
function list(limit = 100, opts = {}) {
|
||||
sweepExpired();
|
||||
const where = [];
|
||||
const params = [];
|
||||
if (opts.from) { where.push('date(created_at) >= date(?)'); params.push(String(opts.from)); }
|
||||
if (opts.to) { where.push('date(created_at) <= date(?)'); params.push(String(opts.to)); }
|
||||
const sql = 'SELECT * FROM custom_qr_payments' +
|
||||
(where.length ? (' WHERE ' + where.join(' AND ')) : '') +
|
||||
' ORDER BY id DESC LIMIT ?';
|
||||
params.push(Math.max(1, Math.min(500, Number(limit) || 100)));
|
||||
return db.prepare(sql).all(...params);
|
||||
}
|
||||
|
||||
function stats() {
|
||||
sweepExpired();
|
||||
const paid = db.prepare("SELECT COUNT(*) c, COALESCE(SUM(amount_unique),0) t FROM custom_qr_payments WHERE status='paid'").get();
|
||||
const pending = db.prepare("SELECT COUNT(*) c FROM custom_qr_payments WHERE status='pending'").get();
|
||||
const warning = db.prepare("SELECT COUNT(*) c FROM custom_qr_payments WHERE status='warning'").get();
|
||||
const rejected = db.prepare("SELECT COUNT(*) c FROM custom_qr_payments WHERE status='rejected'").get();
|
||||
return {
|
||||
paidCount: Number(paid.c || 0),
|
||||
paidTotal: Number(paid.t || 0),
|
||||
pendingCount: Number(pending.c || 0),
|
||||
warningCount: Number(warning.c || 0),
|
||||
rejectedCount: Number(rejected.c || 0)
|
||||
};
|
||||
}
|
||||
|
||||
function candidatesByAmount(amount) {
|
||||
sweepExpired();
|
||||
return db.prepare("SELECT * FROM custom_qr_payments WHERE amount_unique=? AND status IN ('pending','expired') ORDER BY id DESC LIMIT 2").all(Number(amount) || 0);
|
||||
}
|
||||
|
||||
function markManualPaid(id, actor, note) {
|
||||
const row = getById(id);
|
||||
if (!row) throw new Error('Data tidak ditemukan.');
|
||||
if (row.status === 'paid') return row;
|
||||
db.prepare("UPDATE custom_qr_payments SET status='paid', paid_at=NOW_LOCAL(), paid_by=?, resolution_note=? WHERE id=?")
|
||||
.run(String(actor || 'Admin'), String(note || 'Manual pay'), row.id);
|
||||
return getById(id);
|
||||
}
|
||||
|
||||
function acceptWarning(id, actor, note) {
|
||||
const row = getById(id);
|
||||
if (!row) throw new Error('Data tidak ditemukan.');
|
||||
if (row.status !== 'warning') throw new Error('Hanya pembayaran berstatus warning yang bisa diselesaikan.');
|
||||
db.prepare("UPDATE custom_qr_payments SET status='paid', paid_at=NOW_LOCAL(), paid_by=?, resolved_by=?, resolved_at=NOW_LOCAL(), resolution_note=? WHERE id=?")
|
||||
.run(String(actor || 'Admin'), String(actor || 'Admin'), String(note || 'Diterima setelah 20 menit'), row.id);
|
||||
return getById(id);
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
const r = db.prepare('DELETE FROM custom_qr_payments WHERE id=?').run(Number(id) || 0);
|
||||
return r.changes || 0;
|
||||
}
|
||||
|
||||
function removeMany(ids) {
|
||||
const list = (Array.isArray(ids) ? ids : [ids])
|
||||
.map(function (x) { return Number(x); })
|
||||
.filter(function (n) { return Number.isFinite(n) && n > 0; });
|
||||
if (!list.length) return 0;
|
||||
const marks = list.map(function () { return '?'; }).join(',');
|
||||
const r = db.prepare('DELETE FROM custom_qr_payments WHERE id IN (' + marks + ')').run(...list);
|
||||
return r.changes || 0;
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
const r = db.prepare('DELETE FROM custom_qr_payments').run();
|
||||
return r.changes || 0;
|
||||
}
|
||||
|
||||
function regenerate(id, actor) {
|
||||
const old = getById(id);
|
||||
if (!old) throw new Error('Data tidak ditemukan.');
|
||||
if (old.status === 'pending' && !isExpired(old)) throw new Error('QR masih aktif. Tunggu kedaluwarsa atau gunakan QR yang ada.');
|
||||
const row = create({
|
||||
title: old.title,
|
||||
category: old.category,
|
||||
amount: old.amount,
|
||||
note: old.note,
|
||||
createdBy: actor
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
function reject(id, actor, note) {
|
||||
const row = getById(id);
|
||||
if (!row) throw new Error('Data tidak ditemukan.');
|
||||
if (row.status === 'rejected') return row;
|
||||
db.prepare("UPDATE custom_qr_payments SET status='rejected', resolved_by=?, resolved_at=NOW_LOCAL(), resolution_note=? WHERE id=?")
|
||||
.run(String(actor || 'Admin'), String(note || 'Refund / ditolak'), row.id);
|
||||
return getById(id);
|
||||
}
|
||||
|
||||
async function notifyAdminPayment(row, isWarning) {
|
||||
try {
|
||||
const waNotify = require('./waNotifyService');
|
||||
const text = isWarning
|
||||
? `⚠️ *PEMBAYARAN QR CUSTOM (LEWAT 20 MENIT)*\n\n` +
|
||||
`Judul: ${row.title}\nKategori: ${row.category || '-'}\n` +
|
||||
`Nominal: Rp ${Number(row.amount_unique || 0).toLocaleString('id-ID')}\n` +
|
||||
`Dibuat: ${row.created_at}\n\n` +
|
||||
`Nominal persis masuk setelah batas waktu. Buka menu QR-Custom Payment untuk menyelesaikan atau menolak (refund).`
|
||||
: `✅ *PEMBAYARAN QR CUSTOM DITERIMA*\n\n` +
|
||||
`Judul: ${row.title}\nKategori: ${row.category || '-'}\n` +
|
||||
`Nominal: Rp ${Number(row.amount_unique || 0).toLocaleString('id-ID')}\n\n` +
|
||||
`Pembayaran cocok dalam 20 menit.`;
|
||||
await waNotify.notifyAdmin(text);
|
||||
} catch (e) { logger.warn('[CustomQR] notifyAdmin gagal: ' + e.message); }
|
||||
}
|
||||
|
||||
function markPaidFromWebhook(id, notifId) {
|
||||
const row = getById(id);
|
||||
if (!row) return null;
|
||||
db.prepare("UPDATE custom_qr_payments SET status='paid', paid_at=NOW_LOCAL(), paid_by='Webhook', notif_id=? WHERE id=?")
|
||||
.run(notifId || null, row.id);
|
||||
const fresh = getById(id);
|
||||
notifyAdminPayment(fresh, false).catch(() => {});
|
||||
return fresh;
|
||||
}
|
||||
|
||||
function markWarningFromWebhook(id, notifId) {
|
||||
const row = getById(id);
|
||||
if (!row) return null;
|
||||
db.prepare("UPDATE custom_qr_payments SET status='warning', warning_at=NOW_LOCAL(), notif_id=? WHERE id=?")
|
||||
.run(notifId || null, row.id);
|
||||
const fresh = getById(id);
|
||||
notifyAdminPayment(fresh, true).catch(() => {});
|
||||
return fresh;
|
||||
}
|
||||
|
||||
function isExpired(row) {
|
||||
if (!row || !row.expires_at) return false;
|
||||
const t = new Date(row.expires_at).getTime();
|
||||
return Number.isFinite(t) && t < Date.now();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DURATION_MINUTES,
|
||||
isReady,
|
||||
create,
|
||||
list,
|
||||
stats,
|
||||
getById,
|
||||
getByToken,
|
||||
candidatesByAmount,
|
||||
markManualPaid,
|
||||
acceptWarning,
|
||||
regenerate,
|
||||
remove,
|
||||
removeMany,
|
||||
clearAll,
|
||||
reject,
|
||||
markPaidFromWebhook,
|
||||
markWarningFromWebhook,
|
||||
isExpired,
|
||||
sweepExpired
|
||||
};
|
||||
@@ -112,6 +112,34 @@ async function resolveDeviceToken(input) {
|
||||
const found = await findDeviceWithTagVariants(token);
|
||||
if (found && found.device && found.device._id) return found.device;
|
||||
|
||||
// Fallback: cari lewat data pelanggan (ID, HP, PPPoE, tag ACS, atau MAC).
|
||||
try {
|
||||
const db = require('../config/database');
|
||||
const cust = db.prepare(`
|
||||
SELECT pppoe_username, genieacs_tag, mac_address, phone
|
||||
FROM customers
|
||||
WHERE phone = ? OR id = ? OR pppoe_username = ? OR genieacs_tag = ? OR mac_address = ?
|
||||
LIMIT 1
|
||||
`).get(token, token, token, token, token);
|
||||
|
||||
if (cust) {
|
||||
const candidates = Array.from(new Set([
|
||||
cust.pppoe_username, cust.genieacs_tag, cust.mac_address, cust.phone
|
||||
].map(v => String(v || '').trim()).filter(v => v && v !== token)));
|
||||
|
||||
for (const cand of candidates) {
|
||||
const byPppoe = await findDeviceByPppoe(cand);
|
||||
if (byPppoe && byPppoe._id) return byPppoe;
|
||||
const byTag = await findDeviceByTag(cand);
|
||||
if (byTag && byTag._id) return byTag;
|
||||
const variant = await findDeviceWithTagVariants(cand);
|
||||
if (variant && variant.device && variant.device._id) return variant.device;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.debug(`[resolveDeviceToken] Customer DB fallback error: ${e.message}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -132,6 +160,7 @@ const parameterPaths = {
|
||||
'Device.DeviceInfo.SoftwareVersion'
|
||||
],
|
||||
ssid: [
|
||||
'VirtualParameters.WlanSSID',
|
||||
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID',
|
||||
'Device.WiFi.SSID.1.SSID',
|
||||
'Device.WiFi.SSID.2.SSID'
|
||||
@@ -166,6 +195,8 @@ const parameterPaths = {
|
||||
pppoeIP: [
|
||||
'VirtualParameters.pppoeIP',
|
||||
'VirtualParameters.pppIP',
|
||||
'VirtualParameters.WanIP',
|
||||
'VirtualParameters.IPTR069',
|
||||
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.ExternalIPAddress',
|
||||
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress',
|
||||
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.2.WANPPPConnection.1.ExternalIPAddress',
|
||||
@@ -176,8 +207,10 @@ const parameterPaths = {
|
||||
'Device.IP.Interface.1.IPv4Address.1.IPAddress'
|
||||
],
|
||||
pppUsername: [
|
||||
'VirtualParameters.pppoeUsername2',
|
||||
'VirtualParameters.pppoeUsername',
|
||||
'VirtualParameters.pppUsername',
|
||||
'VirtualParameters.PPPoEUser',
|
||||
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.Username',
|
||||
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.2.Username',
|
||||
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.3.Username',
|
||||
@@ -696,11 +729,19 @@ function mapDeviceData(device, tag, isPppoeActive = false) {
|
||||
} catch {}
|
||||
|
||||
let rxPower = getParameterWithPaths(device, parameterPaths.rxPower);
|
||||
if (rxPower !== 'N/A' && rxPower !== '-' && rxPower !== '') {
|
||||
const num = parseFloat(rxPower);
|
||||
if (!isNaN(num) && num > 0) {
|
||||
const dbVal = 30 + (Math.log10(num * Math.pow(10, -7)) * 10);
|
||||
rxPower = (Math.ceil(dbVal * 100) / 100).toFixed(2);
|
||||
if (rxPower !== 'N/A' && rxPower !== '-' && rxPower !== '' && rxPower !== null && rxPower !== undefined) {
|
||||
const raw = parseFloat(rxPower);
|
||||
if (!isNaN(raw)) {
|
||||
if (raw === 0 || raw >= 65535) {
|
||||
rxPower = 'LOS';
|
||||
} else if (raw < 0 && raw > -50) {
|
||||
rxPower = raw.toFixed(2);
|
||||
} else if (raw <= -50) {
|
||||
rxPower = (raw / 100).toFixed(2);
|
||||
} else if (raw > 0) {
|
||||
const dbm = 30 + (Math.log10(raw * 1e-7) * 10);
|
||||
rxPower = (Math.round(dbm * 100) / 100).toFixed(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
const pppoeIP = extractPppoeIp(device);
|
||||
@@ -851,6 +892,7 @@ async function updateSSID(tag, newSSID, actor = null) {
|
||||
try {
|
||||
const ssidClean = String(newSSID ?? '').replace(/[\r\n\t]+/g, '').trim();
|
||||
if (!ssidClean) { logger.warn('[updateSSID] SSID kosong, dibatalkan.'); return false; }
|
||||
newSSID = ssidClean;
|
||||
const device = await resolveDeviceToken(tag);
|
||||
if (!device) return false;
|
||||
const deviceId = encodeURIComponent(device._id);
|
||||
@@ -863,62 +905,96 @@ async function updateSSID(tag, newSSID, actor = null) {
|
||||
const tasksUrl = `/devices/${deviceId}/tasks`;
|
||||
|
||||
const parameterValues = [];
|
||||
const igd = device.InternetGatewayDevice;
|
||||
const dev181 = device.Device;
|
||||
|
||||
// Check supported paths in DB
|
||||
const db = require('../config/database');
|
||||
const row = db.prepare('SELECT params FROM acs_devices WHERE id = ?').get(device._id);
|
||||
const flatParams = row && row.params ? JSON.parse(row.params) : null;
|
||||
|
||||
if (flatParams) {
|
||||
// SSID 2.4G paths
|
||||
const paths24G = [
|
||||
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID',
|
||||
'Device.WiFi.SSID.1.SSID'
|
||||
];
|
||||
paths24G.forEach(p => {
|
||||
if (flatParams[p] !== undefined) {
|
||||
parameterValues.push([p, ssidClean, 'xsd:string']);
|
||||
}
|
||||
});
|
||||
// Check supported paths directly from loaded device object
|
||||
if (igd && igd.LANDevice && igd.LANDevice['1'] && igd.LANDevice['1'].WLANConfiguration) {
|
||||
const wlan = igd.LANDevice['1'].WLANConfiguration;
|
||||
// Selalu ubah SSID 1 (2.4GHz Utama)
|
||||
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID', newSSID, 'xsd:string']);
|
||||
|
||||
// SSID 5G paths
|
||||
const paths5G = [
|
||||
'Device.WiFi.SSID.2.SSID'
|
||||
];
|
||||
for (const idx of [5, 6, 7, 8]) {
|
||||
paths5G.push(`InternetGatewayDevice.LANDevice.1.WLANConfiguration.${idx}.SSID`);
|
||||
}
|
||||
paths5G.forEach(p => {
|
||||
if (flatParams[p] !== undefined) {
|
||||
parameterValues.push([p, `${ssidClean}-5G`, 'xsd:string']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback if no parameters match or device not bootstrapped yet
|
||||
if (parameterValues.length === 0) {
|
||||
parameterValues.push(
|
||||
['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID', ssidClean, 'xsd:string'],
|
||||
['Device.WiFi.SSID.1.SSID', ssidClean, 'xsd:string']
|
||||
// HANYA jika parameter SSID 5G (WLANConfiguration.5) benar-benar ada di perangkat.
|
||||
// JANGAN PERNAH mengubah WLANConfiguration.2 (SSID 2) karena itu adalah SSID2/Guest pada perangkat single-band!
|
||||
const hasFiveG = Boolean(
|
||||
wlan['5'] && (
|
||||
wlan['5'].SSID !== undefined ||
|
||||
wlan['5']._object === true ||
|
||||
wlan['5'].Enable !== undefined ||
|
||||
wlan['5'].KeyPassphrase !== undefined
|
||||
)
|
||||
);
|
||||
if (hasFiveG) {
|
||||
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.5.SSID', `${newSSID}-5G`, 'xsd:string']);
|
||||
}
|
||||
} else if (dev181 && dev181.WiFi) {
|
||||
parameterValues.push(['Device.WiFi.SSID.1.SSID', newSSID, 'xsd:string']);
|
||||
} else {
|
||||
// Fallback check in local DB
|
||||
const db = require('../config/database');
|
||||
const row = db.prepare('SELECT params FROM acs_devices WHERE id = ?').get(device._id);
|
||||
const flatParams = row && row.params ? JSON.parse(row.params) : null;
|
||||
if (flatParams) {
|
||||
if (flatParams['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID'] !== undefined) {
|
||||
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID', newSSID, 'xsd:string']);
|
||||
} else if (flatParams['Device.WiFi.SSID.1.SSID'] !== undefined) {
|
||||
parameterValues.push(['Device.WiFi.SSID.1.SSID', newSSID, 'xsd:string']);
|
||||
}
|
||||
if (flatParams['InternetGatewayDevice.LANDevice.1.WLANConfiguration.5.SSID'] !== undefined) {
|
||||
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.5.SSID', `${newSSID}-5G`, 'xsd:string']);
|
||||
}
|
||||
}
|
||||
if (parameterValues.length === 0) {
|
||||
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID', newSSID, 'xsd:string']);
|
||||
}
|
||||
}
|
||||
|
||||
let ok = false;
|
||||
try {
|
||||
await instance.post(tasksUrl, {
|
||||
name: 'setParameterValues',
|
||||
parameterValues: parameterValues
|
||||
}, { timeout: 20000 });
|
||||
ok = true;
|
||||
} catch (e) {
|
||||
logger.error(`[updateSSID] Failed to set SSID: ${e.message}`);
|
||||
for (const pv of parameterValues) {
|
||||
try {
|
||||
await instance.post(tasksUrl, {
|
||||
name: 'setParameterValues',
|
||||
parameterValues: [pv]
|
||||
}, { timeout: 15000 });
|
||||
ok = true;
|
||||
} catch (e) {
|
||||
logger.error(`[updateSSID] Failed to set parameter ${pv[0]}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh + inform dijalankan di latar belakang agar respons cepat (tidak menahan UI)
|
||||
// Refresh objects untuk trigger update parameter dari ONU
|
||||
try {
|
||||
await instance.post(tasksUrl, { name: 'refreshObject', objectName: 'InternetGatewayDevice.LANDevice.1.WLANConfiguration' }, { timeout: 15000 });
|
||||
} catch (e) {}
|
||||
|
||||
// Tunggu sesaat agar task dieksekusi oleh ONU
|
||||
if (ok) {
|
||||
instance.post(tasksUrl, { name: 'refreshObject', objectName: 'InternetGatewayDevice.LANDevice.1.WLANConfiguration' }, { timeout: 12000 }).catch(() => {});
|
||||
instance.post(tasksUrl, { name: 'inform' }, { timeout: 12000 }).catch(() => {});
|
||||
logger.info(`[updateSSID] Set SSID dikirim untuk ${tag}; refresh & inform berjalan di latar belakang`);
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
|
||||
// Sinkronkan nama WiFi (SSID) baru ke database pelanggan
|
||||
try {
|
||||
const db = require('../config/database');
|
||||
const pppoeUser = extractPppoeUser(device);
|
||||
const tagCandidate = (device._tags && device._tags[0]) || null;
|
||||
const snCandidate = device._deviceId?._SerialNumber || null;
|
||||
const idCandidate = device._id || null;
|
||||
|
||||
const cust = db.prepare(`
|
||||
SELECT id FROM customers
|
||||
WHERE id = ?
|
||||
OR (pppoe_username IS NOT NULL AND pppoe_username != '' AND (pppoe_username = ? OR pppoe_username = ?))
|
||||
OR (genieacs_tag IS NOT NULL AND genieacs_tag != '' AND (genieacs_tag = ? OR genieacs_tag = ?))
|
||||
OR (mac_address IS NOT NULL AND mac_address != '' AND (mac_address = ? OR mac_address = ? OR mac_address = ?))
|
||||
OR phone = ?
|
||||
LIMIT 1
|
||||
`).get(tag, tag, pppoeUser, tag, tagCandidate, tag, snCandidate, idCandidate, tag);
|
||||
|
||||
if (cust && cust.id) {
|
||||
db.prepare('UPDATE customers SET wifi_ssid = ? WHERE id = ?').run(newSSID, cust.id);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug(`[updateSSID] Failed to sync customer wifi_ssid: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Catat audit trail jika berhasil
|
||||
@@ -970,71 +1046,78 @@ async function updatePassword(tag, newPassword, actor = null) {
|
||||
logger.info(`[updatePassword] Setting password for device ${deviceId}, tag ${tag}`);
|
||||
|
||||
const parameterValues = [];
|
||||
|
||||
// Check supported paths in DB
|
||||
const db = require('../config/database');
|
||||
const row = db.prepare('SELECT params FROM acs_devices WHERE id = ?').get(device._id);
|
||||
const flatParams = row && row.params ? JSON.parse(row.params) : null;
|
||||
|
||||
if (flatParams) {
|
||||
// 2.4G password paths
|
||||
const paths24G = [
|
||||
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase',
|
||||
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.KeyPassphrase',
|
||||
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.PreSharedKey',
|
||||
'Device.WiFi.AccessPoint.1.Security.KeyPassphrase',
|
||||
'Device.WiFi.AccessPoint.1.Security.PreSharedKey'
|
||||
];
|
||||
paths24G.forEach(p => {
|
||||
if (flatParams[p] !== undefined) {
|
||||
parameterValues.push([p, pw, 'xsd:string']);
|
||||
}
|
||||
});
|
||||
|
||||
// 5G password paths
|
||||
const paths5G = [
|
||||
'Device.WiFi.AccessPoint.2.Security.KeyPassphrase',
|
||||
'Device.WiFi.AccessPoint.2.Security.PreSharedKey'
|
||||
];
|
||||
for (const idx of [5, 6, 7, 8]) {
|
||||
paths5G.push(
|
||||
`InternetGatewayDevice.LANDevice.1.WLANConfiguration.${idx}.KeyPassphrase`,
|
||||
`InternetGatewayDevice.LANDevice.1.WLANConfiguration.${idx}.PreSharedKey.1.KeyPassphrase`,
|
||||
`InternetGatewayDevice.LANDevice.1.WLANConfiguration.${idx}.PreSharedKey.1.PreSharedKey`
|
||||
);
|
||||
const igd = device.InternetGatewayDevice;
|
||||
const dev181 = device.Device;
|
||||
|
||||
if (igd && igd.LANDevice && igd.LANDevice['1'] && igd.LANDevice['1'].WLANConfiguration) {
|
||||
const wlan = igd.LANDevice['1'].WLANConfiguration;
|
||||
const wlan1 = wlan['1'] || {};
|
||||
|
||||
if (wlan1.KeyPassphrase !== undefined) {
|
||||
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase', pw, 'xsd:string']);
|
||||
}
|
||||
paths5G.forEach(p => {
|
||||
if (flatParams[p] !== undefined) {
|
||||
parameterValues.push([p, pw, 'xsd:string']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback if no parameters match or device not bootstrapped yet
|
||||
if (parameterValues.length === 0) {
|
||||
parameterValues.push(
|
||||
['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase', pw, 'xsd:string'],
|
||||
['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.KeyPassphrase', pw, 'xsd:string'],
|
||||
['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.PreSharedKey', pw, 'xsd:string'],
|
||||
['Device.WiFi.AccessPoint.1.Security.KeyPassphrase', pw, 'xsd:string']
|
||||
if (wlan1.PreSharedKey && typeof wlan1.PreSharedKey === 'object') {
|
||||
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.PreSharedKey', pw, 'xsd:string']);
|
||||
}
|
||||
if (parameterValues.length === 0) {
|
||||
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase', pw, 'xsd:string']);
|
||||
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.PreSharedKey', pw, 'xsd:string']);
|
||||
}
|
||||
|
||||
// 5G if available - HANYA jika WLANConfiguration.5 ada di perangkat. JANGAN PERNAH mengubah WLANConfiguration.2!
|
||||
const hasFiveG = Boolean(
|
||||
wlan['5'] && (
|
||||
wlan['5'].SSID !== undefined ||
|
||||
wlan['5'].KeyPassphrase !== undefined ||
|
||||
wlan['5'].PreSharedKey !== undefined ||
|
||||
wlan['5']._object === true ||
|
||||
wlan['5'].Enable !== undefined
|
||||
)
|
||||
);
|
||||
if (hasFiveG) {
|
||||
const wlan5 = wlan['5'] || {};
|
||||
if (wlan5.KeyPassphrase !== undefined) {
|
||||
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.5.KeyPassphrase', pw, 'xsd:string']);
|
||||
}
|
||||
if (wlan5.PreSharedKey && typeof wlan5.PreSharedKey === 'object') {
|
||||
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.5.PreSharedKey.1.PreSharedKey', pw, 'xsd:string']);
|
||||
}
|
||||
if (!wlan5.KeyPassphrase && (!wlan5.PreSharedKey || typeof wlan5.PreSharedKey !== 'object')) {
|
||||
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.5.KeyPassphrase', pw, 'xsd:string']);
|
||||
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.5.PreSharedKey.1.PreSharedKey', pw, 'xsd:string']);
|
||||
}
|
||||
}
|
||||
} else if (dev181 && dev181.WiFi) {
|
||||
parameterValues.push(['Device.WiFi.AccessPoint.1.Security.KeyPassphrase', pw, 'xsd:string']);
|
||||
} else {
|
||||
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase', pw, 'xsd:string']);
|
||||
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.PreSharedKey', pw, 'xsd:string']);
|
||||
}
|
||||
|
||||
let ok = false;
|
||||
try {
|
||||
await instance.post(tasksUrl, {
|
||||
name: 'setParameterValues',
|
||||
parameterValues: parameterValues
|
||||
}, { timeout: 20000 });
|
||||
ok = true;
|
||||
} catch (e) {
|
||||
logger.error(`[updatePassword] Failed to set password: ${e.message}`);
|
||||
for (const pv of parameterValues) {
|
||||
try {
|
||||
await instance.post(tasksUrl, {
|
||||
name: 'setParameterValues',
|
||||
parameterValues: [pv]
|
||||
}, { timeout: 15000 });
|
||||
ok = true;
|
||||
} catch (e) {
|
||||
logger.debug(`[updatePassword] Param ${pv[0]} failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh di latar belakang agar respons cepat
|
||||
instance.post(tasksUrl, { name: 'refreshObject', objectName: 'InternetGatewayDevice.LANDevice.1.WLANConfiguration' }, { timeout: 12000 }).catch(() => {});
|
||||
// Refresh object - only refresh InternetGatewayDevice path yang lebih universal
|
||||
try {
|
||||
await instance.post(tasksUrl, { name: 'refreshObject', objectName: 'InternetGatewayDevice.LANDevice.1.WLANConfiguration' }, { timeout: 15000 });
|
||||
} catch (e) {}
|
||||
// Skip Device.WiFi.AccessPoint refresh karena tidak semua ONU support (CIOT tidak support)
|
||||
|
||||
// Tunggu sesaat agar task dieksekusi oleh ONU
|
||||
if (ok) {
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
}
|
||||
|
||||
// Catat audit trail jika berhasil
|
||||
if (ok && actor) {
|
||||
auditTrail.logAuditTrail({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -44,7 +44,11 @@ async function resolveDeviceId(force) {
|
||||
try { list = await listDevices(); } catch (e) { list = []; }
|
||||
const has = function (id) { return list.some(function (d) { return String(d.id) === id; }); };
|
||||
let chosen;
|
||||
const connected = list.find(function (d) {
|
||||
return d && (d.state === 'connected' || d.is_connected || d.is_logged_in || d.connected);
|
||||
});
|
||||
if (configured && has(configured)) chosen = configured; // device id diset & ada -> pakai itu
|
||||
else if (connected) chosen = String(connected.id); // utamakan device yang benar-benar terhubung
|
||||
else if (list.length === 1) chosen = String(list[0].id); // cuma 1 device -> otomatis
|
||||
else if (list.length > 1) chosen = configured || String(list[0].id); // >1 device -> pakai yg diset / pertama
|
||||
else chosen = configured; // GOWA tak beri list -> serahkan ke GOWA
|
||||
@@ -131,6 +135,48 @@ async function sendGowaDocument(toPhone, documentBuffer, filename = 'file.pdf',
|
||||
});
|
||||
return { success: true, gateway: 'gowa', raw: json };
|
||||
}
|
||||
async function testReachability(opts = {}) {
|
||||
// Nilai form boleh dipakai tanpa menyimpan pengaturan lebih dulu.
|
||||
const base = String(opts.baseUrl || baseUrl()).trim().replace(/\/+$/, '') || baseUrl();
|
||||
const headers = {};
|
||||
if (opts.username && opts.password) {
|
||||
headers.Authorization = 'Basic ' + Buffer.from(opts.username + ':' + opts.password).toString('base64');
|
||||
} else {
|
||||
Object.assign(headers, commonHeaders());
|
||||
}
|
||||
const out = { baseUrl: base, reachable: false, healthStatus: 0, authOk: false, devices: 0, message: '' };
|
||||
try {
|
||||
const res = await fetch(base + '/health');
|
||||
out.healthStatus = res.status;
|
||||
out.reachable = res.ok;
|
||||
} catch (e) {
|
||||
out.message = 'Tidak terjangkau: ' + e.message;
|
||||
return out;
|
||||
}
|
||||
if (!out.reachable) {
|
||||
out.message = 'GOWA menjawab, tetapi /health mengembalikan HTTP ' + out.healthStatus;
|
||||
return out;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(base + '/devices', { headers: headers });
|
||||
out.authStatus = res.status;
|
||||
const json = await res.json().catch(function () { return null; });
|
||||
const list = (json && Array.isArray(json.results)) ? json.results : [];
|
||||
if (res.ok) {
|
||||
out.authOk = true;
|
||||
out.devices = list.length;
|
||||
out.message = 'GOWA terjangkau' + (list.length ? ' — ' + list.length + ' device terdaftar' : ' — belum ada device, scan QR untuk menautkan');
|
||||
} else if (res.status === 401) {
|
||||
out.message = 'GOWA terjangkau, tetapi username/password Basic Auth salah atau kosong';
|
||||
} else {
|
||||
out.message = 'GOWA terjangkau, tetapi /devices membalas HTTP ' + res.status;
|
||||
}
|
||||
} catch (e) {
|
||||
out.message = 'GOWA terjangkau, tetapi gagal cek kredensial: ' + e.message;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function testGowaConnection(phone, message) {
|
||||
return await sendGowaMessage(phone, message || 'Tes koneksi GOWA berhasil ✅');
|
||||
}
|
||||
@@ -168,21 +214,61 @@ async function ensureDevice() {
|
||||
async function getLoginQr(deviceId) {
|
||||
const dev = deviceId || deviceHeader();
|
||||
const url = baseUrl() + '/app/login' + (dev ? ('?device_id=' + encodeURIComponent(dev)) : '');
|
||||
const res = await fetch(url, { headers: commonHeaders() });
|
||||
const json = await res.json().catch(function () { return null; });
|
||||
const r = (json && json.results) || {};
|
||||
const link = r.qr_link || r.image_path || null;
|
||||
if (link) _lastQr = { link: link, at: Date.now() };
|
||||
return { ok: !!link, qr_link: link, duration: r.qr_duration || null, device_id: r.device_id || dev || null, code: (json && json.code) || null };
|
||||
}
|
||||
async function getLastQrImage() {
|
||||
if (!_lastQr.link) return null;
|
||||
try {
|
||||
const res = await fetch(_lastQr.link, { headers: commonHeaders() });
|
||||
if (!res.ok) return null;
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
return { buffer: buf, contentType: res.headers.get('content-type') || 'image/png' };
|
||||
} catch (e) { return null; }
|
||||
const res = await fetch(url, { headers: commonHeaders() });
|
||||
const text = await res.text();
|
||||
let json = null;
|
||||
try { json = JSON.parse(text); } catch (e) {}
|
||||
if (!res.ok) {
|
||||
const detail = String(
|
||||
(json && (json.message || json.error || json.code)) || text || ''
|
||||
).replace(/\s+/g, ' ').trim().slice(0, 160);
|
||||
return {
|
||||
ok: false,
|
||||
status: res.status,
|
||||
code: (json && json.code) || null,
|
||||
message: detail || ('HTTP ' + res.status)
|
||||
};
|
||||
}
|
||||
const r = (json && json.results) || {};
|
||||
const link = r.qr_link || r.image_path || null;
|
||||
if (link) _lastQr = { link: link, at: Date.now() };
|
||||
return { ok: !!link, qr_link: link, duration: r.qr_duration || null, device_id: r.device_id || dev || null, code: (json && json.code) || null };
|
||||
} catch (e) {
|
||||
return { ok: false, status: 0, code: null, message: e.message };
|
||||
}
|
||||
}
|
||||
// GOWA bisa mengembalikan qr_link absolut ke host internal (127.0.0.1 / nama
|
||||
// container). Ambil path-nya saja lalu tempel ke base URL yang dikonfigurasi,
|
||||
// supaya gambar tetap bisa diambil saat GOWA diakses lewat domain/reverse proxy.
|
||||
function resolveQrLink(link) {
|
||||
const l = String(link || '').trim();
|
||||
if (!l) return '';
|
||||
if (/^data:/i.test(l)) return l;
|
||||
try {
|
||||
const u = new URL(l, baseUrl() + '/');
|
||||
const base = new URL(baseUrl());
|
||||
return base.origin + u.pathname + u.search;
|
||||
} catch (e) {
|
||||
return baseUrl() + (l.charAt(0) === '/' ? l : '/' + l);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { sendGowaMessage, sendGowaImage, sendGowaDocument, testGowaConnection, gowaStatus, listDevices, ensureDevice, getLoginQr, getLastQrImage, normalizePhoneJid, baseUrl, resolveDeviceId, invalidateDeviceCache };
|
||||
async function getLastQrImage() {
|
||||
if (!_lastQr.link) return null;
|
||||
const url = resolveQrLink(_lastQr.link);
|
||||
try {
|
||||
const res = await fetch(url, { headers: commonHeaders() });
|
||||
if (!res.ok) {
|
||||
logger.warn(`[GOWA] Gagal ambil gambar QR: HTTP ${res.status} (${url})`);
|
||||
return null;
|
||||
}
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
return { buffer: buf, contentType: res.headers.get('content-type') || 'image/png' };
|
||||
} catch (e) {
|
||||
logger.warn(`[GOWA] Gagal ambil gambar QR: ${e.message} (${url})`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { sendGowaMessage, sendGowaImage, sendGowaDocument, testGowaConnection, testReachability, gowaStatus, listDevices, ensureDevice, getLoginQr, getLastQrImage, normalizePhoneJid, baseUrl, resolveDeviceId, invalidateDeviceCache };
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -7,6 +7,32 @@ const db = require('../config/database');
|
||||
const qrisUtil = require('../utils/qrisUtil');
|
||||
const { getSettings } = require('../config/settingsManager');
|
||||
|
||||
/**
|
||||
* Cek apakah nominal unik sudah dipakai order lain yang masih menunggu bayar.
|
||||
* WAJIB mencakup invoice, voucher, dan donasi: webhook mencocokkan ketiganya
|
||||
* sekaligus, jadi nominal yang dipakai dua order berbeda akan dianggap ambigu
|
||||
* dan pembayarannya tidak diproses.
|
||||
*/
|
||||
function isQrisAmountTaken(amount, opts = {}) {
|
||||
const amt = Number(amount || 0);
|
||||
if (!Number.isFinite(amt) || amt <= 0) return false;
|
||||
const exInv = Number(opts.excludeInvoiceId || 0);
|
||||
const exVoucher = Number(opts.excludeVoucherOrderId || 0);
|
||||
const exDonation = Number(opts.excludeDonationOrderId || 0);
|
||||
const exCustomQr = Number(opts.excludeCustomQrId || 0);
|
||||
try {
|
||||
const inv = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1').get('unpaid', amt, exInv);
|
||||
if (inv && inv.id) return true;
|
||||
const voc = db.prepare('SELECT id FROM public_voucher_orders WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1').get('pending', amt, exVoucher);
|
||||
if (voc && voc.id) return true;
|
||||
const don = db.prepare('SELECT id FROM public_donation_orders WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1').get('pending', amt, exDonation);
|
||||
if (don && don.id) return true;
|
||||
const cq = db.prepare("SELECT id FROM custom_qr_payments WHERE status IN ('pending','expired') AND amount_unique=? AND id!=? LIMIT 1").get(amt, exCustomQr);
|
||||
if (cq && cq.id) return true;
|
||||
} catch (e) { /* tabel opsional belum ada */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
function ensureInvoiceQris(inv) {
|
||||
const settings = getSettings() || {};
|
||||
const invId = Number((inv && inv.id) || 0);
|
||||
@@ -17,11 +43,10 @@ function ensureInvoiceQris(inv) {
|
||||
let amountUnique = Number(inv.qris_amount_unique || 0);
|
||||
|
||||
if (!(amountUnique > 0 && code > 0 && (amountUnique - code) === base)) {
|
||||
const exists = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1');
|
||||
let chosen = 0, chosenAmt = 0;
|
||||
for (let c = 1; c <= 999; c++) {
|
||||
const a = base + c;
|
||||
if (!exists.get('unpaid', a, invId)) { chosen = c; chosenAmt = a; break; }
|
||||
if (!isQrisAmountTaken(a, { excludeInvoiceId: invId })) { chosen = c; chosenAmt = a; break; }
|
||||
}
|
||||
if (chosen) {
|
||||
code = chosen;
|
||||
@@ -43,4 +68,4 @@ function ensureInvoiceQris(inv) {
|
||||
return { uniqueCode: code, amount: finalAmount, payload };
|
||||
}
|
||||
|
||||
module.exports = { ensureInvoiceQris };
|
||||
module.exports = { ensureInvoiceQris, isQrisAmountTaken };
|
||||
|
||||
@@ -1632,6 +1632,112 @@ async function generateIsolirPortalScript() {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Script isolir sederhana untuk billing yang berjalan di CLOUD.
|
||||
* Asumsi: redirector billing (port isolir_redirect_port) terbuka dari publik.
|
||||
*/
|
||||
async function generateIsolirCloudScript(opts = {}) {
|
||||
const settings = getSettingsWithCache() || {};
|
||||
const rawHost = String(opts.host || settings.public_base_url || settings.app_url || '').trim();
|
||||
if (!rawHost) throw new Error('Isi domain/IP publik billing terlebih dahulu.');
|
||||
|
||||
let url = null;
|
||||
try {
|
||||
url = new URL(/^https?:\/\//i.test(rawHost) ? rawHost : ('https://' + rawHost));
|
||||
} catch (e) {
|
||||
throw new Error('Domain/IP billing tidak valid.');
|
||||
}
|
||||
const hostname = url.hostname;
|
||||
const baseUrl = url.origin;
|
||||
let billingIps = [];
|
||||
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostname)) {
|
||||
billingIps = [hostname];
|
||||
} else {
|
||||
try {
|
||||
const list = await dns.promises.resolve4(hostname);
|
||||
billingIps = (list || []).filter(Boolean);
|
||||
} catch (e) {}
|
||||
if (!billingIps.length) {
|
||||
try { billingIps = [await resolveIpv4(hostname)]; } catch (e) { billingIps = []; }
|
||||
}
|
||||
}
|
||||
if (!billingIps.length) throw new Error('IP publik billing tidak bisa di-resolve. Masukkan IP origin langsung.');
|
||||
const billingIp = billingIps[0];
|
||||
const cfRanges = ['104.16.', '104.17.', '104.18.', '104.19.', '104.20.', '104.21.', '104.22.', '104.23.', '104.24.', '104.25.', '104.26.', '104.27.', '172.64.', '172.65.', '172.66.', '172.67.', '162.158.', '173.245.', '103.21.', '103.22.', '103.31.', '141.101.', '108.162.', '190.93.', '188.114.', '197.234.', '198.41.', '131.0.72.'];
|
||||
const isCdn = billingIps.some(ip => cfRanges.some(p => ip.startsWith(p)));
|
||||
const redirectPort = Math.max(1, Math.min(65535, parseInt(opts.port, 10) || parseInt(settings.isolir_redirect_port, 10) || 8099));
|
||||
const profile = String(opts.profile || 'isolir').trim() || 'isolir';
|
||||
const isolirUrl = String(settings.isolir_portal_url || (baseUrl + '/isolated')).trim();
|
||||
|
||||
const onUp = `/ip firewall address-list remove [find list=LIST_ISOLIR address=$\\"remote-address\\"]; /ip firewall address-list add list=LIST_ISOLIR address=$\\"remote-address\\" comment=$user timeout=23h`;
|
||||
const onDown = `/ip firewall address-list remove [find list=LIST_ISOLIR address=$\\"remote-address\\"]`;
|
||||
|
||||
const lines = [
|
||||
'# ============================================================',
|
||||
'# ISOLIR PAGE — BILLING CLOUD (script sederhana)',
|
||||
'# Tempel di Terminal RouterOS. Ubah variabel di bawah bila perlu.',
|
||||
'# ============================================================',
|
||||
...(isCdn ? [
|
||||
'# PERINGATAN: ' + hostname + ' memakai Cloudflare/CDN.',
|
||||
'# Port ' + redirectPort + ' biasanya TIDAK ikut diproxy Cloudflare.',
|
||||
'# Untuk NAT redirector, gunakan IP ORIGIN (DNS-only) atau pastikan',
|
||||
'# port ' + redirectPort + ' terbuka langsung di server origin.',
|
||||
'# ============================================================',
|
||||
''
|
||||
] : []),
|
||||
':local billingIp "' + billingIp + '"',
|
||||
':local isolirPort ' + redirectPort,
|
||||
':local isolirProfile "' + profile + '"',
|
||||
'',
|
||||
'# --- Bersihkan rule lama buatan script ini ---',
|
||||
'/ip firewall nat remove [find comment~"ISOLIR_CLOUD_"]',
|
||||
'/ip firewall filter remove [find comment~"ISOLIR_CLOUD_"]',
|
||||
'/ip firewall address-list remove [find list=BILLING comment="BILLING_ISOLIR"]',
|
||||
'',
|
||||
'# --- Izinkan billing (semua IP hasil resolve) ---',
|
||||
...billingIps.map(function (ip) { return '/ip firewall address-list add list=BILLING address=' + ip + ' comment="BILLING_ISOLIR"'; }),
|
||||
'',
|
||||
'# --- Hook profil PPPoE isolir ---',
|
||||
'/ppp profile set [find name=$isolirProfile] on-up="' + onUp + '" on-down="' + onDown + '"',
|
||||
'',
|
||||
'# --- DNS isolir diarahkan ke router ---',
|
||||
'/ip firewall nat add chain=dstnat protocol=udp dst-port=53 src-address-list=LIST_ISOLIR action=redirect to-ports=53 comment="ISOLIR_CLOUD_DNS_UDP"',
|
||||
'/ip firewall nat add chain=dstnat protocol=tcp dst-port=53 src-address-list=LIST_ISOLIR action=redirect to-ports=53 comment="ISOLIR_CLOUD_DNS_TCP"',
|
||||
'',
|
||||
'# --- HTTP pelanggan isolir -> redirector cloud ---',
|
||||
'/ip firewall nat add chain=dstnat protocol=tcp dst-port=80 src-address-list=LIST_ISOLIR dst-address-list=!BILLING action=dst-nat to-addresses=$billingIp to-ports=$isolirPort comment="ISOLIR_CLOUD_HTTP"',
|
||||
'',
|
||||
'# --- Filter ---',
|
||||
'/ip firewall filter add chain=forward src-address-list=LIST_ISOLIR protocol=udp dst-port=53 action=accept comment="ISOLIR_CLOUD_DNS"',
|
||||
'/ip firewall filter add chain=forward src-address-list=LIST_ISOLIR protocol=tcp dst-port=53 action=accept comment="ISOLIR_CLOUD_DNS_TCP"',
|
||||
'/ip firewall filter add chain=forward src-address-list=LIST_ISOLIR dst-address-list=BILLING action=accept comment="ISOLIR_CLOUD_ALLOW_BILLING"',
|
||||
'/ip firewall filter add chain=forward src-address-list=LIST_ISOLIR protocol=tcp dst-port=443 action=reject comment="ISOLIR_CLOUD_REJECT_HTTPS"',
|
||||
'/ip firewall filter add chain=forward src-address-list=LIST_ISOLIR action=drop comment="ISOLIR_CLOUD_DROP"',
|
||||
'',
|
||||
'# ============================================================',
|
||||
'# CATATAN BILLING CLOUD',
|
||||
'# 1. Redirector billing harus bisa diakses: http://' + billingIp + ':' + redirectPort,
|
||||
'# (buka port ' + redirectPort + ' di firewall/security group cloud).',
|
||||
'# 2. Di billing set ISOLIR_REDIRECT_URL=' + isolirUrl,
|
||||
'# atau setting isolir_portal_url.',
|
||||
'# 3. Halaman isolir: ' + isolirUrl,
|
||||
'# 4. Jika pakai Cloudflare/CDN, pastikan IP edge yang dipakai ada di',
|
||||
'# address-list BILLING, atau gunakan IP origin langsung.',
|
||||
'# ============================================================',
|
||||
];
|
||||
return {
|
||||
script: lines.join('\n'),
|
||||
billingHost: hostname,
|
||||
billingIp,
|
||||
billingIps,
|
||||
isCdn,
|
||||
redirectPort,
|
||||
profile,
|
||||
isolirUrl,
|
||||
warning: isCdn ? ('Domain memakai Cloudflare/CDN. NAT redirector ke ' + billingIp + ':' + redirectPort + ' biasanya gagal — gunakan IP origin (DNS-only) atau buka port redirector di origin.') : ''
|
||||
};
|
||||
}
|
||||
|
||||
const ISOLIR_ADDR_LIST = 'LIST_ISOLIR';
|
||||
|
||||
/** Nama profil PPPoE isolir yang dipakai pelanggan di router ini (distinct dari DB). */
|
||||
@@ -2128,6 +2234,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 +2293,9 @@ module.exports = {
|
||||
getHotspotProfiles,
|
||||
getPppoeActive,
|
||||
getActivePppoeSessionsMap,
|
||||
getActivePppoeUsers,
|
||||
isolateCustomerPppoe,
|
||||
unisolateCustomerPppoe,
|
||||
getAllActiveSessionsMap,
|
||||
getHotspotActive,
|
||||
getIpPools,
|
||||
@@ -2179,6 +2326,7 @@ module.exports = {
|
||||
setupIsolirFirewall,
|
||||
ensurePppProfileIsolirAddressListHook,
|
||||
generateIsolirPortalScript,
|
||||
generateIsolirCloudScript,
|
||||
manageStaticIp,
|
||||
removeStaticIp
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
369
services/securityWatchdogService.js
Normal 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;
|
||||
346
services/selfHealingService.js
Normal 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;
|
||||
136
services/serviceNoticeNotifyService.js
Normal file
@@ -0,0 +1,136 @@
|
||||
'use strict';
|
||||
/**
|
||||
* serviceNoticeNotifyService.js
|
||||
* Notifikasi WhatsApp ke pelanggan untuk:
|
||||
* - tiket gangguan yang baru dibuat
|
||||
* - pengumuman gangguan/perbaikan dari halaman Status Layanan
|
||||
* - pemberitahuan layanan pulih (mode kembali NORMAL)
|
||||
*
|
||||
* Pengiriman broadcast berjalan di latar belakang dengan jeda acak
|
||||
* mengikuti setting whatsapp_broadcast_delay (anti-banned).
|
||||
*/
|
||||
const customerSvc = require('./customerService');
|
||||
const waNotify = require('./waNotifyService');
|
||||
const { getSetting } = require('../config/settingsManager');
|
||||
const { logger } = require('../config/logger');
|
||||
|
||||
let _running = false;
|
||||
|
||||
function digits(p) {
|
||||
return String(p || '').replace(/\D/g, '');
|
||||
}
|
||||
|
||||
function companyName() {
|
||||
return getSetting('company_header', 'OPEN-ISP') || 'OPEN-ISP';
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(r => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function delayMs() {
|
||||
const sec = Number(getSetting('whatsapp_broadcast_delay', 5)) || 5;
|
||||
return Math.max(2000, sec * 1000) + Math.floor(Math.random() * 1500);
|
||||
}
|
||||
|
||||
function ticketMessage(customer, ticketId, subject) {
|
||||
return `✅ *Laporan Diterima*\n\n` +
|
||||
`Halo *${customer.name || 'Pelanggan'}*, laporan gangguan Anda sudah kami terima.\n\n` +
|
||||
`🎫 *No. Tiket:* #TCK-${ticketId}\n` +
|
||||
`📝 *Subjek:* ${subject}\n\n` +
|
||||
`Tim teknisi akan segera menindaklanjuti. Kami akan mengabari lagi setelah laporan selesai.\n\n` +
|
||||
`_${companyName()}_`;
|
||||
}
|
||||
|
||||
function noticeMessage(notice, customer) {
|
||||
let text = `⚠️ *${notice.label || 'Informasi Layanan'}*\n\n` +
|
||||
`Halo *${customer.name || 'Pelanggan'}*,\n${notice.text || ''}\n`;
|
||||
if (notice.startText) text += `\n🕒 *Mulai:* ${notice.startText}`;
|
||||
if (notice.estimateText) text += `\n⏳ *Estimasi Selesai:* ${notice.estimateText}`;
|
||||
text += `\n\nMohon maaf atas ketidaknyamanannya.\n\n_${companyName()}_`;
|
||||
return text;
|
||||
}
|
||||
|
||||
function recoveryMessage(customer) {
|
||||
return `✅ *Layanan Kembali Normal*\n\n` +
|
||||
`Halo *${customer.name || 'Pelanggan'}*, gangguan/perbaikan layanan sudah selesai dan internet Anda dapat digunakan kembali seperti biasa.\n\n` +
|
||||
`Terima kasih atas kesabaran Anda.\n\n_${companyName()}_`;
|
||||
}
|
||||
|
||||
async function notifyTicketCreated(customer, ticketId, subject) {
|
||||
try {
|
||||
if (!getSetting('whatsapp_enabled', false)) return false;
|
||||
const phone = digits(customer && customer.phone);
|
||||
if (phone.length < 8) return false;
|
||||
return await waNotify.sendText(phone, ticketMessage(customer, ticketId, subject || 'Gangguan Layanan'));
|
||||
} catch (e) {
|
||||
logger.error(`[NoticeWA] notifyTicketCreated: ${e.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectTargets({ target, area, routerId }) {
|
||||
let list = [];
|
||||
try {
|
||||
list = customerSvc.getAllCustomers(
|
||||
'',
|
||||
target === 'router' && routerId ? Number(routerId) : null,
|
||||
'active',
|
||||
target === 'area' ? String(area || '') : '',
|
||||
{}
|
||||
) || [];
|
||||
} catch (e) {
|
||||
logger.warn(`[NoticeWA] gagal memilih target: ${e.message}`);
|
||||
return [];
|
||||
}
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const c of list) {
|
||||
const phone = digits(c.phone);
|
||||
if (phone.length >= 8 && !seen.has(phone)) {
|
||||
seen.add(phone);
|
||||
out.push({ ...c, phone });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kirim pengumuman ke pelanggan aktif (latar belakang).
|
||||
* @returns {{ok:boolean,total?:number,reason?:string}}
|
||||
*/
|
||||
async function broadcastNotice({ mode, target, area, routerId, notice }) {
|
||||
if (_running) return { ok: false, reason: 'busy' };
|
||||
if (!getSetting('whatsapp_enabled', false)) return { ok: false, reason: 'wa_disabled' };
|
||||
|
||||
const recipients = selectTargets({ target: target || 'all', area, routerId });
|
||||
if (!recipients.length) return { ok: false, reason: 'empty' };
|
||||
|
||||
const isRecovery = mode === 'NORMAL';
|
||||
_running = true;
|
||||
logger.info(`[NoticeWA] mulai mode=${mode} target=${target || 'all'} total=${recipients.length}`);
|
||||
|
||||
(async () => {
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
for (const c of recipients) {
|
||||
await sleep(delayMs());
|
||||
try {
|
||||
const ok = await waNotify.sendText(c.phone, isRecovery ? recoveryMessage(c) : noticeMessage(notice, c));
|
||||
if (ok) sent++; else failed++;
|
||||
} catch (e) {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
_running = false;
|
||||
logger.info(`[NoticeWA] selesai mode=${mode} terkirim=${sent} gagal=${failed}`);
|
||||
})();
|
||||
|
||||
return { ok: true, total: recipients.length };
|
||||
}
|
||||
|
||||
function isRunning() {
|
||||
return _running;
|
||||
}
|
||||
|
||||
module.exports = { notifyTicketCreated, broadcastNotice, isRunning };
|
||||
132
services/sessionLimitService.js
Normal 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
|
||||
};
|
||||
@@ -3,7 +3,7 @@ const { getSetting, getSettings, saveSettings } = require('../config/settingsMan
|
||||
const { getAppSetting, saveAppSetting } = require('../config/database');
|
||||
|
||||
const FEATURE_PASSWORD_HASH = 'af03458614be223016c5bc1c0788eaefc6a6bfd0c11cdcd4bc520f49d0cb474a'; // sha256 kode aktivasi — plaintext hanya di settings.json (activation_code), tidak di repo
|
||||
const FEATURE_CONTACT_PHONE = '081947215703';
|
||||
const FEATURE_CONTACT_PHONE = '';
|
||||
|
||||
function getFeaturePasswordHash() {
|
||||
return getSetting('feature_password_hash', FEATURE_PASSWORD_HASH);
|
||||
@@ -32,6 +32,7 @@ const MENU_DEFINITIONS = [
|
||||
{ key: 'map', section: 'main', href: '/admin/map', icon: 'bi bi-map', labelKey: 'admin.nav.network_map', labelDefault: 'Peta Jaringan', roles: ['admin', 'cashier'], bottomNav: true, bottomNavOrder: 4, activePages: ['map'] },
|
||||
{ key: 'whatsapp', section: 'main', href: '/admin/whatsapp', icon: 'bi bi-whatsapp', labelKey: 'admin.nav.whatsapp', labelDefault: 'WhatsApp', roles: ['admin', 'cashier'], activePages: ['whatsapp','broadcast','whatsapp_live_chat','whatsapp_templates'] },
|
||||
{ key: 'automation', section: 'main', href: '/admin/automation', icon: 'bi bi-robot', labelKey: 'admin.nav.automation', labelDefault: 'Automation', roles: ['admin'], activePages: ['automation'] },
|
||||
{ key: 'custom_qr', section: 'main', href: '/admin/custom-qr', icon: 'bi bi-qr-code', labelKey: 'admin.nav.custom_qr', labelDefault: 'QR-Custom Payment', roles: ['admin'], activePages: ['custom_qr'] },
|
||||
|
||||
{ key: 'grp_customers', section: 'billing', href: '/admin/customers', icon: 'bi bi-people', labelKey: 'admin.nav.customers', labelDefault: 'Pelanggan', roles: ['admin', 'cashier'], bottomNav: true, bottomNavOrder: 2, activePages: ['customers', 'onu_stickers'], children: [
|
||||
{ key: 'customers', section: 'billing', href: '/admin/customers', icon: 'bi bi-people', labelKey: 'admin.nav.customers_data', labelDefault: 'Data Pelanggan', roles: ['admin', 'cashier'], activePages: ['customers'] },
|
||||
@@ -103,6 +104,7 @@ const DEFAULT_MENU_STATES = {
|
||||
olts: STATE_HIDDEN,
|
||||
whatsapp: STATE_VISIBLE,
|
||||
automation: STATE_VISIBLE,
|
||||
custom_qr: STATE_VISIBLE,
|
||||
broadcast: STATE_VISIBLE,
|
||||
whatsapp_live_chat: STATE_VISIBLE,
|
||||
promo_slides: STATE_VISIBLE,
|
||||
@@ -115,16 +117,16 @@ const DEFAULT_MENU_STATES = {
|
||||
cashiers_reports: STATE_VISIBLE,
|
||||
collector_payments: STATE_VISIBLE,
|
||||
tickets: STATE_VISIBLE,
|
||||
inventory: STATE_LOCKED,
|
||||
attendance: STATE_LOCKED,
|
||||
payroll: STATE_LOCKED,
|
||||
inventory: STATE_VISIBLE,
|
||||
attendance: STATE_VISIBLE,
|
||||
payroll: STATE_VISIBLE,
|
||||
cash_in: STATE_VISIBLE,
|
||||
expenses: STATE_VISIBLE,
|
||||
expense_categories: STATE_VISIBLE,
|
||||
cashier_attendance: STATE_VISIBLE,
|
||||
technicians: STATE_LOCKED,
|
||||
cashiers: STATE_LOCKED,
|
||||
collectors: STATE_LOCKED,
|
||||
technicians: STATE_VISIBLE,
|
||||
cashiers: STATE_VISIBLE,
|
||||
collectors: STATE_VISIBLE,
|
||||
areas: STATE_VISIBLE,
|
||||
update: STATE_VISIBLE,
|
||||
vouchers_discounts: STATE_VISIBLE,
|
||||
@@ -259,7 +261,7 @@ function enrichMenu(menu, states) {
|
||||
locked,
|
||||
hidden,
|
||||
hrefResolved: menu.href,
|
||||
lockedMessage: locked ? `Menu "${menu.labelDefault}" terkunci. Hubungi ${getFeatureContactPhone()} untuk mendapatkan password aktivasi.` : ''
|
||||
lockedMessage: locked ? `Menu "${menu.labelDefault}" terkunci. Buka dari Pengaturan Sidebar.` : ''
|
||||
};
|
||||
if (Array.isArray(menu.children)) {
|
||||
out.children = menu.children.map((ch) => enrichMenu(ch, states));
|
||||
@@ -267,12 +269,21 @@ function enrichMenu(menu, states) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function isCustomQrReady() {
|
||||
try {
|
||||
const enabled = getSetting('qris_static_enabled', true);
|
||||
const off = enabled === false || enabled === 'false' || enabled === 0 || enabled === '0';
|
||||
return !off && String(getSetting('qris_static_payload', '') || '').trim().length > 0;
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
function getSidebarSections(session) {
|
||||
const states = getStoredMenuStates();
|
||||
return SECTION_DEFINITIONS.map((section) => {
|
||||
const items = MENU_DEFINITIONS
|
||||
.filter((menu) => menu.section === section.key)
|
||||
.filter((menu) => isMenuAllowedForSession(menu, session))
|
||||
.filter((menu) => !(menu.key === 'custom_qr' && !isCustomQrReady()))
|
||||
.map((menu) => {
|
||||
const enriched = enrichMenu(menu, states);
|
||||
if (Array.isArray(enriched.children)) {
|
||||
|
||||
85
services/soundboxService.js
Normal 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
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
904
services/telegramAiService.js
Normal 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
|
||||
};
|
||||
@@ -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
@@ -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
|
||||
};
|
||||
@@ -140,8 +140,12 @@ async function isConnected() {
|
||||
try {
|
||||
if (gw === 'gowa') {
|
||||
const st = await require('./gowaWhatsappService').gowaStatus();
|
||||
const d = (st && st.data && st.data.results) || (st && st.data) || {};
|
||||
val = !!(d.is_connected || d.is_logged_in || d.connected);
|
||||
const raw = (st && st.data && st.data.results) || (st && st.data) || {};
|
||||
const list = Array.isArray(raw) ? raw : [raw];
|
||||
val = list.some(function (d) {
|
||||
return !!(d && (d.is_connected || d.is_logged_in || d.connected ||
|
||||
d.state === 'connected' || d.state === 'logged_in'));
|
||||
});
|
||||
} else if (gw === 'baileys') {
|
||||
const mod = await import('./whatsappBot.mjs');
|
||||
const s = mod.whatsappStatus;
|
||||
|
||||
72
tests/unit/area-access.test.js
Normal 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)');
|
||||
@@ -1 +1 @@
|
||||
0.5.2
|
||||
0.6.14
|
||||
@@ -189,6 +189,7 @@
|
||||
<div class="act-menu">
|
||||
<button type="button" class="act-item primary" onclick="refreshDevice('<%= d.id %>')"><i class="bi bi-arrow-repeat"></i> Refresh Device</button>
|
||||
<button type="button" class="act-item" onclick="openAddWanModal('<%= d.id %>', '<%= d.acs_server_id || selectedAcsId %>', '<%= d.sn %>', '<%= d.manufacturer || "" %>', '<%= d.model || "" %>')"><i class="bi bi-diagram-3"></i> Add WAN (All-in-One)</button>
|
||||
<button type="button" class="act-item" onclick="openWifiModal('<%= d.id %>', '<%= d.acs_server_id || selectedAcsId %>', '<%= d.sn %>')"><i class="bi bi-wifi"></i> Atur WiFi (SSID / Sandi)</button>
|
||||
<div class="act-sep"></div>
|
||||
<button type="button" class="act-item danger" onclick="deleteDevice('<%= d.id %>', '<%= d.acs_server_id || selectedAcsId %>')"><i class="bi bi-trash"></i> <%= t('admin.acs.del_device','Hapus Perangkat') %></button>
|
||||
</div>
|
||||
@@ -276,6 +277,37 @@
|
||||
</div>
|
||||
|
||||
<!-- Unified Add WAN All-in-One Modal -->
|
||||
<div class="mo" id="wifiModal">
|
||||
<div class="mb" style="max-width:520px">
|
||||
<div class="mh">
|
||||
<span class="mt"><i class="bi bi-wifi"></i> Atur WiFi ONU</span>
|
||||
<button class="mc" onclick="closeModal('wifiModal')">×</button>
|
||||
</div>
|
||||
<div class="mbody">
|
||||
<input type="hidden" id="wifiDeviceId">
|
||||
<input type="hidden" id="wifiAcsId">
|
||||
<div style="background:var(--sub-bg);border:1px dashed var(--border);border-radius:10px;padding:12px;margin-bottom:14px;font-size:13px">
|
||||
<div><b>SN:</b> <span id="wifiSn" class="text-primary" style="font-weight:700">-</span></div>
|
||||
<div style="margin-top:4px"><b>SSID saat ini:</b> <span id="wifiCurrentSsid" style="color:var(--muted)">Memuat…</span></div>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label class="fl">SSID Baru (2.4G)</label>
|
||||
<input class="fc" id="wifiSsidInput" placeholder="Nama WiFi baru">
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px">SSID 5G otomatis menjadi <code><nama>-5G</code> jika didukung perangkat. SSID2/Guest tidak diubah.</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-p btn-sm" onclick="saveWifiSsid(this)"><i class="bi bi-save"></i> Simpan SSID</button>
|
||||
<hr style="border-color:var(--border);margin:18px 0">
|
||||
<div class="fg">
|
||||
<label class="fl">Sandi WiFi Baru</label>
|
||||
<input class="fc" type="password" id="wifiPassInput" placeholder="Minimal 8 karakter">
|
||||
</div>
|
||||
<button type="button" class="btn btn-p btn-sm" onclick="saveWifiPassword(this)"><i class="bi bi-shield-lock"></i> Simpan Sandi</button>
|
||||
<div id="wifiMsg" style="margin-top:10px;font-size:12px;color:var(--muted)"></div>
|
||||
</div>
|
||||
<div class="mf"><button type="button" class="btn btn-g" onclick="closeModal('wifiModal')">Tutup</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mo" id="addWanModal">
|
||||
<div class="mb" style="max-width:650px">
|
||||
<div class="mh">
|
||||
@@ -1071,6 +1103,73 @@ async function bulkDelete() {
|
||||
}
|
||||
}
|
||||
|
||||
function openWifiModal(deviceId, acsId, sn) {
|
||||
document.getElementById('wifiDeviceId').value = deviceId;
|
||||
document.getElementById('wifiAcsId').value = acsId || '';
|
||||
document.getElementById('wifiSn').textContent = sn || deviceId;
|
||||
document.getElementById('wifiSsidInput').value = '';
|
||||
document.getElementById('wifiPassInput').value = '';
|
||||
document.getElementById('wifiCurrentSsid').textContent = 'Memuat…';
|
||||
document.getElementById('wifiMsg').textContent = '';
|
||||
openModal('wifiModal');
|
||||
fetch('/admin/acs/api/wifi-settings/' + encodeURIComponent(deviceId) + '?acsId=' + encodeURIComponent(acsId || ''))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (!d || !d.success) {
|
||||
document.getElementById('wifiCurrentSsid').textContent = (d && (d.message || d.error)) || 'Gagal memuat WiFi';
|
||||
return;
|
||||
}
|
||||
var bands = d.bands || [];
|
||||
var b1 = bands.find(function (b) { return String(b.index) === '1'; }) || bands[0] || null;
|
||||
var b5 = bands.find(function (b) { return String(b.index) === '5'; });
|
||||
var parts = [];
|
||||
if (b1) parts.push('2.4G: ' + String(b1.ssid || '-'));
|
||||
if (b5) parts.push('5G: ' + String(b5.ssid || '-'));
|
||||
document.getElementById('wifiCurrentSsid').textContent = parts.join(' • ') || '-';
|
||||
if (b1) document.getElementById('wifiSsidInput').value = String(b1.ssid || '').replace(/-5G$/, '');
|
||||
})
|
||||
.catch(function (e) { document.getElementById('wifiCurrentSsid').textContent = 'Error: ' + e.message; });
|
||||
}
|
||||
|
||||
async function saveWifiSsid(btn) {
|
||||
var id = document.getElementById('wifiDeviceId').value;
|
||||
var ssid = String(document.getElementById('wifiSsidInput').value || '').trim();
|
||||
var msg = document.getElementById('wifiMsg');
|
||||
if (ssid.length < 2) { msg.textContent = 'SSID minimal 2 karakter.'; return; }
|
||||
if (!confirm('Ubah SSID perangkat ini?')) return;
|
||||
var old = btn.innerHTML; btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Menyimpan…';
|
||||
msg.textContent = '';
|
||||
try {
|
||||
var r = await fetch('/admin/acs/api/device/' + encodeURIComponent(id) + '/ssid', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ssid: ssid })
|
||||
});
|
||||
var d = await r.json();
|
||||
msg.style.color = d.success ? 'var(--success)' : 'var(--danger)';
|
||||
msg.textContent = d.success ? d.message : ('Gagal: ' + (d.message || 'unknown'));
|
||||
} catch (e) { msg.style.color = 'var(--danger)'; msg.textContent = 'Error: ' + e.message; }
|
||||
finally { btn.disabled = false; btn.innerHTML = old; }
|
||||
}
|
||||
|
||||
async function saveWifiPassword(btn) {
|
||||
var id = document.getElementById('wifiDeviceId').value;
|
||||
var pass = String(document.getElementById('wifiPassInput').value || '').trim();
|
||||
var msg = document.getElementById('wifiMsg');
|
||||
if (pass.length < 8) { msg.textContent = 'Sandi minimal 8 karakter.'; return; }
|
||||
if (!confirm('Ubah sandi WiFi perangkat ini?')) return;
|
||||
var old = btn.innerHTML; btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Menyimpan…';
|
||||
msg.textContent = '';
|
||||
try {
|
||||
var r = await fetch('/admin/acs/api/device/' + encodeURIComponent(id) + '/password', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pass })
|
||||
});
|
||||
var d = await r.json();
|
||||
msg.style.color = d.success ? 'var(--success)' : 'var(--danger)';
|
||||
msg.textContent = d.success ? d.message : ('Gagal: ' + (d.message || 'unknown'));
|
||||
if (d.success) document.getElementById('wifiPassInput').value = '';
|
||||
} catch (e) { msg.style.color = 'var(--danger)'; msg.textContent = 'Error: ' + e.message; }
|
||||
finally { btn.disabled = false; btn.innerHTML = old; }
|
||||
}
|
||||
|
||||
async function deleteDevice(deviceId, acsId) {
|
||||
if (!confirm('Hapus perangkat ' + deviceId + ' dari database ACS?')) return;
|
||||
try {
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
<option value="true" <%= (settings.auto_delete_overdue_enabled === undefined ? true : settings.auto_delete_overdue_enabled) ? 'selected' : '' %>><%= t('common.active', 'Aktif') %></option>
|
||||
<option value="false" <%= (settings.auto_delete_overdue_enabled === undefined ? true : settings.auto_delete_overdue_enabled) ? '' : 'selected' %>><%= t('common.inactive', 'Nonaktif') %></option>
|
||||
</select>
|
||||
<div class="hint"><%= t('admin.automation.auto_delete_hint', 'Jika Aktif: pelanggan menunggak lebih dari batas (mis. bulan ke-4) dihapus otomatis — harus daftar ulang.') %></div>
|
||||
<div class="hint"><%= t('admin.automation.auto_delete_hint', 'Jika Aktif: pelanggan menunggak satu bulan setelah batas dihapus otomatis — harus daftar ulang.') %></div>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label class="fl"><i class="bi bi-exclamation-triangle"></i> <%= t('admin.automation.arrears_notice', 'Auto-Peringatan Tunggakan') %></label>
|
||||
|
||||
213
views/admin/custom_qr.ejs
Normal file
@@ -0,0 +1,213 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="<%= lang || 'id' %>">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title><%= title %> | <%= company %></title>
|
||||
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
<%- include('partials/sidebar', { activePage: activePage || 'custom_qr', 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-qr-code-simple"></i> QR-Custom Payment</div>
|
||||
<div class="tb-right">
|
||||
<span class="adm-badge"><i class="bi bi-person-fill"></i> Admin</span>
|
||||
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i> Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page">
|
||||
<% if (msg) { %>
|
||||
<div class="alert alert-<%= msg.type==='success'?'s':'d' %>" data-flash="1">
|
||||
<i class="bi bi-<%= msg.type==='success'?'check-circle':'exclamation-circle' %>"></i> <%= msg.text %>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<% if (typeof ready !== 'undefined' && !ready) { %>
|
||||
<div class="alert alert-d"><i class="bi bi-exclamation-triangle"></i> <b>QR-Custom Payment dinonaktifkan.</b> Aktifkan <b>QRIS Statis (Semi-Otomatis)</b> dan isi <b>QRIS static payload</b> di <a href="/admin/settings" style="color:inherit;text-decoration:underline">Pengaturan</a> terlebih dahulu.</div>
|
||||
<% } %>
|
||||
|
||||
<% if (typeof ready === 'undefined' || ready) { %>
|
||||
<div class="card mb16">
|
||||
<div class="card-hd"><h6><i class="bi bi-plus-circle"></i> Buat QR Nominal Custom</h6></div>
|
||||
<div class="card-bd">
|
||||
<form method="POST" action="/admin/custom-qr">
|
||||
<div class="form-row">
|
||||
<div class="fg" style="flex:2"><label class="fl">Judul *</label><input class="fc" name="title" required placeholder="mis. Pembayaran sewa tower / donasi perusahaan"></div>
|
||||
<div class="fg"><label class="fl">Kategori</label><input class="fc" name="category" placeholder="mis. Sewa, Donasi, Lain-lain"></div>
|
||||
<div class="fg"><label class="fl">Nominal (Rp) *</label><input class="fc" type="number" min="1" name="amount" required placeholder="100000"></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="fg" style="flex:2"><label class="fl">Catatan (opsional)</label><input class="fc" name="note" placeholder="Keterangan untuk penerima link"></div>
|
||||
<div class="fg" style="display:flex;align-items:flex-end"><button class="btn btn-p" type="submit"><i class="bi bi-qr-code"></i> Generate QR (20 menit)</button></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<div class="form-row mb16">
|
||||
<div class="fg"><div class="card"><div class="card-bd"><div class="muted" style="font-size:12px">Pembayaran masuk</div><div style="font-size:22px;font-weight:800"><%= stats.paidCount %></div><div class="muted" style="font-size:11px">Rp <%= Number(stats.paidTotal||0).toLocaleString('id-ID') %></div></div></div></div>
|
||||
<div class="fg"><div class="card"><div class="card-bd"><div class="muted" style="font-size:12px">Menunggu (pending)</div><div style="font-size:22px;font-weight:800;color:#fbbf24"><%= stats.pendingCount %></div></div></div></div>
|
||||
<div class="fg"><div class="card"><div class="card-bd"><div class="muted" style="font-size:12px">Warning (lewat 20 menit)</div><div style="font-size:22px;font-weight:800;color:#fb923c"><%= stats.warningCount %></div></div></div></div>
|
||||
<div class="fg"><div class="card"><div class="card-bd"><div class="muted" style="font-size:12px">Ditolak / refund</div><div style="font-size:22px;font-weight:800"><%= stats.rejectedCount %></div></div></div></div>
|
||||
</div>
|
||||
|
||||
<% if (detail) { %>
|
||||
<% const cqExpired = detail.status === 'expired' || (detail.status === 'pending' && detail.expires_at && new Date(detail.expires_at).getTime() < Date.now()); %>
|
||||
<div class="card mb16">
|
||||
<div class="card-hd"><h6><i class="bi bi-qr-code"></i> <%= detail.title %><% if (!cqExpired) { %> — Rp <%= Number(detail.amount_unique||0).toLocaleString('id-ID') %><% } %></h6></div>
|
||||
<div class="card-bd" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:18px">
|
||||
<div style="text-align:center">
|
||||
<% if (detail.status === 'paid' || detail.status === 'rejected' || cqExpired) { %>
|
||||
<div style="padding:24px;border:1px dashed var(--border);border-radius:12px;color:var(--muted)">
|
||||
<% if (cqExpired) { %>QR kedaluwarsa — nominal & QR disembunyikan.<% } else { %>QR tidak aktif — status: <b><%= detail.status %></b><% } %>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<img src="/custom-qr/<%= detail.share_token %>/qr.png" alt="QRIS" style="max-width:240px;background:#fff;padding:10px;border-radius:12px">
|
||||
<div id="cqTimer" data-expires="<%= detail.expires_at || '' %>" style="margin-top:8px;font-size:13px;color:#fbbf24"></div>
|
||||
<% } %>
|
||||
</div>
|
||||
<div style="font-size:13px;line-height:1.8">
|
||||
<div><b>Status:</b> <%= detail.status %></div>
|
||||
<% if (!cqExpired) { %>
|
||||
<div><b>Nominal dasar:</b> Rp <%= Number(detail.amount||0).toLocaleString('id-ID') %></div>
|
||||
<div><b>Kode unik:</b> <%= detail.unique_code %></div>
|
||||
<div><b>Nominal bayar:</b> Rp <%= Number(detail.amount_unique||0).toLocaleString('id-ID') %></div>
|
||||
<% } %>
|
||||
<div><b>Kategori:</b> <%= detail.category || '-' %></div>
|
||||
<div><b>Dibuat:</b> <%= detail.created_at %></div>
|
||||
<div><b>Kedaluwarsa:</b> <%= detail.expires_at || '-' %></div>
|
||||
<% if (detail.note) { %><div><b>Catatan:</b> <%= detail.note %></div><% } %>
|
||||
<div style="margin-top:8px"><b>Link share:</b><br>
|
||||
<input class="fc" readonly id="cqLink" value="<%= baseUrl %>/custom-qr/<%= detail.share_token %>" style="font-size:12px">
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px">
|
||||
<button class="btn btn-g btn-sm" onclick="copyCqLink()"><i class="bi bi-copy"></i> Salin Link</button>
|
||||
<a class="btn btn-g btn-sm" href="<%= baseUrl %>/custom-qr/<%= detail.share_token %>" target="_blank" rel="noopener"><i class="bi bi-box-arrow-up-right"></i> Buka</a>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:10px">
|
||||
<% if (detail.status !== 'paid') { %>
|
||||
<form method="POST" action="/admin/custom-qr/<%= detail.id %>/manual-paid" onsubmit="return confirm('Tandai pembayaran ini LUNAS secara manual?')">
|
||||
<input type="hidden" name="note" value="Manual pay dari panel admin">
|
||||
<button class="btn btn-p btn-sm"><i class="bi bi-check2-circle"></i> Manual Pay</button>
|
||||
</form>
|
||||
<% } %>
|
||||
<% if (detail.status === 'warning') { %>
|
||||
<form method="POST" action="/admin/custom-qr/<%= detail.id %>/accept" onsubmit="return confirm('Terima pembayaran setelah 20 menit?')">
|
||||
<button class="btn btn-p btn-sm"><i class="bi bi-check2"></i> Selesaikan</button>
|
||||
</form>
|
||||
<% } %>
|
||||
<% if (detail.status !== 'rejected') { %>
|
||||
<form method="POST" action="/admin/custom-qr/<%= detail.id %>/reject" onsubmit="return confirm('Tolak / refund pembayaran ini?')">
|
||||
<input type="hidden" name="note" value="Refund dari panel admin">
|
||||
<button class="btn btn-g btn-sm"><i class="bi bi-x-circle"></i> Reject / Refund</button>
|
||||
</form>
|
||||
<% } %>
|
||||
<% if (cqExpired) { %>
|
||||
<form method="POST" action="/admin/custom-qr/<%= detail.id %>/regenerate" onsubmit="return confirm('Buat QR baru dengan kode unik baru?')">
|
||||
<button class="btn btn-p btn-sm"><i class="bi bi-arrow-repeat"></i> Regenerate QR</button>
|
||||
</form>
|
||||
<% } %>
|
||||
</div>
|
||||
<form method="POST" action="/admin/custom-qr/<%= detail.id %>/delete" onsubmit="return confirm('Hapus QR ini dari histori?')" style="display:flex;gap:6px;margin-top:12px;flex-wrap:wrap">
|
||||
<input class="fc" type="password" name="password" required placeholder="Password admin" style="max-width:200px">
|
||||
<button class="btn btn-g btn-sm"><i class="bi bi-trash"></i> Hapus QR ini</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-hd" style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
<h6 style="margin:0"><i class="bi bi-clock-history"></i> Histori QR Custom</h6>
|
||||
<form method="POST" action="/admin/custom-qr/reset" onsubmit="return confirm('Reset SEMUA histori QR custom? Tindakan ini tidak bisa dibatalkan.')" style="display:flex;gap:6px;align-items:center">
|
||||
<input class="fc" type="password" name="password" required placeholder="Password admin" style="max-width:180px">
|
||||
<button class="btn btn-g btn-sm"><i class="bi bi-trash3"></i> Reset Histori</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-bd" style="border-bottom:1px solid var(--border)">
|
||||
<form method="GET" action="/admin/custom-qr" style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end">
|
||||
<div class="fg"><label class="fl">Dari tanggal</label><input class="fc" type="date" name="from" value="<%= filterFrom || '' %>"></div>
|
||||
<div class="fg"><label class="fl">Sampai tanggal</label><input class="fc" type="date" name="to" value="<%= filterTo || '' %>"></div>
|
||||
<button class="btn btn-p btn-sm" type="submit"><i class="bi bi-funnel"></i> Filter</button>
|
||||
<a class="btn btn-g btn-sm" href="/admin/custom-qr"><i class="bi bi-x-circle"></i> Reset Filter</a>
|
||||
</form>
|
||||
</div>
|
||||
<form id="cqBulkForm" method="POST" action="/admin/custom-qr/delete-bulk"></form>
|
||||
<div class="card-bd" style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;border-bottom:1px solid var(--border)">
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;cursor:pointer">
|
||||
<input type="checkbox" id="cqAll" onclick="cqToggleAll(this)"> Pilih semua
|
||||
</label>
|
||||
<input form="cqBulkForm" class="fc" type="password" name="password" required placeholder="Password admin" style="max-width:180px">
|
||||
<input form="cqBulkForm" type="hidden" name="from" value="<%= filterFrom || '' %>">
|
||||
<input form="cqBulkForm" type="hidden" name="to" value="<%= filterTo || '' %>">
|
||||
<button form="cqBulkForm" class="btn btn-g btn-sm" onclick="return confirm('Hapus QR yang dipilih?')"><i class="bi bi-trash"></i> Hapus Terpilih</button>
|
||||
</div>
|
||||
<div class="card-bd" style="overflow:auto">
|
||||
<table class="table" style="width:100%;font-size:12.5px">
|
||||
<thead><tr><th style="width:30px"></th><th>Judul</th><th>Kategori</th><th>Nominal</th><th>Status</th><th>Dibuat</th><th>Kedaluwarsa</th><th>Aksi</th></tr></thead>
|
||||
<tbody>
|
||||
<% if (!rows || rows.length === 0) { %>
|
||||
<tr><td colspan="8" class="muted" style="text-align:center;padding:20px">Belum ada QR custom.</td></tr>
|
||||
<% } %>
|
||||
<% rows.forEach(function(r) { %>
|
||||
<tr>
|
||||
<td><input type="checkbox" class="cq-check" form="cqBulkForm" name="ids" value="<%= r.id %>"></td>
|
||||
<td><b><%= r.title %></b><% if (r.note) { %><div class="muted" style="font-size:11px"><%= r.note %></div><% } %></td>
|
||||
<td><%= r.category || '-' %></td>
|
||||
<td>Rp <%= Number(r.amount_unique||0).toLocaleString('id-ID') %></td>
|
||||
<td>
|
||||
<span style="font-weight:700;color:<%= r.status==='paid'?'#4ade80':(r.status==='warning'?'#fb923c':(r.status==='rejected'?'#94a3b8':(r.status==='expired'?'#f87171':'#fbbf24'))) %>"><%= r.status %></span>
|
||||
</td>
|
||||
<td><%= r.created_at %></td>
|
||||
<td><%= r.expires_at || '-' %></td>
|
||||
<td style="white-space:nowrap">
|
||||
<a class="btn btn-g btn-sm" href="/admin/custom-qr?id=<%= r.id %>"><i class="bi bi-eye"></i></a>
|
||||
<% if (r.status !== 'paid' && r.status !== 'rejected') { %>
|
||||
<form method="POST" action="/admin/custom-qr/<%= r.id %>/manual-paid" style="display:inline" onsubmit="return confirm('Tandai LUNAS manual?')">
|
||||
<button class="btn btn-g btn-sm" title="Manual pay"><i class="bi bi-check2-circle"></i></button>
|
||||
</form>
|
||||
<% } %>
|
||||
<% if (r.status === 'expired') { %>
|
||||
<form method="POST" action="/admin/custom-qr/<%= r.id %>/regenerate" style="display:inline" onsubmit="return confirm('Buat QR baru dengan kode unik baru?')">
|
||||
<button class="btn btn-g btn-sm" title="Regenerate"><i class="bi bi-arrow-repeat"></i></button>
|
||||
</form>
|
||||
<% } %>
|
||||
</td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function cqToggleAll(el) {
|
||||
var on = !!(el && el.checked);
|
||||
document.querySelectorAll('.cq-check').forEach(function (c) { c.checked = on; });
|
||||
}
|
||||
function copyCqLink() {
|
||||
var el = document.getElementById('cqLink');
|
||||
if (!el) return;
|
||||
try { el.select(); document.execCommand('copy'); } catch (e) {}
|
||||
if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(el.value).catch(function () {});
|
||||
}
|
||||
(function () {
|
||||
var el = document.getElementById('cqTimer');
|
||||
if (!el) return;
|
||||
var exp = el.getAttribute('data-expires');
|
||||
function tick() {
|
||||
if (!exp) { el.textContent = ''; return; }
|
||||
var d = new Date(exp).getTime() - Date.now();
|
||||
if (d <= 0) { el.textContent = 'Lewat 20 menit — pembayaran tetap dicek.'; return; }
|
||||
var m = Math.floor(d / 60000), s = Math.floor((d % 60000) / 1000);
|
||||
el.textContent = 'Sisa waktu: ' + m + ' menit ' + s + ' detik';
|
||||
}
|
||||
tick(); setInterval(tick, 1000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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')">×</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')">×</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')">×</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')">×</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) {
|
||||
|
||||
@@ -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://gitea.oxpoe.qzz.io/oxpoe/open-isp" target="_blank" rel="noopener" style="color: #cbd5e1; font-size: 11px; text-decoration: none; font-weight: 700;">
|
||||
<a href="https://gitea.oxpoe.xyz/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,7 +163,7 @@ 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://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">
|
||||
<a href="https://gitea.oxpoe.xyz/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()">
|
||||
@@ -241,3 +241,31 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<div id="restart-fab" style="display:none;position:fixed;right:18px;bottom:84px;z-index:9999;max-width:330px">
|
||||
<div style="background:#7c2d12;color:#fff;border:1px solid #f59e0b;border-radius:12px;padding:12px 14px;box-shadow:0 10px 30px rgba(0,0,0,.35);font-size:13px;line-height:1.45">
|
||||
<b><i class="bi bi-exclamation-triangle-fill"></i> Billing perlu restart</b>
|
||||
<div id="restart-fab-text" style="margin-top:4px;color:#fde68a">Update sudah diterapkan, tetapi kode lama masih berjalan.</div>
|
||||
<a href="/admin/update" style="display:inline-block;margin-top:8px;background:#f59e0b;color:#111;font-weight:700;padding:6px 10px;border-radius:8px;text-decoration:none">Cara restart</a>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
function checkRestartRequired() {
|
||||
fetch('/admin/api/restart-required', { cache: 'no-store' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
var el = document.getElementById('restart-fab');
|
||||
if (!el) return;
|
||||
el.style.display = (d && d.pending) ? 'block' : 'none';
|
||||
if (d && d.pending && d.version) {
|
||||
var t = document.getElementById('restart-fab-text');
|
||||
if (t) t.textContent = 'Update v' + d.version + ' sudah diterapkan. Restart aplikasi agar fitur/fix baru aktif.';
|
||||
}
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
checkRestartRequired();
|
||||
setInterval(checkRestartRequired, 30000);
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -527,6 +527,9 @@
|
||||
<button type="button" class="btn btn-sm btn-g" id="tab-btn-script" onclick="switchIsolirTab('script')">
|
||||
<i class="bi bi-filetype-txt me-1"></i> Copy Script RouterOS (.rsc)
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-g" id="tab-btn-cloud" onclick="switchIsolirTab('cloud')">
|
||||
<i class="bi bi-cloud-arrow-up me-1"></i> Cloud (Sederhana)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- TAB 1: Auto Setup via API -->
|
||||
@@ -554,6 +557,23 @@
|
||||
<div id="isolir-auto-setup-result" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- TAB 3: Billing Cloud (script sederhana) -->
|
||||
<div id="isolir-tab-cloud" style="display:none;">
|
||||
<div class="alert alert-info p-3 small mb-3" style="background:rgba(56,189,248,0.10); border:1px solid rgba(56,189,248,0.3); color:#e0f2fe;">
|
||||
<h6 class="fw-bold mb-1"><i class="bi bi-cloud me-1"></i> Billing di Cloud</h6>
|
||||
<p class="mb-1 small">Script sederhana: DNS diarahkan ke router, HTTP pelanggan isolir di-NAT ke <b>redirector billing</b>, HTTPS ditolak agar browser fallback ke HTTP.</p>
|
||||
<p class="mb-0 small">Syarat: port redirector (default <b>8099</b>) harus bisa diakses dari publik, dan <code>ISOLIR_REDIRECT_URL</code> di billing diisi <code>https://domain/isolated</code>.</p>
|
||||
</div>
|
||||
<div class="form-row" style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<div class="fg" style="flex:2;min-width:200px"><label class="fl">Domain / IP publik billing (disarankan IP origin, bukan CDN)</label><input class="fc" id="ic-host" value="<%= typeof defaultPublicBase !== 'undefined' ? defaultPublicBase : '' %>" placeholder="https://billing.domain.com"></div>
|
||||
<div class="fg" style="flex:1;min-width:100px"><label class="fl">Port redirector</label><input class="fc" id="ic-port" type="number" value="8099"></div>
|
||||
<div class="fg" style="flex:1;min-width:100px"><label class="fl">Profil PPPoE</label><input class="fc" id="ic-profile" value="isolir"></div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-p btn-sm my-2" onclick="loadIsolirCloudScript(this)"><i class="bi bi-magic"></i> Generate Script Cloud</button>
|
||||
<div id="isolir-cloud-meta" class="small text-muted mb-2"></div>
|
||||
<textarea id="isolir-cloud-body" class="fc" readonly rows="18" style="width:100%;font-family:ui-monospace,monospace;font-size:12px;background:rgba(15,23,42,0.9);color:#818cf8;border-radius:8px;"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- TAB 2: RouterOS Manual Script (.rsc) -->
|
||||
<div id="isolir-tab-script" style="display:none;">
|
||||
<p class="small text-muted mb-2"><%= t('admin.rt.script_hint','Script ini di-generate otomatis berdasarkan IP server & URL billing (app_url). Anda dapat menyalin dan menempelkannya di Winbox Terminal MikroTik.') %></p>
|
||||
@@ -563,6 +583,7 @@
|
||||
</div>
|
||||
<div class="mf">
|
||||
<button type="button" class="btn btn-g" onclick="closeModal('isolirScriptModal')"><%= t('common.close', 'Tutup') %></button>
|
||||
<button type="button" class="btn btn-p" id="btn-copy-cloud-modal" style="display:none;" onclick="copyIsolirCloudScript()"><i class="bi bi-clipboard me-1"></i> Salin Script Cloud</button>
|
||||
<button type="button" class="btn btn-p" id="btn-copy-script-modal" style="display:none;" onclick="copyIsolirScript()"><i class="bi bi-clipboard me-1"></i> <%= t('admin.routers.isolir_script_copy', 'Salin script') %></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -891,27 +912,60 @@ async function testConn(id) {
|
||||
}
|
||||
|
||||
function switchIsolirTab(tab) {
|
||||
const autoTab = document.getElementById('isolir-tab-auto');
|
||||
const scriptTab = document.getElementById('isolir-tab-script');
|
||||
const autoBtn = document.getElementById('tab-btn-auto');
|
||||
const scriptBtn = document.getElementById('tab-btn-script');
|
||||
const copyBtn = document.getElementById('btn-copy-script-modal');
|
||||
const panels = {
|
||||
auto: document.getElementById('isolir-tab-auto'),
|
||||
script: document.getElementById('isolir-tab-script'),
|
||||
cloud: document.getElementById('isolir-tab-cloud')
|
||||
};
|
||||
const btns = {
|
||||
auto: document.getElementById('tab-btn-auto'),
|
||||
script: document.getElementById('tab-btn-script'),
|
||||
cloud: document.getElementById('tab-btn-cloud')
|
||||
};
|
||||
const copyScript = document.getElementById('btn-copy-script-modal');
|
||||
const copyCloud = document.getElementById('btn-copy-cloud-modal');
|
||||
Object.keys(panels).forEach(function (k) {
|
||||
if (panels[k]) panels[k].style.display = (k === tab) ? 'block' : 'none';
|
||||
if (btns[k]) btns[k].className = 'btn btn-sm ' + (k === tab ? 'btn-p' : 'btn-g');
|
||||
});
|
||||
if (copyScript) copyScript.style.display = tab === 'script' ? 'inline-block' : 'none';
|
||||
if (copyCloud) copyCloud.style.display = tab === 'cloud' ? 'inline-block' : 'none';
|
||||
}
|
||||
|
||||
if (tab === 'auto') {
|
||||
autoTab.style.display = 'block';
|
||||
scriptTab.style.display = 'none';
|
||||
autoBtn.className = 'btn btn-sm btn-p';
|
||||
scriptBtn.className = 'btn btn-sm btn-g';
|
||||
if (copyBtn) copyBtn.style.display = 'none';
|
||||
} else {
|
||||
autoTab.style.display = 'none';
|
||||
scriptTab.style.display = 'block';
|
||||
autoBtn.className = 'btn btn-sm btn-g';
|
||||
scriptBtn.className = 'btn btn-sm btn-p';
|
||||
if (copyBtn) copyBtn.style.display = 'inline-block';
|
||||
async function loadIsolirCloudScript(btn) {
|
||||
const hostEl = document.getElementById('ic-host');
|
||||
const portEl = document.getElementById('ic-port');
|
||||
const profEl = document.getElementById('ic-profile');
|
||||
const body = document.getElementById('isolir-cloud-body');
|
||||
const meta = document.getElementById('isolir-cloud-meta');
|
||||
const old = btn ? btn.innerHTML : '';
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Membuat...'; }
|
||||
if (body) body.value = '';
|
||||
if (meta) meta.textContent = 'Membuat script...';
|
||||
try {
|
||||
const url = '/admin/api/isolir-cloud-script?host=' + encodeURIComponent(hostEl ? hostEl.value : '') +
|
||||
'&port=' + encodeURIComponent(portEl ? portEl.value : '') +
|
||||
'&profile=' + encodeURIComponent(profEl ? profEl.value : '');
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Gagal membuat script.');
|
||||
if (body) body.value = data.script || '';
|
||||
if (meta) meta.textContent = 'Billing: ' + (data.billingHost || '-') + ' → ' + ((data.billingIps || [data.billingIp]).join(', ')) + ' | redirect port ' + data.redirectPort + ' | ' + (data.isolirUrl || '') + (data.warning ? ' — ' + data.warning : '');
|
||||
} catch (e) {
|
||||
if (meta) meta.textContent = 'Error: ' + e.message;
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = old; }
|
||||
}
|
||||
}
|
||||
|
||||
function copyIsolirCloudScript() {
|
||||
const ta = document.getElementById('isolir-cloud-body');
|
||||
if (!ta || !ta.value) return;
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); } catch (e) {}
|
||||
alert('Script cloud disalin ke clipboard.');
|
||||
}
|
||||
|
||||
async function setupFirewall(routerId) {
|
||||
openIsolirScriptModal(routerId);
|
||||
switchIsolirTab('auto');
|
||||
|
||||
@@ -68,6 +68,43 @@
|
||||
<input class="fc" name="notice_note" id="n_note" value="<%= settings.notice_note || '' %>" placeholder="<%= t('admin.service_notice.note_ph', 'Kosongkan untuk teks bawaan') %>">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row" style="margin-top:4px">
|
||||
<div class="fg">
|
||||
<label class="fl" style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" name="notice_wa" value="1" id="n_wa">
|
||||
Kirim notifikasi WhatsApp ke pelanggan saat status berubah
|
||||
</label>
|
||||
<div class="hint">Terkirim otomatis saat mode diubah (gangguan/perbaikan, atau kembali NORMAL = layanan pulih).</div>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label class="fl">Target Notifikasi</label>
|
||||
<select class="fs" name="notice_wa_target" id="n_wa_target">
|
||||
<option value="all">Semua pelanggan aktif</option>
|
||||
<option value="area">Per area</option>
|
||||
<option value="router">Per router</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row" id="n_wa_extra" style="display:none">
|
||||
<div class="fg" id="n_wa_area_wrap">
|
||||
<label class="fl">Area</label>
|
||||
<select class="fs" name="notice_wa_area">
|
||||
<option value="">— pilih area —</option>
|
||||
<% (areas || []).forEach(function(a){ %>
|
||||
<option value="<%= a %>"><%= a %></option>
|
||||
<% }); %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fg" id="n_wa_router_wrap" style="display:none">
|
||||
<label class="fl">Router</label>
|
||||
<select class="fs" name="notice_wa_router">
|
||||
<option value="">— pilih router —</option>
|
||||
<% (routers || []).forEach(function(r){ %>
|
||||
<option value="<%= r.id %>"><%= r.name %></option>
|
||||
<% }); %>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-bd" style="border-top:1px solid var(--border);padding-top:16px;text-align:right">
|
||||
<button type="submit" class="btn btn-p"><i class="bi bi-save"></i> <%= t('common.save', 'Simpan') %></button>
|
||||
@@ -140,6 +177,19 @@
|
||||
[els.mode, els.start, els.interval, els.note].forEach(function(el){ el.addEventListener(ev, render); });
|
||||
});
|
||||
render();
|
||||
|
||||
var waTarget = document.getElementById('n_wa_target');
|
||||
var waExtra = document.getElementById('n_wa_extra');
|
||||
var waArea = document.getElementById('n_wa_area_wrap');
|
||||
var waRouter = document.getElementById('n_wa_router_wrap');
|
||||
function renderWaTarget(){
|
||||
var v = waTarget.value;
|
||||
waExtra.style.display = v === 'all' ? 'none' : 'flex';
|
||||
waArea.style.display = v === 'area' ? '' : 'none';
|
||||
waRouter.style.display = v === 'router' ? '' : 'none';
|
||||
}
|
||||
waTarget.addEventListener('change', renderWaTarget);
|
||||
renderWaTarget();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -173,6 +173,7 @@
|
||||
<button type="button" class="set-tab" data-cat="notif" onclick="setSetTab('notif')"><i class="bi bi-bell"></i> <%= t('admin.settings.tab_notif','Notifikasi & WhatsApp') %></button>
|
||||
<button type="button" class="set-tab" data-cat="keamanan" onclick="setSetTab('keamanan')"><i class="bi bi-shield-lock"></i> <%= t('admin.settings.tab_security','Keamanan') %></button>
|
||||
<button type="button" class="set-tab" data-cat="pembayaran" onclick="setSetTab('pembayaran')"><i class="bi bi-wallet2"></i> <%= t('admin.settings.tab_payment','Pembayaran') %></button>
|
||||
<button type="button" class="set-tab" data-cat="ai" onclick="setSetTab('ai')"><i class="bi bi-robot"></i> AI</button>
|
||||
<button type="button" class="set-tab" data-cat="branding" onclick="setSetTab('branding')"><i class="bi bi-palette"></i> Branding</button>
|
||||
</div>
|
||||
<div class="set-search">
|
||||
@@ -208,6 +209,59 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card mb16" data-setcat="ai">
|
||||
<div class="card-hd"><h6><i class="bi bi-robot"></i> AI & 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 style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:12px">
|
||||
<button type="button" class="btn btn-g" onclick="testAiApi(this)"><i class="bi bi-plug"></i> Test API</button>
|
||||
<span id="ai-test-status" style="font-size:12px;color:var(--muted)">Menguji API key & model yang sedang diisi (tanpa simpan).</span>
|
||||
</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 & 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">
|
||||
@@ -219,11 +273,26 @@
|
||||
<label class="fl"><%= t('admin.settings.company_name', 'Nama Aplikasi / Perusahaan') %></label>
|
||||
<input class="fc" name="company_header" value="<%= settings.company_header || '' %>">
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label class="fl">Nama PT / Sub-header (kop invoice & PDF)</label>
|
||||
<input class="fc" name="company_subheader" value="<%= settings.company_subheader || '' %>" placeholder="Contoh: PT Nama Perusahaan">
|
||||
<div style="font-size:10px;color:var(--muted);margin-top:4px">Dipakai pada kop invoice/PDF. Bisa berbeda tiap instalasi.</div>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label class="fl"><%= t('admin.set.public_url','URL Publik Aplikasi (Public Base URL)') %></label>
|
||||
<input class="fc" name="public_base_url" value="<%= settings.public_base_url || '' %>" placeholder="https://portal.domain-anda.com">
|
||||
<div style="font-size:10px; color:var(--muted); margin-top:4px"><%= t('admin.set.link_hint','Digunakan untuk menyusun tautan login pelanggan yang dikirim via WhatsApp. Contoh: ') %><span class="mono">https://portal.domain-anda.com</span></div>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label class="fl">URL Halaman Isolir</label>
|
||||
<input class="fc" name="isolir_portal_url" value="<%= settings.isolir_portal_url || '' %>" placeholder="https://domain-anda/isolated">
|
||||
<div style="font-size:10px;color:var(--muted);margin-top:4px">Tujuan redirect pelanggan terisolir (dipakai redirector & generator script cloud).</div>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label class="fl">Port Redirector Isolir</label>
|
||||
<input class="fc" type="number" min="1" max="65535" name="isolir_redirect_port" value="<%= settings.isolir_redirect_port || 8099 %>">
|
||||
<div style="font-size:10px;color:var(--muted);margin-top:4px">Port server redirector di billing (default 8099). Ubah NAT MikroTik bila port diganti.</div>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label class="fl"><%= t('admin.set.id_prefix','Prefix ID Pelanggan') %></label>
|
||||
<input class="fc" name="customer_id_prefix" value="<%= settings.customer_id_prefix || 'SKY' %>" placeholder="SKY" style="text-transform:uppercase" maxlength="12">
|
||||
@@ -323,6 +392,71 @@
|
||||
</div>
|
||||
|
||||
<!-- GenieACS & MikroTik -->
|
||||
<div class="card mb16" data-setcat="umum">
|
||||
<div class="card-hd"><h6><i class="bi bi-window-desktop"></i> Kustomisasi Beranda & Portal Pelanggan</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-megaphone"></i> Banner & Status</h6>
|
||||
<div class="fg">
|
||||
<label class="fl">Banner Pengumuman Portal</label>
|
||||
<select class="fs" name="portal_banner_enabled">
|
||||
<option value="false" <%= !settings.portal_banner_enabled ? 'selected' : '' %>>Nonaktif</option>
|
||||
<option value="true" <%= settings.portal_banner_enabled ? 'selected' : '' %>>Aktif</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label class="fl">Tipe Banner</label>
|
||||
<select class="fs" name="portal_banner_type">
|
||||
<option value="info" <%= (settings.portal_banner_type === 'info' || !settings.portal_banner_type) ? 'selected' : '' %>>Info (Biru)</option>
|
||||
<option value="warning" <%= settings.portal_banner_type === 'warning' ? 'selected' : '' %>>Peringatan (Kuning)</option>
|
||||
<option value="danger" <%= settings.portal_banner_type === 'danger' ? 'selected' : '' %>>Gangguan (Merah)</option>
|
||||
<option value="success" <%= settings.portal_banner_type === 'success' ? 'selected' : '' %>>Promo (Hijau)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label class="fl">Teks Banner</label>
|
||||
<textarea class="fc" name="portal_banner_text" rows="2" placeholder="Contoh: Maintenance jaringan Kamis 01.00-04.00 WIB."><%= settings.portal_banner_text || '' %></textarea>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label class="fl">Status Jaringan</label>
|
||||
<select class="fs" name="portal_network_status">
|
||||
<option value="normal" <%= (settings.portal_network_status === 'normal' || !settings.portal_network_status) ? 'selected' : '' %>>🟢 Normal</option>
|
||||
<option value="maintenance" <%= settings.portal_network_status === 'maintenance' ? 'selected' : '' %>>🟠 Pemeliharaan</option>
|
||||
<option value="issue" <%= settings.portal_network_status === 'issue' ? 'selected' : '' %>>🔴 Ada Kendala</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label class="fl">Keterangan Status (opsional)</label>
|
||||
<input class="fc" name="portal_network_status_text" value="<%= settings.portal_network_status_text || '' %>" placeholder="Kosongkan untuk teks bawaan">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h6 style="font-size:13px;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:1px"><i class="bi bi-card-heading"></i> Hero & Media Sosial</h6>
|
||||
<div class="fg">
|
||||
<label class="fl">Badge Kecil</label>
|
||||
<input class="fc" name="portal_hero_badge" value="<%= settings.portal_hero_badge || '' %>" placeholder="Portal Pelanggan">
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label class="fl">Judul Utama</label>
|
||||
<input class="fc" name="portal_hero_title" value="<%= settings.portal_hero_title || '' %>" placeholder="Kosongkan untuk teks bawaan">
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label class="fl">Deskripsi</label>
|
||||
<textarea class="fc" name="portal_hero_subtitle" rows="2" placeholder="Kosongkan untuk teks bawaan"><%= settings.portal_hero_subtitle || '' %></textarea>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label class="fl">Link Google Maps Kantor</label>
|
||||
<input class="fc" name="social_maps_url" value="<%= settings.social_maps_url || '' %>" placeholder="https://maps.google.com/...">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="fg"><label class="fl">Instagram</label><input class="fc" name="social_instagram" value="<%= settings.social_instagram || '' %>" placeholder="https://instagram.com/..."></div>
|
||||
<div class="fg"><label class="fl">Facebook</label><input class="fc" name="social_facebook" value="<%= settings.social_facebook || '' %>" placeholder="https://facebook.com/..."></div>
|
||||
</div>
|
||||
<div class="fg"><label class="fl">TikTok</label><input class="fc" name="social_tiktok" value="<%= settings.social_tiktok || '' %>" placeholder="https://tiktok.com/@..."></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-setcat="jaringan">
|
||||
<h6 style="font-size:13px;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:1px"><i class="bi bi-router"></i> <%= t('admin.settings.external_api', 'API Eksternal') %></h6>
|
||||
|
||||
@@ -591,9 +725,18 @@
|
||||
</script>
|
||||
<div class="fg">
|
||||
<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">
|
||||
<input class="fc" name="project_url" value="<%= settings.project_url || 'https://gitea.oxpoe.xyz/oxpoe/open-isp' %>" placeholder="https://gitea.oxpoe.xyz/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">
|
||||
<label class="fl">Sumber Update (host Git)</label>
|
||||
<input class="fc" name="update_base_url" value="<%= settings.update_base_url || 'https://gitea.oxpoe.xyz' %>" placeholder="https://gitea.oxpoe.xyz">
|
||||
<div style="font-size:10px; color:var(--muted); margin-top:4px">Dipakai menu <b>Update</b> untuk <code>git fetch</code>. Bisa Gitea (<code>https://gitea.oxpoe.xyz</code>) atau Forgejo (<code>https://forgejo.oxpoe.xyz</code>).</div>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label class="fl">Repo Update (owner/nama)</label>
|
||||
<input class="fc" name="update_repo" value="<%= settings.update_repo || 'oxpoe/open-isp' %>" placeholder="oxpoe/open-isp">
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label class="fl"><%= t('admin.set.uptime_link','Link Uptime Website (Status)') %></label>
|
||||
<input class="fc" name="uptime_url" value="<%= settings.uptime_url || '' %>" placeholder="https://status.domain-anda.com">
|
||||
@@ -747,7 +890,7 @@
|
||||
<div style="margin-top: 15px; border-top: 1px dashed rgba(0,0,0,0.1); padding-top: 15px;">
|
||||
<h6 style="font-size:11px; margin-bottom: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;"><i class="bi bi-phone"></i> Hubungkan Webhook Notifikasi E-Wallet (Mode 2)</h6>
|
||||
<%
|
||||
const _base = (settings.public_base_url || '').replace(/\/+$/, '');
|
||||
const _base = String(typeof webhookBaseUrl !== 'undefined' && webhookBaseUrl ? webhookBaseUrl : (settings.public_base_url || '')).replace(/\/+$/, '');
|
||||
const _secret = String(settings.webhook_secret || '');
|
||||
const macroWebhookUrlFull = _base + '/api/webhook/v1/payment-notif?secret_key=' + encodeURIComponent(_secret);
|
||||
%>
|
||||
@@ -773,6 +916,7 @@
|
||||
<option value="false" <%= (settings.webhook_unmatched_notify_enabled === undefined ? true : settings.webhook_unmatched_notify_enabled) ? '' : 'selected' %>><%= t('common.inactive','Nonaktif') %></option>
|
||||
</select>
|
||||
<div class="hint"><%= t('admin.set.wh_wa_hint','Kirim WA ke admin tiap ada pembayaran terdeteksi tapi belum cocok (untuk dicocokkan manual via jam notif vs bukti transfer).') %></div>
|
||||
<div class="hint" style="margin-top:6px"><i class="bi bi-info-circle"></i> Secret dibuat otomatis jika kosong. URL memakai <b>Public Base URL</b>; bila kosong memakai host saat ini. Salin URL + token ke aplikasi notification forwarder.</div>
|
||||
</div>
|
||||
<div class="fg" style="margin-top:10px">
|
||||
<label class="fl"><i class="bi bi-clock-history"></i> <%= t('admin.set.wh_retention','Retensi Notifikasi Webhook (Otomasi)') %></label>
|
||||
@@ -1187,5 +1331,60 @@ function setSearch(q) {
|
||||
document.querySelectorAll('.set-tab').forEach(function (b) { b.addEventListener('click', function () { setSetTab(this.getAttribute('data-cat')); }); });
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
async function testAiApi(btn) {
|
||||
var st = document.getElementById('ai-test-status');
|
||||
var provider = (document.querySelector('select[name="ai_provider"]') || {}).value || 'deepseek';
|
||||
var map = {
|
||||
deepseek: { key: 'deepseek_api_key', model: 'deepseek_model', base: '' },
|
||||
openai: { key: 'openai_api_key', model: 'openai_model', base: '' },
|
||||
groq: { key: 'groq_api_key', model: 'groq_model', base: '' },
|
||||
openrouter: { key: 'openrouter_api_key', model: 'openrouter_model', base: '' },
|
||||
custom: { key: 'custom_ai_api_key', model: 'custom_ai_model', base: 'custom_ai_base_url' }
|
||||
};
|
||||
var m = map[provider] || map.deepseek;
|
||||
function val(name) { var el = document.querySelector('[name="' + name + '"]'); return el ? el.value : ''; }
|
||||
var old = btn ? btn.innerHTML : '';
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Menguji...'; }
|
||||
if (st) { st.style.color = 'var(--muted)'; st.textContent = 'Menguji ' + provider + '...'; }
|
||||
try {
|
||||
var r = await fetch('/admin/settings/test-ai', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
provider: provider,
|
||||
apiKey: val(m.key),
|
||||
model: val(m.model),
|
||||
baseUrl: m.base ? val(m.base) : ''
|
||||
})
|
||||
});
|
||||
var d = await r.json();
|
||||
if (st) {
|
||||
st.style.color = d.ok ? 'var(--success)' : 'var(--danger)';
|
||||
st.textContent = (d.ok ? '✅ ' : '❌ ') + (d.message || (d.ok ? 'API valid.' : 'API tidak valid.'));
|
||||
}
|
||||
} catch (e) {
|
||||
if (st) { st.style.color = 'var(--danger)'; st.textContent = '❌ Gagal menguji: ' + e.message; }
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = old; }
|
||||
}
|
||||
}
|
||||
</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>
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
</div>
|
||||
<div class="card-bd">
|
||||
<p class="hint" style="margin-top:0">
|
||||
<%= t('admin.sb.intro', 'Setiap menu bisa diatur menjadi Tampil, Sembunyikan, atau Kunci. Menu yang dikunci akan meminta password khusus sebelum dibuka.') %>
|
||||
<%= t('admin.sb.intro', 'Setiap menu bisa diatur menjadi Tampil atau Sembunyikan.') %>
|
||||
</p>
|
||||
|
||||
<% if (canManageSidebar) { %>
|
||||
@@ -76,7 +76,7 @@
|
||||
<select class="fs" name="menu_state[<%= menu.key %>]">
|
||||
<option value="visible" <%= menu.state === 'visible' ? 'selected' : '' %>><%= t('admin.sb.st_visible', 'Tampil') %></option>
|
||||
<option value="hidden" <%= menu.state === 'hidden' ? 'selected' : '' %>><%= t('admin.sb.st_hidden', 'Sembunyikan') %></option>
|
||||
<option value="locked" <%= menu.state === 'locked' ? 'selected' : '' %>><%= t('admin.sb.st_locked', 'Kunci') %></option>
|
||||
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -97,7 +97,7 @@
|
||||
<select class="fs" name="menu_state[<%= ch.key %>]">
|
||||
<option value="visible" <%= ch.state === 'visible' ? 'selected' : '' %>><%= t('admin.sb.st_visible', 'Tampil') %></option>
|
||||
<option value="hidden" <%= ch.state === 'hidden' ? 'selected' : '' %>><%= t('admin.sb.st_hidden', 'Sembunyikan') %></option>
|
||||
<option value="locked" <%= ch.state === 'locked' ? 'selected' : '' %>><%= t('admin.sb.st_locked', 'Kunci') %></option>
|
||||
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -108,12 +108,9 @@
|
||||
</div>
|
||||
<div class="sidebar-settings-save-grid" style="margin-top:16px">
|
||||
<div class="fg" style="margin:0">
|
||||
<label class="fl"><%= t('admin.sb.pass_label', 'Password Aktivasi (Seikhlasnya)') %></label>
|
||||
<input type="password" class="fc" name="feature_password" placeholder="<%= t('admin.sb.pass_ph', 'Masukkan password aktivasi sebelum menyimpan') %>" required>
|
||||
<div class="hint" style="margin-top:6px"><%= t('admin.sb.pass_hint', 'Perubahan status menu sidebar hanya akan disimpan jika password aktivasi benar.') %></div>
|
||||
</div>
|
||||
<div class="hint" style="align-self:center">
|
||||
<%= t('admin.sb.no_pass', 'Belum punya kode aktivasi?') %> <a href="/donate" target="_blank" rel="noopener" style="color:var(--primary);font-weight:700;"><i class="bi bi-heart-fill me-1"></i> <%= t('admin.sb.get_donation', 'Dapatkan kode aktivasi GRATIS di halaman Dukungan OPEN-ISP') %></a> <%= t('admin.sb.or_contact', 'atau hubungi') %> <strong><%= featureContactPhone %></strong>.
|
||||
<%= t('admin.sb.no_pass', 'Menu bisa ditampilkan/disembunyikan dari halaman ini.') %> <a href="/donate" target="_blank" rel="noopener" style="color:var(--primary);font-weight:700;"><i class="bi bi-heart-fill me-1"></i> <%= t('admin.sb.get_donation', 'Dukung pengembangan OPEN-ISP') %></a>.
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:16px;flex-wrap:wrap">
|
||||
@@ -138,12 +135,12 @@
|
||||
</div>
|
||||
<div class="card-bd">
|
||||
<div class="alert alert-s" style="margin-bottom:12px">
|
||||
<i class="bi bi-info-circle"></i> <%= t('admin.sb.note_lock', 'Jika menu berstatus Kunci diklik dari sidebar, sistem menampilkan cara mengambil kode aktivasi GRATIS atau menghubungi') %> <strong><%= featureContactPhone %></strong>.
|
||||
<i class="bi bi-info-circle"></i> <%= t('admin.sb.note_lock', 'Menu berstatus Kunci tidak dapat dibuka sampai diaktifkan dari halaman ini.') %>
|
||||
</div>
|
||||
<ul style="margin:0;padding-left:18px;display:grid;gap:8px">
|
||||
<li><%= t('admin.sb.note1', 'Status Sembunyikan akan menghilangkan menu dari sidebar dan menolak akses URL langsung.') %></li>
|
||||
<li><%= t('admin.sb.note2', 'Status Kunci tetap menampilkan menu, tetapi tidak bisa dibuka sampai statusnya diubah di halaman ini.') %></li>
|
||||
<li><%= t('admin.sb.note3', 'Perubahan status menu hanya dapat disimpan setelah memasukkan password aktivasi yang benar.') %></li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,18 @@
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<% if (typeof restartPending !== 'undefined' && restartPending) { %>
|
||||
<div class="alert alert-d" style="display:flex;gap:10px;align-items:flex-start">
|
||||
<i class="bi bi-exclamation-triangle-fill" style="font-size:18px;margin-top:1px"></i>
|
||||
<div>
|
||||
<b>Restart diperlukan.</b>
|
||||
Update ke versi <b><%= restartPending.version || '-' %></b> sudah diterapkan ke file,
|
||||
tetapi aplikasi masih menjalankan kode lama. Fitur/fix baru belum aktif sampai aplikasi di-restart.
|
||||
<a href="#restart-help" style="color:inherit;text-decoration:underline;font-weight:700">Lihat cara restart</a>.
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<div class="card mb16">
|
||||
<div class="card-hd"><h6><i class="bi bi-git"></i> <%= t('admin.update.status_title', 'Status Versi') %></h6></div>
|
||||
<div class="card-bd">
|
||||
@@ -79,6 +91,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb16" id="restart-help">
|
||||
<div class="card-hd"><h6><i class="bi bi-arrow-repeat"></i> <%= t('admin.update.restart_title', 'Cara Restart Aplikasi') %></h6></div>
|
||||
<div class="card-bd" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:14px;font-size:12px">
|
||||
<div style="border:1px solid var(--border);border-radius:12px;padding:12px 14px;background:rgba(255,255,255,.02);line-height:1.75">
|
||||
<div style="font-weight:800;margin-bottom:8px"><i class="bi bi-server"></i> <%= t('admin.update.restart_selfhosted', 'Self-hosted') %></div>
|
||||
<div><b>PM2</b><br><code>pm2 restart all</code> <span style="color:var(--muted)">(atau <code>pm2 restart open-isp</code>)</span></div>
|
||||
<div style="margin-top:8px"><b>Manual (tanpa PM2)</b><br><code>pkill -f "node app-customer.js"</code><br><code>nohup node app-customer.js > logs/run.log 2>&1 &</code></div>
|
||||
<div style="margin-top:8px"><b>Quadlet / systemd</b><br><code>systemctl --user restart open-isp</code> <span style="color:var(--muted)">(user)</span><br><code>sudo systemctl restart open-isp</code> <span style="color:var(--muted)">(system)</span></div>
|
||||
<div style="margin-top:8px"><b>Docker / Podman Compose</b><br><code>docker compose restart</code><br><code>podman compose restart</code> <span style="color:var(--muted)">(atau <code>up -d</code> bila env berubah)</span></div>
|
||||
<div style="margin-top:8px;color:var(--muted)">Jalankan dari folder aplikasi, lalu muat ulang halaman ini.</div>
|
||||
</div>
|
||||
<div style="border:1px solid var(--border);border-radius:12px;padding:12px 14px;background:rgba(59,130,246,.06);line-height:1.75">
|
||||
<div style="font-weight:800;margin-bottom:8px"><i class="bi bi-cloud"></i> <%= t('admin.update.restart_cloud', 'Cloud / managed') %></div>
|
||||
<div><i class="bi bi-check2-circle" style="color:var(--success)"></i> Restart dilakukan <b>otomatis pada update berikutnya</b>.</div>
|
||||
<div style="margin-top:8px"><i class="bi bi-headset"></i> Butuh restart sekarang? <b>Hubungi admin server</b> — jangan jalankan perintah restart sendiri.</div>
|
||||
<div style="margin-top:8px;color:var(--muted)">Cocok untuk instance yang dikelola penyedia/hosting bersama.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if (log) { %>
|
||||
<div class="card mb16">
|
||||
<div class="card-hd"><h6><i class="bi bi-terminal"></i> <%= t('admin.update.log_title', 'Log Update') %></h6></div>
|
||||
|
||||
@@ -154,6 +154,25 @@
|
||||
<!-- 3. GOWA (go-whatsapp-web-multidevice) FIELDS -->
|
||||
<div id="gowa_fields_container" style="<%= waGatewayType === 'gowa' ? '' : 'display:none;' %> background: rgba(173,255,47,0.05); padding: 16px; border-radius: 10px; border: 1px solid var(--primary-glow); margin-bottom: 16px;">
|
||||
<div class="fw6 mb12" style="color: var(--primary); font-size: 13px;"><i class="bi bi-lightning-charge-fill"></i> Konfigurasi GOWA (go-whatsapp-web-multidevice) — Ringan / Self-Hosted</div>
|
||||
<div class="form-row mb12">
|
||||
<div class="fg" style="flex:1;">
|
||||
<label class="fl">Mode Akses GOWA</label>
|
||||
<select class="fs" name="gowa_mode" id="gowa_mode" onchange="toggleGowaMode()">
|
||||
<option value="cloud" <%= gowaSettings.gowa_mode === 'local' ? '' : 'selected' %>>Cloud (HTTPS) — disarankan</option>
|
||||
<option value="local" <%= gowaSettings.gowa_mode === 'local' ? 'selected' : '' %>>Local (HTTP) — wajib polyfill</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fg" style="flex:2;">
|
||||
<label class="fl">Keterangan</label>
|
||||
<div id="gowa_mode_hint" style="font-size:11.5px;color:var(--muted);line-height:1.5">
|
||||
<% if (gowaSettings.gowa_mode === 'local') { %>
|
||||
Base URL <code>http://IP:3000</code>. UI GOWA wajib diberi polyfill <code>crypto.randomUUID</code> (atau akses lewat SSH tunnel). Panduan: <a href="https://gitea.oxpoe.xyz/oxpoe/open-isp/wiki/Konfigurasi" target="_blank" rel="noopener" style="color:var(--primary)">Wiki → Konfigurasi → GOWA Local</a>.
|
||||
<% } else { %>
|
||||
Base URL <code>https://domain-gowa</code> dengan TLS. UI GOWA langsung normal, tanpa polyfill.
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row mb12">
|
||||
<div class="fg" style="flex:2;">
|
||||
<label class="fl">Base URL GOWA *</label>
|
||||
@@ -162,7 +181,8 @@
|
||||
</div>
|
||||
<div class="fg" style="flex:1;">
|
||||
<label class="fl"><%= t('admin.wa.ph_deviceid','Device ID (opsional)') %></label>
|
||||
<input class="fc" name="gowa_device_id" value="<%= gowaSettings.gowa_device_id %>" placeholder="multi-device (mis. device1)">
|
||||
<input class="fc" name="gowa_device_id" value="<%= gowaSettings.gowa_device_id %>" placeholder="kosongkan = ikuti device yang terhubung">
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px">Kosongkan untuk otomatis mengikuti device yang login via QR. Isi hanya bila ingin menunjuk device GOWA tertentu.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row mb12">
|
||||
@@ -180,8 +200,10 @@
|
||||
<div style="font-size:11.5px;color:var(--muted);margin-bottom:8px"><%- t('admin.wa.qr_hint','Klik tombol, lalu buka WhatsApp → Perangkat Tertaut → Tautkan perangkat, scan QR. QR berlaku ~30 detik & diperbarui otomatis.') %></div>
|
||||
<button type="button" class="btn btn-p btn-sm" onclick="showGowaQr()"><i class="bi bi-qr-code-scan"></i> Tampilkan QR</button>
|
||||
<a href="<%= gowaUiUrl %>" target="_blank" rel="noopener" class="btn btn-g btn-sm" style="margin-left:6px"><i class="bi bi-box-arrow-up-right"></i> Buka UI GOWA</a>
|
||||
<button type="button" class="btn btn-g btn-sm" style="margin-left:6px" onclick="testGowaConn(this)"><i class="bi bi-plug"></i> Test Koneksi</button>
|
||||
<div id="gowa_conn_status" style="display:none;margin-top:8px;font-size:12px;line-height:1.5"></div>
|
||||
<div id="gowa_qr_wrap" style="display:none;margin-top:10px;text-align:center">
|
||||
<img id="gowa_qr_img" alt="QR GOWA" style="max-width:240px;background:#fff;padding:10px;border-radius:12px">
|
||||
<img id="gowa_qr_img" alt="QR GOWA" style="max-width:240px;background:#fff;padding:10px;border-radius:12px" onerror="var s=document.getElementById('gowa_qr_status');if(s)s.textContent='Gambar QR gagal dimuat — cek log server (base URL/kredensial GOWA).'">
|
||||
<div id="gowa_qr_status" style="font-size:12px;color:var(--muted);margin-top:6px"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,8 +219,22 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="fg" style="flex:1;">
|
||||
<label class="fl">Mode Webhook Secret</label>
|
||||
<div style="display:flex;gap:6px;margin-bottom:8px">
|
||||
<button type="button" class="btn btn-sm <%= gowaSettings.gowa_secret_mode === 'auto' ? 'btn-p' : 'btn-g' %>" id="gsm-btn-auto" onclick="gowaSecretMode('auto')"><i class="bi bi-magic"></i> Auto Generate</button>
|
||||
<button type="button" class="btn btn-sm <%= gowaSettings.gowa_secret_mode === 'manual' ? 'btn-p' : 'btn-g' %>" id="gsm-btn-manual" onclick="gowaSecretMode('manual')"><i class="bi bi-pencil-square"></i> Manual</button>
|
||||
</div>
|
||||
<label class="fl">Webhook Secret (HMAC)</label>
|
||||
<input class="fc" name="gowa_webhook_secret" value="<%= gowaSettings.gowa_webhook_secret %>" placeholder="secret">
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input class="fc" id="gowa_webhook_secret" name="gowa_webhook_secret" data-mode="<%= gowaSettings.gowa_secret_mode %>" data-locked="1" value="<%= gowaSettings.gowa_webhook_secret %>" placeholder="otomatis dibuat">
|
||||
<button type="button" class="btn btn-g" onclick="copyGowaSecret(this)" title="Salin webhook secret"><i class="bi bi-copy"></i></button>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px">
|
||||
<button type="button" class="btn btn-p btn-sm" id="gsm-btn-save" onclick="gowaSecretSave(this)"><i class="bi bi-save"></i> Simpan Secret</button>
|
||||
<button type="button" class="btn btn-g btn-sm" id="gsm-btn-edit" onclick="gowaSecretEdit()"><i class="bi bi-unlock"></i> Ubah</button>
|
||||
</div>
|
||||
<div id="gowa_secret_status" style="font-size:11px;color:var(--muted);margin-top:6px"></div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px">Auto Generate: secret dibuat & dikunci otomatis. Manual: isi sendiri lalu klik Simpan Secret. Samakan dengan <code>WHATSAPP_WEBHOOK_SECRET</code> di GOWA.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -218,6 +254,47 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function testGowaConn(btn) {
|
||||
var el = document.getElementById('gowa_conn_status');
|
||||
var old = btn ? btn.innerHTML : '';
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Menguji...'; }
|
||||
if (el) { el.style.display = 'block'; el.style.color = 'var(--muted)'; el.textContent = 'Menghubungi ' + (document.querySelector('input[name="gowa_base_url"]') || {}).value + ' ...'; }
|
||||
try {
|
||||
var payload = {
|
||||
gowa_base_url: (document.querySelector('input[name="gowa_base_url"]') || {}).value || '',
|
||||
gowa_username: (document.querySelector('input[name="gowa_username"]') || {}).value || '',
|
||||
gowa_password: (document.querySelector('input[name="gowa_password"]') || {}).value || ''
|
||||
};
|
||||
var r = await fetch('/admin/whatsapp/test-gowa-connection', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
var d = await r.json();
|
||||
if (el) {
|
||||
el.textContent = d.message || (d.ok ? 'GOWA terjangkau.' : 'GOWA tidak terjangkau.');
|
||||
el.style.color = (d.ok && d.authOk) ? 'var(--success)' : (d.ok ? '#f59e0b' : 'var(--danger)');
|
||||
}
|
||||
} catch (e) {
|
||||
if (el) { el.style.color = 'var(--danger)'; el.textContent = 'Gagal menguji koneksi: ' + e.message; }
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = old; }
|
||||
}
|
||||
}
|
||||
|
||||
function toggleGowaMode() {
|
||||
var mode = (document.getElementById('gowa_mode') || {}).value || 'cloud';
|
||||
var hint = document.getElementById('gowa_mode_hint');
|
||||
var base = document.querySelector('input[name="gowa_base_url"]');
|
||||
if (mode === 'local') {
|
||||
if (hint) hint.innerHTML = 'Base URL <code>http://IP:3000</code>. UI GOWA wajib diberi polyfill <code>crypto.randomUUID</code> (atau akses lewat SSH tunnel). Panduan: <a href="https://gitea.oxpoe.xyz/oxpoe/open-isp/wiki/Konfigurasi" target="_blank" rel="noopener" style="color:var(--primary)">Wiki → Konfigurasi → GOWA Local</a>.';
|
||||
if (base) base.placeholder = 'http://IP:3000';
|
||||
} else {
|
||||
if (hint) hint.innerHTML = 'Base URL <code>https://domain-gowa</code> dengan TLS. UI GOWA langsung normal, tanpa polyfill.';
|
||||
if (base) base.placeholder = 'https://gowa.domainmu.com';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleGatewayFields() {
|
||||
const val = document.getElementById('wa_gateway_type').value;
|
||||
document.getElementById('fonnte_fields_container').style.display = val === 'fonnte' ? 'block' : 'none';
|
||||
@@ -228,12 +305,38 @@
|
||||
// Cooldown QR disinkronkan dengan GoWA: interval mengikuti qr_duration dari server.
|
||||
var _gowaQrRefresh = null; // penjadwal ambil QR berikutnya (timeout)
|
||||
var _gowaQrTick = null; // interval hitung mundur 1 detik
|
||||
var _gowaQrPoll = null; // pantau status setelah scan
|
||||
function showGowaQr() {
|
||||
var wrap = document.getElementById('gowa_qr_wrap');
|
||||
if (wrap) wrap.style.display = 'block';
|
||||
var st = document.getElementById('gowa_qr_status');
|
||||
if (st) st.textContent = 'Memuat QR...';
|
||||
if (st) { st.style.color = 'var(--muted)'; st.textContent = 'Memuat QR...'; }
|
||||
gowaQrCycle();
|
||||
startGowaPoll();
|
||||
}
|
||||
function stopGowaPoll() {
|
||||
if (_gowaQrPoll) { clearInterval(_gowaQrPoll); _gowaQrPoll = null; }
|
||||
}
|
||||
function startGowaPoll() {
|
||||
stopGowaPoll();
|
||||
_gowaQrPoll = setInterval(async function () {
|
||||
try {
|
||||
var r = await fetch('/admin/api/whatsapp/status', { cache: 'no-store' });
|
||||
var s = await r.json();
|
||||
var st = document.getElementById('gowa_qr_status');
|
||||
if (s && s.connection === 'open') {
|
||||
if (_gowaQrRefresh) { clearTimeout(_gowaQrRefresh); _gowaQrRefresh = null; }
|
||||
if (_gowaQrTick) { clearInterval(_gowaQrTick); _gowaQrTick = null; }
|
||||
stopGowaPoll();
|
||||
var img = document.getElementById('gowa_qr_img'); if (img) img.src = '';
|
||||
var wrap = document.getElementById('gowa_qr_wrap'); if (wrap) wrap.style.display = 'none';
|
||||
if (st) { st.style.color = 'var(--success)'; st.textContent = '✅ Terhubung ke WhatsApp.'; }
|
||||
if (typeof checkStatus === 'function') checkStatus();
|
||||
} else if (s && s.connection === 'connecting') {
|
||||
if (st) { st.style.color = 'var(--warning, #f59e0b)'; st.textContent = 'Terautentikasi, sedang menyinkronkan...'; }
|
||||
}
|
||||
} catch (e) {}
|
||||
}, 3000);
|
||||
}
|
||||
function gowaQrCycle() {
|
||||
fetch('/admin/whatsapp/gowa/qr', { cache: 'no-store' }).then(function (r) { return r.json(); }).then(function (d) {
|
||||
@@ -257,6 +360,88 @@
|
||||
render();
|
||||
_gowaQrTick = setInterval(function () { remaining--; if (remaining < 0) remaining = 0; render(); }, 1000);
|
||||
}
|
||||
function gowaSecretRender() {
|
||||
var input = document.getElementById('gowa_webhook_secret');
|
||||
if (!input) return;
|
||||
var mode = input.getAttribute('data-mode') || 'auto';
|
||||
var locked = (mode === 'auto') ? true : (input.getAttribute('data-locked') === '1');
|
||||
var save = document.getElementById('gsm-btn-save');
|
||||
var edit = document.getElementById('gsm-btn-edit');
|
||||
var auto = document.getElementById('gsm-btn-auto');
|
||||
var manual = document.getElementById('gsm-btn-manual');
|
||||
var st = document.getElementById('gowa_secret_status');
|
||||
input.readOnly = locked;
|
||||
if (save) save.disabled = (mode === 'auto') || locked;
|
||||
if (edit) edit.style.display = (mode === 'manual' && locked) ? 'inline-block' : 'none';
|
||||
if (auto) auto.className = 'btn btn-sm ' + (mode === 'auto' ? 'btn-p' : 'btn-g');
|
||||
if (manual) manual.className = 'btn btn-sm ' + (mode === 'manual' ? 'btn-p' : 'btn-g');
|
||||
if (st) {
|
||||
if (mode === 'auto') st.textContent = 'Mode otomatis: secret dibuat dan dikunci. Salin ke GOWA.';
|
||||
else if (locked) st.textContent = 'Mode manual: secret tersimpan dan dikunci. Klik Ubah untuk mengganti.';
|
||||
else st.textContent = 'Mode manual: masukkan secret (min. 16 karakter), lalu Simpan Secret.';
|
||||
}
|
||||
}
|
||||
async function gowaSecretMode(mode) {
|
||||
var input = document.getElementById('gowa_webhook_secret');
|
||||
if (!input) return;
|
||||
try {
|
||||
var r = await fetch('/admin/whatsapp/gowa-secret', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: mode })
|
||||
});
|
||||
var d = await r.json();
|
||||
if (!d.ok) { alert(d.message || 'Gagal mengubah mode secret.'); return; }
|
||||
if (d.secret) input.value = d.secret;
|
||||
input.setAttribute('data-mode', d.mode);
|
||||
input.setAttribute('data-locked', d.mode === 'manual' ? '0' : '1');
|
||||
gowaSecretRender();
|
||||
} catch (e) { alert('Gagal: ' + e.message); }
|
||||
}
|
||||
async function gowaSecretSave(btn) {
|
||||
var input = document.getElementById('gowa_webhook_secret');
|
||||
if (!input) return;
|
||||
var secret = String(input.value || '').trim();
|
||||
if (secret.length < 16) { alert('Secret minimal 16 karakter.'); return; }
|
||||
var old = btn ? btn.innerHTML : '';
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Menyimpan...'; }
|
||||
try {
|
||||
var r = await fetch('/admin/whatsapp/gowa-secret', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: 'manual', secret: secret })
|
||||
});
|
||||
var d = await r.json();
|
||||
if (!d.ok) { alert(d.message || 'Gagal menyimpan secret.'); return; }
|
||||
input.value = d.secret || secret;
|
||||
input.setAttribute('data-mode', 'manual');
|
||||
input.setAttribute('data-locked', '1');
|
||||
gowaSecretRender();
|
||||
} catch (e) { alert('Gagal: ' + e.message); }
|
||||
finally { if (btn) { btn.disabled = false; btn.innerHTML = old; } gowaSecretRender(); }
|
||||
}
|
||||
function gowaSecretEdit() {
|
||||
var input = document.getElementById('gowa_webhook_secret');
|
||||
if (!input || input.getAttribute('data-mode') !== 'manual') return;
|
||||
input.setAttribute('data-locked', '0');
|
||||
gowaSecretRender();
|
||||
input.focus();
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', gowaSecretRender);
|
||||
|
||||
function copyGowaSecret(btn) {
|
||||
var el = document.getElementById('gowa_webhook_secret');
|
||||
if (!el) return;
|
||||
var val = String(el.value || '');
|
||||
try { el.select(); document.execCommand('copy'); } catch (e) {}
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(val).catch(function () {});
|
||||
}
|
||||
if (btn) {
|
||||
var old = btn.innerHTML;
|
||||
btn.innerHTML = '<i class="bi bi-check2"></i>';
|
||||
setTimeout(function () { btn.innerHTML = old; }, 1200);
|
||||
}
|
||||
}
|
||||
|
||||
function copyGowaWebhook() {
|
||||
var el = document.getElementById('gowa_webhook_url');
|
||||
if (!el) return;
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
<h1 style="font-size:22px"><%= t('donate.title','Dukung OPEN-ISP') %></h1>
|
||||
<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://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>
|
||||
<i class="bi bi-info-circle"></i> <strong><%= t('donate.menu_code_free','Semua menu sudah terbuka') %></strong> <%= t('donate.free_hint','— donasi bersifat sukarela dan') %> <strong><%= t('donate.not_required','tidak wajib') %></strong>.
|
||||
<a href="https://gitea.oxpoe.xyz/oxpoe/open-isp/wiki" target="_blank" rel="noopener" style="color:#ADFF2F;font-weight:700;"> <%= t('donate.open_wiki','Buka Wiki') %></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -70,10 +70,10 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="inputPhone"><i class="bi bi-whatsapp"></i> <%= t('donate.phone','Nomor WhatsApp * (untuk kode aktivasi)') %></label>
|
||||
<label for="inputPhone"><i class="bi bi-whatsapp"></i> <%= t('donate.phone','Nomor WhatsApp') %></label>
|
||||
<div class="wrap"><i class="bi bi-whatsapp lead-ic"></i>
|
||||
<input type="tel" id="inputPhone" class="inp" placeholder="Contoh: 081234567890" required></div>
|
||||
<div class="form-note"><%= t('donate.thanks_note','Ucapan terima kasih & kode aktivasi sidebar dikirim ke nomor ini.') %></div>
|
||||
<div class="form-note"><%= t('donate.thanks_note','Ucapan terima kasih dikirim ke nomor ini.') %></div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -105,7 +105,7 @@
|
||||
<div id="paymentStatusBox" class="auth-alert info" style="justify-content:center;margin-top:14px">
|
||||
<div style="text-align:center">
|
||||
<div style="display:flex;align-items:center;justify-content:center;gap:8px"><div class="spin-pulse"></div><span style="font-weight:700" id="statusBoxText"><%= t('donate.waiting_payment','Menunggu Pembayaran Masuk…') %></span></div>
|
||||
<div style="font-size:11.5px;margin-top:4px"><%= t('donate.auto_detect','Sistem otomatis mendeteksi transaksi & mengirim kode aktivasi ke WA Anda.') %></div>
|
||||
<div style="font-size:11.5px;margin-top:4px"><%= t('donate.auto_detect','Sistem otomatis mendeteksi transaksi pembayaran.') %></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
<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://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>
|
||||
<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.xyz/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 -->
|
||||
@@ -229,7 +229,7 @@
|
||||
async function generateQris() {
|
||||
if (isPaid) return;
|
||||
var amt = parseInt(document.getElementById('inputAmount').value, 10) || 50000;
|
||||
var phone = document.getElementById('inputPhone').value.trim() || '081947215703';
|
||||
var phone = document.getElementById('inputPhone').value.trim();
|
||||
var name = document.getElementById('inputName').value.trim() || 'Hamba Allah';
|
||||
var notes = document.getElementById('inputNotes').value.trim();
|
||||
try {
|
||||
|
||||
@@ -315,7 +315,7 @@
|
||||
</a>
|
||||
<p class="muted" style="font-size:14px;max-width:40ch"><%= t('landing.footer.tagline','Micro Internet Provider dan Server Platform.') %></p>
|
||||
<p class="muted" style="font-size:13px;margin-top:8px;max-width:40ch">Internet Freedom — We Are Open-Source.</p>
|
||||
<p class="muted" style="font-size:12.5px;margin-top:12px"><i class="bi bi-building"></i> PT Artha Mega Data</p>
|
||||
<p class="muted" style="font-size:12.5px;margin-top:12px"><% if (typeof settings !== 'undefined' && settings && settings.company_subheader) { %><%= settings.company_subheader %><% } else { %>Made with <i class="bi bi-heart-fill" style="color:#e5484d"></i> from Oxpoe in INA.<% } %></p>
|
||||
</div>
|
||||
<div>
|
||||
<h4><%= t('landing.footer.nav','Navigasi') %></h4>
|
||||
|
||||
@@ -31,6 +31,21 @@
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<%
|
||||
const _portal = (typeof settings !== 'undefined' && settings) ? settings : {};
|
||||
const _pbColors = {
|
||||
info: { bg: 'rgba(59,130,246,.16)', border: '#3b82f6', fg: '#bfdbfe' },
|
||||
warning: { bg: 'rgba(245,158,11,.16)', border: '#f59e0b', fg: '#fde68a' },
|
||||
danger: { bg: 'rgba(239,68,68,.16)', border: '#ef4444', fg: '#fecaca' },
|
||||
success: { bg: 'rgba(34,197,94,.16)', border: '#22c55e', fg: '#bbf7d0' }
|
||||
};
|
||||
const _pb = _pbColors[_portal.portal_banner_type] || _pbColors.info;
|
||||
%>
|
||||
<% if (_portal.portal_banner_enabled && _portal.portal_banner_text) { %>
|
||||
<div style="background:<%= _pb.bg %>;border-bottom:1px solid <%= _pb.border %>;color:<%= _pb.fg %>;padding:9px 16px;text-align:center;font-size:13px;line-height:1.45">
|
||||
<i class="bi bi-megaphone-fill"></i> <strong><%= _portal.portal_banner_text %></strong>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<!-- ═══ HEADER ═══ -->
|
||||
<header class="site-header">
|
||||
@@ -43,7 +58,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 +75,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>
|
||||
@@ -68,9 +84,21 @@
|
||||
<section class="auth-hero">
|
||||
<div class="container auth-grid">
|
||||
<div class="auth-copy reveal">
|
||||
<span class="eyebrow"><i class="bi bi-shield-lock-fill"></i> <%= t('landing.footer.portal','Portal Pelanggan') %></span>
|
||||
<h1><%= t('login.manage','Kelola layanan internet Anda') %> <span class="hl"><%= t('login.manage_hl','dalam satu tempat') %></span></h1>
|
||||
<p class="lead"><%= t('login.manage_desc','Masuk untuk melihat tagihan, melakukan pembayaran, memantau koneksi, dan mengatur WiFi perangkat Anda.') %></p>
|
||||
<span class="eyebrow"><i class="bi bi-shield-lock-fill"></i> <%= _portal.portal_hero_badge || t('landing.footer.portal','Portal Pelanggan') %></span>
|
||||
<% if (_portal.portal_hero_title) { %>
|
||||
<h1><%= _portal.portal_hero_title %></h1>
|
||||
<% } else { %>
|
||||
<h1><%= t('login.manage','Kelola layanan internet Anda') %> <span class="hl"><%= t('login.manage_hl','dalam satu tempat') %></span></h1>
|
||||
<% } %>
|
||||
<p class="lead"><%= _portal.portal_hero_subtitle || t('login.manage_desc','Masuk untuk melihat tagihan, melakukan pembayaran, memantau koneksi, dan mengatur WiFi perangkat Anda.') %></p>
|
||||
<% if (_portal.portal_network_status) {
|
||||
const _ns = { normal: ['#22c55e', 'Jaringan Normal'], maintenance: ['#f59e0b', 'Pemeliharaan Terjadwal'], issue: ['#ef4444', 'Sedang Ada Kendala Teknis'] }[_portal.portal_network_status] || ['#22c55e', 'Jaringan Normal'];
|
||||
%>
|
||||
<div style="display:inline-flex;align-items:center;gap:7px;margin-top:10px;padding:5px 11px;border-radius:999px;background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.12);font-size:12px;color:var(--muted)">
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:<%= _ns[0] %>;display:inline-block"></span>
|
||||
<%= _portal.portal_network_status_text || _ns[1] %>
|
||||
</div>
|
||||
<% } %>
|
||||
<ul class="auth-points">
|
||||
<li><i class="bi bi-check-circle-fill"></i> <%= t('login.chk_desc','Cek & bayar tagihan kapan saja') %></li>
|
||||
<li><i class="bi bi-check-circle-fill"></i> <%= t('login.mon_desc','Pantau trafik dan status koneksi') %></li>
|
||||
@@ -204,7 +232,15 @@
|
||||
</a>
|
||||
<p class="muted" style="font-size:14px;max-width:40ch">Micro Internet Provider dan Server Platform.</p>
|
||||
<p class="muted" style="font-size:13px;margin-top:8px">Internet Freedom — We Are Open-Source.</p>
|
||||
<p class="muted" style="font-size:12.5px;margin-top:12px"><i class="bi bi-building"></i> PT Artha Mega Data</p>
|
||||
<p class="muted" style="font-size:12.5px;margin-top:12px"><% if (typeof settings !== 'undefined' && settings && settings.company_subheader) { %><%= settings.company_subheader %><% } else { %>Made with <i class="bi bi-heart-fill" style="color:#e5484d"></i> from Oxpoe in INA.<% } %></p>
|
||||
<% if (_portal.social_maps_url || _portal.social_instagram || _portal.social_facebook || _portal.social_tiktok) { %>
|
||||
<div style="display:flex;gap:10px;margin-top:12px;flex-wrap:wrap;font-size:16px">
|
||||
<% if (_portal.social_maps_url) { %><a href="<%= _portal.social_maps_url %>" target="_blank" rel="noopener" title="Maps"><i class="bi bi-geo-alt-fill"></i></a><% } %>
|
||||
<% if (_portal.social_instagram) { %><a href="<%= _portal.social_instagram %>" target="_blank" rel="noopener" title="Instagram"><i class="bi bi-instagram"></i></a><% } %>
|
||||
<% if (_portal.social_facebook) { %><a href="<%= _portal.social_facebook %>" target="_blank" rel="noopener" title="Facebook"><i class="bi bi-facebook"></i></a><% } %>
|
||||
<% if (_portal.social_tiktok) { %><a href="<%= _portal.social_tiktok %>" target="_blank" rel="noopener" title="TikTok"><i class="bi bi-tiktok"></i></a><% } %>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Portal</h4>
|
||||
@@ -223,13 +259,13 @@
|
||||
<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>
|
||||
<div class="foot-bottom">
|
||||
<span>© <span id="year"></span> <%= settings.company_header || 'OPEN-ISP' %>. Seluruh hak cipta dilindungi. <span style="opacity:.6">· v<%= appVersion %> (<%= appVersionName %>)</span></span>
|
||||
<span class="mono">Fiber Optic Internet Service · <a href="/blackhole" style="opacity:.32;color:inherit;text-decoration:none" title="Staff access">blackhole</a> · <a href="#" onclick="hardResetApp();return false" style="opacity:.32;color:inherit;text-decoration:none" title="Bersihkan cache aplikasi">muat ulang aplikasi</a></span>
|
||||
<span class="mono">tiawidianti & irpankomara · <a href="/blackhole" style="opacity:.32;color:inherit;text-decoration:none" title="Staff access">blackhole</a> · <a href="#" onclick="hardResetApp();return false" style="opacity:.32;color:inherit;text-decoration:none" title="Bersihkan cache aplikasi">muat ulang aplikasi</a></span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -936,7 +936,7 @@
|
||||
<i class="ph ph-user-circle nav-icon"></i><%= t('pwa.nav.account','Akun') %></button>
|
||||
</nav>
|
||||
<div class="p-3 mt-auto" style="border-top:1px solid var(--border);">
|
||||
<div class="flex items-center gap-3 p-3 rounded-[14px] press cursor-pointer">
|
||||
<button type="button" onclick="switchTab('profile')" class="flex items-center gap-3 p-3 rounded-[14px] press cursor-pointer w-full text-left" style="background:transparent;border:0;" aria-label="<%= t('pwa.nav.account','Akun') %>">
|
||||
<div class="avatar-ring" style="width:38px;height:38px;padding:2px;">
|
||||
<span class="avatar-init" style="width:34px;height:34px;font-size:13px;" role="img" aria-label="<%= portal.name %>"><%= portal.initials %></span>
|
||||
</div>
|
||||
@@ -945,7 +945,7 @@
|
||||
<p class="text-[11px] truncate" style="color:var(--muted);"><%= portal.packageName %><%= portal.speedDown !== '-' ? ' · ' + portal.speedDown + ' Mbps' : '' %></p>
|
||||
</div>
|
||||
<i class="ph ph-caret-right text-sm" style="color:var(--muted-2);"></i>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -976,9 +976,9 @@
|
||||
<i class="ph-fill ph-bell text-lg" style="color:var(--text);"></i>
|
||||
<span class="notif-dot"<%= (portal.notifications && portal.notifications.length) ? '' : ' style="display:none"' %>></span>
|
||||
</button>
|
||||
<div class="mobile-only md:hidden avatar-ring" style="padding:2px;cursor:pointer;" onclick="switchTab('profile')">
|
||||
<button type="button" class="mobile-only md:hidden avatar-ring press" style="padding:2px;cursor:pointer;background:transparent;border:0;" onclick="switchTab('profile')" aria-label="<%= t('pwa.nav.account','Akun') %>">
|
||||
<span class="avatar-init" style="width:32px;height:32px;font-size:12px;" role="img" aria-label="<%= portal.name %>"><%= portal.initials %></span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||