feat(akses): batasi teknisi & kolektor sesuai wilayah tugas (port dari upstream)

- techService: getTechAreaWhere + canTechAccessCustomer, filter pool/stats
- techPortal: filter peta, ODP, daftar perangkat; guard perangkat per wilayah; area pelanggan baru dipaksa sesuai wilayah
- customerService: getCollectorCustomerWhere + canCollectorAccessCustomer (multi-area)
- collectorPortal: daftar tagihan pakai helper; tolak bayar/cetak/kirim PDF di luar wilayah
- customerAPI: dashboard & pay-bill kolektor dibatasi wilayah
- tests/unit/area-access.test.js: self-check logika area
This commit is contained in:
oxpoe
2026-11-05 01:16:26 +07:00
parent 2a29739dc3
commit d24cabf818
6 changed files with 290 additions and 30 deletions

View File

@@ -177,16 +177,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 +345,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 +474,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 +503,9 @@ router.post('/invoice/:id/send-pdf-wa', requireCollectorSession, async (req, res
const customer = customerSvc.getCustomerById(inv.customer_id);
if (!customer || !customer.phone) return res.json({ success: false, message: 'Nomor HP pelanggan tidak valid' });
if (!customerSvc.canCollectorAccessCustomer(Number(req.session.collectorId || 0), customer)) {
return res.json({ success: false, message: 'Invoice di luar wilayah tugas Anda' });
}
const settings = getSettings();
const pdfBuffer = await pdfSvc.generateInvoicePdfBuffer(inv, customer, settings);

View File

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

View File

@@ -176,7 +176,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 +201,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 +353,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 +378,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 +485,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 +533,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 +588,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 +618,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 +628,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 +661,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 +694,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);
});