chore: project cleanup — dead code, logging, tests, WS hardening

- Delete unused dataMerger, formatter, Device model, dead service exports,
  and the empty agents/ + .gitkeep placeholders.
- Extract STORE_MODES and MDM device-type filters into a shared constants.js.
- Anchor bot regexes (^help|^store|^analyze) so "analyze store 305" no
  longer fires both handlers; replace catch-all noise.
- Hoist inline require() calls in integrations to top-of-file imports.
- Harden WebSocket server: Authorization header support, single-agent
  enforcement, bounded pending requests, server-level error handler,
  coalesced cache refresh in Meraki client.
- Wrap Meraki/MDM network calls with withRetry; add request timeouts.
- Migrate all console.* calls onto utils/logger.js (LOG_LEVEL aware);
  drive Webex framework logLevel from env.
- Refactor storeDetail.js: shared renderClientLine + buildMdmSection
  helpers cut duplication roughly in half.
- Refresh README structure, document LOG_LEVEL, add npm run agent script,
  add jest testMatch + new tests (handlers, HealthReport, Store, ws).

Verified: npm run lint clean, 7 suites / 31 tests passing.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Joseph McQueen 2026-06-24 17:21:22 -04:00
parent 459301cc4d
commit 0a46d25bd6
38 changed files with 8109 additions and 1164 deletions

View file

@ -4,6 +4,10 @@
# NEVER commit .env — it is gitignored.
# =============================================================================
# --- Logging ---
# debug | info | warn | error (default: info)
LOG_LEVEL=info
# --- Webex Bot (required for bot functionality) ---
WEBEX_ACCESS_TOKEN=your_webex_bot_access_token_here
BOT_NAME=NetAnalyzer

5
.prettierignore Normal file
View file

@ -0,0 +1,5 @@
node_modules
coverage
.env*
.git
package-lock.json

9
.prettierrc.json Normal file
View file

@ -0,0 +1,9 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "avoid"
}

View file

@ -42,6 +42,7 @@ SIW and some MDM systems are only reachable from specific internal networks. The
## Setup
1. **Clone and install**
```bash
git clone <repo>
cd netanalyzer
@ -49,6 +50,7 @@ SIW and some MDM systems are only reachable from specific internal networks. The
```
2. **Configure environment**
```bash
cp .env.example .env
# Edit .env with your real credentials
@ -67,6 +69,7 @@ SIW and some MDM systems are only reachable from specific internal networks. The
## Running
### Main server + bot (where the Webex connection lives)
```bash
npm start
# or for development with auto-reload
@ -74,10 +77,12 @@ npm run dev
```
This starts:
- The Webex bot framework (listens for messages in Webex spaces)
- The WebSocket server on the port defined in `WS_PORT` (default 8080)
### Remote agent (run on a machine that can reach internal systems)
```bash
# On the internal machine
node remoteAgent.js
@ -99,40 +104,59 @@ The bot also responds to variations containing "store" or "analyze".
```
.
├── bot/
│ └── handlers.js # Webex command handlers
│ └── handlers.js # Webex command handlers (store / analyze / help)
├── config/
│ └── index.js # Centralized configuration from env
│ └── index.js # Centralized env-driven configuration + validation
├── integrations/
│ ├── storeDetail.js # Full store report builder
│ ├── storeDetail.js # Full store report builder (Meraki + SIW + MDM)
│ └── storeHealth.js # Health score + summary
├── models/
│ ├── Store.js # Store domain model (normalizes SIW location)
│ └── HealthReport.js # Score / issue accumulator
├── services/
│ ├── meraki.js # Meraki API client + caching
│ ├── siw.js # SIW calls (via remote proxy)
│ ├── mdm.js # Workspace ONE MDM client
│ └── websocket.js # WebSocket server + proxyRequest helper
│ ├── meraki.js # Meraki API client (cached, retried)
│ ├── siw.js # SIW calls (proxied via the remote agent)
│ ├── mdm.js # Workspace ONE MDM client (retried)
│ └── websocket.js # WS server + proxyRequest helper
├── tests/
│ ├── *.test.js # Unit tests
│ ├── integration/ # Integration tests using mocks
│ └── mocks/ # Service mocks for tests
├── utils/
│ ├── formatter.js
│ └── dataMerger.js # (currently unused)
├── remoteAgent.js # Lightweight proxy client
├── server.js # Main entry point (bot + ws server)
│ ├── logger.js # Structured JSON logger (LOG_LEVEL aware)
│ ├── merakiMatcher.js # Device ↔ Meraki client matching
│ ├── retry.js # withRetry wrapper (exponential backoff)
│ └── validate.js # Input parsers
├── constants.js # Shared constants (STORE_MODES, MDM device types)
├── remoteAgent.js # Lightweight proxy client (run on internal host)
├── server.js # Main entry point (bot + WS server)
├── package.json
├── eslint.config.js
├── .prettierrc.json
└── .env.example
```
## Logging
The app emits single-line JSON to stdout/stderr via `utils/logger.js`. Set `LOG_LEVEL` in `.env` to one of `debug | info | warn | error` (default: `info`).
## Security Notes
- **Never commit `.env`** (it is gitignored).
- The WebSocket connection between server and remote agent is protected by a shared token (`WS_TOKEN`).
- **Never commit `.env`** (it is gitignored, along with `.env.bak.*`).
- The WebSocket connection between server and remote agent is protected by a shared `WS_TOKEN`. The remote agent now sends the token in an `Authorization: Bearer` header (the legacy `?token=...` query parameter still works for older deployments but should be migrated).
- Only one remote agent may be connected at a time; a newer connection replaces the older one and any in-flight proxy requests are rejected (rather than silently hanging).
- All SIW communication uses Basic Auth and is only performed through the remote agent.
- Meraki and MDM calls use tokens that should be scoped to the minimum necessary permissions.
## Next Steps / Roadmap
## Development
See the phased improvement plan in the project history (or ask the maintainer for current priorities):
- Phase 0: Foundational hygiene (git, secrets, docs) ← **current**
- Phase 1: Input validation, shared logic extraction, graceful shutdown
- Phase 2: Linting, tests, structured logging, basic resilience
- Phase 3: Domain modeling and architectural cleanup
```bash
npm run lint # eslint + prettier check
npm run lint:fix # auto-fix lint issues
npm run format # prettier write
npm test # jest (unit + integration tests using mocks)
npm run test:watch
```
## License

View file

@ -1 +0,0 @@
# Reserved for future agent implementations

View file

@ -1,18 +1,39 @@
const { getStoreDetail } = require('../integrations/storeDetail');
const { getStoreHealth } = require('../integrations/storeHealth');
const { parseStoreNumber } = require('../utils/validate');
const { STORE_MODES } = require('../constants');
const logger = require('../utils/logger');
function parseStoreCommand(text) {
const storeNumber = parseStoreNumber(text);
if (!storeNumber) return { storeNumber: null, mode: null };
const lower = text.toLowerCase();
let mode = STORE_MODES.DEFAULT;
if (lower.includes(' pos')) mode = STORE_MODES.POS;
else if (lower.includes(' ios') || lower.includes(' iphone')) mode = STORE_MODES.IOS;
return { storeNumber, mode };
}
async function handleStoreCommand(bot, trigger) {
const words = trigger.message.text.trim().split(/\s+/);
const storeNumber = words[1];
const { storeNumber, mode } = parseStoreCommand(trigger.message.text);
if (!storeNumber || isNaN(storeNumber)) {
return bot.say('markdown', 'Usage: `store <number>`\nExample: `store 305`');
if (!storeNumber) {
return bot.say(
'markdown',
'Usage: `store <number>`\n\nExample: `store 305`\n\nOptions:\n• `store 305` — info + network + server\n• `store 305 pos` — POS systems\n• `store 305 ios` — iOS devices\n\nType `help store` for more details.'
);
}
bot.say('markdown', `🔍 Analyzing Store **${storeNumber}**...`);
const modeLabel =
mode === STORE_MODES.POS ? 'POS' : mode === STORE_MODES.IOS ? 'iOS' : 'Overview';
bot.say('markdown', `🔍 Analyzing Store **${storeNumber}** (${modeLabel})...`);
try {
const fullReport = await getStoreDetail(storeNumber);
const sections = fullReport.split(/\n\n(?=\*\*)/);
const report = await getStoreDetail(storeNumber, mode);
const sections = report.split(/\n\n(?=\*\*)/);
for (let i = 0; i < sections.length; i++) {
const section = sections[i].trim();
@ -22,35 +43,105 @@ async function handleStoreCommand(bot, trigger) {
}
}
} catch (err) {
console.error('❌ Analysis error:', err);
logger.error('Store analysis error', { storeNumber, error: err.message });
bot.say('markdown', `❌ Error analyzing store **${storeNumber}**:\n${err.message}`);
}
}
const { getStoreHealth } = require('../integrations/storeHealth');
async function handleAnalyzeCommand(bot, trigger) {
const words = trigger.message.text.trim().split(/\s+/);
const storeNumber = words[1];
const { storeNumber, mode } = parseStoreCommand(trigger.message.text);
if (!storeNumber || isNaN(storeNumber)) {
return bot.say('markdown', 'Usage: `analyze <number>`\nExample: `analyze 305`');
if (!storeNumber) {
return bot.say(
'markdown',
'Usage: `analyze <number>`\n\nExample: `analyze 305`\n\nOptions:\n• `analyze 305` — full health summary\n• `analyze 305 pos` — POS health (broken only)\n• `analyze 305 ios` — iOS health (broken only)\n\nType `help analyze` for more details.'
);
}
bot.say('markdown', `🔍 Running Health Analysis for Store **${storeNumber}**...`);
const modeLabel =
mode === STORE_MODES.POS ? 'POS' : mode === STORE_MODES.IOS ? 'iOS' : 'Overview';
bot.say('markdown', `🔍 Running Health Analysis for Store **${storeNumber}** (${modeLabel})...`);
try {
const health = await getStoreHealth(storeNumber);
const health = await getStoreHealth(storeNumber, mode);
if (health && health.summary) {
bot.say('markdown', health.summary); // ← Correct way
let output = health.summary;
// For sub-modes or analyze, only show broken components
if (mode !== STORE_MODES.DEFAULT) {
const lines = output.split('\n');
const issueStart = lines.findIndex(l => l.includes('Issues Detected') || l.includes('⚠️'));
if (issueStart > -1) {
output =
lines.slice(0, issueStart + 1).join('\n') +
'\n' +
lines
.slice(issueStart + 1)
.filter(l => l.trim().startsWith('-'))
.join('\n');
}
}
bot.say('markdown', output);
} else {
bot.say('markdown', '✅ Analysis completed, but no summary was generated.');
}
} catch (err) {
console.error('❌ Health analysis error:', err);
logger.error('Store health analysis error', { storeNumber, error: err.message });
bot.say('markdown', `❌ Error analyzing store **${storeNumber}**:\n${err.message}`);
}
}
module.exports = { handleStoreCommand, handleAnalyzeCommand };
async function handleHelpCommand(bot, trigger) {
const text = trigger.message.text.toLowerCase().trim();
let response;
if (text.includes('store')) {
response = `**Store Commands**
\`store <number>\` — Store info/details, active network devices, and the store server
\`store <number> pos\` — Store server, registers, mobile registers, printers, and payment terminals
\`store <number> ios\` — All iOS devices
**Tip:** Type the command without a number for usage examples.`;
} else if (text.includes('analyze')) {
response = `**Analyze Commands** (shows only broken components for sub-modes)
\`analyze <number>\` — Overall health summary
\`analyze <number> pos\` — POS health (only issues)
\`analyze <number> ios\` — iOS health (only issues)
**Tip:** Type the command without a number for usage examples.`;
} else {
response = `**NetAnalyzer Help**
Available Commands:
**Store Commands:**
\`store <number>\` — Store info/details, active network devices, and the store server
\`store <number> pos\` — Store server, registers, mobile registers, printers, and payment terminals
\`store <number> ios\` — All iOS devices
**Analyze Commands:**
\`analyze <number>\` — Overall health summary
\`analyze <number> pos\` — POS health (broken components only)
\`analyze <number> ios\` — iOS health (broken components only)
**Tips:**
Most commands work in both group spaces and 1:1 chats.
Type a command without a number for usage examples.
Use \`help store\` or \`help analyze\` for more details.
Try \`store 782\` or \`analyze 782\` to get started.`;
}
bot.say('markdown', response);
}
module.exports = {
handleStoreCommand,
handleAnalyzeCommand,
handleHelpCommand,
parseStoreCommand,
};

View file

@ -1,29 +1,85 @@
require('dotenv').config();
const logger = require('../utils/logger');
/**
* Required environment variables for core operation.
* The app will refuse to start if these are missing.
*/
const REQUIRED_ENV_VARS = ['WEBEX_ACCESS_TOKEN', 'MERAKI_API_KEY', 'MERAKI_ORG_ID', 'WS_TOKEN'];
/**
* Recommended environment variables for full functionality.
* Warnings will be logged but startup will continue.
*/
const RECOMMENDED_ENV_VARS = [
'SIW_BASE_URL',
'SIW_USERNAME',
'SIW_PASSWORD',
'WS1_BASE_URL',
'WS1_TOKEN_URL',
'WS1_CLIENT_ID',
'WS1_CLIENT_SECRET',
'WS1_TENANT_CODE',
];
function validateEnvironment() {
const missingRequired = REQUIRED_ENV_VARS.filter(
key => !process.env[key] || process.env[key].trim() === ''
);
const missingRecommended = RECOMMENDED_ENV_VARS.filter(
key => !process.env[key] || process.env[key].trim() === ''
);
if (missingRequired.length > 0) {
logger.error('Missing required environment variables', { missing: missingRequired });
throw new Error(`Missing required environment variables: ${missingRequired.join(', ')}`);
}
if (missingRecommended.length > 0) {
logger.warn('Optional environment variables not set; some features may be limited', {
missing: missingRecommended,
});
}
const wsPort = parseInt(process.env.WS_PORT, 10);
if (process.env.WS_PORT && (isNaN(wsPort) || wsPort < 1 || wsPort > 65535)) {
throw new Error('WS_PORT must be a valid port number between 1 and 65535');
}
logger.info('Environment validation passed');
}
// Skip validation when running unit tests so tests don't require a fully
// populated .env. Integration tests can opt in by unsetting JEST_WORKER_ID.
if (!process.env.JEST_WORKER_ID) {
validateEnvironment();
}
module.exports = {
webex: {
token: process.env.WEBEX_ACCESS_TOKEN,
name: process.env.BOT_NAME || 'NetAnalyzer'
},
meraki: {
baseUrl: 'https://api.meraki.com/api/v1',
apiKey: process.env.MERAKI_API_KEY,
orgId: process.env.MERAKI_ORG_ID
},
ws: {
port: parseInt(process.env.WS_PORT) || 8080,
token: process.env.WS_TOKEN
},
siw: {
baseUrl: process.env.SIW_BASE_URL,
username: process.env.SIW_USERNAME,
password: process.env.SIW_PASSWORD
},
mdm: {
baseUrl: process.env.WS1_BASE_URL,
tokenUrl: process.env.WS1_TOKEN_URL,
clientId: process.env.WS1_CLIENT_ID,
clientSecret: process.env.WS1_CLIENT_SECRET,
tenantCode: process.env.WS1_TENANT_CODE
}
logLevel: process.env.LOG_LEVEL || 'info',
webex: {
token: process.env.WEBEX_ACCESS_TOKEN,
name: process.env.BOT_NAME || 'NetAnalyzer',
},
meraki: {
baseUrl: 'https://api.meraki.com/api/v1',
apiKey: process.env.MERAKI_API_KEY,
orgId: process.env.MERAKI_ORG_ID,
},
ws: {
port: parseInt(process.env.WS_PORT) || 8080,
token: process.env.WS_TOKEN,
},
siw: {
baseUrl: process.env.SIW_BASE_URL,
username: process.env.SIW_USERNAME,
password: process.env.SIW_PASSWORD,
},
mdm: {
baseUrl: process.env.WS1_BASE_URL,
tokenUrl: process.env.WS1_TOKEN_URL,
clientId: process.env.WS1_CLIENT_ID,
clientSecret: process.env.WS1_CLIENT_SECRET,
tenantCode: process.env.WS1_TENANT_CODE,
},
};

43
constants.js Normal file
View file

@ -0,0 +1,43 @@
/**
* Shared constants used across the bot, integrations, and services.
*/
const STORE_MODES = Object.freeze({
DEFAULT: 'default', // store info + active network devices + store server
POS: 'pos', // store server + registers + mobile registers + printers + payment terminals
IOS: 'ios', // all iOS devices
});
/**
* MDM (Workspace ONE) device-name conventions.
* Devices are classified by substring on UserName or DeviceFriendlyName.
*/
const MDM_DEVICE_TYPES = Object.freeze({
SERVER: 'SRV',
MOBILE_REGISTER: 'MR',
CUSTOMER_DISPLAY: 'CD',
IPHONE: 'IPH',
});
/**
* Return the canonical device-name string used to classify an MDM device.
*/
function mdmDeviceName(device) {
if (!device) return '';
return (device.UserName || device.DeviceFriendlyName || '').toString();
}
/**
* Filter MDM devices whose name contains the given marker (SRV/MR/CD/IPH).
*/
function filterMdmByType(devices, marker) {
if (!Array.isArray(devices) || !marker) return [];
return devices.filter(d => mdmDeviceName(d).includes(marker));
}
module.exports = {
STORE_MODES,
MDM_DEVICE_TYPES,
mdmDeviceName,
filterMdmByType,
};

46
eslint.config.js Normal file
View file

@ -0,0 +1,46 @@
const js = require('@eslint/js');
const globals = require('globals');
const prettierPlugin = require('eslint-plugin-prettier');
const prettierConfig = require('eslint-config-prettier');
module.exports = [
js.configs.recommended,
prettierConfig,
{
languageOptions: {
ecmaVersion: 2022,
sourceType: 'commonjs',
globals: {
...globals.node,
},
},
plugins: {
prettier: prettierPlugin,
},
rules: {
'prettier/prettier': 'error',
'no-console': 'off',
'no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
'no-constant-condition': ['error', { checkLoops: false }],
},
},
// Jest globals for test files
{
files: ['**/*.test.js', '**/__tests__/**/*.js'],
languageOptions: {
globals: {
...globals.jest,
},
},
},
{
ignores: ['node_modules/**', 'coverage/**', '.git/**'],
},
];

View file

@ -1,344 +1,304 @@
const { getStoreLocation, getStoreRegisters, getStorePrinters, getStorePaymentTerminals } = require('../services/siw');
const { findMerakiNetwork, getMerakiDeviceAvailabilities, getMerakiClients } = require('../services/meraki');
const { formatStoreReport } = require('../utils/formatter');
const {
getStoreLocation,
getStoreRegisters,
getStorePrinters,
getStorePaymentTerminals,
} = require('../services/siw');
const {
findMerakiNetwork,
getMerakiDeviceAvailabilities,
getMerakiClients,
} = require('../services/meraki');
const { getMDMDevices } = require('../services/mdm');
const { createStore } = require('../models/Store');
const {
findMatchingClient,
getClientStatus,
formatLastSeen,
buildMerakiClientLink,
} = require('../utils/merakiMatcher');
const { STORE_MODES, MDM_DEVICE_TYPES, filterMdmByType } = require('../constants');
const logger = require('../utils/logger');
function formatLastSeen(lastSeen) {
if (!lastSeen) return 'N/A';
const now = new Date();
const seen = new Date(lastSeen);
const diffMs = now - seen;
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return 'Just now';
if (diffMin < 60) return `${diffMin} min ago`;
const hours = Math.floor(diffMin / 60);
return `${hours} hr ago`;
function shouldFetchForMode(mode, category) {
if (mode === STORE_MODES.IOS) {
return ['mdm', 'meraki', 'merakiClients'].includes(category);
}
if (mode === STORE_MODES.POS) {
return ['siw', 'mdm', 'meraki', 'merakiClients'].includes(category);
}
return true; // default fetches most things
}
async function getStoreDetail(storeNumber) {
console.log(`🔍 Starting full analysis for store ${storeNumber}`);
async function getStoreDetail(storeNumber, mode = STORE_MODES.DEFAULT) {
logger.info('Starting store analysis', { storeNumber, mode });
let locationData, merakiNetwork, registers, printers, paymentTerminals, merakiClients = [], mdmDevices = [];
let locationData = null,
merakiNetwork = null,
registers = [],
printers = [],
paymentTerminals = [],
merakiClients = [],
mdmDevices = [];
try {
// Phase 1: Fast lookups
[locationData, merakiNetwork] = await Promise.all([
getStoreLocation(storeNumber),
findMerakiNetwork(storeNumber)
]);
try {
// Phase 1: basic lookups
[locationData, merakiNetwork] = await Promise.all([
shouldFetchForMode(mode, 'location') ? getStoreLocation(storeNumber) : Promise.resolve(null),
shouldFetchForMode(mode, 'meraki') ? findMerakiNetwork(storeNumber) : Promise.resolve(null),
]);
// Phase 2: Heavy parallel lookups
const parallelPromises = [
getStoreRegisters(storeNumber),
getStorePrinters(storeNumber),
getStorePaymentTerminals(storeNumber)
];
// Phase 2: heavy data, fanned out in parallel
const dataPromises = [];
if (merakiNetwork) {
parallelPromises.push(
getMerakiClients(merakiNetwork.id),
require('../services/mdm').getMDMDevices(storeNumber)
);
}
const results = await Promise.all(parallelPromises);
registers = results[0];
printers = results[1];
paymentTerminals = results[2];
if (merakiNetwork) {
console.log(`Meraki Network:`)
console.log(JSON.stringify(merakiNetwork, null, 2))
merakiClients = results[3] || [];
mdmDevices = results[4] || [];
}
console.log(`✅ Fetched: ${registers?.length || 0} registers, ${printers?.length || 0} printers, ${paymentTerminals?.length || 0} payment terminals`);
if (merakiNetwork) {
console.log(`✅ Fetched ${merakiClients.length} Meraki clients`);
console.log(`✅ Fetched ${mdmDevices.length} MDM devices`);
}
} catch (err) {
console.error('❌ Error fetching data:', err.message);
}
let report = formatStoreReport(locationData, storeNumber);
// === Meraki Network Hardware ===
if (merakiNetwork) {
report += `**🌐 [${merakiNetwork.name}](${merakiNetwork.url})**\n\n`;
let activeDevices = [];
try {
const availabilities = await getMerakiDeviceAvailabilities(merakiNetwork.id);
activeDevices = availabilities.filter(d => d.status !== 'dormant');
} catch (e) {
console.error('Device availabilities failed:', e.message);
}
// Sort: Rear Switch → Front Switch → APs
activeDevices.sort((a, b) => {
const nameA = (a.name || '').trim().toUpperCase();
const nameB = (b.name || '').trim().toUpperCase();
if (nameA.includes('SWR') || /SW.*R\d?$/.test(nameA)) return -1;
if (nameB.includes('SWR') || /SW.*R\d?$/.test(nameB)) return 1;
if (nameA.includes('SWF') || /SW.*F\d/.test(nameA)) return -1;
if (nameB.includes('SWF') || /SW.*F\d/.test(nameB)) return 1;
return nameA.localeCompare(nameB);
});
report += `**🛠️ Active Network Devices (${activeDevices.length})**\n`;
activeDevices.forEach(dev => {
const statusEmoji = dev.status === 'online' ? '✅' : '⚠️';
const deviceName = (dev.name || dev.serial || 'Unknown').trim();
report += `**${deviceName}** — ${statusEmoji} ${dev.status}`;
if (dev.serial && merakiNetwork.url) {
// Extract short network code from the URL (e.g. JCz-ucnd)
const urlParts = merakiNetwork.url.split('/n/');
const networkCode = urlParts[1] ? urlParts[1].split('/')[0] : merakiNetwork.id;
let link = `${urlParts[0]}/n/${networkCode}`;
if (dev.productType === 'switch') {
link += `/manage/switches/${dev.serial}/summary`;
} else if (dev.productType === 'wireless') {
link += `/manage/access_points/${dev.serial}/summary`;
} else {
link += `/manage/${dev.serial}/summary`;
}
report += ` → [Dashboard](${link})`;
}
report += `\n`;
});
if (shouldFetchForMode(mode, 'siw')) {
dataPromises.push(
getStoreRegisters(storeNumber),
getStorePrinters(storeNumber),
getStorePaymentTerminals(storeNumber)
);
} else {
report += `\n⚠️ No matching Meraki network found.\n`;
dataPromises.push(Promise.resolve([]), Promise.resolve([]), Promise.resolve([]));
}
// === Store Registers ===
if (registers && registers.length > 0) {
report += `\n**🖥️ Store Registers (${registers.length})**\n`;
registers.forEach(reg => {
const regNum = reg.register_number ? `Register ${reg.register_number}` : 'Register';
const brand = reg.brand_display_name || 'Unknown';
const type = reg.register_type_name || 'Unknown';
const name = (reg.register_display_name || reg.printer_name || 'Unknown').trim();
const match = merakiClients.find(c =>
c?.description &&
(c.description.trim().toLowerCase() === name.toLowerCase() ||
c.description.includes(name))
);
const status = match
? (match.status === 'Online' ? '✅ Online' : '❌ Offline')
: '❓ Unknown';
const lastSeen = match ? formatLastSeen(match.lastSeen) : '';
const deviceName = match?.recentDeviceName ? ` via ${match.recentDeviceName}` : '';
const connection = match?.recentDeviceConnection ? ` (${match.recentDeviceConnection})` : '';
let link = '';
if (match && merakiNetwork?.url) {
const networkCode = merakiNetwork.url.split('/n/')[1]?.split('/')[0] || merakiNetwork.id;
link = ` → [Meraki Client](https://n976.dashboard.meraki.com/${merakiNetwork.name.replace(/ /g, '-')}/n/${networkCode}/manage/clients/${match.id}/overview)`;
}
report += `**${regNum}** — ${brand}${type}${status}${lastSeen}${deviceName}${connection}${link}\n`;
});
}
// === Store Printers (USB filtered) ===
const filteredPrinters = (printers || []).filter(p =>
(p.connection_type_name || '').toLowerCase() !== 'usb'
dataPromises.push(
merakiNetwork && shouldFetchForMode(mode, 'merakiClients')
? getMerakiClients(merakiNetwork.id)
: Promise.resolve([])
);
if (filteredPrinters.length > 0) {
report += `\n**🖨️ Store Printers (${filteredPrinters.length})**\n`;
dataPromises.push(
shouldFetchForMode(mode, 'mdm') ? getMDMDevices(storeNumber) : Promise.resolve([])
);
filteredPrinters.forEach(printer => {
const name = (printer.printer_name || 'Unknown').trim();
const model = printer.printer_model_name || 'N/A';
const [reg, prn, pay, clients, mdm] = await Promise.all(dataPromises);
registers = reg || [];
printers = prn || [];
paymentTerminals = pay || [];
merakiClients = clients || [];
mdmDevices = mdm || [];
const match = merakiClients.find(c =>
c?.description &&
(c.description.trim().toLowerCase() === name.toLowerCase() ||
c.description.includes(name))
);
logger.info('Store data fetched', { mode });
} catch (err) {
logger.error('Error fetching store data', { error: err.message });
}
const status = match
? (match.status === 'Online' ? '✅ Online' : '❌ Offline')
: '❓ Unknown';
let report = '';
const lastSeen = match ? formatLastSeen(match.lastSeen) : '';
const deviceName = match?.recentDeviceName ? ` via ${match.recentDeviceName}` : '';
const connection = match?.recentDeviceConnection ? ` (${match.recentDeviceConnection})` : '';
if (mode === STORE_MODES.DEFAULT) {
report += createStore(locationData, storeNumber).toSummary();
report += await buildActiveNetworkDevices(merakiNetwork);
report += buildStoreServers(mdmDevices, merakiClients, merakiNetwork);
} else if (mode === STORE_MODES.POS) {
report += buildStoreServers(mdmDevices, merakiClients, merakiNetwork);
report += buildRegisters(registers, merakiClients, merakiNetwork);
report += buildMobileRegisters(mdmDevices, merakiClients, merakiNetwork);
report += buildPrinters(printers, merakiClients, merakiNetwork);
report += buildPaymentTerminals(paymentTerminals, merakiClients, merakiNetwork);
} else if (mode === STORE_MODES.IOS) {
report += buildIOSDevices(mdmDevices, merakiClients, merakiNetwork);
}
let link = '';
if (match && merakiNetwork?.url) {
const networkCode = merakiNetwork.url.split('/n/')[1]?.split('/')[0] || merakiNetwork.id;
link = ` → [Meraki Client](https://n976.dashboard.meraki.com/${merakiNetwork.name.replace(/ /g, '-')}/n/${networkCode}/manage/clients/${match.id}/overview)`;
}
return report || 'No matching data for this view.';
}
report += `**${name}** — ${model}${status}${lastSeen}${deviceName}${connection}${link}\n`;
});
// === Section Builders ===
/**
* Render a single "device → matched Meraki client" line.
* `prefixParts` is rendered before the status; `identifiers` is passed
* straight through to findMatchingClient.
*/
function renderClientLine({ prefixParts, identifiers, merakiClients, merakiNetwork }) {
const match = findMatchingClient(merakiClients, identifiers);
const status = getClientStatus(match);
const lastSeen = formatLastSeen(match?.lastSeen);
const recentDevice = match?.recentDeviceName ? ` via ${match.recentDeviceName}` : '';
const connection = match?.recentDeviceConnection ? ` (${match.recentDeviceConnection})` : '';
const link = match ? ` → [Meraki Client](${buildMerakiClientLink(merakiNetwork, match)})` : '';
const prefix = prefixParts.filter(Boolean).join(' — ');
return `${prefix}${status}${lastSeen}${recentDevice}${connection}${link}\n`;
}
async function buildActiveNetworkDevices(merakiNetwork) {
if (!merakiNetwork) return '\n⚠ No matching Meraki network found.\n';
let out = `**🌐 [${merakiNetwork.name}](${merakiNetwork.url})**\n\n`;
let activeDevices = [];
try {
const availabilities = await getMerakiDeviceAvailabilities(merakiNetwork.id);
activeDevices = availabilities.filter(d => d.status !== 'dormant');
} catch (e) {
logger.error('Device availabilities failed', { error: e.message });
}
activeDevices.sort((a, b) => {
const nameA = (a.name || '').trim().toUpperCase();
const nameB = (b.name || '').trim().toUpperCase();
if (nameA.includes('SWR') || /SW.*R\d?$/.test(nameA)) return -1;
if (nameB.includes('SWR') || /SW.*R\d?$/.test(nameB)) return 1;
if (nameA.includes('SWF') || /SW.*F\d/.test(nameA)) return -1;
if (nameB.includes('SWF') || /SW.*F\d/.test(nameB)) return 1;
return nameA.localeCompare(nameB);
});
out += `**🛠️ Active Network Devices (${activeDevices.length})**\n`;
activeDevices.forEach(dev => {
const statusEmoji = dev.status === 'online' ? '✅' : '⚠️';
const deviceName = (dev.name || dev.serial || 'Unknown').trim();
out += `**${deviceName}** — ${statusEmoji} ${dev.status}`;
if (dev.serial && merakiNetwork.url) {
const urlParts = merakiNetwork.url.split('/n/');
const networkCode = urlParts[1] ? urlParts[1].split('/')[0] : merakiNetwork.id;
let link = `${urlParts[0]}/n/${networkCode}`;
if (dev.productType === 'switch') link += `/manage/switches/${dev.serial}/summary`;
else if (dev.productType === 'wireless')
link += `/manage/access_points/${dev.serial}/summary`;
else link += `/manage/${dev.serial}/summary`;
out += ` → [Dashboard](${link})`;
}
out += `\n`;
});
if (paymentTerminals && paymentTerminals.length > 0) {
report += `\n**💳 Payment Terminals (${paymentTerminals.length})**\n`;
return out;
}
paymentTerminals.forEach(term => {
const deviceName = term.device_name || 'Unknown Terminal';
const adyenName = term.adyen_device_name || 'N/A';
const model = term.device_model_name || 'N/A';
const type = term.device_type_name || 'N/A';
function buildMdmSection({
devices,
marker,
title,
fallbackName,
merakiClients,
merakiNetwork,
formatPrefix,
}) {
const filtered = filterMdmByType(devices, marker);
if (filtered.length === 0) return '';
const match = merakiClients.find(c =>
c?.description &&
(c.description.includes(deviceName) ||
c.description.includes(adyenName) ||
(term.ip_address && c.description.includes(term.ip_address.split('.')[0])))
);
let out = `\n**${title} (${filtered.length})**\n`;
const status = match
? (match.status === 'Online' ? '✅ Online' : '❌ Offline')
: '❓ Unknown';
filtered.forEach(dev => {
const name = dev.UserName || dev.DeviceFriendlyName || fallbackName;
out += renderClientLine({
prefixParts: formatPrefix ? formatPrefix(name, dev) : [`**${name}**`],
identifiers: {
UserName: dev.UserName,
DeviceFriendlyName: dev.DeviceFriendlyName,
name,
},
merakiClients,
merakiNetwork,
});
});
const lastSeen = match ? formatLastSeen(match.lastSeen) : '';
const recentDevice = match?.recentDeviceName ? ` via ${match.recentDeviceName}` : '';
const connection = match?.recentDeviceConnection ? ` (${match.recentDeviceConnection})` : '';
return out;
}
let link = '';
if (match && merakiNetwork?.url) {
const networkCode = merakiNetwork.url.split('/n/')[1]?.split('/')[0] || merakiNetwork.id;
link = ` → [Meraki Client](https://n976.dashboard.meraki.com/${merakiNetwork.name.replace(/ /g, '-')}/n/${networkCode}/manage/clients/${match.id}/overview)`;
}
function buildStoreServers(mdmDevices, merakiClients, merakiNetwork) {
return buildMdmSection({
devices: mdmDevices,
marker: MDM_DEVICE_TYPES.SERVER,
title: '🖥️ Store Servers',
fallbackName: 'Unknown Server',
merakiClients,
merakiNetwork,
});
}
report += `**${deviceName}** — ${adyenName}${model}${type}${status}${lastSeen}${recentDevice}${connection}${link}\n`;
});
}
function buildMobileRegisters(mdmDevices, merakiClients, merakiNetwork) {
return buildMdmSection({
devices: mdmDevices,
marker: MDM_DEVICE_TYPES.MOBILE_REGISTER,
title: '📱 Mobile Registers',
fallbackName: 'Unknown Mobile Register',
merakiClients,
merakiNetwork,
});
}
if (mdmDevices && mdmDevices.length > 0) {
const servers = mdmDevices.filter(d => (d.UserName || d.DeviceFriendlyName || '').includes('SRV'));
const mobileRegs = mdmDevices.filter(d => (d.UserName || d.DeviceFriendlyName || '').includes('MR'));
const custDisplays = mdmDevices.filter(d => (d.UserName || d.DeviceFriendlyName || '').includes('CD'));
const iphones = mdmDevices.filter(d => (d.UserName || d.DeviceFriendlyName || '').includes('IPH'));
function buildIOSDevices(mdmDevices, merakiClients, merakiNetwork) {
return buildMdmSection({
devices: mdmDevices,
marker: MDM_DEVICE_TYPES.IPHONE,
title: '📱 Store iPhones',
fallbackName: 'Unknown iPhone',
merakiClients,
merakiNetwork,
});
}
// === Store Servers ===
if (servers.length > 0) {
report += `\n**🖥️ Store Servers (${servers.length})**\n`;
function buildRegisters(registers, merakiClients, merakiNetwork) {
if (!registers || registers.length === 0) return '';
servers.forEach(dev => {
const name = dev.UserName || dev.DeviceFriendlyName || 'Unknown Server';
const match = merakiClients.find(c =>
c?.description &&
(c.description.includes(name) || (dev.UserName && c.description.includes(dev.UserName)))
);
let out = `\n**🖥️ Store Registers (${registers.length})**\n`;
const status = match ? (match.status === 'Online' ? '✅ Online' : '❌ Offline') : '❓ Unknown';
const lastSeen = match ? formatLastSeen(match.lastSeen) : '';
const recentDevice = match?.recentDeviceName ? ` via ${match.recentDeviceName}` : '';
const connection = match?.recentDeviceConnection ? ` (${match.recentDeviceConnection})` : '';
registers.forEach(reg => {
const regNum = reg.register_number ? `Register ${reg.register_number}` : 'Register';
const brand = reg.brand_display_name || 'Unknown';
const type = reg.register_type_name || 'Unknown';
const name = (reg.register_display_name || reg.printer_name || 'Unknown').trim();
let link = '';
if (match && merakiNetwork?.url) {
const networkCode = merakiNetwork.url.split('/n/')[1]?.split('/')[0] || merakiNetwork.id;
link = ` → [Meraki Client](https://n976.dashboard.meraki.com/${merakiNetwork.name.replace(/ /g, '-')}/n/${networkCode}/manage/clients/${match.id}/overview)`;
}
out += renderClientLine({
prefixParts: [`**${regNum}**`, brand, type],
identifiers: { name, register_display_name: name },
merakiClients,
merakiNetwork,
});
});
report += `**${name}** — ${status}${lastSeen}${recentDevice}${connection}${link}\n`;
});
}
return out;
}
// === Mobile Registers ===
if (mobileRegs.length > 0) {
report += `\n**📱 Mobile Registers (${mobileRegs.length})**\n`;
function buildPrinters(printers, merakiClients, merakiNetwork) {
const filtered = (printers || []).filter(
p => (p.connection_type_name || '').toLowerCase() !== 'usb'
);
if (filtered.length === 0) return '';
mobileRegs.forEach(dev => {
const name = dev.UserName || dev.DeviceFriendlyName || 'Unknown Mobile Register';
const match = merakiClients.find(c =>
c?.description &&
(c.description.includes(name) || (dev.UserName && c.description.includes(dev.UserName)))
);
let out = `\n**🖨️ Store Printers (${filtered.length})**\n`;
const status = match ? (match.status === 'Online' ? '✅ Online' : '❌ Offline') : '❓ Unknown';
const lastSeen = match ? formatLastSeen(match.lastSeen) : '';
const recentDevice = match?.recentDeviceName ? ` via ${match.recentDeviceName}` : '';
const connection = match?.recentDeviceConnection ? ` (${match.recentDeviceConnection})` : '';
filtered.forEach(printer => {
const name = (printer.printer_name || 'Unknown').trim();
const model = printer.printer_model_name || 'N/A';
out += renderClientLine({
prefixParts: [`**${name}**`, model],
identifiers: { name, printer_name: name },
merakiClients,
merakiNetwork,
});
});
let link = '';
if (match && merakiNetwork?.url) {
const networkCode = merakiNetwork.url.split('/n/')[1]?.split('/')[0] || merakiNetwork.id;
link = ` → [Meraki Client](https://n976.dashboard.meraki.com/${merakiNetwork.name.replace(/ /g, '-')}/n/${networkCode}/manage/clients/${match.id}/overview)`;
}
return out;
}
report += `**${name}** — ${status}${lastSeen}${recentDevice}${connection}${link}\n`;
});
}
function buildPaymentTerminals(paymentTerminals, merakiClients, merakiNetwork) {
if (!paymentTerminals || paymentTerminals.length === 0) return '';
// === Customer Displays ===
if (custDisplays.length > 0) {
report += `\n**🖥️ Customer Displays (${custDisplays.length})**\n`;
let out = `\n**💳 Payment Terminals (${paymentTerminals.length})**\n`;
custDisplays.forEach(dev => {
const name = dev.UserName || dev.DeviceFriendlyName || 'Unknown Display';
const match = merakiClients.find(c =>
c?.description &&
(c.description.includes(name) || (dev.UserName && c.description.includes(dev.UserName)))
);
paymentTerminals.forEach(term => {
const deviceName = term.device_name || 'Unknown Terminal';
const adyenName = term.adyen_device_name || 'N/A';
const model = term.device_model_name || 'N/A';
const type = term.device_type_name || 'N/A';
out += renderClientLine({
prefixParts: [`**${deviceName}**`, adyenName, model, type],
identifiers: { deviceName, adyenName, ip_address: term.ip_address },
merakiClients,
merakiNetwork,
});
});
const status = match ? (match.status === 'Online' ? '✅ Online' : '❌ Offline') : '❓ Unknown';
const lastSeen = match ? formatLastSeen(match.lastSeen) : '';
const recentDevice = match?.recentDeviceName ? ` via ${match.recentDeviceName}` : '';
const connection = match?.recentDeviceConnection ? ` (${match.recentDeviceConnection})` : '';
let link = '';
if (match && merakiNetwork?.url) {
const networkCode = merakiNetwork.url.split('/n/')[1]?.split('/')[0] || merakiNetwork.id;
link = ` → [Meraki Client](https://n976.dashboard.meraki.com/${merakiNetwork.name.replace(/ /g, '-')}/n/${networkCode}/manage/clients/${match.id}/overview)`;
}
report += `**${name}** — ${status}${lastSeen}${recentDevice}${connection}${link}\n`;
});
}
// === Store iPhones ===
if (iphones.length > 0) {
report += `\n**📱 Store iPhones (${iphones.length})**\n`;
iphones.forEach(dev => {
const name = dev.UserName || dev.DeviceFriendlyName || 'Unknown iPhone';
const match = merakiClients.find(c =>
c?.description &&
(c.description.includes(name) || (dev.UserName && c.description.includes(dev.UserName)))
);
const status = match ? (match.status === 'Online' ? '✅ Online' : '❌ Offline') : '❓ Unknown';
const lastSeen = match ? formatLastSeen(match.lastSeen) : '';
const recentDevice = match?.recentDeviceName ? ` via ${match.recentDeviceName}` : '';
const connection = match?.recentDeviceConnection ? ` (${match.recentDeviceConnection})` : '';
let link = '';
if (match && merakiNetwork?.url) {
const networkCode = merakiNetwork.url.split('/n/')[1]?.split('/')[0] || merakiNetwork.id;
link = ` → [Meraki Client](https://n976.dashboard.meraki.com/${merakiNetwork.name.replace(/ /g, '-')}/n/${networkCode}/manage/clients/${match.id}/overview)`;
}
report += `**${name}** — ${status}${lastSeen}${recentDevice}${connection}${link}\n`;
});
}
}
return report;
return out;
}
module.exports = { getStoreDetail };

View file

@ -1,219 +1,243 @@
const { getStoreLocation } = require('../services/siw');
const { findMerakiNetwork, getMerakiDeviceAvailabilities, getMerakiClients } = require('../services/meraki');
const { getStoreLocation, getStorePaymentTerminals, getStorePrinters } = require('../services/siw');
const {
findMerakiNetwork,
getMerakiDeviceAvailabilities,
getMerakiClients,
} = require('../services/meraki');
const { getMDMDevices } = require('../services/mdm');
const { findMatchingClient, getClientStatus } = require('../utils/merakiMatcher');
const { createHealthReport } = require('../models/HealthReport');
const { MDM_DEVICE_TYPES, filterMdmByType } = require('../constants');
const logger = require('../utils/logger');
async function getStoreHealth(storeNumber) {
console.log(`🔍 Running health analysis for store ${storeNumber}`);
// Configurable scoring penalties (higher = worse impact)
const SCORING = {
noMerakiNetwork: 50,
switchOffline: 15,
apOffline: 5,
apAlerting: 3,
serverOffline: 35,
noMdmData: 20,
paymentTerminalOffline: 5,
printerOffline: 4,
// Mobile registers, customer displays and iPhones currently contribute to issues list only
};
let locationData, merakiNetwork, merakiClients = [], mdmDevices = [], paymentTerminals = [], printers = [];
async function getStoreHealth(storeNumber, _mode = 'default') {
logger.info('Running health analysis', { storeNumber });
try {
[locationData, merakiNetwork] = await Promise.all([
getStoreLocation(storeNumber),
findMerakiNetwork(storeNumber)
]);
let locationData,
merakiNetwork,
merakiClients = [],
mdmDevices = [],
paymentTerminals = [],
printers = [];
if (merakiNetwork) {
[merakiClients, mdmDevices, paymentTerminals, printers] = await Promise.all([
getMerakiClients(merakiNetwork.id),
getMDMDevices(storeNumber),
require('../services/siw').getStorePaymentTerminals(storeNumber),
require('../services/siw').getStorePrinters(storeNumber)
]);
}
} catch (err) {
console.error('❌ Health analysis error:', err.message);
}
try {
[locationData, merakiNetwork] = await Promise.all([
getStoreLocation(storeNumber),
findMerakiNetwork(storeNumber),
]);
let summary = `**📊 Store ${storeNumber} Health Summary - ${locationData?.name || 'Unknown Store'}**\n\n`;
let overallScore = 100;
const issues = [];
// === Network Infrastructure ===
summary += `**🌐 Network Infrastructure**\n`;
if (merakiNetwork) {
summary += `- Meraki Network: ✅ [${merakiNetwork.name}](${merakiNetwork.url})\n`;
try {
const avail = await getMerakiDeviceAvailabilities(merakiNetwork.id);
const switches = avail.filter(d => d.productType === 'switch' && d.status !== 'dormant');
const aps = avail.filter(d => d.productType === 'wireless' && d.status !== 'dormant');
const swOnline = switches.filter(d => d.status === 'online').length;
const apOnline = aps.filter(d => d.status === 'online').length;
const apAlerting = aps.filter(d => d.status === 'alerting').length;
summary += `- Switching: ${swOnline}/${switches.length} online\n`;
summary += `- Access Points: ${apOnline}/${aps.length} online`;
if (apAlerting > 0) {
summary += ` (${apAlerting} alerting)`;
}
summary += `\n\n`;
// Scoring & Issues
if (swOnline < switches.length) {
const offlineSw = switches.length - swOnline;
overallScore -= offlineSw * 15;
issues.push(`${offlineSw} switch(es) offline`);
}
if (apOnline < aps.length || apAlerting > 0) {
const totalApIssues = aps.length - apOnline;
if (apAlerting > 0) {
overallScore -= totalApIssues * 3;
issues.push(`${apAlerting} access point(s) alerting (check cabling/power)`);
} else {
overallScore -= totalApIssues * 5;
issues.push(`${totalApIssues} access point(s) offline`);
}
}
} catch (e) {
console.error('Meraki availability error:', e.message);
}
} else {
summary += `- Meraki Network: ❌ Not Found\n\n`;
overallScore -= 50;
issues.push("No Meraki network found");
[merakiClients, mdmDevices, paymentTerminals, printers] = await Promise.all([
getMerakiClients(merakiNetwork.id),
getMDMDevices(storeNumber),
getStorePaymentTerminals(storeNumber),
getStorePrinters(storeNumber),
]);
}
} catch (err) {
logger.error('Health analysis error', { error: err.message });
}
// === Core Systems ===
summary += `**🖥️ POS Systems**\n`;
const healthReport = createHealthReport(storeNumber, locationData?.name || 'Unknown Store');
if (mdmDevices && mdmDevices.length > 0) {
const servers = mdmDevices.filter(d => (d.UserName || d.DeviceFriendlyName || '').includes('SRV'));
const mobileRegs = mdmDevices.filter(d => (d.UserName || d.DeviceFriendlyName || '').includes('MR'));
const custDisplays = mdmDevices.filter(d => (d.UserName || d.DeviceFriendlyName || '').includes('CD'));
const iphones = mdmDevices.filter(d => (d.UserName || d.DeviceFriendlyName || '').includes('IPH'));
// Server (heavy penalty)
let serverStatus = '❓ Unknown';
if (servers.length > 0) {
const match = merakiClients.find(c =>
c?.description && servers.some(s =>
(s.UserName || s.DeviceFriendlyName || '').toLowerCase().includes((c.description || '').toLowerCase())
)
);
serverStatus = match && match.status === 'Online' ? '✅ Online' : '❌ Offline';
if (serverStatus === '❌ Offline') {
overallScore -= 35;
issues.push(`Store Server ${servers[0]?.UserName} is offline`);
}
}
summary += `- Store Server: ${serverStatus}\n`;
// Mobile Registers
let mobileOnline = 0;
mobileRegs.forEach(reg => {
const name = (reg.UserName || reg.DeviceFriendlyName || '').trim();
const match = merakiClients.find(c =>
c?.description && c.description.toLowerCase().includes(name.toLowerCase()) ||
(c.user && c.user.toLowerCase().includes(name.toLowerCase()))
);
const status = match && match.status === 'Online' ? '✅ Online' : '❌ Offline';
if (status === '✅ Online') mobileOnline++;
if (status === '❌ Offline') issues.push(`Mobile Register ${name} is offline`);
});
summary += `- Mobile Registers: ${mobileOnline}/${mobileRegs.length} online\n`;
// Customer Displays & iPhones (lighter penalty)
let cdOnline = 0;
custDisplays.forEach(cd => {
const name = (cd.UserName || cd.DeviceFriendlyName || '').trim();
const match = merakiClients.find(c =>
c?.description && c.description.toLowerCase().includes(name.toLowerCase()) ||
(c.user && c.user.toLowerCase().includes(name.toLowerCase()))
);
if (match && match.status === 'Online') cdOnline++;
if (match && match.status !== 'Online') issues.push(`Customer Display ${name} is offline`);
});
summary += `- Customer Displays: ${cdOnline}/${custDisplays.length} online\n`;
let iphoneOnline = 0;
iphones.forEach(phone => {
const name = (phone.UserName || phone.DeviceFriendlyName || '').trim();
const match = merakiClients.find(c =>
c?.description && c.description.toLowerCase().includes(name.toLowerCase()) ||
(c.user && c.user.toLowerCase().includes(name.toLowerCase()))
);
if (match && match.status === 'Online') iphoneOnline++;
if (match && match.status !== 'Online') issues.push(`iPhone ${name} is offline`);
});
summary += `- Store iPhones: ${iphoneOnline}/${iphones.length} online\n\n`;
} else {
summary += `- No MDM data available\n\n`;
overallScore -= 20;
}
// === POS & Peripherals (with per-device penalty) ===
summary += `**💳 POS Peripherals**\n`;
// Payment Terminals
if (paymentTerminals && paymentTerminals.length > 0) {
let onlinePayments = 0;
paymentTerminals.forEach(term => {
const name = (term.device_name || term.adyen_device_name || '').trim().toLowerCase();
const cleanIp = (term.ip_address || '').split('.')[0].toLowerCase();
const match = merakiClients.find(c =>
c?.description && (
c.description.toLowerCase().includes(name) ||
c.description.toLowerCase().includes(cleanIp)
)
);
const status = match && match.status === 'Online' ? '✅ Online' : '❌ Offline';
if (status === '✅ Online') onlinePayments++;
if (status === '❌ Offline') {
overallScore -= 5; // penalty per offline terminal
issues.push(`Payment Terminal ${name} is offline`);
}
});
summary += `- Payment Terminals: ${onlinePayments}/${paymentTerminals.length} online\n`;
}
// Printers
const filteredPrinters = (printers || []).filter(p =>
(p.connection_type_name || '').toLowerCase() !== 'usb'
// === Network Infrastructure ===
if (merakiNetwork) {
healthReport.addSection(
'**🌐 Network Infrastructure**',
`- Meraki Network: ✅ [${merakiNetwork.name}](${merakiNetwork.url})\n`
);
if (filteredPrinters.length > 0) {
let onlinePrinters = 0;
filteredPrinters.forEach(printer => {
const name = (printer.printer_name || '').trim().toLowerCase();
const match = merakiClients.find(c =>
c?.description && c.description.toLowerCase().includes(name)
);
const status = match && match.status === 'Online' ? '✅ Online' : '❌ Offline';
if (status === '✅ Online') onlinePrinters++;
if (status === '❌ Offline') {
overallScore -= 4; // penalty per offline printer
issues.push(`Printer ${name} is offline`);
}
});
try {
const avail = await getMerakiDeviceAvailabilities(merakiNetwork.id);
const switches = avail.filter(d => d.productType === 'switch' && d.status !== 'dormant');
const aps = avail.filter(d => d.productType === 'wireless' && d.status !== 'dormant');
summary += `- Printers: ${onlinePrinters}/${filteredPrinters.length} online\n`;
const swOnline = switches.filter(d => d.status === 'online').length;
const apOnline = aps.filter(d => d.status === 'online').length;
const apAlerting = aps.filter(d => d.status === 'alerting').length;
let netContent = `- Switching: ${swOnline}/${switches.length} online\n`;
netContent += `- Access Points: ${apOnline}/${aps.length} online`;
if (apAlerting > 0) {
netContent += ` (${apAlerting} alerting)`;
}
netContent += `\n\n`;
healthReport.addSection('', netContent); // append
// Scoring & Issues
if (swOnline < switches.length) {
const offlineSw = switches.length - swOnline;
healthReport.deduct(offlineSw * SCORING.switchOffline, `${offlineSw} switch(es) offline`);
}
if (apOnline < aps.length || apAlerting > 0) {
const totalApIssues = aps.length - apOnline;
if (apAlerting > 0) {
healthReport.deduct(
totalApIssues * SCORING.apAlerting,
`${apAlerting} access point(s) alerting (check cabling/power)`
);
} else {
healthReport.deduct(
totalApIssues * SCORING.apOffline,
`${totalApIssues} access point(s) offline`
);
}
}
} catch (e) {
logger.error('Meraki availability error', { error: e.message });
}
} else {
healthReport.addSection('**🌐 Network Infrastructure**', `- Meraki Network: ❌ Not Found\n\n`);
healthReport.deduct(SCORING.noMerakiNetwork, 'No Meraki network found');
}
// === Overall Status ===
overallScore = Math.max(0, Math.round(overallScore));
// === Core Systems ===
healthReport.addSection('**🖥️ POS Systems**', '');
let statusEmoji = overallScore >= 90 ? '🟢' : overallScore >= 70 ? '🟡' : '🔴';
let statusText = overallScore >= 90 ? 'Good' : overallScore >= 70 ? 'Fair' : 'Needs Attention';
if (mdmDevices && mdmDevices.length > 0) {
const servers = filterMdmByType(mdmDevices, MDM_DEVICE_TYPES.SERVER);
const mobileRegs = filterMdmByType(mdmDevices, MDM_DEVICE_TYPES.MOBILE_REGISTER);
const custDisplays = filterMdmByType(mdmDevices, MDM_DEVICE_TYPES.CUSTOMER_DISPLAY);
const iphones = filterMdmByType(mdmDevices, MDM_DEVICE_TYPES.IPHONE);
summary += `\n**Overall Status**: ${statusEmoji} ${statusText} (${overallScore}% healthy)\n\n`;
if (issues.length > 0) {
summary += `**⚠️ Issues Detected**\n`;
issues.forEach(issue => summary += `- ${issue}\n`);
} else {
summary += `**✅ No major issues detected**\n`;
// Server (heavy penalty)
let serverStatus = '❓ Unknown';
if (servers.length > 0) {
const srv = servers[0];
const match = findMatchingClient(merakiClients, {
UserName: srv.UserName,
DeviceFriendlyName: srv.DeviceFriendlyName,
});
serverStatus = getClientStatus(match);
if (serverStatus === '❌ Offline') {
healthReport.deduct(
SCORING.serverOffline,
`Store Server ${srv?.UserName || srv?.DeviceFriendlyName} is offline`
);
}
}
healthReport.addSection('', `- Store Server: ${serverStatus}\n`);
return { summary };
// Mobile Registers
let mobileOnline = 0;
mobileRegs.forEach(reg => {
const name = (reg.UserName || reg.DeviceFriendlyName || '').trim();
const match = findMatchingClient(merakiClients, {
UserName: reg.UserName,
DeviceFriendlyName: reg.DeviceFriendlyName,
});
const status = getClientStatus(match);
if (status === '✅ Online') mobileOnline++;
if (status === '❌ Offline') healthReport.addIssue(`Mobile Register ${name} is offline`);
});
healthReport.addSection(
'',
`- Mobile Registers: ${mobileOnline}/${mobileRegs.length} online\n`
);
// Customer Displays & iPhones (lighter penalty)
let cdOnline = 0;
custDisplays.forEach(cd => {
const name = (cd.UserName || cd.DeviceFriendlyName || '').trim();
const match = findMatchingClient(merakiClients, {
UserName: cd.UserName,
DeviceFriendlyName: cd.DeviceFriendlyName,
});
const status = getClientStatus(match);
if (status === '✅ Online') cdOnline++;
if (status === '❌ Offline') healthReport.addIssue(`Customer Display ${name} is offline`);
});
healthReport.addSection('', `- Customer Displays: ${cdOnline}/${custDisplays.length} online\n`);
let iphoneOnline = 0;
iphones.forEach(phone => {
const name = (phone.UserName || phone.DeviceFriendlyName || '').trim();
const match = findMatchingClient(merakiClients, {
UserName: phone.UserName,
DeviceFriendlyName: phone.DeviceFriendlyName,
});
const status = getClientStatus(match);
if (status === '✅ Online') iphoneOnline++;
if (status === '❌ Offline') healthReport.addIssue(`iPhone ${name} is offline`);
});
healthReport.addSection('', `- Store iPhones: ${iphoneOnline}/${iphones.length} online\n\n`);
} else {
healthReport.addSection('', `- No MDM data available\n\n`);
healthReport.deduct(SCORING.noMdmData);
}
// === POS & Peripherals (with per-device penalty) ===
healthReport.addSection('**💳 POS Peripherals**', '');
// Payment Terminals
if (paymentTerminals && paymentTerminals.length > 0) {
let onlinePayments = 0;
paymentTerminals.forEach(term => {
const match = findMatchingClient(merakiClients, {
name: term.device_name,
adyenName: term.adyen_device_name,
ip_address: term.ip_address,
});
const terminalName = (term.device_name || term.adyen_device_name || 'unknown').trim();
const status = getClientStatus(match);
if (status === '✅ Online') onlinePayments++;
if (status === '❌ Offline') {
healthReport.deduct(
SCORING.paymentTerminalOffline,
`Payment Terminal ${terminalName} is offline`
);
}
});
healthReport.addSection(
'',
`- Payment Terminals: ${onlinePayments}/${paymentTerminals.length} online\n`
);
}
// Printers
const filteredPrinters = (printers || []).filter(
p => (p.connection_type_name || '').toLowerCase() !== 'usb'
);
if (filteredPrinters.length > 0) {
let onlinePrinters = 0;
filteredPrinters.forEach(printer => {
const name = (printer.printer_name || '').trim().toLowerCase();
const match = findMatchingClient(merakiClients, { name: printer.printer_name });
const status = getClientStatus(match);
if (status === '✅ Online') onlinePrinters++;
if (status === '❌ Offline') {
healthReport.deduct(SCORING.printerOffline, `Printer ${name} is offline`);
}
});
healthReport.addSection(
'',
`- Printers: ${onlinePrinters}/${filteredPrinters.length} online\n`
);
}
// Finalize and return
const report = healthReport.finalize();
return { summary: report.summary };
}
module.exports = { getStoreHealth };

View file

@ -1 +0,0 @@
# Reserved for domain models / DTOs

73
models/HealthReport.js Normal file
View file

@ -0,0 +1,73 @@
/**
* Domain model for Store Health Report.
* Encapsulates score, issues, and summary generation.
*/
class HealthReport {
constructor(storeNumber, storeName = 'Unknown Store') {
this.storeNumber = storeNumber;
this.storeName = storeName;
this.overallScore = 100;
this.issues = [];
this.sections = [];
}
deduct(points, reason) {
this.overallScore = Math.max(0, this.overallScore - points);
if (reason) {
this.issues.push(reason);
}
}
addSection(title, content) {
this.sections.push({ title, content });
}
addIssue(issue) {
if (issue && !this.issues.includes(issue)) {
this.issues.push(issue);
}
}
finalize() {
this.overallScore = Math.round(this.overallScore);
const statusEmoji = this.overallScore >= 90 ? '🟢' : this.overallScore >= 70 ? '🟡' : '🔴';
const statusText =
this.overallScore >= 90 ? 'Good' : this.overallScore >= 70 ? 'Fair' : 'Needs Attention';
let summary = `**📊 Store ${this.storeNumber} Health Summary - ${this.storeName}**\n\n`;
this.sections.forEach(section => {
summary += section.content + '\n';
});
summary += `\n**Overall Status**: ${statusEmoji} ${statusText} (${this.overallScore}% healthy)\n\n`;
if (this.issues.length > 0) {
summary += `**⚠️ Issues Detected**\n`;
this.issues.forEach(issue => (summary += `- ${issue}\n`));
} else {
summary += `**✅ No major issues detected**\n`;
}
this.summary = summary;
return this;
}
toJSON() {
return {
storeNumber: this.storeNumber,
storeName: this.storeName,
overallScore: this.overallScore,
issues: [...this.issues],
summary: this.summary,
};
}
}
function createHealthReport(storeNumber, storeName) {
return new HealthReport(storeNumber, storeName);
}
module.exports = { HealthReport, createHealthReport };

39
models/Store.js Normal file
View file

@ -0,0 +1,39 @@
/**
* Domain model for a Store.
* Normalizes location data coming from SIW.
*/
class Store {
constructor(data = {}, storeNumber) {
this.number = String(storeNumber || data.store_number || '').trim();
this.name = data.name || data.store_name || `Store ${this.number}`;
this.address = data.address || '';
this.address2 = data.address2 || '';
this.address3 = data.address3 || '';
this.city = data.city || '';
this.state = data.state || '';
this.postalCode = data.postal_code || '';
this.countryCode = data.country_code || 'US';
this.phone = data.phone || '';
this.districtId = data.district_id || null;
this.regionId = data.region_id || null;
}
get fullAddress() {
const parts = [this.address, this.address2, this.address3].filter(Boolean);
const cityState = `${this.city}, ${this.state} ${this.postalCode}`.trim();
return [...parts, cityState, this.phone ? `Phone: ${this.phone}` : '']
.filter(Boolean)
.join('\n');
}
toSummary() {
return `### Store ${this.number} - ${this.name}\n\n** Details**\n${this.fullAddress}\nDistrict ID: ${this.districtId || 'N/A'} Region ID: ${this.regionId || 'N/A'}`;
}
}
function createStore(data, storeNumber) {
return new Store(data, storeNumber);
}
module.exports = { Store, createStore };

6262
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,15 @@
"description": "NetAnalyzer - Webex bot for store network and device health analysis using Meraki, SIW, and MDM integrations",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
"agent": "node remoteAgent.js",
"dev": "nodemon server.js",
"dev:agent": "nodemon remoteAgent.js",
"lint": "eslint . --ext .js",
"lint:fix": "eslint . --ext .js --fix",
"format": "prettier --write \"**/*.{js,json,md}\"",
"format:check": "prettier --check \"**/*.{js,json,md}\"",
"test": "jest",
"test:watch": "jest --watch"
},
"keywords": [
"webex",
@ -29,6 +37,20 @@
"ws": "^8.20.1"
},
"devDependencies": {
"nodemon": "^3.1.14"
"@eslint/js": "^10.0.1",
"eslint": "^10.5.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
"globals": "^17.7.0",
"jest": "^30.4.2",
"nodemon": "^3.1.14",
"prettier": "^3.8.4"
},
"jest": {
"testEnvironment": "node",
"testMatch": [
"<rootDir>/tests/**/*.test.js"
],
"verbose": true
}
}

View file

@ -3,29 +3,45 @@ const axios = require('axios');
require('dotenv').config();
const WS_URL = process.env.WS_URL;
const WS_TOKEN = process.env.WS_TOKEN;
if (!WS_URL) {
console.error('❌ WS_URL is not set in .env');
process.exit(1);
}
const INITIAL_BACKOFF_MS = 2000;
const MAX_BACKOFF_MS = 60000;
const PROXY_TIMEOUT_MS = 30000;
let ws = null;
let reconnectAttempts = 0;
const INITIAL_BACKOFF = 2000; // 2 seconds
let shuttingDown = false;
/**
* If WS_TOKEN is provided, send it as an Authorization: Bearer header so the
* secret stays out of access logs. (The server still accepts the legacy
* ?token=... query parameter for backward compatibility.)
*/
function buildClientOptions() {
if (!WS_TOKEN) return undefined;
return { headers: { Authorization: `Bearer ${WS_TOKEN}` } };
}
function connect() {
console.log(`🔄 Connecting to ${WS_URL}...`);
ws = new WebSocket(WS_URL);
ws = new WebSocket(WS_URL, buildClientOptions());
ws.on('open', () => {
console.log('✅ Remote Agent connected to NetAnalyzer');
reconnectAttempts = 0;
});
ws.on('message', async (data) => {
ws.on('message', async data => {
let request;
try {
const request = JSON.parse(data);
request = JSON.parse(data);
if (request.action !== 'proxyRequest') return;
console.log(`🔄 Proxying ${request.method || 'GET'} ${request.url}`);
@ -36,33 +52,36 @@ function connect() {
headers: request.headers || {},
auth: request.auth || undefined,
data: request.body || undefined,
timeout: 30000,
timeout: PROXY_TIMEOUT_MS,
});
ws.send(JSON.stringify({
requestId: request.requestId,
status: response.status,
data: response.data,
headers: response.headers
}));
ws.send(
JSON.stringify({
requestId: request.requestId,
status: response.status,
data: response.data,
headers: response.headers,
})
);
} catch (err) {
console.error('Proxy error:', err.message);
ws.send(JSON.stringify({
requestId: request.requestId,
error: err.message,
status: err.response?.status || 500,
data: err.response?.data || null
}));
ws.send(
JSON.stringify({
requestId: request ? request.requestId : null,
error: err.message,
status: err.response?.status || 500,
data: err.response?.data || null,
})
);
}
});
ws.on('close', (code, reason) => {
console.log(`❌ Disconnected (code: ${code}). Reconnecting...`);
scheduleReconnect();
ws.on('close', code => {
console.log(`❌ Disconnected (code: ${code}).`);
if (!shuttingDown) scheduleReconnect();
});
ws.on('error', (err) => {
ws.on('error', err => {
console.error('WebSocket error:', err.message);
});
}
@ -70,22 +89,32 @@ function connect() {
function scheduleReconnect() {
reconnectAttempts++;
// Exponential backoff, capped at 60 seconds
const backoff = Math.min(INITIAL_BACKOFF * Math.pow(1.5, reconnectAttempts - 1), 60000);
const backoff = Math.min(
INITIAL_BACKOFF_MS * Math.pow(1.5, reconnectAttempts - 1),
MAX_BACKOFF_MS
);
console.log(`⏳ Reconnecting in ${Math.round(backoff/1000)}s... (attempt ${reconnectAttempts})`);
console.log(
`⏳ Reconnecting in ${Math.round(backoff / 1000)}s... (attempt ${reconnectAttempts})`
);
setTimeout(() => {
connect();
}, backoff);
setTimeout(connect, backoff);
}
// Start initial connection
connect();
// Graceful shutdown
process.on('SIGINT', () => {
console.log('🛑 Shutting down remote agent...');
if (ws) ws.close();
function shutdownRemote(signal) {
console.log(`🛑 ${signal} received. Shutting down remote agent...`);
shuttingDown = true;
if (ws) {
try {
ws.close();
} catch (_e) {
// ignore close errors during shutdown
}
}
process.exit(0);
});
}
process.on('SIGINT', () => shutdownRemote('SIGINT'));
process.on('SIGTERM', () => shutdownRemote('SIGTERM'));

123
server.js
View file

@ -1,51 +1,108 @@
const Framework = require('webex-node-bot-framework');
const config = require('./config');
const { startWebSocketServer } = require('./services/websocket');
const { handleStoreCommand, handleAnalyzeCommand } = require('./bot/handlers');
const { startWebSocketServer, stopWebSocketServer } = require('./services/websocket');
const { handleStoreCommand, handleAnalyzeCommand, handleHelpCommand } = require('./bot/handlers');
const logger = require('./utils/logger');
const framework = new Framework({
token: config.webex.token,
removeWebhooksOnStart: true,
logLevel: 'debug'
logLevel: config.logLevel,
});
console.log('🚀 Starting NetAnalyzer Framework...');
logger.info('Starting NetAnalyzer Framework', { botName: config.webex.name });
// Register command handlers
framework.hears(/store/i, handleStoreCommand, '**store <number>** - Analyze a store');
framework.hears(/analyze/i, handleAnalyzeCommand, '**analyze <number>** - High-level store health check');
// Register command handlers (anchored at the start of the message so
// "please analyze store 305" doesn't fire both store and analyze handlers).
framework.hears(
/^\s*help\b/i,
handleHelpCommand,
'Show available commands (try: help, help store, help analyze)'
);
framework.hears(
/^\s*store\b/i,
handleStoreCommand,
'store <number> — info + network + server\nstore <number> pos — POS devices\nstore <number> ios — iOS devices'
);
framework.hears(
/^\s*analyze\b/i,
handleAnalyzeCommand,
'analyze <number> — health summary\nanalyze <number> pos — POS health (broken only)\nanalyze <number> ios — iOS health (broken only)'
);
// Catch-all handler for debugging (lowest priority)
framework.hears(/.*/, (bot, trigger) => {
// Only log and respond if this pattern matched
if (trigger.match && trigger.match[0].length > 0) {
const text = trigger.message.text.toLowerCase();
// Friendly fallback for anything that didn't match the commands above.
framework.hears(
/.*/,
(bot, trigger) => {
logger.info('Unhandled message', { text: trigger.message.text });
bot.say(
'I heard: ' +
trigger.message.text +
'\n\nTry `store <number>`, `store <number> pos`, `analyze <number>`, or `help` for commands.'
);
},
99999
);
// Skip if message contains known commands already handled
if (!text.includes('store') && !text.includes('analyze')) {
console.log('📨 Received message:', trigger.message.text);
bot.say('I heard: ' + trigger.message.text + '\nTry: `store <number>` or `analyze <number>` for store analysis');
}
}
}, 99999); // lowest priority
// Framework events
framework.on('initialized', () => {
console.log('✅ Framework initialized and connected via WebSocket!');
logger.info('Framework initialized and connected via WebSocket');
});
framework.on('spawn', (bot, id, addedBy) => {
console.log(`🟢 Bot spawned in room: ${bot.room.title || 'Unknown'}`);
framework.on('spawn', (bot, _id, addedBy) => {
logger.info('Bot spawned in room', { room: bot.room.title || 'Unknown' });
if (addedBy) {
bot.say('NetAnalyzer is ready! Try `store <number>` or `analyze <number>`');
}
bot.say(
'NetAnalyzer is ready!\n\nTry `store 782`, `store 782 pos`, `analyze 782`, or type `help` for all commands.'
);
}
});
// Start framework and WebSocket server sequentially
framework.start().then(() => {
console.log('WebSocket server for remote agent starting...');
startWebSocketServer();
}).catch(err => {
console.error('❌ Error starting framework:', err);
process.exit(1);
framework
.start()
.then(() => {
logger.info('WebSocket server for remote agent starting');
startWebSocketServer();
})
.catch(err => {
logger.error('Error starting framework', { error: err.message });
process.exit(1);
});
// ==================== Graceful Shutdown ====================
let isShuttingDown = false;
async function shutdown(signal) {
if (isShuttingDown) return;
isShuttingDown = true;
logger.info('Shutting down gracefully', { signal });
try {
stopWebSocketServer();
// webex-node-bot-framework may not expose a clean stop; do what we can.
if (framework && typeof framework.stop === 'function') {
await framework.stop().catch(() => {});
}
logger.info('Cleanup complete. Exiting.');
} catch (err) {
logger.error('Error during shutdown', { error: err.message });
} finally {
process.exit(0);
}
}
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('uncaughtException', err => {
logger.error('Uncaught Exception', { error: err.message, stack: err.stack });
shutdown('uncaughtException');
});
process.on('unhandledRejection', reason => {
logger.error('Unhandled Rejection', {
reason: reason instanceof Error ? reason.message : String(reason),
});
});

View file

@ -1,51 +1,60 @@
const axios = require('axios');
const config = require('../config');
const { withRetry } = require('../utils/retry');
const logger = require('../utils/logger');
const RETRY_OPTS = { retries: 2, initialDelayMs: 300 };
async function getMDMToken() {
console.log('🔑 Requesting Workspace ONE token...');
try {
const response = await axios.post(
config.mdm.tokenUrl,
new URLSearchParams({
grant_type: 'client_credentials',
client_id: config.mdm.clientId,
client_secret: config.mdm.clientSecret,
}),
{
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
}
);
logger.debug('Requesting Workspace ONE token');
console.log('✅ MDM Token acquired');
return response.data.access_token;
} catch (err) {
console.error('❌ MDM Token failed:', err.message);
throw err;
}
const response = await withRetry(
() =>
axios.post(
config.mdm.tokenUrl,
new URLSearchParams({
grant_type: 'client_credentials',
client_id: config.mdm.clientId,
client_secret: config.mdm.clientSecret,
}),
{
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
timeout: 15000,
}
),
RETRY_OPTS
);
logger.info('MDM token acquired');
return response.data.access_token;
}
async function getMDMDevices(storeNumber) {
const storePadded = String(storeNumber).padStart(6, '0');
console.log(`🔍 Fetching MDM devices for store ${storePadded}`);
logger.info('Fetching MDM devices', { storePadded });
try {
const token = await getMDMToken();
const response = await axios.get(`${config.mdm.baseUrl}/api/mdm/devices/search`, {
params: { user: storePadded },
headers: {
Authorization: `Bearer ${token}`,
'aw-tenant-code': config.mdm.tenantCode,
Accept: 'application/json'
}
});
const response = await withRetry(
() =>
axios.get(`${config.mdm.baseUrl}/api/mdm/devices/search`, {
params: { user: storePadded },
headers: {
Authorization: `Bearer ${token}`,
'aw-tenant-code': config.mdm.tenantCode,
Accept: 'application/json',
},
timeout: 20000,
}),
RETRY_OPTS
);
const devices = response.data.Devices || [];
console.log(`✅ Fetched ${devices.length} MDM devices [getMDMDevices]`);
logger.info('Fetched MDM devices', { count: devices.length });
return devices;
} catch (err) {
console.error(`❌ MDM devices fetch failed:`, err.message);
logger.error('MDM devices fetch failed', { error: err.message });
return [];
}
}

View file

@ -1,55 +1,71 @@
const axios = require('axios');
const config = require('../config');
const { parseStoreNumber } = require('../utils/validate');
const { withRetry } = require('../utils/retry');
const logger = require('../utils/logger');
const merakiAxios = axios.create({
baseURL: config.meraki.baseUrl,
headers: {
'X-Cisco-Meraki-API-Key': config.meraki.apiKey,
'Content-Type': 'application/json'
}
'Content-Type': 'application/json',
},
timeout: 20000,
});
// In-memory cache
let cachedNetworks = [];
let lastCacheTime = 0;
let inFlightRefresh = null;
const CACHE_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
const RETRY_OPTS = { retries: 2, initialDelayMs: 500 };
async function refreshMerakiNetworksCache() {
// Coalesce concurrent callers onto a single in-flight refresh.
if (inFlightRefresh) return inFlightRefresh;
const start = Date.now();
console.log('🔄 Refreshing Meraki networks cache...');
logger.info('Refreshing Meraki networks cache');
try {
const url = `/organizations/${config.meraki.orgId}/networks?perPage=5000`;
const res = await merakiAxios.get(url);
cachedNetworks = res.data || [];
lastCacheTime = Date.now();
inFlightRefresh = (async () => {
try {
const url = `/organizations/${config.meraki.orgId}/networks?perPage=5000`;
const res = await withRetry(() => merakiAxios.get(url), RETRY_OPTS);
cachedNetworks = res.data || [];
lastCacheTime = Date.now();
logger.info('Meraki networks cache refreshed', {
count: cachedNetworks.length,
durationMs: Date.now() - start,
});
} catch (err) {
logger.error('Failed to refresh Meraki networks cache', { error: err.message });
// Keep the (possibly stale) cache and let callers decide what to do.
} finally {
inFlightRefresh = null;
}
})();
console.log(`✅ Cached ${cachedNetworks.length} Meraki networks (${Date.now() - start}ms)`);
} catch (err) {
console.error('❌ Failed to refresh Meraki networks cache:', err.message);
}
return inFlightRefresh;
}
async function getMerakiNetworks(forceRefresh = false) {
const now = Date.now();
if (forceRefresh || cachedNetworks.length === 0 || (now - lastCacheTime > CACHE_TTL_MS)) {
if (forceRefresh || cachedNetworks.length === 0 || now - lastCacheTime > CACHE_TTL_MS) {
await refreshMerakiNetworksCache();
}
return cachedNetworks;
}
async function findMerakiNetwork(storeNum) {
if (!storeNum) return null;
const normalized = parseStoreNumber(storeNum);
if (!normalized) return null;
const raw = String(storeNum).trim();
const match = raw.match(/\d+/);
if (!match) return null;
let searchTerm = match[0].padStart(5, '0').slice(-5);
// Many store networks in Meraki are named with a 5-digit padded store number.
const searchTerm = normalized.padStart(5, '0').slice(-5);
const networks = await getMerakiNetworks();
console.log(`🔍 Searching for store "${raw}" → 5-digit term "${searchTerm}"`);
logger.debug('Searching Meraki networks', { storeNum: normalized, searchTerm });
let bestMatch = null;
let bestScore = -1;
@ -58,59 +74,41 @@ async function findMerakiNetwork(storeNum) {
const name = (net.name || '').toLowerCase();
const term = searchTerm.toLowerCase();
if (name.includes(term)) {
const score = (name.includes(` ${term}`) || name.includes(`-${term}`) || name.includes(term + ' ')) ? 100 : 50;
if (score > bestScore) {
bestScore = score;
bestMatch = net;
}
if (!name.includes(term)) continue;
const score =
name.includes(` ${term}`) || name.includes(`-${term}`) || name.includes(`${term} `)
? 100
: 50;
if (score > bestScore) {
bestScore = score;
bestMatch = net;
}
}
if (bestMatch) {
console.log(`✅ Best Meraki match: ${bestMatch.name} (ID: ${bestMatch.id})`);
return bestMatch;
}
console.log(`❌ No Meraki network found for store ${storeNum}`);
return null;
}
async function getNetworkClients(networkId) {
if (!networkId) return [];
try {
const res = await merakiAxios.get(`/networks/${networkId}/clients?timespan=7200`); // last 2 hours
return res.data || [];
} catch (err) {
console.error(`❌ Failed to get clients for network ${networkId}:`, err.message);
return [];
}
}
async function getAllMerakiDevices(networkId) {
if (!networkId) return [];
try {
const res = await merakiAxios.get(`/networks/${networkId}/devices`);
return res.data || [];
} catch (err) {
console.error(`❌ Failed to get devices for network ${networkId}:`, err.message);
return [];
logger.info('Best Meraki network match', { name: bestMatch.name, id: bestMatch.id });
} else {
logger.info('No Meraki network match', { storeNum });
}
return bestMatch;
}
async function getMerakiDeviceAvailabilities(networkId) {
if (!networkId || !config.meraki.orgId) return [];
try {
const res = await merakiAxios.get(
`/organizations/${config.meraki.orgId}/devices/availabilities`,
{
params: { networkIds: [networkId] }
}
const res = await withRetry(
() =>
merakiAxios.get(`/organizations/${config.meraki.orgId}/devices/availabilities`, {
params: { networkIds: [networkId] },
}),
RETRY_OPTS
);
return res.data || [];
} catch (err) {
console.error(`❌ Device availabilities failed:`, err.message);
logger.error('Device availabilities failed', { error: err.message });
return [];
}
}
@ -119,28 +117,28 @@ async function getMerakiClients(networkId) {
if (!networkId) return [];
try {
// 7 days = much better chance of catching registers & printers
const timespanSeconds = 7 * 24 * 60 * 60; // 604800
const res = await merakiAxios.get(
`/networks/${networkId}/clients?perPage=1000&timespan=${timespanSeconds}`
// 7-day window catches registers and printers that only check in occasionally.
const timespanSeconds = 7 * 24 * 60 * 60;
const res = await withRetry(
() =>
merakiAxios.get(`/networks/${networkId}/clients`, {
params: { perPage: 1000, timespan: timespanSeconds },
}),
RETRY_OPTS
);
const clients = res.data || [];
console.log(`✅ Fetched ${clients.length} Meraki clients (7-day timespan) [getMerakiClients]`);
logger.info('Fetched Meraki clients', { count: clients.length, networkId });
return clients;
} catch (err) {
console.error(`❌ Failed to fetch Meraki clients:`, err.message);
logger.error('Failed to fetch Meraki clients', { error: err.message, networkId });
return [];
}
}
module.exports = {
findMerakiNetwork,
getNetworkClients,
getMerakiClients,
getAllMerakiDevices,
getMerakiDeviceAvailabilities,
refreshMerakiNetworksCache
refreshMerakiNetworksCache,
};

View file

@ -1,82 +1,56 @@
const config = require('../config');
const { proxyRequest } = require('./websocket');
const { parseStoreNumber } = require('../utils/validate');
async function getStoreLocation(storeNumber) {
const authString = Buffer.from(
`${config.siw.username}:${config.siw.password}`
).toString('base64');
const requestConfig = {
method: 'GET',
url: `${config.siw.baseUrl}/StoreLocation/${storeNumber}`,
headers: {
'Authorization': `Basic ${authString}`
}
};
const result = await proxyRequest(requestConfig);
return result.data;
function getSiwAuthHeaders() {
const { username, password } = config.siw;
if (!username || !password) {
throw new Error('SIW credentials are not configured (SIW_USERNAME / SIW_PASSWORD)');
}
const authString = Buffer.from(`${username}:${password}`).toString('base64');
return { Authorization: `Basic ${authString}` };
}
async function getStoreNetwork(storeNumber) {
const authString = Buffer.from(
`${config.siw.username}:${config.siw.password}`
).toString('base64');
const requestConfig = {
function buildSiwRequest(path) {
if (!config.siw.baseUrl) {
throw new Error('SIW_BASE_URL is not configured');
}
return {
method: 'GET',
url: `${config.siw.baseUrl}/StoreNetwork/${storeNumber}`,
headers: {
'Authorization': `Basic ${authString}`
}
url: `${config.siw.baseUrl}${path}`,
headers: getSiwAuthHeaders(),
};
}
const result = await proxyRequest(requestConfig);
async function getStoreLocation(storeNumber) {
const normalized = parseStoreNumber(storeNumber);
if (!normalized) throw new Error('Invalid store number');
const result = await proxyRequest(buildSiwRequest(`/StoreLocation/${normalized}`));
return result.data;
}
async function getStoreRegisters(storeNumber) {
const authString = Buffer.from(
`${config.siw.username}:${config.siw.password}`
).toString('base64');
const normalized = parseStoreNumber(storeNumber);
if (!normalized) throw new Error('Invalid store number');
const requestConfig = {
method: 'GET',
url: `${config.siw.baseUrl}/StoreRegister/${storeNumber}`,
headers: { 'Authorization': `Basic ${authString}` }
};
const result = await proxyRequest(requestConfig);
const result = await proxyRequest(buildSiwRequest(`/StoreRegister/${normalized}`));
return result.data || [];
}
async function getStorePrinters(storeNumber) {
const authString = Buffer.from(
`${config.siw.username}:${config.siw.password}`
).toString('base64');
const normalized = parseStoreNumber(storeNumber);
if (!normalized) throw new Error('Invalid store number');
const requestConfig = {
method: 'GET',
url: `${config.siw.baseUrl}/StorePrinter/${storeNumber}`,
headers: { 'Authorization': `Basic ${authString}` }
};
const result = await proxyRequest(requestConfig);
const result = await proxyRequest(buildSiwRequest(`/StorePrinter/${normalized}`));
return result.data || [];
}
async function getStorePaymentTerminals(storeNumber) {
const authString = Buffer.from(
`${config.siw.username}:${config.siw.password}`
).toString('base64');
const normalized = parseStoreNumber(storeNumber);
if (!normalized) throw new Error('Invalid store number');
const requestConfig = {
method: 'GET',
url: `${config.siw.baseUrl}/StorePayment/${storeNumber}`,
headers: { 'Authorization': `Basic ${authString}` }
};
const result = await proxyRequest(requestConfig);
const result = await proxyRequest(buildSiwRequest(`/StorePayment/${normalized}`));
return result.data || [];
}
@ -84,5 +58,5 @@ module.exports = {
getStoreLocation,
getStoreRegisters,
getStorePrinters,
getStorePaymentTerminals // ← new
getStorePaymentTerminals,
};

View file

@ -1,103 +1,198 @@
const WebSocket = require('ws');
const config = require('../config');
const logger = require('../utils/logger');
const PROXY_REQUEST_TIMEOUT_MS = 45000;
const PING_INTERVAL_MS = 30000;
const MAX_PENDING_REQUESTS = 200;
let wss;
let connectedAgent = null;
const pendingRequests = new Map(); // requestId → { resolve, reject, timeout }
let pingInterval = null;
const pendingRequests = new Map(); // requestId → { resolve, reject, timeout }
/**
* Pull a bearer token from the standard `Authorization` header first
* (preferred keeps the token out of access logs) and fall back to the
* legacy `?token=` query parameter for backward compatibility.
*/
function extractToken(req) {
const auth = req.headers['authorization'];
if (auth && /^Bearer\s+/i.test(auth)) {
return auth.replace(/^Bearer\s+/i, '').trim();
}
try {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
return url.searchParams.get('token');
} catch (_e) {
return null;
}
}
function rejectPending(reason) {
for (const { reject, timeout } of pendingRequests.values()) {
clearTimeout(timeout);
reject(new Error(reason));
}
pendingRequests.clear();
}
function handleAgentMessage(data) {
let response;
try {
response = JSON.parse(data);
} catch (e) {
logger.error('Failed to parse agent message', { error: e.message });
return;
}
const { requestId } = response;
if (!requestId || !pendingRequests.has(requestId)) {
logger.warn('Received response with unknown requestId', { requestId });
return;
}
const { resolve, reject, timeout } = pendingRequests.get(requestId);
clearTimeout(timeout);
pendingRequests.delete(requestId);
if (response.error) {
const err = new Error(response.error);
err.status = response.status;
reject(err);
} else {
resolve(response);
}
}
function startWebSocketServer() {
wss = new WebSocket.Server({ port: config.ws.port });
wss = new WebSocket.Server({ port: config.ws.port });
wss.on('connection', (ws, req) => {
// Better URL parsing and logging
let token = null;
try {
const url = new URL(req.url, `http://${req.headers.host}/netanalyze`);
token = url.searchParams.get('token');
console.log(`🔑 Incoming connection from ${req.socket.remoteAddress} | Path: ${req.url} | Token present: ${!!token}`);
} catch (e) {
console.log('❌ Failed to parse connection URL');
}
wss.on('error', err => {
logger.error('WebSocket server error', { error: err.message });
});
if (!token || token !== config.ws.token) {
console.log(`❌ Token mismatch! Received: "${token}" | Expected: "${config.ws.token ? config.ws.token.substring(0, 8) + '...' : 'MISSING'}"`);
ws.close(1008, 'Invalid or missing token');
return;
}
wss.on('connection', (ws, req) => {
const token = extractToken(req);
const expected = config.ws.token;
const remote = req.socket.remoteAddress;
console.log('✅ Authorized Remote Agent connected');
connectedAgent = ws;
if (!expected) {
logger.error('WS_TOKEN not configured; refusing connection', { remote });
ws.close(1011, 'Server misconfigured');
return;
}
ws.on('message', (data) => {
try {
const response = JSON.parse(data);
const requestId = response.requestId;
if (!token || token !== expected) {
logger.warn('Rejected agent connection: token mismatch', {
remote,
tokenPresent: !!token,
});
ws.close(1008, 'Invalid or missing token');
return;
}
if (requestId && pendingRequests.has(requestId)) {
const { resolve, reject, timeout } = pendingRequests.get(requestId);
clearTimeout(timeout);
pendingRequests.delete(requestId);
if (connectedAgent && connectedAgent.readyState === WebSocket.OPEN) {
logger.warn('Replacing previously connected remote agent', { remote });
try {
connectedAgent.close(1013, 'Replaced by newer agent');
} catch (_e) {
// ignore
}
rejectPending('Remote agent replaced; in-flight requests dropped');
}
if (response.error) {
const err = new Error(response.error);
err.status = response.status;
reject(err);
} else {
resolve(response);
}
} else {
console.log('⚠️ Received response with unknown requestId:', requestId);
}
} catch (e) {
console.error('Failed to parse agent message:', e);
}
});
logger.info('Remote agent connected', { remote });
connectedAgent = ws;
ws.isAlive = true;
ws.on('close', (code, reason) => {
console.log(`❌ Remote Agent disconnected | Code: ${code} | Reason: ${reason || 'none'}`);
connectedAgent = null;
});
// Add ping/pong to keep connection alive
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
ws.on('message', handleAgentMessage);
ws.on('pong', () => {
ws.isAlive = true;
});
ws.on('error', err => {
logger.error('Remote agent socket error', { error: err.message });
});
ws.on('close', (code, reason) => {
logger.info('Remote agent disconnected', {
code,
reason: reason?.toString() || 'none',
});
if (connectedAgent === ws) {
connectedAgent = null;
rejectPending('Remote agent disconnected');
}
});
});
// Keep connections alive
const interval = setInterval(() => {
wss.clients.forEach((ws) => {
if (ws.isAlive === false) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000); // every 30 seconds
pingInterval = setInterval(() => {
wss.clients.forEach(ws => {
if (ws.isAlive === false) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, PING_INTERVAL_MS);
console.log(`WebSocket server running on ws://0.0.0.0:${config.ws.port}`);
logger.info('WebSocket server listening', { port: config.ws.port });
}
function stopWebSocketServer() {
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
if (connectedAgent) {
try {
connectedAgent.close();
} catch (_e) {
// ignore errors during shutdown
}
connectedAgent = null;
}
if (wss) {
logger.info('Shutting down WebSocket server');
wss.close(() => logger.info('WebSocket server closed'));
wss = null;
}
rejectPending('Server shutting down');
}
async function proxyRequest(requestConfig) {
return new Promise((resolve, reject) => {
if (!connectedAgent || connectedAgent.readyState !== WebSocket.OPEN) {
return reject(new Error('No remote agent connected'));
}
return new Promise((resolve, reject) => {
if (!connectedAgent || connectedAgent.readyState !== WebSocket.OPEN) {
return reject(new Error('No remote agent connected'));
}
const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2)}`;
if (pendingRequests.size >= MAX_PENDING_REQUESTS) {
return reject(new Error('Too many in-flight proxy requests'));
}
const timeout = setTimeout(() => {
pendingRequests.delete(requestId);
reject(new Error(`Proxy request timeout after 45s`));
}, 45000); // Increased timeout
const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2)}`;
pendingRequests.set(requestId, { resolve, reject, timeout });
const timeout = setTimeout(() => {
pendingRequests.delete(requestId);
reject(new Error(`Proxy request timeout after ${PROXY_REQUEST_TIMEOUT_MS / 1000}s`));
}, PROXY_REQUEST_TIMEOUT_MS);
const payload = JSON.stringify({
action: 'proxyRequest',
requestId,
...requestConfig
});
pendingRequests.set(requestId, { resolve, reject, timeout });
connectedAgent.send(payload);
const payload = JSON.stringify({
action: 'proxyRequest',
requestId,
...requestConfig,
});
connectedAgent.send(payload, err => {
if (err) {
clearTimeout(timeout);
pendingRequests.delete(requestId);
reject(err);
}
});
});
}
module.exports = { startWebSocketServer, proxyRequest };
module.exports = { startWebSocketServer, stopWebSocketServer, proxyRequest };

41
tests/handlers.test.js Normal file
View file

@ -0,0 +1,41 @@
const { parseStoreCommand } = require('../bot/handlers');
const { STORE_MODES } = require('../constants');
describe('parseStoreCommand', () => {
it('parses a default store command', () => {
expect(parseStoreCommand('store 305')).toEqual({
storeNumber: '305',
mode: STORE_MODES.DEFAULT,
});
});
it('detects pos mode', () => {
expect(parseStoreCommand('store 305 pos')).toEqual({
storeNumber: '305',
mode: STORE_MODES.POS,
});
});
it('detects ios mode (ios or iphone)', () => {
expect(parseStoreCommand('store 305 ios')).toEqual({
storeNumber: '305',
mode: STORE_MODES.IOS,
});
expect(parseStoreCommand('analyze 305 iphone')).toEqual({
storeNumber: '305',
mode: STORE_MODES.IOS,
});
});
it('returns null storeNumber for non-numeric input', () => {
expect(parseStoreCommand('store')).toEqual({ storeNumber: null, mode: null });
expect(parseStoreCommand('analyze something')).toEqual({ storeNumber: null, mode: null });
});
it('parses analyze commands the same way', () => {
expect(parseStoreCommand('analyze 782')).toEqual({
storeNumber: '782',
mode: STORE_MODES.DEFAULT,
});
});
});

View file

@ -0,0 +1,52 @@
const { createHealthReport } = require('../models/HealthReport');
describe('HealthReport', () => {
it('starts at a perfect score of 100', () => {
const r = createHealthReport('305', 'Test Store').finalize();
expect(r.overallScore).toBe(100);
expect(r.summary).toContain('🟢 Good');
expect(r.summary).toContain('No major issues');
});
it('deducts points and floors at zero', () => {
const r = createHealthReport('305');
r.deduct(60, 'major outage');
r.deduct(80, 'second outage');
r.finalize();
expect(r.overallScore).toBe(0);
expect(r.summary).toContain('🔴 Needs Attention');
});
it('chooses the right status emoji at each threshold', () => {
const fair = createHealthReport('1');
fair.deduct(15, 'minor');
fair.finalize();
expect(fair.overallScore).toBe(85);
expect(fair.summary).toContain('🟡 Fair');
const bad = createHealthReport('1');
bad.deduct(35, 'big issue');
bad.finalize();
expect(bad.summary).toContain('🔴 Needs Attention');
});
it('deduplicates identical issues added via addIssue', () => {
const r = createHealthReport('1');
r.addIssue('same problem');
r.addIssue('same problem');
r.addIssue('different problem');
r.finalize();
const occurrences = (r.summary.match(/same problem/g) || []).length;
expect(occurrences).toBe(1);
expect(r.summary).toContain('different problem');
});
it('toJSON returns a serializable snapshot', () => {
const r = createHealthReport('305', 'Test').finalize();
const j = r.toJSON();
expect(j.storeNumber).toBe('305');
expect(j.storeName).toBe('Test');
expect(j.overallScore).toBe(100);
expect(typeof j.summary).toBe('string');
});
});

View file

@ -0,0 +1,29 @@
/**
* Integration test for store health analysis using mocks.
*/
const { getStoreHealth } = require('../../integrations/storeHealth');
// Mock the services
jest.mock('../../services/meraki', () => require('../mocks/mockMeraki'));
jest.mock('../../services/mdm', () => require('../mocks/mockMdm'));
// SIW is required dynamically in the file, so we mock the whole module
jest.mock('../../services/siw', () => require('../mocks/mockSiw'));
describe('storeHealth integration (with mocks)', () => {
it('returns a health summary with reasonable score for a known store', async () => {
const result = await getStoreHealth('305');
expect(result).toHaveProperty('summary');
expect(result.summary).toContain('Store 305 Health Summary');
expect(result.summary).toContain('Overall Status');
});
it('handles missing Meraki network gracefully', async () => {
// Store 999 does not exist in mock
const result = await getStoreHealth('999');
expect(result.summary).toContain('No Meraki network found');
});
});

View file

@ -0,0 +1,87 @@
const {
findMatchingClient,
getClientStatus,
formatLastSeen,
buildMerakiClientLink,
} = require('../utils/merakiMatcher');
describe('merakiMatcher', () => {
const sampleClients = [
{ id: 'c1', description: 'Register 305', status: 'Online', lastSeen: '2025-01-01T10:00:00Z' },
{
id: 'c2',
description: 'Printer Front',
status: 'Offline',
lastSeen: Date.now() - 5 * 60 * 1000,
},
{ id: 'c3', description: '192.168.10.45 - Terminal', status: 'Online', lastSeen: null },
{ id: 'c4', description: 'SRV-042', status: 'Online' },
];
describe('findMatchingClient', () => {
it('matches by exact or substring on description', () => {
expect(findMatchingClient(sampleClients, { name: 'Register 305' })).toBe(sampleClients[0]);
expect(findMatchingClient(sampleClients, { name: 'printer' })).toBe(sampleClients[1]);
});
it('matches using multiple identifier fields', () => {
const match = findMatchingClient(sampleClients, {
deviceName: 'Terminal',
ip_address: '192.168.10.45',
});
expect(match).toBe(sampleClients[2]);
});
it('matches MDM style UserName / DeviceFriendlyName', () => {
const match = findMatchingClient(sampleClients, {
UserName: 'SRV-042',
DeviceFriendlyName: 'Server 042',
});
expect(match).toBe(sampleClients[3]);
});
it('returns null when no clients or no match', () => {
expect(findMatchingClient([], { name: 'foo' })).toBeNull();
expect(findMatchingClient(sampleClients, { name: 'nonexistent' })).toBeNull();
});
});
describe('getClientStatus', () => {
it('returns correct emojis and fallback', () => {
expect(getClientStatus({ status: 'Online' })).toBe('✅ Online');
expect(getClientStatus({ status: 'Offline' })).toBe('❌ Offline');
expect(getClientStatus(null)).toBe('❓ Unknown');
expect(getClientStatus(undefined)).toBe('❓ Unknown');
});
});
describe('formatLastSeen', () => {
it('handles missing value', () => {
expect(formatLastSeen(null)).toBe('N/A');
expect(formatLastSeen(undefined)).toBe('N/A');
});
it('formats recent times', () => {
const justNow = new Date();
expect(formatLastSeen(justNow)).toBe('Just now');
const tenMin = new Date(Date.now() - 10 * 60 * 1000);
expect(formatLastSeen(tenMin)).toMatch(/10 min ago/);
});
});
describe('buildMerakiClientLink', () => {
const network = { id: 'N_123', name: 'Store 305', url: 'https://example.com/n/ABC123' };
it('builds a client link when possible', () => {
const client = { id: 'c99' };
const link = buildMerakiClientLink(network, client);
expect(link).toContain('/manage/clients/c99/overview');
});
it('returns empty string on bad input', () => {
expect(buildMerakiClientLink(null, {})).toBe('');
expect(buildMerakiClientLink(network, null)).toBe('');
});
});
});

13
tests/mocks/mockMdm.js Normal file
View file

@ -0,0 +1,13 @@
/**
* Mock MDM (Workspace ONE) service for testing.
*/
function getMDMDevices(storeNumber) {
const padded = String(storeNumber).padStart(6, '0');
return Promise.resolve([
{ UserName: `SRV-${padded}`, DeviceFriendlyName: 'Store Server' },
{ UserName: `MR-${padded}`, DeviceFriendlyName: 'Mobile Register' },
]);
}
module.exports = { getMDMDevices };

42
tests/mocks/mockMeraki.js Normal file
View file

@ -0,0 +1,42 @@
/**
* Mock Meraki service for testing.
*/
const mockNetworks = [
{ id: 'N_001', name: 'Store 001', url: 'https://example.com/n/STORE001' },
{ id: 'N_305', name: 'Store 305 - Main', url: 'https://example.com/n/STORE305' },
];
function findMerakiNetwork(storeNum) {
const num = String(storeNum).padStart(3, '0');
return Promise.resolve(mockNetworks.find(n => n.name.includes(num)) || null);
}
function getMerakiClients(networkId) {
if (networkId === 'N_305') {
return Promise.resolve([
{ id: 'c1', description: 'Register 305', status: 'Online', lastSeen: new Date() },
{
id: 'c2',
description: 'Printer Front',
status: 'Offline',
lastSeen: Date.now() - 10 * 60 * 1000,
},
]);
}
return Promise.resolve([]);
}
function getMerakiDeviceAvailabilities(_networkId) {
return Promise.resolve([
{ serial: 'SW1', name: 'SWR-001', productType: 'switch', status: 'online' },
{ serial: 'AP1', name: 'AP-Front', productType: 'wireless', status: 'online' },
{ serial: 'AP2', name: 'AP-Back', productType: 'wireless', status: 'alerting' },
]);
}
module.exports = {
findMerakiNetwork,
getMerakiClients,
getMerakiDeviceAvailabilities,
};

44
tests/mocks/mockSiw.js Normal file
View file

@ -0,0 +1,44 @@
/**
* Mock SIW service for testing.
*/
function getStoreLocation(storeNumber) {
return Promise.resolve({
name: `Store ${storeNumber}`,
address: '123 Main St',
city: 'Anytown',
state: 'CA',
postal_code: '90210',
phone: '555-1234',
});
}
function getStoreRegisters(_storeNumber) {
return Promise.resolve([
{
register_number: '1',
register_display_name: 'Register 305',
brand_display_name: 'NCR',
register_type_name: 'POS',
},
]);
}
function getStorePrinters(_storeNumber) {
return Promise.resolve([
{ printer_name: 'Printer Front', printer_model_name: 'Epson', connection_type_name: 'network' },
]);
}
function getStorePaymentTerminals(_storeNumber) {
return Promise.resolve([
{ device_name: 'Terminal 1', adyen_device_name: 'Adyen-01', ip_address: '192.168.1.50' },
]);
}
module.exports = {
getStoreLocation,
getStoreRegisters,
getStorePrinters,
getStorePaymentTerminals,
};

41
tests/store.test.js Normal file
View file

@ -0,0 +1,41 @@
const { createStore, Store } = require('../models/Store');
describe('Store model', () => {
it('uses store_number from data when no explicit number given', () => {
const s = createStore({ store_number: '305', name: 'Foo' });
expect(s.number).toBe('305');
expect(s.name).toBe('Foo');
});
it('falls back to `Store <n>` when name is missing', () => {
const s = createStore({}, 42);
expect(s.name).toBe('Store 42');
});
it('handles missing address fields without throwing', () => {
const s = createStore({}, 1);
expect(() => s.toSummary()).not.toThrow();
const summary = s.toSummary();
expect(summary).toContain('Store 1');
expect(summary).toContain('District ID: N/A');
});
it('builds a full address from optional parts', () => {
const s = new Store(
{
address: '123 Main',
address2: 'Suite 5',
city: 'Anytown',
state: 'CA',
postal_code: '90210',
phone: '555-1234',
},
'305'
);
const addr = s.fullAddress;
expect(addr).toContain('123 Main');
expect(addr).toContain('Suite 5');
expect(addr).toContain('Anytown, CA 90210');
expect(addr).toContain('Phone: 555-1234');
});
});

28
tests/validate.test.js Normal file
View file

@ -0,0 +1,28 @@
const { parseStoreNumber } = require('../utils/validate');
describe('parseStoreNumber', () => {
it('extracts a simple store number', () => {
expect(parseStoreNumber('store 782')).toBe('782');
expect(parseStoreNumber('analyze 305')).toBe('305');
});
it('handles numbers with surrounding text', () => {
expect(parseStoreNumber('please analyze store 1234 now')).toBe('1234');
});
it('returns null for invalid input', () => {
expect(parseStoreNumber(null)).toBeNull();
expect(parseStoreNumber('')).toBeNull();
expect(parseStoreNumber('store abc')).toBeNull();
expect(parseStoreNumber('store 0')).toBeNull();
expect(parseStoreNumber('store -42')).toBeNull();
});
it('ignores numbers that are too long', () => {
expect(parseStoreNumber('store 12345678901')).toBeNull(); // 11 digits
});
it('normalizes by removing leading zeros in result (via parseInt)', () => {
expect(parseStoreNumber('store 00042')).toBe('42');
});
});

16
tests/websocket.test.js Normal file
View file

@ -0,0 +1,16 @@
// Avoid pulling in the real config (which would require env vars). The
// websocket module only reads config.ws.{port,token}, so a small stub is enough.
jest.mock('../config', () => ({
ws: { port: 0, token: 'test-token' },
logLevel: 'error',
}));
const { proxyRequest } = require('../services/websocket');
describe('proxyRequest', () => {
it('rejects when no remote agent is connected', async () => {
await expect(proxyRequest({ method: 'GET', url: 'http://x' })).rejects.toThrow(
'No remote agent connected'
);
});
});

View file

@ -1,30 +0,0 @@
function mergeDeviceData(siwDevices, merakiClients) {
return siwDevices.map(siw => {
// Primary match: name
let match = merakiClients.find(m =>
m.description && siw.name &&
m.description.toLowerCase().includes(siw.name.toLowerCase())
);
// Fallback: MAC address
if (!match && siw.mac) {
match = merakiClients.find(m =>
m.mac && m.mac.toLowerCase() === siw.mac.toLowerCase()
);
}
return {
name: siw.name || siw.deviceName || 'Unknown',
ip: siw.ip || siw.ipAddress || match?.ip || 'N/A',
status: match?.status === 'Online' ? '✅ Connected' : '❌ Disconnected',
lastSeen: match?.lastSeen
? new Date(match.lastSeen * 1000).toLocaleString()
: 'N/A',
deeplink: match?.mac
? `https://dashboard.meraki.com/o/${match.orgId || 'your-org'}/n/${match.networkId || 'your-net'}/clients?mac=${match.mac}`
: null
};
}).sort((a, b) => a.name.localeCompare(b.name));
}
module.exports = { mergeDeviceData };

View file

@ -1,15 +0,0 @@
function formatStoreReport(location, storeNumber) {
let msg = `### Store ${storeNumber} - ${location.name || 'Unknown'}\n\n`;
msg += `** Details**\n`;
msg += `${location.address || ''}\n`;
if (location.address2) msg += `${location.address2}\n`;
if (location.address3) msg += `${location.address3}\n`;
msg += `${location.city || ''}, ${location.state || ''} ${location.postal_code || ''} ${location.country_code || 'US'}\n`;
msg += `Phone: ${location.phone || 'N/A'}\n`;
msg += `District ID: ${location.district_id || 'N/A'} Region ID: ${location.region_id || 'N/A'}\n`;
return msg;
}
module.exports = { formatStoreReport };

45
utils/logger.js Normal file
View file

@ -0,0 +1,45 @@
/**
* Lightweight structured logger.
*
* Honors LOG_LEVEL (debug | info | warn | error). Defaults to 'info'.
* Output is single-line JSON for easy ingestion by log shippers.
*
* Note: requires config-free defaults so it can be imported anywhere without
* pulling in config/index.js (which validates env on load).
*/
const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 };
function configuredLevel() {
const raw = (process.env.LOG_LEVEL || 'info').toLowerCase();
return LEVELS[raw] ?? LEVELS.info;
}
function shouldEmit(level) {
return LEVELS[level] >= configuredLevel();
}
function log(level, message, meta = {}) {
if (!shouldEmit(level)) return;
const entry = {
ts: new Date().toISOString(),
level,
msg: message,
...meta,
};
const line = JSON.stringify(entry);
if (level === 'error') console.error(line);
else if (level === 'warn') console.warn(line);
else console.log(line);
}
const logger = {
debug: (msg, meta) => log('debug', msg, meta),
info: (msg, meta) => log('info', msg, meta),
warn: (msg, meta) => log('warn', msg, meta),
error: (msg, meta) => log('error', msg, meta),
};
module.exports = logger;

100
utils/merakiMatcher.js Normal file
View file

@ -0,0 +1,100 @@
/**
* Device matching and Meraki client utilities.
* Centralizes the fragile name/description matching logic used across reports.
*/
/**
* Find the best matching Meraki client for a device using multiple strategies.
*
* identifiers can contain: name, deviceName, adyenName, UserName, DeviceFriendlyName, ip
*/
function findMatchingClient(merakiClients = [], identifiers = {}) {
if (!merakiClients.length) return null;
const names = [
identifiers.name,
identifiers.deviceName,
identifiers.adyenName,
identifiers.UserName,
identifiers.DeviceFriendlyName,
identifiers.printer_name,
identifiers.register_display_name,
]
.filter(Boolean)
.map(n => String(n).toLowerCase().trim());
const ipPrefix = identifiers.ip_address
? String(identifiers.ip_address).split('.')[0]
: identifiers.ip
? String(identifiers.ip).split('.')[0]
: null;
for (const client of merakiClients) {
if (!client?.description) continue;
const desc = client.description.toLowerCase().trim();
// Strategy 1: exact or substring match on any name
if (names.some(n => n && (desc === n || desc.includes(n) || n.includes(desc)))) {
return client;
}
// Strategy 2: IP prefix fallback
if (ipPrefix && desc.includes(ipPrefix)) {
return client;
}
// Strategy 3: check client.user field
if (client.user) {
const user = client.user.toLowerCase();
if (names.some(n => n && user.includes(n))) {
return client;
}
}
}
return null;
}
function getClientStatus(client) {
if (!client) return '❓ Unknown';
return client.status === 'Online' ? '✅ Online' : '❌ Offline';
}
function formatLastSeen(lastSeen) {
if (!lastSeen) return 'N/A';
const now = new Date();
const seen = new Date(lastSeen);
const diffMs = now - seen;
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return 'Just now';
if (diffMin < 60) return `${diffMin} min ago`;
const hours = Math.floor(diffMin / 60);
return `${hours} hr ago`;
}
/**
* Build a best-effort link to the Meraki client detail page.
* Note: This logic is environment-specific (dashboard URLs).
*/
function buildMerakiClientLink(merakiNetwork, client) {
if (!client || !merakiNetwork?.url) return '';
try {
const networkCode = merakiNetwork.url.split('/n/')[1]?.split('/')[0] || merakiNetwork.id;
const base = merakiNetwork.url.split('/n/')[0] || 'https://n976.dashboard.meraki.com';
// Different sections for switches vs APs vs clients
// Default to clients view
return `${base}/${merakiNetwork.name.replace(/ /g, '-')}/n/${networkCode}/manage/clients/${client.id}/overview`;
} catch (_e) {
return '';
}
}
module.exports = {
findMatchingClient,
getClientStatus,
formatLastSeen,
buildMerakiClientLink,
};

38
utils/retry.js Normal file
View file

@ -0,0 +1,38 @@
/**
* Simple retry wrapper with exponential backoff.
* Usage: await withRetry(() => someAsyncCall(), { retries: 3 })
*/
async function withRetry(fn, options = {}) {
const {
retries = 3,
initialDelayMs = 500,
maxDelayMs = 5000,
factor = 2,
onRetry = null,
} = options;
let attempt = 0;
let delay = initialDelayMs;
while (true) {
try {
return await fn();
} catch (err) {
attempt++;
if (attempt > retries) {
throw err;
}
if (onRetry) {
onRetry(err, attempt);
}
await new Promise(resolve => setTimeout(resolve, delay));
delay = Math.min(delay * factor, maxDelayMs);
}
}
}
module.exports = { withRetry };

28
utils/validate.js Normal file
View file

@ -0,0 +1,28 @@
/**
* Validation helpers for NetAnalyzer
*/
/**
* Extract and validate a store number from user input.
* Looks for the first sequence of 1-10 digits.
* Returns a normalized string (no leading zeros issues) or null.
*
* @param {string} text
* @returns {string|null}
*/
function parseStoreNumber(text) {
if (!text || typeof text !== 'string') return null;
// Match 1-10 digits not preceded by minus sign
const match = text.trim().match(/(?<!-)\b(\d{1,10})\b/);
if (!match) return null;
const num = parseInt(match[1], 10);
if (!num || num <= 0) return null;
return String(num);
}
module.exports = {
parseStoreNumber,
};