Alert tenants for token lifecycle events
Browse files- src/jobs/pollSessions.job.js +3 -0
- src/routes/portal.routes.js +12 -0
- src/routes/tokens.routes.js +18 -0
- src/utils/messages.js +12 -0
- src/utils/tokenClientAlerts.js +96 -0
src/jobs/pollSessions.job.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
const db = require('../config/db');
|
| 2 |
const omada = require('../services/omada');
|
| 3 |
const { alertAdmin } = require('../utils/adminAlert');
|
|
|
|
| 4 |
|
| 5 |
function normalizeMac(mac) {
|
| 6 |
return String(mac || '')
|
|
@@ -111,6 +112,8 @@ async function pollSessions() {
|
|
| 111 |
|
| 112 |
if (!token) {
|
| 113 |
console.log(`[pollSessions] Created manual Omada session for mac=${mac} device=${device.id} without token`);
|
|
|
|
|
|
|
| 114 |
}
|
| 115 |
}
|
| 116 |
} catch (err) {
|
|
|
|
| 1 |
const db = require('../config/db');
|
| 2 |
const omada = require('../services/omada');
|
| 3 |
const { alertAdmin } = require('../utils/adminAlert');
|
| 4 |
+
const { alertWifiClient } = require('../utils/tokenClientAlerts');
|
| 5 |
|
| 6 |
function normalizeMac(mac) {
|
| 7 |
return String(mac || '')
|
|
|
|
| 112 |
|
| 113 |
if (!token) {
|
| 114 |
console.log(`[pollSessions] Created manual Omada session for mac=${mac} device=${device.id} without token`);
|
| 115 |
+
} else {
|
| 116 |
+
alertWifiClient('token_started', token.id, { mac }).catch(() => {});
|
| 117 |
}
|
| 118 |
}
|
| 119 |
} catch (err) {
|
src/routes/portal.routes.js
CHANGED
|
@@ -12,6 +12,7 @@ const { alertAdmin } = require('../utils/adminAlert');
|
|
| 12 |
const { makePaymentAssetUrl } = require('../utils/portalPaymentStorage');
|
| 13 |
const { snippeWebhookUrl } = require('../utils/webhookUrl');
|
| 14 |
const { decryptSecret } = require('../utils/paymentSecrets');
|
|
|
|
| 15 |
|
| 16 |
const normalizeMac = mac => String(mac || '').toUpperCase().replace(/[:\-]/g, '');
|
| 17 |
const normalizePortalMac = mac => String(mac || '').trim().toUpperCase().replace(/:/g, '-');
|
|
@@ -175,6 +176,7 @@ async function recordOmadaVoucherUse({ siteId, code, mac }) {
|
|
| 175 |
|
| 176 |
const now = new Date();
|
| 177 |
let expiresAt = token.expires_at ? new Date(token.expires_at) : null;
|
|
|
|
| 178 |
|
| 179 |
if (token.status === 'revoked') {
|
| 180 |
await conn.rollback();
|
|
@@ -245,6 +247,10 @@ async function recordOmadaVoucherUse({ siteId, code, mac }) {
|
|
| 245 |
|
| 246 |
await conn.commit();
|
| 247 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
return {
|
| 249 |
token: updatedRows[0] || { ...token, status: 'active', locked_mac: mac, expires_at: expiresAt },
|
| 250 |
expiresAt,
|
|
@@ -882,6 +888,7 @@ router.post('/:siteId/auth', portalAuthLimiter, async (req, res) => {
|
|
| 882 |
|
| 883 |
const now = new Date();
|
| 884 |
let expiresAt = token.expires_at ? new Date(token.expires_at) : null;
|
|
|
|
| 885 |
trace.step('expiry_window_prepared', { existingExpiresAt: token.expires_at });
|
| 886 |
|
| 887 |
if (token.status === 'unused') {
|
|
@@ -897,6 +904,7 @@ router.post('/:siteId/auth', portalAuthLimiter, async (req, res) => {
|
|
| 897 |
[mac, now, expiresAt, token.id]
|
| 898 |
);
|
| 899 |
trace.step('token_activated', { expiresAt: expiresAt.toISOString() });
|
|
|
|
| 900 |
} else if (!expiresAt) {
|
| 901 |
expiresAt = new Date(now.getTime() + token.duration_seconds * 1000);
|
| 902 |
await db.query(
|
|
@@ -929,6 +937,10 @@ router.post('/:siteId/auth', portalAuthLimiter, async (req, res) => {
|
|
| 929 |
trace.step('session_created');
|
| 930 |
}
|
| 931 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 932 |
const unitLabel = (v, u) => `${v}${u === 2 ? 'Mbps' : 'Kbps'}`;
|
| 933 |
const secsLeft = Math.floor((expiresAt - now) / 1000);
|
| 934 |
trace.step('response_ready', { secsLeft, expiresAt: expiresAt.toISOString() });
|
|
|
|
| 12 |
const { makePaymentAssetUrl } = require('../utils/portalPaymentStorage');
|
| 13 |
const { snippeWebhookUrl } = require('../utils/webhookUrl');
|
| 14 |
const { decryptSecret } = require('../utils/paymentSecrets');
|
| 15 |
+
const { alertWifiClient } = require('../utils/tokenClientAlerts');
|
| 16 |
|
| 17 |
const normalizeMac = mac => String(mac || '').toUpperCase().replace(/[:\-]/g, '');
|
| 18 |
const normalizePortalMac = mac => String(mac || '').trim().toUpperCase().replace(/:/g, '-');
|
|
|
|
| 176 |
|
| 177 |
const now = new Date();
|
| 178 |
let expiresAt = token.expires_at ? new Date(token.expires_at) : null;
|
| 179 |
+
const shouldAlertStarted = token.status === 'unused' || !token.activated_at;
|
| 180 |
|
| 181 |
if (token.status === 'revoked') {
|
| 182 |
await conn.rollback();
|
|
|
|
| 247 |
|
| 248 |
await conn.commit();
|
| 249 |
|
| 250 |
+
if (shouldAlertStarted) {
|
| 251 |
+
alertWifiClient('token_started', token.id, { mac }).catch(() => {});
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
return {
|
| 255 |
token: updatedRows[0] || { ...token, status: 'active', locked_mac: mac, expires_at: expiresAt },
|
| 256 |
expiresAt,
|
|
|
|
| 888 |
|
| 889 |
const now = new Date();
|
| 890 |
let expiresAt = token.expires_at ? new Date(token.expires_at) : null;
|
| 891 |
+
let shouldAlertStarted = false;
|
| 892 |
trace.step('expiry_window_prepared', { existingExpiresAt: token.expires_at });
|
| 893 |
|
| 894 |
if (token.status === 'unused') {
|
|
|
|
| 904 |
[mac, now, expiresAt, token.id]
|
| 905 |
);
|
| 906 |
trace.step('token_activated', { expiresAt: expiresAt.toISOString() });
|
| 907 |
+
shouldAlertStarted = true;
|
| 908 |
} else if (!expiresAt) {
|
| 909 |
expiresAt = new Date(now.getTime() + token.duration_seconds * 1000);
|
| 910 |
await db.query(
|
|
|
|
| 937 |
trace.step('session_created');
|
| 938 |
}
|
| 939 |
|
| 940 |
+
if (shouldAlertStarted) {
|
| 941 |
+
alertWifiClient('token_started', token.id, { mac }).catch(() => {});
|
| 942 |
+
}
|
| 943 |
+
|
| 944 |
const unitLabel = (v, u) => `${v}${u === 2 ? 'Mbps' : 'Kbps'}`;
|
| 945 |
const secsLeft = Math.floor((expiresAt - now) / 1000);
|
| 946 |
trace.step('response_ready', { secsLeft, expiresAt: expiresAt.toISOString() });
|
src/routes/tokens.routes.js
CHANGED
|
@@ -2,6 +2,7 @@ const router = require('express').Router();
|
|
| 2 |
const db = require('../config/db');
|
| 3 |
const { requireAuth } = require('../middleware/auth');
|
| 4 |
const omada = require('../services/omada');
|
|
|
|
| 5 |
|
| 6 |
router.use(requireAuth);
|
| 7 |
|
|
@@ -650,6 +651,10 @@ router.post('/:id/extend', async (req, res) => {
|
|
| 650 |
await connection.commit();
|
| 651 |
|
| 652 |
const updated = await loadTokenRow(token.token_id, req.client.id);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 653 |
|
| 654 |
res.json({
|
| 655 |
success: true,
|
|
@@ -770,6 +775,12 @@ router.patch('/:id/duration', async (req, res) => {
|
|
| 770 |
await connection.commit();
|
| 771 |
|
| 772 |
const updated = await loadTokenRow(token.token_id, req.client.id);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 773 |
|
| 774 |
res.json({
|
| 775 |
success: true,
|
|
@@ -911,6 +922,13 @@ router.delete('/:id', async (req, res) => {
|
|
| 911 |
|
| 912 |
await connection.commit();
|
| 913 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 914 |
res.json({
|
| 915 |
success: true,
|
| 916 |
action,
|
|
|
|
| 2 |
const db = require('../config/db');
|
| 3 |
const { requireAuth } = require('../middleware/auth');
|
| 4 |
const omada = require('../services/omada');
|
| 5 |
+
const { alertWifiClient } = require('../utils/tokenClientAlerts');
|
| 6 |
|
| 7 |
router.use(requireAuth);
|
| 8 |
|
|
|
|
| 651 |
await connection.commit();
|
| 652 |
|
| 653 |
const updated = await loadTokenRow(token.token_id, req.client.id);
|
| 654 |
+
alertWifiClient('token_extended', token.token_id, {
|
| 655 |
+
minutes: Math.ceil(grantedSeconds / 60),
|
| 656 |
+
expiresAt: auditNewExpiresAt,
|
| 657 |
+
}).catch(() => {});
|
| 658 |
|
| 659 |
res.json({
|
| 660 |
success: true,
|
|
|
|
| 775 |
await connection.commit();
|
| 776 |
|
| 777 |
const updated = await loadTokenRow(token.token_id, req.client.id);
|
| 778 |
+
if (deltaSeconds > 0) {
|
| 779 |
+
alertWifiClient('token_extended', token.token_id, {
|
| 780 |
+
minutes: Math.ceil(deltaSeconds / 60),
|
| 781 |
+
expiresAt: nextExpiresAt,
|
| 782 |
+
}).catch(() => {});
|
| 783 |
+
}
|
| 784 |
|
| 785 |
res.json({
|
| 786 |
success: true,
|
|
|
|
| 922 |
|
| 923 |
await connection.commit();
|
| 924 |
|
| 925 |
+
if (action !== 'deleted') {
|
| 926 |
+
alertWifiClient('token_revoked', token.token_id, {
|
| 927 |
+
action,
|
| 928 |
+
mac: token.locked_mac,
|
| 929 |
+
}).catch(() => {});
|
| 930 |
+
}
|
| 931 |
+
|
| 932 |
res.json({
|
| 933 |
success: true,
|
| 934 |
action,
|
src/utils/messages.js
CHANGED
|
@@ -18,6 +18,18 @@ module.exports = {
|
|
| 18 |
? `Your WiFi access for ${deviceName} (${planName}) has expired. Buy a new bundle to reconnect.`
|
| 19 |
: `Your WiFi access for ${deviceName} has expired. Buy a new bundle to reconnect.`,
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
// Tenant receives this after their device subscription renews
|
| 22 |
deviceRenewal: (deviceName, expiryDate) =>
|
| 23 |
`"${deviceName}" renewed until ${expiryDate}. Keep selling!`,
|
|
|
|
| 18 |
? `Your WiFi access for ${deviceName} (${planName}) has expired. Buy a new bundle to reconnect.`
|
| 19 |
: `Your WiFi access for ${deviceName} has expired. Buy a new bundle to reconnect.`,
|
| 20 |
|
| 21 |
+
// Tenant receives this when a guest starts using a token
|
| 22 |
+
tokenStartedAlert: (code, deviceName, mac) =>
|
| 23 |
+
`Token ${code} started being used on ${deviceName}${mac ? ` by ${mac}` : ''}.`,
|
| 24 |
+
|
| 25 |
+
// Tenant receives this when a token extension is granted
|
| 26 |
+
tokenExtendedAlert: (code, deviceName, minutes, expiresAt = null) =>
|
| 27 |
+
`Token ${code} on ${deviceName} was extended by ${minutes} minute(s)${expiresAt ? `. New expiry: ${expiresAt}` : ''}.`,
|
| 28 |
+
|
| 29 |
+
// Tenant receives this when a token is revoked or voided
|
| 30 |
+
tokenRevokedAlert: (code, deviceName, action = 'revoked') =>
|
| 31 |
+
`Token ${code} on ${deviceName} was ${action}.`,
|
| 32 |
+
|
| 33 |
// Tenant receives this after their device subscription renews
|
| 34 |
deviceRenewal: (deviceName, expiryDate) =>
|
| 35 |
`"${deviceName}" renewed until ${expiryDate}. Keep selling!`,
|
src/utils/tokenClientAlerts.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const db = require('../config/db');
|
| 2 |
+
const sms = require('../services/sms');
|
| 3 |
+
const messages = require('./messages');
|
| 4 |
+
const { canSendTenantNotification } = require('./notificationPreferences');
|
| 5 |
+
|
| 6 |
+
function formatDateTime(value) {
|
| 7 |
+
if (!value) return null;
|
| 8 |
+
const date = new Date(value);
|
| 9 |
+
if (Number.isNaN(date.getTime())) return null;
|
| 10 |
+
return date.toLocaleString('en-TZ', {
|
| 11 |
+
timeZone: 'Africa/Dar_es_Salaam',
|
| 12 |
+
year: 'numeric',
|
| 13 |
+
month: 'short',
|
| 14 |
+
day: '2-digit',
|
| 15 |
+
hour: '2-digit',
|
| 16 |
+
minute: '2-digit',
|
| 17 |
+
});
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
async function loadTokenContext(tokenId) {
|
| 21 |
+
return db.queryOne(
|
| 22 |
+
`SELECT t.id AS token_id, t.code AS token_code, t.locked_mac, t.expires_at,
|
| 23 |
+
d.id AS device_id, d.name AS device_name,
|
| 24 |
+
c.id AS client_id, c.phone AS client_phone
|
| 25 |
+
FROM access_tokens t
|
| 26 |
+
JOIN devices d ON d.id = t.device_id
|
| 27 |
+
JOIN clients c ON c.id = t.client_id
|
| 28 |
+
WHERE t.id = ?
|
| 29 |
+
LIMIT 1`,
|
| 30 |
+
[tokenId]
|
| 31 |
+
);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
async function alertWifiClient(eventType, tokenId, options = {}) {
|
| 35 |
+
try {
|
| 36 |
+
const token = await loadTokenContext(tokenId);
|
| 37 |
+
if (!token?.client_id) return false;
|
| 38 |
+
if (!(await canSendTenantNotification(token.client_id, 'purchase_alerts'))) return false;
|
| 39 |
+
|
| 40 |
+
const mac = options.mac || token.locked_mac || null;
|
| 41 |
+
const minutes = options.minutes || null;
|
| 42 |
+
const action = options.action || 'revoked';
|
| 43 |
+
const expiresAt = formatDateTime(options.expiresAt || token.expires_at);
|
| 44 |
+
|
| 45 |
+
let alertKey = null;
|
| 46 |
+
let module = 'tokens';
|
| 47 |
+
let content = null;
|
| 48 |
+
|
| 49 |
+
if (eventType === 'token_started') {
|
| 50 |
+
alertKey = `token-started-${token.token_id}-${String(mac || 'unknown').replace(/[^A-Za-z0-9]/g, '')}`;
|
| 51 |
+
content = messages.tokenStartedAlert(token.token_code, token.device_name, mac);
|
| 52 |
+
} else if (eventType === 'token_extended') {
|
| 53 |
+
alertKey = `token-extended-${token.token_id}-${Date.now()}`;
|
| 54 |
+
content = messages.tokenExtendedAlert(token.token_code, token.device_name, minutes, expiresAt);
|
| 55 |
+
} else if (eventType === 'token_revoked') {
|
| 56 |
+
alertKey = `token-revoked-${token.token_id}-${Date.now()}`;
|
| 57 |
+
content = messages.tokenRevokedAlert(token.token_code, token.device_name, action);
|
| 58 |
+
} else {
|
| 59 |
+
return false;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
const result = await db.query(
|
| 63 |
+
`INSERT IGNORE INTO omada_alerts
|
| 64 |
+
(client_id, device_id, omada_alert_id, alert_key, module, level, content, device_mac, alert_time)
|
| 65 |
+
VALUES (?, ?, ?, ?, ?, 'info', ?, ?, NOW())`,
|
| 66 |
+
[
|
| 67 |
+
token.client_id,
|
| 68 |
+
token.device_id,
|
| 69 |
+
alertKey,
|
| 70 |
+
eventType,
|
| 71 |
+
module,
|
| 72 |
+
content,
|
| 73 |
+
mac,
|
| 74 |
+
]
|
| 75 |
+
);
|
| 76 |
+
|
| 77 |
+
if (Number(result?.affectedRows || 0) === 0) {
|
| 78 |
+
return false;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
if (token.client_phone) {
|
| 82 |
+
sms.sendSMS(token.client_phone, content).catch(err => {
|
| 83 |
+
console.error('[token-alert] SMS failed:', err.message);
|
| 84 |
+
});
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
return true;
|
| 88 |
+
} catch (err) {
|
| 89 |
+
console.error('[token-alert] failed:', err.message, { eventType, tokenId });
|
| 90 |
+
return false;
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
module.exports = {
|
| 95 |
+
alertWifiClient,
|
| 96 |
+
};
|