Files
open-isp/routes/techPortal.js
T
oxpoe eaf05f2811 OPEN-ISP v0.5 (pickle-lime) — rilis open-source perdana
Aplikasi billing & manajemen ISP (fiber optic) — PWA pelanggan, portal teknisi & kolektor,
admin panel lengkap, multi-vendor OLT, RADIUS, WhatsApp/Telegram, pembayaran QRIS.

Kredit & lisensi (ISC):
- Base kode & repo: alijayanet/billing-rtrw oleh Alijaya Net — terima kasih banyak
  atas base kode dan repo yang bermanfaat.
- Add-on: GoWA, LibreSpeed/OpenSpeedTest, Uptime Kuma, notification-forwarder.
- Dikembangkan menjadi OPEN-ISP oleh oxpoe — https://github.com/oxpoe/open-isp
2026-10-09 05:51:21 +07:00

1288 lines
49 KiB
JavaScript

const express = require('express');
const router = express.Router();
const techSvc = require('../services/techService');
const customerSvc = require('../services/customerService');
const odpSvc = require('../services/odpService');
const { getSetting, getNowLocal, getCurrentDateInTimezone, getNowLocalISO, formatDateLocal, getSettings, formatTimeLocal, parseDateInTimezone } = require('../config/settingsManager');
const mikrotikService = require('../services/mikrotikService');
const db = require('../config/database');
const oltSvc = require('../services/oltService');
const attendanceSvc = require('../services/attendanceService');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { uploadAttendance, removeAttendanceFile } = require('../middleware/attendanceUpload');
const genieacsApi = require('../config/genieacs');
const rawAxios = require('axios');
const axios = {
get: async (url, config = {}) => {
if (genieacsApi.isBuiltinAcsEnabled() && (url.startsWith('local/') || url === 'local')) {
const reqPath = url.replace(/^local/, '');
const instance = genieacsApi.createAxiosInstance({ id: 'builtin', url: 'local' });
return instance.get(reqPath, config);
}
return rawAxios.get(url, config);
},
post: async (url, data, config = {}) => {
if (genieacsApi.isBuiltinAcsEnabled() && url.startsWith('local/')) {
const reqPath = url.replace(/^local/, '');
const instance = genieacsApi.createAxiosInstance({ id: 'builtin', url: 'local' });
return instance.post(reqPath, data, config);
}
return rawAxios.post(url, data, config);
},
delete: async (url, config = {}) => {
if (genieacsApi.isBuiltinAcsEnabled() && url.startsWith('local/')) {
const reqPath = url.replace(/^local/, '');
const instance = genieacsApi.createAxiosInstance({ id: 'builtin', url: 'local' });
return instance.delete(reqPath, config);
}
return rawAxios.delete(url, config);
},
put: async (url, data, config = {}) => {
if (genieacsApi.isBuiltinAcsEnabled() && url.startsWith('local/')) {
const reqPath = url.replace(/^local/, '');
const instance = genieacsApi.createAxiosInstance({ id: 'builtin', url: 'local' });
return instance.put(reqPath, data, config);
}
return rawAxios.put(url, data, config);
}
};
// Configure multer for photo uploads
const storage = multer.diskStorage({
destination: function (req, file, cb) {
const uploadDir = path.join(__dirname, '../public/uploads/tickets');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
cb(null, uploadDir);
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, 'ticket-' + uniqueSuffix + path.extname(file.originalname));
}
});
const upload = multer({
storage: storage,
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB max
fileFilter: function (req, file, cb) {
const allowedTypes = /jpeg|jpg|png|gif|webp/;
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mimetype = allowedTypes.test(file.mimetype);
if (mimetype && extname) {
return cb(null, true);
} else {
cb(new Error('Hanya file gambar yang diperbolehkan (JPEG, PNG, GIF, WebP)'));
}
}
});
const waSendDedup = new Map();
function normalizeWaDigits(input) {
let digits = String(input || '').replace(/\D/g, '');
if (!digits) return '';
if (digits.startsWith('0')) digits = '62' + digits.slice(1);
if (digits.length < 8) return '';
return digits;
}
function shouldSendWa(key, ttlMs = 15000) {
const now = Date.now();
const last = waSendDedup.get(key);
if (last && (now - last) < ttlMs) return false;
waSendDedup.set(key, now);
if (waSendDedup.size > 5000) {
for (const [k, t] of waSendDedup.entries()) {
if ((now - t) > 10 * 60 * 1000) waSendDedup.delete(k);
}
}
return true;
}
function requireTechSession(req, res, next) {
if (req.session && req.session.isTechnician && req.session.techId) {
return next();
}
res.redirect('/tech/login');
}
function flashMsg(req) {
const m = req.session._msg;
delete req.session._msg;
return m || null;
}
function company() { return getSetting('company_header', 'ISP App'); }
router.use((req, res, next) => {
res.locals.session = req.session;
res.locals.settings = getSettings();
res.locals.formatDateLocal = formatDateLocal;
res.locals.formatTimeLocal = formatTimeLocal;
res.locals.parseDateInTimezone = parseDateInTimezone;
res.locals.getNowLocal = getNowLocal;
next();
});
let loginRateLimiter = (req, res, next) => next();
try {
const rlMod = require('../middleware/rateLimiter');
if (rlMod && typeof rlMod.loginRateLimiter === 'function') {
loginRateLimiter = rlMod.loginRateLimiter;
}
} catch (e) {}
// --- AUTH ---
router.get('/login', (req, res) => {
if (req.session && req.session.isTechnician) return res.redirect('/tech');
res.render('tech/login', { title: 'Teknisi Login', company: company(), error: null });
});
router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), (req, res) => {
const { username, password } = req.body;
const tech = techSvc.authenticate(username, password);
if (tech) {
req.session.isTechnician = true;
req.session.techId = tech.id;
req.session.techName = tech.name;
return res.redirect('/tech');
}
res.render('tech/login', { title: 'Teknisi Login', company: company(), error: 'Username atau password salah!' });
});
router.get('/logout', (req, res) => {
req.session.destroy();
res.redirect('/tech/login');
});
// --- DASHBOARD (My Tickets) ---
router.get('/', requireTechSession, (req, res) => {
const techId = req.session.techId;
const stats = techSvc.getTechStats(techId);
const myTickets = techSvc.getAssignedTickets(techId);
res.render('tech/dashboard', {
title: 'Dashboard Teknisi',
company: company(),
techName: req.session.techName,
activePage: 'dashboard',
stats,
tickets: myTickets,
msg: flashMsg(req)
});
});
// --- OPEN TICKETS (Pool) ---
router.get('/pool', requireTechSession, (req, res) => {
const openTickets = techSvc.getOpenTickets();
res.render('tech/pool', {
title: 'Tiket Baru',
company: company(),
activePage: 'pool',
tickets: openTickets,
msg: flashMsg(req)
});
});
// --- HISTORY TICKETS ---
router.get('/history', requireTechSession, (req, res) => {
const techId = req.session.techId;
const historyTickets = techSvc.getResolvedTickets(techId);
res.render('tech/history', {
title: 'Riwayat Tiket',
company: company(),
activePage: 'history',
tickets: historyTickets,
msg: flashMsg(req)
});
});
// --- NETWORK MAP ---
router.get('/map', requireTechSession, (req, res) => {
const customers = customerSvc.getAllCustomers();
const odps = odpSvc.getAllOdps();
res.render('tech/map', {
title: 'Peta Jaringan',
company: company(),
activePage: 'map',
customers,
odps,
msg: flashMsg(req),
settings: getSetting('office_lat') ? { office_lat: getSetting('office_lat'), office_lng: getSetting('office_lng') } : {}
});
});
// --- ACTIONS ---
router.post('/tickets/:id/take', requireTechSession, (req, res) => {
try {
techSvc.takeTicket(req.params.id, req.session.techId);
req.session._msg = { type: 'success', text: 'Tiket berhasil diambil. Silakan mulai kerjakan.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal mengambil tiket: ' + e.message };
}
res.redirect('/tech');
});
router.post('/tickets/:id/update', requireTechSession, upload.array('photos', 10), async (req, res) => {
try {
const { status, notes } = req.body;
const ticketId = req.params.id;
const techId = req.session.techId;
// Prepare photo data
let photoPaths = [];
let photoMetadata = [];
if (req.files && req.files.length > 0) {
photoPaths = req.files.map(f => '/uploads/tickets/' + f.filename);
photoMetadata = req.files.map((f, idx) => ({
filename: f.filename,
originalName: f.originalname,
size: f.size,
uploadedAt: getNowLocalISO(),
lat: req.body.gps_lat || '',
lng: req.body.gps_lng || ''
}));
}
// Update ticket with photos and notes
techSvc.updateTicketStatus(ticketId, techId, status, {
notes: notes || '',
photos: JSON.stringify(photoPaths),
photoMetadata: JSON.stringify(photoMetadata)
});
req.session._msg = { type: 'success', text: 'Status keluhan berhasil diperbarui.' };
// --- WHATSAPP NOTIFICATION FOR RESOLVED TICKET ---
if (status === 'resolved') {
try {
const { getSettingsWithCache } = require('../config/settingsManager');
const settings = getSettingsWithCache();
if (settings.whatsapp_enabled) {
const waNotify = require('../services/waNotifyService');
const ticketSvc = require('../services/ticketService');
const ticket = ticketSvc.getTicketById(ticketId);
if (ticket) {
const photoCount = photoPaths.length;
const photoText = photoCount > 0 ? `\n📸 *Foto Pekerjaan:* ${photoCount} foto terlampir` : '';
const waMsg = `✅ *TIKET KELUHAN SELESAI*\n\n` +
`🎫 *ID Tiket:* #${ticket.id}\n` +
`👤 *Pelanggan:* ${ticket.customer_name}\n` +
`📝 *Subjek:* ${ticket.subject}\n` +
`🛠️ *Teknisi:* ${req.session.techName}${photoText}\n\n` +
`Keluhan Anda telah selesai dikerjakan. Terima kasih atas kesabarannya.`;
// Kirim ke Pelanggan
if (ticket.customer_phone) {
const digits = normalizeWaDigits(ticket.customer_phone);
if (digits) {
const key = `ticket:resolved:customer:${ticketId}:${digits}`;
if (shouldSendWa(key)) await waNotify.sendText(digits, waMsg);
}
}
// Kirim ke Admin dengan info foto
if (settings.whatsapp_admin_numbers && settings.whatsapp_admin_numbers.length > 0) {
const notesText = notes ? `\n💬 *Catatan:* ${notes}` : '';
const adminMsg = `✅ *LAPORAN TIKET SELESAI*\n\n` +
`🎫 *ID Tiket:* #${ticket.id}\n` +
`👤 *Pelanggan:* ${ticket.customer_name}\n` +
`🛠️ *Teknisi:* ${req.session.techName}\n` +
`📝 *Subjek:* ${ticket.subject}\n` +
`💬 *Pesan:* ${ticket.message}${notesText}${photoText}`;
const recipients = new Set();
for (const adminPhone of settings.whatsapp_admin_numbers) {
const digits = normalizeWaDigits(adminPhone);
if (digits) recipients.add(digits);
}
for (const digits of recipients) {
const key = `ticket:resolved:admin:${ticketId}:${digits}`;
if (!shouldSendWa(key)) continue;
await waNotify.sendText(digits, adminMsg);
}
}
}
}
} catch (waErr) {
console.error(`[TechPortal] WA Notification Error: ${waErr.message}`);
}
}
// -------------------------------------------------
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal update keluhan: ' + e.message };
}
res.redirect('/tech');
});
// --- MONITORING ONU ---
router.get('/monitoring', requireTechSession, async (req, res) => {
const acsServers = genieacsApi.getAllACSServers();
let pppoeProfiles = [];
try {
// Support multi-router: get routerId from query parameter if provided
const selectedRouterId = req.query.router_id ? Number(req.query.router_id) : null;
pppoeProfiles = await mikrotikService.getPppoeProfiles(selectedRouterId);
} catch (e) {
console.error('Failed to load PPPoE profiles from MikroTik:', e.message);
}
res.render('tech/monitoring', {
title: 'Monitoring ONU',
company: company(),
activePage: 'monitoring',
acsServers,
pppoeProfiles,
msg: flashMsg(req)
});
});
// --- CREATE CUSTOMER (Technician) ---
router.get('/customers/new', requireTechSession, (req, res) => {
const packages = customerSvc.getAllPackages();
const odps = odpSvc.getAllOdps();
const routers = mikrotikService.getAllRouters();
const olts = oltSvc.getAllOlts();
res.render('tech/create_customer', {
title: 'Tambah Pelanggan',
company: company(),
activePage: 'create_customer',
packages,
odps,
routers,
olts,
msg: flashMsg(req)
});
});
router.post('/customers', requireTechSession, express.urlencoded({ extended: true }), async (req, res) => {
try {
const name = String(req.body.name || '').trim();
if (!name) throw new Error('Nama pelanggan wajib diisi');
const customerData = {
name,
phone: String(req.body.phone || '').trim(),
email: String(req.body.email || '').trim(),
nik: String(req.body.nik || '').trim(),
address: String(req.body.address || '').trim(),
package_id: req.body.package_id ? Number(req.body.package_id) : null,
pppoe_username: String(req.body.pppoe_username || '').trim(),
router_id: req.body.router_id ? Number(req.body.router_id) : null,
olt_id: req.body.olt_id ? Number(req.body.olt_id) : null,
odp_id: req.body.odp_id ? Number(req.body.odp_id) : null,
pon_port: String(req.body.pon_port || '').trim(),
lat: String(req.body.lat || '').trim(),
lng: String(req.body.lng || '').trim(),
isolir_profile: String(req.body.isolir_profile || 'isolir').trim() || 'isolir',
status: String(req.body.status || 'active').trim() || 'active',
install_date: req.body.install_date ? String(req.body.install_date).trim() : null,
notes: String(req.body.notes || '').trim(),
auto_isolate: req.body.auto_isolate !== undefined ? Number(req.body.auto_isolate) : 1,
isolate_day: req.body.isolate_day !== undefined ? Number(req.body.isolate_day) : 10
};
// VALIDATION: If customer has PPPoE connection, router_id is REQUIRED
if (customerData.pppoe_username && !customerData.router_id) {
throw new Error('Router harus dipilih jika menggunakan koneksi PPPoE');
}
if (customerData.pppoe_username) {
const existing = db.prepare('SELECT id, name FROM customers WHERE router_id IS ? AND pppoe_username = ? LIMIT 1').get(customerData.router_id ?? null, customerData.pppoe_username);
if (existing) throw new Error(`PPPoE Username sudah dipakai pelanggan lain: ${existing.name}`);
let conn = null;
try {
conn = await mikrotikService.getConnection(customerData.router_id || null);
const results = await conn.client.menu('/ppp/secret')
.where('service', 'pppoe')
.where('name', customerData.pppoe_username)
.get();
if (!Array.isArray(results) || results.length === 0) throw new Error('PPPoE Username tidak ditemukan di MikroTik');
} finally {
if (conn && conn.api) conn.api.close();
}
}
const inserted = customerSvc.createCustomer(customerData);
if (customerData.pppoe_username) {
let targetProfile = '';
if (customerData.status === 'suspended') {
targetProfile = customerData.isolir_profile || 'isolir';
} else if (customerData.package_id) {
const pkg = customerSvc.getPackageById(customerData.package_id);
if (pkg) targetProfile = pkg.name;
}
if (targetProfile) {
try {
await mikrotikService.setPppoeProfile(customerData.pppoe_username, targetProfile, customerData.router_id);
} catch (mErr) {}
}
}
const updateOdpFlag = String(req.body.update_odp || '') === '1';
if (updateOdpFlag && customerData.odp_id) {
const existing = odpSvc.getOdpById(customerData.odp_id);
if (existing) {
const newLat = String(req.body.odp_lat || '').trim();
const newLng = String(req.body.odp_lng || '').trim();
const newCap = req.body.odp_port_capacity !== undefined && req.body.odp_port_capacity !== null && String(req.body.odp_port_capacity).trim() !== ''
? Number(req.body.odp_port_capacity)
: (existing.port_capacity || 16);
const newPon = String(req.body.odp_pon_port || '').trim();
odpSvc.updateOdp(existing.id, {
name: existing.name,
olt_id: existing.olt_id,
pon_port: newPon || existing.pon_port || '',
port_capacity: Number.isFinite(newCap) && newCap > 0 ? Math.floor(newCap) : (existing.port_capacity || 16),
lat: newLat || existing.lat || '',
lng: newLng || existing.lng || '',
description: existing.description || ''
});
}
}
req.session._msg = { type: 'success', text: `Pelanggan "${name}" berhasil dibuat.` };
res.redirect('/tech/customers/new');
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal membuat pelanggan: ' + e.message };
res.redirect('/tech/customers/new');
}
});
// API Endpoints for Technician
const customerDevice = require('../services/customerDeviceService');
router.get('/api/mikrotik/pppoe-users', requireTechSession, async (req, res) => {
try {
const routerId = req.query.routerId ? Number(req.query.routerId) : null;
const users = await mikrotikService.getPppoeUsers(routerId);
const usedRows = db.prepare("SELECT pppoe_username FROM customers WHERE router_id IS ? AND pppoe_username IS NOT NULL AND TRIM(pppoe_username) != ''").all(routerId);
const used = new Set(usedRows.map(r => String(r.pppoe_username).trim()).filter(Boolean));
const filtered = (Array.isArray(users) ? users : []).filter(u => u && u.name && !used.has(String(u.name).trim()));
res.json(filtered);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.get('/api/mikrotik/pppoe-profiles', requireTechSession, async (req, res) => {
try {
const routerId = req.query.routerId ? Number(req.query.routerId) : null;
const profiles = await mikrotikService.getPppoeProfiles(routerId);
res.json(profiles);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.get('/api/odps/:id/ports', requireTechSession, (req, res) => {
try {
const odpId = Number(req.params.id);
if (!odpId) return res.status(400).json({ error: 'ODP tidak valid' });
const usage = odpSvc.getOdpPortUsage(odpId);
if (!usage) return res.status(404).json({ error: 'ODP tidak ditemukan' });
res.json(usage);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
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 byPppoe = new Map();
const byTag = new Map();
for (const c of customers) {
const pu = String(c.pppoe_username || '').trim().toLowerCase();
const tg = String(c.genieacs_tag || '').trim();
if (pu) byPppoe.set(pu, c);
if (tg) byTag.set(tg, c);
}
const result = await customerDevice.listAllDevices(999999, acs);
if (!result.ok) return res.json({ error: result.message });
const activeSessionsMap = await mikrotikService.getActivePppoeSessionsMap().catch(() => new Map());
let devices = result.devices.map(d => {
const pppoeUser = customerDevice.extractPppoeUser(d);
const isPppoeActive = pppoeUser && pppoeUser !== 'N/A' && pppoeUser !== '-' && activeSessionsMap.has(pppoeUser.toLowerCase());
const mapped = customerDevice.mapDeviceData(d, d._tags?.[0] || d._id, isPppoeActive);
const pu = String(mapped.pppoeUsername || '').trim();
const puKey = pu && pu !== 'N/A' ? pu.toLowerCase() : '';
let customer = puKey ? byPppoe.get(puKey) : null;
if (!customer && Array.isArray(d._tags)) {
for (const t of d._tags) {
const hit = byTag.get(String(t || '').trim());
if (hit) { customer = hit; break; }
}
}
return {
id: d._id,
tags: d._tags || [],
serialNumber: mapped.serialNumber,
lastInform: d._lastInform,
status: mapped.status.toLowerCase(),
pppoeIP: mapped.pppoeIP,
pppoeUsername: mapped.pppoeUsername,
rxPower: mapped.rxPower,
uptime: mapped.uptime,
model: mapped.model,
softwareVersion: mapped.softwareVersion,
userConnected: mapped.totalAssociations,
ssid: mapped.ssid,
customerId: customer ? customer.id : null,
customerName: customer ? customer.name : '',
customerPhone: customer ? customer.phone : '',
acsServerName: d._acs_server_name || 'Default ACS',
acsServerId: d._acs_server_id || 'legacy',
manufacturer: d._deviceId?._Manufacturer || d._deviceId?.Manufacturer || '-'
};
});
if (search) {
const s = search.toLowerCase();
devices = devices.filter(d =>
d.id.toLowerCase().includes(s) ||
d.tags.some(t => t.toLowerCase().includes(s)) ||
d.serialNumber.toLowerCase().includes(s) ||
(d.pppoeUsername && d.pppoeUsername !== 'N/A' && d.pppoeUsername.toLowerCase().includes(s)) ||
(d.customerName && d.customerName.toLowerCase().includes(s)) ||
(d.customerPhone && d.customerPhone.toLowerCase().includes(s))
);
}
if (acs && acs !== 'all') {
devices = devices.filter(d => String(d.acsServerId) === String(acs));
}
if (status && status !== 'all') devices = devices.filter(d => d.status === status);
res.json({ devices: devices, total: devices.length });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.get('/api/device/:tag', requireTechSession, async (req, res) => {
try {
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);
} catch (e) {
res.status(500).json({ error: 'Failed to get device details' });
}
});
router.post('/api/device/:tag/ssid', requireTechSession, express.json(), async (req, res) => {
const { ssid } = req.body;
if (!ssid) return res.status(400).json({ error: 'SSID required' });
const ok = await customerDevice.updateSSID(req.params.tag, ssid);
// Kirim notifikasi WhatsApp ke pelanggan
if (ok) {
try {
const { getSettingsWithCache } = require('../config/settingsManager');
const settings = getSettingsWithCache();
if (settings.whatsapp_enabled) {
const tag = req.params.tag;
const cust = customerSvc.findCustomerByAny(tag);
if (cust && cust.phone) {
const waNotify = require('../services/waNotifyService');
if (true) {
const now = getNowLocal();
const msg = `\ud83d\udcf6 *PERUBAHAN SSID WIFI*\n\n` +
`\ud83d\udc64 *Pelanggan:* ${cust.name}\n` +
`\ud83d\udd52 *Waktu:* ${now}\n\n` +
`SSID WiFi Anda sudah diperbarui menjadi:\n` +
`\ud83d\udce1 *${ssid}*\n\n` +
`Silakan pilih SSID baru di perangkat Anda untuk terhubung.\n` +
`\u26a0\ufe0f Jangan bagikan info ini ke orang lain.`;
await waNotify.sendText(cust.phone, msg);
}
}
}
} catch (e) { /* ignore WA notification errors */ }
}
res.json({ success: ok });
});
router.post('/api/device/:tag/password', requireTechSession, express.json(), async (req, res) => {
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);
// Kirim notifikasi WhatsApp ke pelanggan
if (ok) {
try {
const { getSettingsWithCache } = require('../config/settingsManager');
const settings = getSettingsWithCache();
if (settings.whatsapp_enabled) {
const tag = req.params.tag;
const cust = customerSvc.findCustomerByAny(tag);
if (cust && cust.phone) {
const waNotify = require('../services/waNotifyService');
if (true) {
const now = getNowLocal();
const msg = `\ud83d\udd11 *PERUBAHAN PASSWORD WIFI*\n\n` +
`\ud83d\udc64 *Pelanggan:* ${cust.name}\n` +
`\ud83d\udd52 *Waktu:* ${now}\n\n` +
`Password WiFi Anda sudah diperbarui menjadi:\n` +
`\ud83d\udd10 *${password}*\n\n` +
`Silakan gunakan password baru untuk terhubung.\n` +
`\u26a0\ufe0f Jangan bagikan password ini ke orang lain.`;
await waNotify.sendText(cust.phone, msg);
}
}
}
} catch (e) { /* ignore WA notification errors */ }
}
res.json({ success: ok });
});
router.post('/api/device/:tag/reboot', requireTechSession, async (req, res) => {
const result = await customerDevice.requestReboot(req.params.tag);
res.json(result);
});
// ─── ATTENDANCE ROUTES ───────────────────────────────────────────────────────
// Get attendance page
router.get('/attendance', requireTechSession, (req, res) => {
const techId = req.session.techId;
const todayAttendance = attendanceSvc.getTodayAttendance('technician', techId);
const history = attendanceSvc.getAttendanceHistory('technician', techId, 10);
// Get monthly summary
const now = getCurrentDateInTimezone();
const summary = attendanceSvc.getMonthlyAttendanceSummary(
'technician',
techId,
now.getFullYear(),
now.getMonth() + 1
);
res.render('tech/attendance', {
title: 'Absensi',
company: company(),
activePage: 'attendance',
techName: req.session.techName,
todayAttendance,
history,
summary,
msg: flashMsg(req)
});
});
// Check-in
router.post('/attendance/checkin', requireTechSession, uploadAttendance.single('photo'), (req, res) => {
try {
const techId = req.session.techId;
const techName = req.session.techName;
if (!req.file) {
return res.json({ success: false, message: 'Foto check-in wajib diunggah' });
}
// Check if already checked in today
const today = attendanceSvc.getTodayAttendance('technician', techId);
if (today) {
removeAttendanceFile(req.file);
return res.json({ success: false, message: 'Anda sudah melakukan check-in hari ini' });
}
// Prepare photo path
let photoPath = '';
if (req.file) {
photoPath = '/uploads/attendance/' + req.file.filename;
}
const result = attendanceSvc.checkIn({
employee_type: 'technician',
employee_id: techId,
employee_name: techName,
lat: req.body.lat || '',
lng: req.body.lng || '',
note: req.body.note || '',
photo: photoPath
});
res.json({ success: true, message: 'Check-in berhasil!', id: result.lastInsertRowid });
} catch (e) {
removeAttendanceFile(req.file);
res.json({ success: false, message: 'Gagal check-in: ' + e.message });
}
});
// Check-out
router.post('/attendance/checkout', requireTechSession, uploadAttendance.single('photo'), (req, res) => {
try {
const techId = req.session.techId;
if (!req.file) {
return res.json({ success: false, message: 'Foto check-out wajib diunggah' });
}
// Get today's attendance
const today = attendanceSvc.getTodayAttendance('technician', techId);
if (!today) {
removeAttendanceFile(req.file);
return res.json({ success: false, message: 'Anda belum check-in hari ini' });
}
if (today.status === 'checked_out') {
removeAttendanceFile(req.file);
return res.json({ success: false, message: 'Anda sudah check-out hari ini' });
}
// Prepare photo path
let photoPath = '';
if (req.file) {
photoPath = '/uploads/attendance/' + req.file.filename;
}
attendanceSvc.checkOut(today.id, {
lat: req.body.lat || '',
lng: req.body.lng || '',
note: req.body.note || '',
photo: photoPath
});
res.json({ success: true, message: 'Check-out berhasil!' });
} catch (e) {
removeAttendanceFile(req.file);
res.json({ success: false, message: 'Gagal check-out: ' + e.message });
}
});
// Get attendance history (API)
router.get('/api/attendance/history', requireTechSession, (req, res) => {
try {
const techId = req.session.techId;
const limit = req.query.limit ? parseInt(req.query.limit) : 30;
const history = attendanceSvc.getAttendanceHistory('technician', techId, limit);
res.json({ success: true, data: history });
} catch (e) {
res.json({ success: false, message: e.message });
}
});
// Helpers for GenieACS Server DB access inside Technician Portal
function getACSServers(id = null) {
if (genieacsApi.isBuiltinAcsEnabled()) {
const builtinServer = {
id: 'builtin',
name: 'Built-in ACS',
url: 'local',
status: 'active'
};
if (id && id !== 'all') {
return id === 'builtin' ? [builtinServer] : [];
}
return [builtinServer];
}
const legacyACS = getLegacyACS();
const legacyServer = legacyACS.acs_url ? {
id: 'legacy',
name: 'Default ACS',
url: legacyACS.acs_url,
username: legacyACS.acs_user,
password: legacyACS.acs_pass
} : null;
if (id === 'legacy') return legacyServer ? [legacyServer] : [];
let query = 'SELECT * FROM genieacs_servers';
let params = [];
if (id && id !== 'all') {
query += ' WHERE id = ?';
params.push(id);
const row = db.prepare(query).get(params);
return row ? [row] : [];
}
const rows = db.prepare(query).all(params);
return legacyServer ? [legacyServer, ...rows] : rows;
}
function getLegacyACS() {
return {
acs_url: getSetting('genieacs_url', ''),
acs_user: getSetting('genieacs_username', ''),
acs_pass: getSetting('genieacs_password', '')
};
}
function getAxiosConfig(server) {
const config = {
timeout: 15000,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
};
if (server.username && server.password) {
config.auth = {
username: server.username,
password: server.password
};
}
return config;
}
function normalizeUrl(url) {
if (!url) return '';
return url.endsWith('/') ? url.slice(0, -1) : url;
}
function toBool(value) {
return value === true || value === 'true' || value === 'on' || value === 1 || value === '1';
}
function normalizeSelectionArray(value) {
const items = Array.isArray(value) ? value : (value ? [value] : []);
return Array.from(new Set(
items.map(v => String(v || '').trim()).filter(Boolean)
));
}
function buildSingleParamTasks(parameterValues) {
return (parameterValues || [])
.filter(pv => Array.isArray(pv) && pv.length >= 2 && pv[0])
.map(pv => ({
name: 'setParameterValues',
payload: { parameterValues: [pv] }
}));
}
function buildBuiltinAddWanWorkflow({
mode,
parsedVlan,
pppoeUser,
pppoePass,
dhcp,
lanPorts,
wlanSsids,
configureWifi,
wifiSsid24,
wifiPass24,
wifiSsid5,
wifiPass5,
manufacturer,
wlanConfig
}) {
const isPppoe = mode === 'pppoe';
const connectionType = isPppoe ? 'WANPPPConnection' : 'WANIPConnection';
const lanPortsArray = normalizeSelectionArray(lanPorts);
const wlanSsidsArray = normalizeSelectionArray(wlanSsids);
const baseConnPath = `InternetGatewayDevice.WANDevice.1.WANConnectionDevice.{{wanDeviceInstance}}.${connectionType}.{{wanConnectionInstance}}`;
const followup = [];
const baseParamValues = [
[`${baseConnPath}.Enable`, true, 'xsd:boolean'],
[`${baseConnPath}.ConnectionType`, isPppoe ? 'IP_Routed' : 'Bridged', 'xsd:string']
];
if (isPppoe) {
baseParamValues.push(
[`${baseConnPath}.NATEnabled`, true, 'xsd:boolean'],
[`${baseConnPath}.Username`, pppoeUser, 'xsd:string'],
[`${baseConnPath}.Password`, pppoePass, 'xsd:string']
);
}
followup.push(...buildSingleParamTasks(baseParamValues));
const vendorParamValues = [];
if (manufacturer.includes('huawei')) {
vendorParamValues.push(
[`${baseConnPath}.X_HW_VLAN`, parsedVlan, 'xsd:unsignedInt'],
[`${baseConnPath}.X_HW_VLANID`, parsedVlan, 'xsd:unsignedInt'],
[`${baseConnPath}.X_HW_VLANMark`, true, 'xsd:boolean'],
[`${baseConnPath}.X_HW_WANMode`, isPppoe ? 'WAN_PPPOE' : 'WAN_BRIDGE', 'xsd:string']
);
if (lanPortsArray.length > 0) {
vendorParamValues.push([`${baseConnPath}.X_HW_LANBind`, lanPortsArray.join(','), 'xsd:string']);
}
if (wlanSsidsArray.length > 0) {
vendorParamValues.push([`${baseConnPath}.X_HW_SSIDBind`, wlanSsidsArray.join(','), 'xsd:string']);
}
} else if (manufacturer.includes('zte')) {
vendorParamValues.push(
[`${baseConnPath}.VLANIDMark`, parsedVlan, 'xsd:unsignedInt'],
[`${baseConnPath}.VLANID`, parsedVlan, 'xsd:unsignedInt'],
[`${baseConnPath}.X_ZTE_VLAN`, parsedVlan, 'xsd:unsignedInt'],
[`${baseConnPath}.VLANMode`, 1, 'xsd:unsignedInt']
);
if (lanPortsArray.length > 0) {
vendorParamValues.push([`${baseConnPath}.X_ZTE_LANBind`, lanPortsArray.join(','), 'xsd:string']);
}
if (wlanSsidsArray.length > 0) {
vendorParamValues.push([`${baseConnPath}.X_ZTE_SSIDBind`, wlanSsidsArray.join(','), 'xsd:string']);
}
} else {
vendorParamValues.push(
[`${baseConnPath}.VLANIDMark`, parsedVlan, 'xsd:unsignedInt'],
[`${baseConnPath}.VLANID`, parsedVlan, 'xsd:unsignedInt'],
[`${baseConnPath}.VLANMode`, 1, 'xsd:unsignedInt']
);
}
followup.push(...buildSingleParamTasks(vendorParamValues));
followup.push({
name: 'setParameterValues',
payload: {
parameterValues: [[
'InternetGatewayDevice.LANDevice.1.LANHostConfigManagement.DHCPServerEnable',
toBool(dhcp),
'xsd:boolean'
]]
}
});
const verifyNames = [
`${baseConnPath}.Enable`,
`${baseConnPath}.ConnectionType`,
`${baseConnPath}.ExternalIPAddress`,
`${baseConnPath}.Uptime`
];
if (isPppoe) {
verifyNames.push(`${baseConnPath}.Username`, `${baseConnPath}.NATEnabled`);
}
const wifiParamValues = [];
const wlanObj = wlanConfig || {};
if (toBool(configureWifi)) {
if (wlanObj['1'] && wifiSsid24) {
wifiParamValues.push([`InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID`, wifiSsid24, 'xsd:string']);
verifyNames.push('InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID');
if (wifiPass24) {
wifiParamValues.push(
['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.PreSharedKey', wifiPass24, 'xsd:string'],
['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase', wifiPass24, 'xsd:string']
);
}
}
const fiveGIndex = wlanObj['5'] ? '5' : (wlanObj['2'] ? '2' : null);
if (fiveGIndex && wifiSsid5) {
wifiParamValues.push([`InternetGatewayDevice.LANDevice.1.WLANConfiguration.${fiveGIndex}.SSID`, wifiSsid5, 'xsd:string']);
verifyNames.push(`InternetGatewayDevice.LANDevice.1.WLANConfiguration.${fiveGIndex}.SSID`);
if (wifiPass5) {
wifiParamValues.push(
[`InternetGatewayDevice.LANDevice.1.WLANConfiguration.${fiveGIndex}.PreSharedKey.1.PreSharedKey`, wifiPass5, 'xsd:string'],
[`InternetGatewayDevice.LANDevice.1.WLANConfiguration.${fiveGIndex}.KeyPassphrase`, wifiPass5, 'xsd:string']
);
}
}
}
followup.push(...buildSingleParamTasks(wifiParamValues));
followup.push({
name: 'getParameterValues',
payload: {
parameterNames: Array.from(new Set(verifyNames))
}
});
return {
name: 'addObject',
objectName: 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice',
instanceVariable: 'wanDeviceInstance',
followup: [{
name: 'addObject',
payload: {
objectName: `InternetGatewayDevice.WANDevice.1.WANConnectionDevice.{{wanDeviceInstance}}.${connectionType}`,
instanceVariable: 'wanConnectionInstance',
followup
}
}]
};
}
function getNestedValue(obj, path) {
try {
const parts = path.split('.');
let current = obj;
for (const part of parts) {
if (!current) return null;
current = current[part];
}
if (current && typeof current === 'object' && '_value' in current) {
return current._value;
}
return current;
} catch (e) {
return null;
}
}
// GET /tech/api/wifi-settings/:deviceId
router.get('/api/wifi-settings/:deviceId', requireTechSession, async (req, res) => {
try {
const { deviceId } = req.params;
const { acsId } = req.query;
const servers = getACSServers(acsId);
if (servers.length === 0) return res.status(404).json({ success: false, message: 'ACS Server not found' });
const server = servers[0];
const baseUrl = normalizeUrl(server.url);
const response = await axios.get(`${baseUrl}/devices`, {
...getAxiosConfig(server),
params: {
query: JSON.stringify({ _id: deviceId }),
projection: 'InternetGatewayDevice.LANDevice.1.WLANConfiguration'
}
});
const deviceData = Array.isArray(response.data) && response.data.length > 0 ? response.data[0] : null;
if (!deviceData) return res.status(404).json({ success: false, message: 'Device not found' });
const wlanConfig = deviceData.InternetGatewayDevice?.LANDevice?.['1']?.WLANConfiguration || {};
const bands = [];
// Return all SSID indices (1 to 8) that exist on the ONU
for (let i = 1; i <= 8; i++) {
if (wlanConfig[String(i)]) {
bands.push({
index: String(i),
ssid: getNestedValue(wlanConfig[String(i)], 'SSID') || `SSID ${i}`,
name: i <= 4 ? `Wi-Fi 2.4GHz (SSID ${i})` : `Wi-Fi 5GHz (SSID ${i})`
});
}
}
res.json({ success: true, bands });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});
// POST /tech/api/add-wan/:deviceId
router.post('/api/add-wan/:deviceId', requireTechSession, async (req, res) => {
try {
const { deviceId } = req.params;
const {
acsId,
mode,
vlanId,
pppoeUser,
pppoePass,
pppoeProfile,
autoCreateMikrotik,
lanPorts,
wlanSsids,
configureWifi,
wifiSsid24,
wifiPass24,
wifiSsid5,
wifiPass5,
dhcp
} = req.body;
// 1. Validasi awal
const normalizedMode = String(mode || '').trim().toLowerCase();
if (!['pppoe', 'bridge'].includes(normalizedMode)) {
return res.json({ success: false, message: 'Mode WAN tidak valid' });
}
const parsedVlan = parseInt(vlanId, 10);
if (isNaN(parsedVlan) || parsedVlan < 1 || parsedVlan > 4094) {
return res.json({ success: false, message: 'VLAN ID tidak valid (harus 1-4094)' });
}
const trimmedPppoeUser = String(pppoeUser || '').trim();
const trimmedPppoePass = String(pppoePass || '').trim();
if (normalizedMode === 'pppoe' && (!trimmedPppoeUser || !trimmedPppoePass)) {
return res.json({ success: false, message: 'Username dan password PPPoE wajib diisi untuk mode PPPoE' });
}
const servers = getACSServers(acsId);
if (servers.length === 0) return res.json({ success: false, message: 'ACS Server tidak ditemukan' });
const server = servers[0];
const baseUrl = normalizeUrl(server.url);
const config = getAxiosConfig(server);
// 2. Jika Auto-create MikroTik diaktifkan
if (normalizedMode === 'pppoe' && toBool(autoCreateMikrotik)) {
try {
await mikrotikService.createPppoeSecret({
username: trimmedPppoeUser,
password: trimmedPppoePass,
profile: pppoeProfile || 'default'
});
} catch (mErr) {
console.error('[TechAddWAN] Failed to create PPPoE Secret in MikroTik:', mErr.message);
return res.json({ success: false, message: `Gagal membuat akun PPPoE di MikroTik: ${mErr.message}` });
}
}
// 3. Ambil data instansi WANConnectionDevice saat ini untuk menghitung nextInstance
const getDeviceRes = await axios.get(`${baseUrl}/devices`, {
...config,
params: {
query: JSON.stringify({ _id: deviceId }),
projection: '_id,_deviceId.Manufacturer,_deviceId._Manufacturer,InternetGatewayDevice.WANDevice.1.WANConnectionDevice,InternetGatewayDevice.LANDevice.1.WLANConfiguration'
}
});
const deviceData = Array.isArray(getDeviceRes.data) && getDeviceRes.data.length > 0 ? getDeviceRes.data[0] : null;
if (!deviceData) return res.json({ success: false, message: 'CPE/Device tidak ditemukan di GenieACS' });
const manufacturer = (deviceData._deviceId?._Manufacturer || deviceData._deviceId?.Manufacturer || '').toLowerCase();
const wlanConfig = deviceData.InternetGatewayDevice?.LANDevice?.['1']?.WLANConfiguration || {};
const isBuiltinServer = String(server.id || '').trim() === 'builtin' || baseUrl === 'local';
if (isBuiltinServer) {
const task = buildBuiltinAddWanWorkflow({
mode: normalizedMode,
parsedVlan,
pppoeUser: trimmedPppoeUser,
pppoePass: trimmedPppoePass,
dhcp,
lanPorts,
wlanSsids,
configureWifi,
wifiSsid24,
wifiPass24,
wifiSsid5,
wifiPass5,
manufacturer,
wlanConfig
});
await axios.post(`${baseUrl}/devices/${encodeURIComponent(deviceId)}/tasks`, task, config);
return res.json({ success: true, message: 'Workflow Add WAN built-in berhasil dikirim. Task akan mengikuti instance asli dari ONU.' });
}
const wanConnObj = deviceData.InternetGatewayDevice?.WANDevice?.['1']?.WANConnectionDevice || {};
const existingKeys = Object.keys(wanConnObj).map(Number).filter(n => !isNaN(n));
const nextInstance = existingKeys.length > 0 ? Math.max(...existingKeys) + 1 : 2;
await axios.post(`${baseUrl}/devices/${encodeURIComponent(deviceId)}/tasks`, {
name: 'addObject',
objectName: 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.'
}, config);
const connectionType = normalizedMode === 'pppoe' ? 'WANPPPConnection' : 'WANIPConnection';
await axios.post(`${baseUrl}/devices/${encodeURIComponent(deviceId)}/tasks`, {
name: 'addObject',
objectName: `InternetGatewayDevice.WANDevice.1.WANConnectionDevice.${nextInstance}.${connectionType}.`
}, config);
const paramValues = [];
const baseConnPath = `InternetGatewayDevice.WANDevice.1.WANConnectionDevice.${nextInstance}.${connectionType}.1`;
paramValues.push([`${baseConnPath}.Enable`, true, 'xsd:boolean']);
if (normalizedMode === 'pppoe') {
paramValues.push(
[`${baseConnPath}.ConnectionType`, 'IP_Routed', 'xsd:string'],
[`${baseConnPath}.NATEnabled`, true, 'xsd:boolean'],
[`${baseConnPath}.Username`, trimmedPppoeUser, 'xsd:string'],
[`${baseConnPath}.Password`, trimmedPppoePass, 'xsd:string']
);
} else {
paramValues.push([`${baseConnPath}.ConnectionType`, 'Bridged', 'xsd:string']);
}
axios.post(`${baseUrl}/devices/${encodeURIComponent(deviceId)}/tasks`, {
name: 'setParameterValues',
parameterValues: [[`InternetGatewayDevice.LANDevice.1.LANHostConfigManagement.DHCPServerEnable`, toBool(dhcp), 'xsd:boolean']]
}, config).catch(() => {});
const lanPortsArray = normalizeSelectionArray(lanPorts);
const wlanSsidsArray = normalizeSelectionArray(wlanSsids);
if (manufacturer.includes('huawei')) {
paramValues.push(
[`${baseConnPath}.X_HW_VLAN`, parsedVlan, 'xsd:unsignedInt'],
[`${baseConnPath}.X_HW_VLANID`, parsedVlan, 'xsd:unsignedInt'],
[`${baseConnPath}.X_HW_VLANMark`, true, 'xsd:boolean'],
[`${baseConnPath}.X_HW_WANMode`, normalizedMode === 'pppoe' ? 'WAN_PPPOE' : 'WAN_BRIDGE', 'xsd:string']
);
if (lanPortsArray.length > 0) {
paramValues.push([`${baseConnPath}.X_HW_LANBind`, lanPortsArray.join(','), 'xsd:string']);
}
if (wlanSsidsArray.length > 0) {
paramValues.push([`${baseConnPath}.X_HW_SSIDBind`, wlanSsidsArray.join(','), 'xsd:string']);
}
} else if (manufacturer.includes('zte')) {
paramValues.push(
[`${baseConnPath}.VLANIDMark`, parsedVlan, 'xsd:unsignedInt'],
[`${baseConnPath}.VLANID`, parsedVlan, 'xsd:unsignedInt'],
[`${baseConnPath}.X_ZTE_VLAN`, parsedVlan, 'xsd:unsignedInt'],
[`${baseConnPath}.VLANMode`, 1, 'xsd:unsignedInt']
);
if (lanPortsArray.length > 0) {
paramValues.push([`${baseConnPath}.X_ZTE_LANBind`, lanPortsArray.join(','), 'xsd:string']);
}
if (wlanSsidsArray.length > 0) {
paramValues.push([`${baseConnPath}.X_ZTE_SSIDBind`, wlanSsidsArray.join(','), 'xsd:string']);
}
} else {
paramValues.push(
[`${baseConnPath}.VLANIDMark`, parsedVlan, 'xsd:unsignedInt'],
[`${baseConnPath}.VLANID`, parsedVlan, 'xsd:unsignedInt'],
[`${baseConnPath}.VLANMode`, 1, 'xsd:unsignedInt']
);
}
await axios.post(`${baseUrl}/devices/${encodeURIComponent(deviceId)}/tasks`, {
name: 'setParameterValues',
parameterValues: paramValues
}, config);
if (toBool(configureWifi)) {
const wifiParamValues = [];
if (wlanConfig['1'] && wifiSsid24) {
wifiParamValues.push([`InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID`, wifiSsid24, 'xsd:string']);
if (wifiPass24) {
wifiParamValues.push(
[`InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.PreSharedKey`, wifiPass24, 'xsd:string'],
[`InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase`, wifiPass24, 'xsd:string']
);
}
}
const fiveGIndex = wlanConfig['5'] ? '5' : (wlanConfig['2'] ? '2' : null);
if (fiveGIndex && wifiSsid5) {
wifiParamValues.push([`InternetGatewayDevice.LANDevice.1.WLANConfiguration.${fiveGIndex}.SSID`, wifiSsid5, 'xsd:string']);
if (wifiPass5) {
wifiParamValues.push(
[`InternetGatewayDevice.LANDevice.1.WLANConfiguration.${fiveGIndex}.PreSharedKey.1.PreSharedKey`, wifiPass5, 'xsd:string'],
[`InternetGatewayDevice.LANDevice.1.WLANConfiguration.${fiveGIndex}.KeyPassphrase`, wifiPass5, 'xsd:string']
);
}
}
if (wifiParamValues.length > 0) {
await axios.post(`${baseUrl}/devices/${encodeURIComponent(deviceId)}/tasks`, {
name: 'setParameterValues',
parameterValues: wifiParamValues
}, config);
}
}
axios.post(`${baseUrl}/devices/${encodeURIComponent(deviceId)}/tasks`, {
name: 'refreshObject',
objectName: ''
}, config).catch(() => {});
res.json({ success: true, message: 'Semua antrean tugas Add WAN (dan Wi-Fi) berhasil dikirimkan ke GenieACS.' });
} catch (err) {
res.json({ success: false, message: err.message });
}
});
module.exports = router;