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:
@@ -177,16 +177,10 @@ router.get('/', requireCollectorSession, (req, res) => {
|
|||||||
const todayDay = now.getDate();
|
const todayDay = now.getDate();
|
||||||
|
|
||||||
const collectorId = Number(req.session.collectorId || 0);
|
const collectorId = Number(req.session.collectorId || 0);
|
||||||
const collectorObj = db.prepare('SELECT area FROM collectors WHERE id = ?').get(collectorId);
|
const collectorObj = db.prepare('SELECT id, area FROM collectors WHERE id = ?').get(collectorId);
|
||||||
const collectorArea = String(collectorObj?.area || req.session.collectorArea || '').trim();
|
req.session.collectorArea = String(collectorObj?.area || req.session.collectorArea || '').trim();
|
||||||
req.session.collectorArea = collectorArea;
|
|
||||||
|
|
||||||
let collectorWhere = '(c.collector_id = ? OR c.collector_id IS NULL)';
|
const { where: collectorWhere, params: collectorParams } = customerSvc.getCollectorCustomerWhere(collectorObj, 'c');
|
||||||
let collectorParams = [collectorId];
|
|
||||||
if (collectorArea) {
|
|
||||||
collectorWhere = '(c.collector_id = ? OR ((c.collector_id IS NULL OR c.collector_id = 0) AND LOWER(TRIM(c.area)) = LOWER(TRIM(?))))';
|
|
||||||
collectorParams = [collectorId, collectorArea];
|
|
||||||
}
|
|
||||||
|
|
||||||
let q = `
|
let q = `
|
||||||
SELECT c.id as customer_id,
|
SELECT c.id as customer_id,
|
||||||
@@ -351,6 +345,14 @@ router.post('/payment-request', requireCollectorSession, express.urlencoded({ ex
|
|||||||
if (!inv) throw new Error('Tagihan tidak ditemukan');
|
if (!inv) throw new Error('Tagihan tidak ditemukan');
|
||||||
if (String(inv.status || '').toLowerCase() === 'paid') throw new Error('Tagihan sudah lunas');
|
if (String(inv.status || '').toLowerCase() === 'paid') throw new Error('Tagihan sudah lunas');
|
||||||
|
|
||||||
|
{
|
||||||
|
const collectorIdCheck = Number(req.session.collectorId || 0);
|
||||||
|
const invCust = customerSvc.getCustomerById(inv.customer_id);
|
||||||
|
if (!customerSvc.canCollectorAccessCustomer(collectorIdCheck, invCust)) {
|
||||||
|
throw new Error('Tagihan ini di luar wilayah/tugas Anda');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const existingPending = db.prepare(`
|
const existingPending = db.prepare(`
|
||||||
SELECT id FROM collector_payment_requests
|
SELECT id FROM collector_payment_requests
|
||||||
WHERE invoice_id = ? AND status = 'pending'
|
WHERE invoice_id = ? AND status = 'pending'
|
||||||
@@ -472,6 +474,9 @@ router.get('/invoice/:id/print-thermal', requireCollectorSession, (req, res) =>
|
|||||||
|
|
||||||
const customer = customerSvc.getCustomerById(invoice.customer_id);
|
const customer = customerSvc.getCustomerById(invoice.customer_id);
|
||||||
if (!customer) return res.status(404).send('Data pelanggan tidak ditemukan');
|
if (!customer) return res.status(404).send('Data pelanggan tidak ditemukan');
|
||||||
|
if (!customerSvc.canCollectorAccessCustomer(Number(req.session.collectorId || 0), customer)) {
|
||||||
|
return res.status(403).send('Invoice di luar wilayah tugas Anda');
|
||||||
|
}
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
res.render('collector/print_thermal', {
|
res.render('collector/print_thermal', {
|
||||||
@@ -498,6 +503,9 @@ router.post('/invoice/:id/send-pdf-wa', requireCollectorSession, async (req, res
|
|||||||
|
|
||||||
const customer = customerSvc.getCustomerById(inv.customer_id);
|
const customer = customerSvc.getCustomerById(inv.customer_id);
|
||||||
if (!customer || !customer.phone) return res.json({ success: false, message: 'Nomor HP pelanggan tidak valid' });
|
if (!customer || !customer.phone) return res.json({ success: false, message: 'Nomor HP pelanggan tidak valid' });
|
||||||
|
if (!customerSvc.canCollectorAccessCustomer(Number(req.session.collectorId || 0), customer)) {
|
||||||
|
return res.json({ success: false, message: 'Invoice di luar wilayah tugas Anda' });
|
||||||
|
}
|
||||||
|
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
const pdfBuffer = await pdfSvc.generateInvoicePdfBuffer(inv, customer, settings);
|
const pdfBuffer = await pdfSvc.generateInvoicePdfBuffer(inv, customer, settings);
|
||||||
|
|||||||
@@ -740,6 +740,13 @@ router.get('/app/collector/dashboard', requireCollectorApiAuth, (req, res) => {
|
|||||||
const curMonth = now.getMonth() + 1;
|
const curMonth = now.getMonth() + 1;
|
||||||
const curYear = now.getFullYear();
|
const curYear = now.getFullYear();
|
||||||
|
|
||||||
|
const isFullAdmin = req.collector?.role === 'admin' || req.collector?.role === 'root';
|
||||||
|
const collectorId = Number(req.collector?.collectorId || req.collector?.id || 0);
|
||||||
|
const collector = collectorId
|
||||||
|
? db.prepare('SELECT id, name, area, auto_approve FROM collectors WHERE id = ?').get(collectorId)
|
||||||
|
: null;
|
||||||
|
const colFilter = (!isFullAdmin && collector) ? customerSvc.getCollectorCustomerWhere(collector, 'c') : null;
|
||||||
|
|
||||||
let q = `
|
let q = `
|
||||||
SELECT c.id, c.name, c.phone, c.address, c.status, c.area, c.isolate_day, c.pppoe_username,
|
SELECT c.id, c.name, c.phone, c.address, c.status, c.area, c.isolate_day, c.pppoe_username,
|
||||||
p.name as package_name, p.price as package_price,
|
p.name as package_name, p.price as package_price,
|
||||||
@@ -752,6 +759,11 @@ router.get('/app/collector/dashboard', requireCollectorApiAuth, (req, res) => {
|
|||||||
`;
|
`;
|
||||||
const params = [curMonth, curYear];
|
const params = [curMonth, curYear];
|
||||||
|
|
||||||
|
if (colFilter) {
|
||||||
|
q += ` AND ${colFilter.where}`;
|
||||||
|
params.push(...colFilter.params);
|
||||||
|
}
|
||||||
|
|
||||||
if (filterStatus === 'unpaid') {
|
if (filterStatus === 'unpaid') {
|
||||||
q += ` AND (i.status = 'unpaid' OR i.status IS NULL)`;
|
q += ` AND (i.status = 'unpaid' OR i.status IS NULL)`;
|
||||||
} else if (filterStatus === 'today') {
|
} else if (filterStatus === 'today') {
|
||||||
@@ -770,6 +782,7 @@ router.get('/app/collector/dashboard', requireCollectorApiAuth, (req, res) => {
|
|||||||
q += ` ORDER BY (CASE WHEN c.status = 'suspended' THEN 0 WHEN i.status = 'unpaid' THEN 1 ELSE 2 END), c.name ASC LIMIT 150`;
|
q += ` ORDER BY (CASE WHEN c.status = 'suspended' THEN 0 WHEN i.status = 'unpaid' THEN 1 ELSE 2 END), c.name ASC LIMIT 150`;
|
||||||
const list = db.prepare(q).all(...params) || [];
|
const list = db.prepare(q).all(...params) || [];
|
||||||
|
|
||||||
|
const statsWhere = colFilter ? ` WHERE ${colFilter.where}` : '';
|
||||||
const stats = db.prepare(`
|
const stats = db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
SUM(CASE WHEN (i.status = 'unpaid' OR i.status IS NULL) THEN 1 ELSE 0 END) as unpaid_count,
|
SUM(CASE WHEN (i.status = 'unpaid' OR i.status IS NULL) THEN 1 ELSE 0 END) as unpaid_count,
|
||||||
@@ -779,7 +792,8 @@ router.get('/app/collector/dashboard', requireCollectorApiAuth, (req, res) => {
|
|||||||
FROM customers c
|
FROM customers c
|
||||||
LEFT JOIN packages p ON p.id = c.package_id
|
LEFT JOIN packages p ON p.id = c.package_id
|
||||||
LEFT JOIN invoices i ON i.customer_id = c.id AND i.period_month = ? AND i.period_year = ?
|
LEFT JOIN invoices i ON i.customer_id = c.id AND i.period_month = ? AND i.period_year = ?
|
||||||
`).get(todayDay, curMonth, curYear) || {};
|
${statsWhere}
|
||||||
|
`).get(todayDay, curMonth, curYear, ...(colFilter ? colFilter.params : [])) || {};
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -819,7 +833,17 @@ router.post('/app/collector/pay-bill', requireCollectorApiAuth, async (req, res)
|
|||||||
if (!inv) return res.status(404).json({ success: false, message: 'Tagihan tidak ditemukan' });
|
if (!inv) return res.status(404).json({ success: false, message: 'Tagihan tidak ditemukan' });
|
||||||
|
|
||||||
const collectorId = Number(req.collector?.collectorId || req.collector?.id || 0);
|
const collectorId = Number(req.collector?.collectorId || req.collector?.id || 0);
|
||||||
const collector = collectorId ? db.prepare('SELECT id, name, auto_approve FROM collectors WHERE id = ?').get(collectorId) : null;
|
const collector = collectorId ? db.prepare('SELECT id, name, area, auto_approve FROM collectors WHERE id = ?').get(collectorId) : null;
|
||||||
|
|
||||||
|
const isFullAdmin = req.collector?.role === 'admin' || req.collector?.role === 'root';
|
||||||
|
if (!isFullAdmin && collector) {
|
||||||
|
const cust = customerSvc.getCustomerById(inv.customer_id);
|
||||||
|
if (!cust) return res.status(404).json({ success: false, message: 'Pelanggan tidak ditemukan' });
|
||||||
|
if (!customerSvc.canCollectorAccessCustomer(collector, cust)) {
|
||||||
|
return res.status(403).json({ success: false, message: 'Akses ditolak: pelanggan di luar wilayah/tugas Anda' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const collectorName = collector?.name || req.collector?.name || 'Kolektor Lapangan';
|
const collectorName = collector?.name || req.collector?.name || 'Kolektor Lapangan';
|
||||||
const isAutoApprove = collector && collector.auto_approve === 1;
|
const isAutoApprove = collector && collector.auto_approve === 1;
|
||||||
|
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ router.get('/', requireTechSession, (req, res) => {
|
|||||||
|
|
||||||
// --- OPEN TICKETS (Pool) ---
|
// --- OPEN TICKETS (Pool) ---
|
||||||
router.get('/pool', requireTechSession, (req, res) => {
|
router.get('/pool', requireTechSession, (req, res) => {
|
||||||
const openTickets = techSvc.getOpenTickets();
|
const openTickets = techSvc.getOpenTickets(req.session.techId);
|
||||||
res.render('tech/pool', {
|
res.render('tech/pool', {
|
||||||
title: 'Tiket Baru',
|
title: 'Tiket Baru',
|
||||||
company: company(),
|
company: company(),
|
||||||
@@ -201,8 +201,15 @@ router.get('/history', requireTechSession, (req, res) => {
|
|||||||
|
|
||||||
// --- NETWORK MAP ---
|
// --- NETWORK MAP ---
|
||||||
router.get('/map', requireTechSession, (req, res) => {
|
router.get('/map', requireTechSession, (req, res) => {
|
||||||
const customers = customerSvc.getAllCustomers();
|
const tech = techSvc.getTechById(req.session.techId);
|
||||||
const odps = odpSvc.getAllOdps();
|
let customers = customerSvc.getAllCustomers();
|
||||||
|
let odps = odpSvc.getAllOdps();
|
||||||
|
|
||||||
|
const filter = tech ? techSvc.getTechAreaWhere(tech, 'c') : null;
|
||||||
|
if (filter && filter.isRestricted) {
|
||||||
|
customers = customers.filter(c => filter.areas.includes(String(c.area || '').trim().toLowerCase()));
|
||||||
|
odps = odps.filter(o => !o.area || filter.areas.includes(String(o.area || '').trim().toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
res.render('tech/map', {
|
res.render('tech/map', {
|
||||||
title: 'Peta Jaringan',
|
title: 'Peta Jaringan',
|
||||||
@@ -346,7 +353,12 @@ router.get('/monitoring', requireTechSession, async (req, res) => {
|
|||||||
// --- CREATE CUSTOMER (Technician) ---
|
// --- CREATE CUSTOMER (Technician) ---
|
||||||
router.get('/customers/new', requireTechSession, (req, res) => {
|
router.get('/customers/new', requireTechSession, (req, res) => {
|
||||||
const packages = customerSvc.getAllPackages();
|
const packages = customerSvc.getAllPackages();
|
||||||
const odps = odpSvc.getAllOdps();
|
let odps = odpSvc.getAllOdps();
|
||||||
|
const tech = techSvc.getTechById(req.session.techId);
|
||||||
|
const filter = tech ? techSvc.getTechAreaWhere(tech, 'c') : null;
|
||||||
|
if (filter && filter.isRestricted) {
|
||||||
|
odps = odps.filter(o => !o.area || filter.areas.includes(String(o.area || '').trim().toLowerCase()));
|
||||||
|
}
|
||||||
const routers = mikrotikService.getAllRouters();
|
const routers = mikrotikService.getAllRouters();
|
||||||
const olts = oltSvc.getAllOlts();
|
const olts = oltSvc.getAllOlts();
|
||||||
res.render('tech/create_customer', {
|
res.render('tech/create_customer', {
|
||||||
@@ -366,8 +378,20 @@ router.post('/customers', requireTechSession, express.urlencoded({ extended: tru
|
|||||||
const name = String(req.body.name || '').trim();
|
const name = String(req.body.name || '').trim();
|
||||||
if (!name) throw new Error('Nama pelanggan wajib diisi');
|
if (!name) throw new Error('Nama pelanggan wajib diisi');
|
||||||
|
|
||||||
|
const tech = techSvc.getTechById(req.session.techId);
|
||||||
|
const areaFilter = tech ? techSvc.getTechAreaWhere(tech, 'c') : null;
|
||||||
|
let areaInput = String(req.body.area || '').trim();
|
||||||
|
if (areaFilter && areaFilter.isRestricted) {
|
||||||
|
if (!areaInput) {
|
||||||
|
areaInput = areaFilter.areas[0];
|
||||||
|
} else if (!areaFilter.areas.includes(areaInput.toLowerCase())) {
|
||||||
|
throw new Error(`Anda hanya dapat mendaftarkan pelanggan di wilayah tugas Anda (${tech.area})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const customerData = {
|
const customerData = {
|
||||||
name,
|
name,
|
||||||
|
area: areaInput,
|
||||||
phone: String(req.body.phone || '').trim(),
|
phone: String(req.body.phone || '').trim(),
|
||||||
email: String(req.body.email || '').trim(),
|
email: String(req.body.email || '').trim(),
|
||||||
nik: String(req.body.nik || '').trim(),
|
nik: String(req.body.nik || '').trim(),
|
||||||
@@ -461,6 +485,16 @@ router.post('/customers', requireTechSession, express.urlencoded({ extended: tru
|
|||||||
// API Endpoints for Technician
|
// API Endpoints for Technician
|
||||||
const customerDevice = require('../services/customerDeviceService');
|
const customerDevice = require('../services/customerDeviceService');
|
||||||
|
|
||||||
|
// Teknisi hanya boleh menyentuh perangkat pelanggan di wilayahnya.
|
||||||
|
function checkTechTagAccess(req, tag) {
|
||||||
|
const tech = req.session?.techId ? techSvc.getTechById(req.session.techId) : null;
|
||||||
|
const filter = tech ? techSvc.getTechAreaWhere(tech, 'c') : null;
|
||||||
|
if (!filter || !filter.isRestricted) return true;
|
||||||
|
const cust = customerSvc.findCustomerByAny(tag);
|
||||||
|
if (!cust) return true;
|
||||||
|
return filter.areas.includes(String(cust.area || '').trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/api/mikrotik/pppoe-users', requireTechSession, async (req, res) => {
|
router.get('/api/mikrotik/pppoe-users', requireTechSession, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const routerId = req.query.routerId ? Number(req.query.routerId) : null;
|
const routerId = req.query.routerId ? Number(req.query.routerId) : null;
|
||||||
@@ -499,10 +533,15 @@ router.get('/api/odps/:id/ports', requireTechSession, (req, res) => {
|
|||||||
router.get('/api/devices', requireTechSession, async (req, res) => {
|
router.get('/api/devices', requireTechSession, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { search, status, acs, limit = 999999, offset = 0 } = req.query;
|
const { search, status, acs, limit = 999999, offset = 0 } = req.query;
|
||||||
const customers = db.prepare('SELECT id, name, phone, pppoe_username, genieacs_tag FROM customers').all();
|
const tech = techSvc.getTechById(req.session.techId);
|
||||||
|
const areaFilter = tech ? techSvc.getTechAreaWhere(tech, 'c') : null;
|
||||||
|
const customers = db.prepare('SELECT id, name, phone, pppoe_username, genieacs_tag, area FROM customers').all();
|
||||||
|
const allowedCustomerIds = new Set();
|
||||||
const byPppoe = new Map();
|
const byPppoe = new Map();
|
||||||
const byTag = new Map();
|
const byTag = new Map();
|
||||||
for (const c of customers) {
|
for (const c of customers) {
|
||||||
|
if (areaFilter && areaFilter.isRestricted && !areaFilter.areas.includes(String(c.area || '').trim().toLowerCase())) continue;
|
||||||
|
allowedCustomerIds.add(c.id);
|
||||||
const pu = String(c.pppoe_username || '').trim().toLowerCase();
|
const pu = String(c.pppoe_username || '').trim().toLowerCase();
|
||||||
const tg = String(c.genieacs_tag || '').trim();
|
const tg = String(c.genieacs_tag || '').trim();
|
||||||
if (pu) byPppoe.set(pu, c);
|
if (pu) byPppoe.set(pu, c);
|
||||||
@@ -549,6 +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) {
|
if (search) {
|
||||||
const s = search.toLowerCase();
|
const s = search.toLowerCase();
|
||||||
devices = devices.filter(d =>
|
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) => {
|
router.get('/api/device/:tag', requireTechSession, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
if (!checkTechTagAccess(req, req.params.tag)) return res.status(403).json({ error: 'Perangkat di luar wilayah tugas Anda' });
|
||||||
const data = await customerDevice.getCustomerDeviceData(req.params.tag);
|
const data = await customerDevice.getCustomerDeviceData(req.params.tag);
|
||||||
if (!data || data.status === 'Tidak ditemukan') return res.status(404).json({ error: 'Device not found' });
|
if (!data || data.status === 'Tidak ditemukan') return res.status(404).json({ error: 'Device not found' });
|
||||||
res.json(data);
|
res.json(data);
|
||||||
@@ -584,6 +628,7 @@ router.get('/api/device/:tag', requireTechSession, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post('/api/device/:tag/ssid', requireTechSession, express.json(), async (req, res) => {
|
router.post('/api/device/:tag/ssid', requireTechSession, express.json(), async (req, res) => {
|
||||||
|
if (!checkTechTagAccess(req, req.params.tag)) return res.status(403).json({ error: 'Perangkat di luar wilayah tugas Anda' });
|
||||||
const { ssid } = req.body;
|
const { ssid } = req.body;
|
||||||
if (!ssid) return res.status(400).json({ error: 'SSID required' });
|
if (!ssid) return res.status(400).json({ error: 'SSID required' });
|
||||||
const ok = await customerDevice.updateSSID(req.params.tag, ssid);
|
const ok = await customerDevice.updateSSID(req.params.tag, ssid);
|
||||||
@@ -616,6 +661,7 @@ router.post('/api/device/:tag/ssid', requireTechSession, express.json(), async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post('/api/device/:tag/password', requireTechSession, express.json(), async (req, res) => {
|
router.post('/api/device/:tag/password', requireTechSession, express.json(), async (req, res) => {
|
||||||
|
if (!checkTechTagAccess(req, req.params.tag)) return res.status(403).json({ error: 'Perangkat di luar wilayah tugas Anda' });
|
||||||
const { password } = req.body;
|
const { password } = req.body;
|
||||||
if (!password || password.length < 8) return res.status(400).json({ error: 'Password minimal 8 karakter' });
|
if (!password || password.length < 8) return res.status(400).json({ error: 'Password minimal 8 karakter' });
|
||||||
const ok = await customerDevice.updatePassword(req.params.tag, password);
|
const ok = await customerDevice.updatePassword(req.params.tag, password);
|
||||||
@@ -648,6 +694,7 @@ router.post('/api/device/:tag/password', requireTechSession, express.json(), asy
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post('/api/device/:tag/reboot', requireTechSession, async (req, res) => {
|
router.post('/api/device/:tag/reboot', requireTechSession, async (req, res) => {
|
||||||
|
if (!checkTechTagAccess(req, req.params.tag)) return res.status(403).json({ error: 'Perangkat di luar wilayah tugas Anda' });
|
||||||
const result = await customerDevice.requestReboot(req.params.tag);
|
const result = await customerDevice.requestReboot(req.params.tag);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,6 +71,59 @@ function calculateExpiredAt(startDateStr, durationDays = 30) {
|
|||||||
return `${y}-${m}-${day} 23:59:59`;
|
return `${y}-${m}-${day} 23:59:59`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batas wilayah tugas kolektor (dukung multi-area dipisah koma).
|
||||||
|
* area kosong / "semua" / "all" = tanpa batas wilayah.
|
||||||
|
*/
|
||||||
|
function getCollectorCustomerWhere(collector, tableAlias = 'c') {
|
||||||
|
let col = collector;
|
||||||
|
if (typeof collector === 'number' || typeof collector === 'string') {
|
||||||
|
col = db.prepare('SELECT id, area FROM collectors WHERE id = ?').get(Number(collector));
|
||||||
|
}
|
||||||
|
const colId = Number(col?.id || 0);
|
||||||
|
const areas = String(col?.area || '')
|
||||||
|
.split(',')
|
||||||
|
.map(a => a.trim().toLowerCase())
|
||||||
|
.filter(a => a.length > 0 && a !== 'semua' && a !== 'all');
|
||||||
|
|
||||||
|
if (areas.length > 0) {
|
||||||
|
const placeholders = areas.map(() => '?').join(', ');
|
||||||
|
return {
|
||||||
|
where: `(LOWER(TRIM(${tableAlias}.area)) IN (${placeholders}) AND (${tableAlias}.collector_id = ? OR ${tableAlias}.collector_id IS NULL OR ${tableAlias}.collector_id = 0))`,
|
||||||
|
params: [...areas, colId],
|
||||||
|
isRestricted: true,
|
||||||
|
areas,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
where: `(${tableAlias}.collector_id = ? OR ${tableAlias}.collector_id IS NULL OR ${tableAlias}.collector_id = 0)`,
|
||||||
|
params: [colId],
|
||||||
|
isRestricted: false,
|
||||||
|
areas: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function canCollectorAccessCustomer(collector, customer) {
|
||||||
|
if (!collector || !customer) return false;
|
||||||
|
let col = collector;
|
||||||
|
if (typeof collector === 'number' || typeof collector === 'string') {
|
||||||
|
col = db.prepare('SELECT id, area FROM collectors WHERE id = ?').get(Number(collector));
|
||||||
|
}
|
||||||
|
const colId = Number(col?.id || 0);
|
||||||
|
const custColId = Number(customer?.collector_id || 0);
|
||||||
|
if (custColId > 0 && custColId !== colId) return false;
|
||||||
|
|
||||||
|
const areas = String(col?.area || '')
|
||||||
|
.split(',')
|
||||||
|
.map(a => a.trim().toLowerCase())
|
||||||
|
.filter(a => a.length > 0 && a !== 'semua' && a !== 'all');
|
||||||
|
if (areas.length > 0) {
|
||||||
|
const custArea = String(customer?.area || '').trim().toLowerCase();
|
||||||
|
return !!custArea && areas.includes(custArea);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function getAllCustomers(search = '', routerId = null, filterStatus = '', filterArea = '', opts = {}) {
|
function getAllCustomers(search = '', routerId = null, filterStatus = '', filterArea = '', opts = {}) {
|
||||||
const now = getCurrentDateInTimezone();
|
const now = getCurrentDateInTimezone();
|
||||||
const month = now.getMonth() + 1;
|
const month = now.getMonth() + 1;
|
||||||
@@ -955,7 +1008,7 @@ Dihapus: ${removed.join(', ') || '-'}`);
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getAllCustomers, getAllCustomerAreas, getCustomerById, createCustomer, updateCustomer, deleteCustomer, bulkDeleteCustomers, getCustomerStats,
|
getAllCustomers, getAllCustomerAreas, getCollectorCustomerWhere, canCollectorAccessCustomer, getCustomerById, createCustomer, updateCustomer, deleteCustomer, bulkDeleteCustomers, getCustomerStats,
|
||||||
getAllPackages, getPackageById, createPackage, updatePackage, deletePackage,
|
getAllPackages, getPackageById, createPackage, updatePackage, deletePackage,
|
||||||
suspendCustomer, activateCustomer, autoDeactivateOverdueCustomers, generatePppoeAccount, findCustomerByAny, findCustomerForLogin, updateCustomerCablePath,
|
suspendCustomer, activateCustomer, autoDeactivateOverdueCustomers, generatePppoeAccount, findCustomerByAny, findCustomerForLogin, updateCustomerCablePath,
|
||||||
resetPromoCyclesUsed, getEffectiveRouterId
|
resetPromoCyclesUsed, getEffectiveRouterId
|
||||||
|
|||||||
@@ -9,20 +9,56 @@ function getTechById(id) {
|
|||||||
return db.prepare('SELECT id, username, name, phone, area FROM technicians WHERE id = ?').get(id);
|
return db.prepare('SELECT id, username, name, phone, area FROM technicians WHERE id = ?').get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batas wilayah tugas teknisi.
|
||||||
|
* area kosong / "semua" / "all" = tanpa batas (semua wilayah).
|
||||||
|
*/
|
||||||
|
function getTechAreaWhere(technician, tableAlias = 'c') {
|
||||||
|
let tech = technician;
|
||||||
|
if (typeof technician === 'number' || typeof technician === 'string') {
|
||||||
|
tech = db.prepare('SELECT id, area FROM technicians WHERE id = ?').get(Number(technician));
|
||||||
|
}
|
||||||
|
const areas = String(tech?.area || '')
|
||||||
|
.split(',')
|
||||||
|
.map(a => a.trim().toLowerCase())
|
||||||
|
.filter(a => a.length > 0 && a !== 'semua' && a !== 'all');
|
||||||
|
|
||||||
|
if (areas.length > 0) {
|
||||||
|
const placeholders = areas.map(() => '?').join(', ');
|
||||||
|
return {
|
||||||
|
where: `LOWER(TRIM(${tableAlias}.area)) IN (${placeholders})`,
|
||||||
|
params: areas,
|
||||||
|
isRestricted: true,
|
||||||
|
areas,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { where: '1=1', params: [], isRestricted: false, areas: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function canTechAccessCustomer(technician, customer) {
|
||||||
|
if (!technician) return false;
|
||||||
|
const filter = getTechAreaWhere(technician);
|
||||||
|
if (!filter.isRestricted) return true;
|
||||||
|
const custArea = String(customer?.area || '').trim().toLowerCase();
|
||||||
|
return !!custArea && filter.areas.includes(custArea);
|
||||||
|
}
|
||||||
|
|
||||||
function getTechStats(techId) {
|
function getTechStats(techId) {
|
||||||
const total = db.prepare("SELECT COUNT(*) as count FROM tickets WHERE technician_id = ?").get(techId).count;
|
const total = db.prepare("SELECT COUNT(*) as count FROM tickets WHERE technician_id = ?").get(techId).count;
|
||||||
const inProgress = db.prepare("SELECT COUNT(*) as count FROM tickets WHERE technician_id = ? AND status = 'in_progress'").get(techId).count;
|
const inProgress = db.prepare("SELECT COUNT(*) as count FROM tickets WHERE technician_id = ? AND status = 'in_progress'").get(techId).count;
|
||||||
const resolved = db.prepare("SELECT COUNT(*) as count FROM tickets WHERE technician_id = ? AND status = 'resolved'").get(techId).count;
|
const resolved = db.prepare("SELECT COUNT(*) as count FROM tickets WHERE technician_id = ? AND status = 'resolved'").get(techId).count;
|
||||||
|
|
||||||
// Ambil open tickets yang belum ada teknisinya atau di-assign ke teknisi ini
|
const filter = techId ? getTechAreaWhere(techId, 'c') : null;
|
||||||
const open = db.prepare("SELECT COUNT(*) as count FROM tickets WHERE status = 'open'").get().count;
|
const open = filter && filter.isRestricted
|
||||||
|
? db.prepare(`SELECT COUNT(*) as count FROM tickets t JOIN customers c ON t.customer_id = c.id WHERE t.status = 'open' AND ${filter.where}`).get(...filter.params).count
|
||||||
|
: db.prepare("SELECT COUNT(*) as count FROM tickets WHERE status = 'open'").get().count;
|
||||||
|
|
||||||
return { total, open, inProgress, resolved };
|
return { total, open, inProgress, resolved };
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAssignedTickets(techId) {
|
function getAssignedTickets(techId) {
|
||||||
return db.prepare(`
|
return db.prepare(`
|
||||||
SELECT t.*, c.name as customer_name, c.phone as customer_phone, c.address as customer_address
|
SELECT t.*, c.name as customer_name, c.phone as customer_phone, c.address as customer_address, c.area as customer_area
|
||||||
FROM tickets t
|
FROM tickets t
|
||||||
JOIN customers c ON t.customer_id = c.id
|
JOIN customers c ON t.customer_id = c.id
|
||||||
WHERE t.technician_id = ? AND t.status != 'resolved'
|
WHERE t.technician_id = ? AND t.status != 'resolved'
|
||||||
@@ -30,19 +66,21 @@ function getAssignedTickets(techId) {
|
|||||||
`).all(techId);
|
`).all(techId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOpenTickets() {
|
function getOpenTickets(techId = null) {
|
||||||
|
const filter = techId ? getTechAreaWhere(techId, 'c') : null;
|
||||||
|
const areaWhere = filter && filter.isRestricted ? `AND ${filter.where}` : '';
|
||||||
return db.prepare(`
|
return db.prepare(`
|
||||||
SELECT t.*, c.name as customer_name, c.phone as customer_phone, c.address as customer_address
|
SELECT t.*, c.name as customer_name, c.phone as customer_phone, c.address as customer_address, c.area as customer_area
|
||||||
FROM tickets t
|
FROM tickets t
|
||||||
JOIN customers c ON t.customer_id = c.id
|
JOIN customers c ON t.customer_id = c.id
|
||||||
WHERE t.status = 'open'
|
WHERE t.status = 'open' ${areaWhere}
|
||||||
ORDER BY t.created_at DESC
|
ORDER BY t.created_at DESC
|
||||||
`).all();
|
`).all(...(filter && filter.isRestricted ? filter.params : []));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getResolvedTickets(techId) {
|
function getResolvedTickets(techId) {
|
||||||
return db.prepare(`
|
return db.prepare(`
|
||||||
SELECT t.*, c.name as customer_name, c.phone as customer_phone, c.address as customer_address
|
SELECT t.*, c.name as customer_name, c.phone as customer_phone, c.address as customer_address, c.area as customer_area
|
||||||
FROM tickets t
|
FROM tickets t
|
||||||
JOIN customers c ON t.customer_id = c.id
|
JOIN customers c ON t.customer_id = c.id
|
||||||
WHERE t.technician_id = ? AND t.status = 'resolved'
|
WHERE t.technician_id = ? AND t.status = 'resolved'
|
||||||
@@ -52,11 +90,27 @@ function getResolvedTickets(techId) {
|
|||||||
|
|
||||||
function takeTicket(ticketId, techId) {
|
function takeTicket(ticketId, techId) {
|
||||||
let validTechId = Number(techId);
|
let validTechId = Number(techId);
|
||||||
const exists = db.prepare('SELECT id FROM technicians WHERE id = ?').get(validTechId);
|
let tech = db.prepare('SELECT id, area FROM technicians WHERE id = ?').get(validTechId);
|
||||||
if (!exists) {
|
if (!tech) {
|
||||||
const fallback = db.prepare('SELECT id FROM technicians WHERE is_active = 1 ORDER BY id ASC LIMIT 1').get();
|
const fallback = db.prepare('SELECT id, area FROM technicians WHERE is_active = 1 ORDER BY id ASC LIMIT 1').get();
|
||||||
if (fallback) validTechId = fallback.id;
|
if (!fallback) throw new Error('Teknisi tidak ditemukan.');
|
||||||
|
validTechId = fallback.id;
|
||||||
|
tech = fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ticket = db.prepare(`
|
||||||
|
SELECT t.id, t.customer_id, t.status, c.area as customer_area
|
||||||
|
FROM tickets t
|
||||||
|
LEFT JOIN customers c ON t.customer_id = c.id
|
||||||
|
WHERE t.id = ?
|
||||||
|
`).get(ticketId);
|
||||||
|
|
||||||
|
if (!ticket) throw new Error('Tiket tidak ditemukan.');
|
||||||
|
if (ticket.status !== 'open') throw new Error('Tiket sudah diambil oleh teknisi lain atau sudah selesai.');
|
||||||
|
if (ticket.customer_id && !canTechAccessCustomer(tech, { area: ticket.customer_area })) {
|
||||||
|
throw new Error(`Tiket ini berada di wilayah "${ticket.customer_area || 'Tanpa Area'}", di luar wilayah tugas Anda (${tech.area}).`);
|
||||||
|
}
|
||||||
|
|
||||||
db.prepare("UPDATE tickets SET technician_id = ?, status = 'in_progress', updated_at = (NOW_LOCAL()) WHERE id = ?").run(validTechId, ticketId);
|
db.prepare("UPDATE tickets SET technician_id = ?, status = 'in_progress', updated_at = (NOW_LOCAL()) WHERE id = ?").run(validTechId, ticketId);
|
||||||
return validTechId;
|
return validTechId;
|
||||||
}
|
}
|
||||||
@@ -102,6 +156,8 @@ function createTechnician(data) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
authenticate,
|
authenticate,
|
||||||
getTechById,
|
getTechById,
|
||||||
|
getTechAreaWhere,
|
||||||
|
canTechAccessCustomer,
|
||||||
getTechStats,
|
getTechStats,
|
||||||
getAssignedTickets,
|
getAssignedTickets,
|
||||||
getOpenTickets,
|
getOpenTickets,
|
||||||
|
|||||||
72
tests/unit/area-access.test.js
Normal file
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)');
|
||||||
Reference in New Issue
Block a user