124 lines
7.5 KiB
JavaScript
124 lines
7.5 KiB
JavaScript
// routes/salesPortal.js — Sales Order module, CUSTOMER portal API (/api/sales-portal)
|
|
// Customers (SAP business partners — msale's dealer_master) log in with their
|
|
// SAP customer code + password. Their token is signed with a DIFFERENT secret
|
|
// from employee tokens, so no employee endpoint anywhere in the ERP can ever
|
|
// accept a customer token (they'd fail verifyToken), and vice versa.
|
|
'use strict';
|
|
const express = require('express');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const jwt = require('jsonwebtoken');
|
|
const masters = require('../services/sales/masters');
|
|
const orders = require('../services/sales/orders');
|
|
const sap = require('../services/sales/sap');
|
|
const salesRoutes = require('./sales');
|
|
|
|
const router = express.Router();
|
|
const CUSTOMER_SECRET = `${process.env.JWT_SECRET || 'sap-portal-secret'}::sales-customer`;
|
|
|
|
const wrap = fn => async (req, res) => {
|
|
try { const out = await fn(req, res); if (out !== undefined && !res.headersSent) res.json({ success: true, data: out }); }
|
|
catch (e) { if (!res.headersSent) res.status(e.status || 500).json({ success: false, message: e.message }); }
|
|
};
|
|
|
|
// Simple in-memory throttle on top of the per-account lockout: max 20 login
|
|
// attempts per IP per 10 minutes.
|
|
const _hits = new Map();
|
|
function throttled(ip) {
|
|
const now = Date.now(); const arr = (_hits.get(ip) || []).filter(t => now - t < 600000);
|
|
arr.push(now); _hits.set(ip, arr);
|
|
return arr.length > 20;
|
|
}
|
|
|
|
router.post('/login', wrap(async (req, res) => {
|
|
if (throttled(req.ip)) { res.status(429).json({ success: false, message: 'Too many login attempts — please wait a few minutes.' }); return; }
|
|
const { cardCode, password } = req.body || {};
|
|
if (!cardCode || !password) { res.status(400).json({ success: false, message: 'Customer code and password are required' }); return; }
|
|
const r = await masters.customerLogin(cardCode, password);
|
|
if (!r.ok) { res.status(401).json({ success: false, message: r.message }); return; }
|
|
const a = r.account;
|
|
const token = jwt.sign({ type: 'customer', cardCode: a.cardCode, name: a.cardName }, CUSTOMER_SECRET, { expiresIn: '12h' });
|
|
return { token, customer: { cardCode: a.cardCode, cardName: a.cardName, mustChangePwd: a.mustChangePwd } };
|
|
}));
|
|
|
|
// Auth for everything below.
|
|
router.use(async (req, res, next) => {
|
|
const h = req.headers.authorization || '';
|
|
const token = h.startsWith('Bearer ') ? h.slice(7) : '';
|
|
if (!token) return res.status(401).json({ success: false, message: 'Please log in' });
|
|
try {
|
|
const p = jwt.verify(token, CUSTOMER_SECRET);
|
|
if (p.type !== 'customer') throw new Error('bad token');
|
|
const acc = await masters.getCustomerAccount(p.cardCode);
|
|
if (!acc || !acc.active || acc.locked) return res.status(401).json({ success: false, message: 'Your login is inactive or locked' });
|
|
req.actor = { type: 'customer', cardCode: p.cardCode, name: p.name || p.cardCode };
|
|
req.auditActor = `customer:${p.cardCode}`;
|
|
next();
|
|
} catch (_e) { res.status(401).json({ success: false, message: 'Session expired — please log in again' }); }
|
|
});
|
|
|
|
router.get('/me', wrap(async (req) => {
|
|
const s = await masters.getSettings();
|
|
const acc = await masters.getCustomerAccount(req.actor.cardCode);
|
|
const c = await sap.getCustomer(s.company, req.actor.cardCode);
|
|
return { account: { cardCode: acc.cardCode, cardName: acc.cardName, email: acc.email, mustChangePwd: acc.mustChangePwd, lastLogin: acc.lastLogin },
|
|
customer: c, wallet: await orders.walletSummary(req.actor.cardCode), divisions: await masters.listDivisions(), allDivisions: await masters.listDivisions(true), settings: masters.publicSettings(s) };
|
|
}));
|
|
router.post('/change-password', wrap(async (req) => {
|
|
await masters.changeCustomerPassword(req.actor.cardCode, req.body.oldPassword, req.body.newPassword);
|
|
return { ok: true };
|
|
}));
|
|
router.get('/products', wrap(async (req) => (req.query.divisionId ? masters.listOrderableProducts(parseInt(req.query.divisionId)) : [])));
|
|
|
|
// Suggested "Your order ref. no." for the next order (the customer may override it).
|
|
router.get('/next-order-no', wrap(async req => ({ custOrderNo: await orders.nextCustOrderNo(req.actor.cardCode) })));
|
|
router.get('/orders', wrap(req => orders.listOrders(req.actor, req.query)));
|
|
router.get('/orders/:id', wrap(req => orders.getOrder(req.params.id, req.actor)));
|
|
router.post('/orders', wrap(req => orders.createOrder(req.actor, req.body)));
|
|
router.put('/orders/:id/resubmit', wrap(req => orders.resubmit(req.actor, req.params.id, req.body)));
|
|
router.post('/orders/:id/cancel', wrap(req => orders.customerCancel(req.actor, req.params.id, req.body.remarks)));
|
|
router.post('/orders/:id/pay', wrap(req => orders.submitPayment(req.actor, req.params.id, req.body)));
|
|
router.get('/ledger', wrap(async (req) => ({ summary: await orders.walletSummary(req.actor.cardCode), ledger: await orders.ledger(req.actor.cardCode, req.query) })));
|
|
|
|
router.get('/orders/:id/invoices', wrap(async (req) => {
|
|
const o = await orders.getOrderRaw(req.params.id);
|
|
if (!o || o.cardCode !== req.actor.cardCode) { const e = new Error('Order not found'); e.status = 404; throw e; }
|
|
if (![8, 9].includes(o.status)) return { invoices: [] };
|
|
const invoices = await sap.invoicesForOrder(o.company, o.id);
|
|
const batches = await sap.batchesWithCoa(o.company, invoices.map(i => i.docEntry));
|
|
const { query } = require('../services/sales/db');
|
|
const pdfs = invoices.length ? await query(`SELECT DISTINCT INVOICE_NO FROM dbo.ZSO_INVOICE_FILES WHERE INVOICE_NO IN (${invoices.map(() => '?').join(',')})`, invoices.map(i => String(i.invoiceNo))) : [];
|
|
const pdfSet = new Set(pdfs.map(p => String(p.INVOICE_NO)));
|
|
for (const inv of invoices) {
|
|
inv.hasPdf = pdfSet.has(String(inv.invoiceNo));
|
|
inv.attachments = inv.atcEntry ? await sap.attachmentLines(o.company, inv.atcEntry) : [];
|
|
inv.batches = batches.filter(b => b.invoiceDocEntry === inv.docEntry);
|
|
delete inv.atcEntry;
|
|
}
|
|
return { invoices };
|
|
}));
|
|
router.get('/attachment/:abs/:line', wrap((req, res) => salesRoutes.streamAttachment(req, res, req.actor)));
|
|
router.get('/invoice-pdf/:invoiceNo', wrap((req, res) => salesRoutes.streamInvoicePdf(req, res, req.actor)));
|
|
|
|
// Documents — customers may attach to their OWN orders only (PO copy, payment proof).
|
|
router.post('/docs', salesRoutes.upload.single('file'), wrap(async (req) => {
|
|
try {
|
|
if (!req.file) throw Object.assign(new Error('No file uploaded'), { status: 400 });
|
|
const o = await orders.getOrderRaw(req.body.entityId);
|
|
if (!o || o.cardCode !== req.actor.cardCode || req.body.entity !== 'order') throw Object.assign(new Error('Not allowed'), { status: 403 });
|
|
const docType = ['po_copy', 'payment', 'other'].includes(req.body.docType) ? req.body.docType : 'other';
|
|
return { id: await orders.addDoc('order', o.id, docType, req.body.title, req.file.filename, req.file.originalname, req.actor.name) };
|
|
} catch (e) { if (req.file) fs.unlink(req.file.path, () => {}); throw e; }
|
|
}));
|
|
router.get('/docs/:id', wrap(async (req, res) => {
|
|
const d = await orders.getDoc(req.params.id);
|
|
const o = d && d.ENTITY === 'order' ? await orders.getOrderRaw(d.ENTITY_ID) : null;
|
|
if (!o || o.cardCode !== req.actor.cardCode) throw Object.assign(new Error('Not allowed'), { status: 403 });
|
|
const full = path.join(salesRoutes.DOC_DIR, path.basename(d.FILE_NAME));
|
|
if (!fs.existsSync(full)) throw Object.assign(new Error('File missing'), { status: 404 });
|
|
res.setHeader('Content-Disposition', `inline; filename="${(d.ORIG_NAME || d.FILE_NAME).replace(/"/g, '')}"`);
|
|
res.sendFile(full);
|
|
}));
|
|
|
|
module.exports = router;
|