chore: Phase 0 initial hygiene

- Initialize git repository
- Add comprehensive .gitignore (protects .env and secrets)
- Fix package.json (correct main entry, add metadata)
- Expand .env.example with all required variables and comments
- Add README.md with architecture, setup, and commands
- Clean up empty scaffolding directories (logs removed, agents/models marked)
- Backup previous .env file locally

This establishes a safe foundation before further development.
This commit is contained in:
Joseph McQueen 2026-06-24 13:32:37 -04:00
commit 459301cc4d
19 changed files with 13547 additions and 0 deletions

33
.env.example Normal file
View file

@ -0,0 +1,33 @@
# =============================================================================
# NetAnalyzer Environment Configuration
# Copy this file to .env and fill in your actual values.
# NEVER commit .env — it is gitignored.
# =============================================================================
# --- Webex Bot (required for bot functionality) ---
WEBEX_ACCESS_TOKEN=your_webex_bot_access_token_here
BOT_NAME=NetAnalyzer
# --- Meraki (required for network device discovery and client status) ---
MERAKI_API_KEY=your_meraki_api_key_here
MERAKI_ORG_ID=your_meraki_organization_id_here
# --- WebSocket Remote Agent ---
WS_PORT=8080
# Token used to authenticate the remoteAgent.js when connecting to this server
WS_TOKEN=generate_a_strong_random_token_here
# For the remote agent process (remoteAgent.js), point to the main server
WS_URL=ws://localhost:8080?token=generate_a_strong_random_token_here
# --- SIW / Store Information Warehouse (proxied via remote agent) ---
SIW_BASE_URL=https://your-siw-api.example.com/api
SIW_USERNAME=your_siw_username
SIW_PASSWORD=your_siw_password
# --- Workspace ONE (MDM / AirWatch) ---
WS1_BASE_URL=https://your-tenant.awmdm.com
WS1_TOKEN_URL=https://your-tenant.awmdm.com/api/mdm/token
WS1_CLIENT_ID=your_ws1_client_id
WS1_CLIENT_SECRET=your_ws1_client_secret
WS1_TENANT_CODE=your_ws1_tenant_code

30
.gitignore vendored Normal file
View file

@ -0,0 +1,30 @@
# Dependencies
node_modules/
# Environment secrets - CRITICAL
.env
.env.*
!.env.example
# Logs
logs/
*.log
npm-debug.log*
# macOS
.DS_Store
# IDE / Editor
.vscode/
.idea/
*.swp
*.swo
# Test / Coverage
coverage/
.nyc_output/
# Misc
*.tgz
tmp/
temp/

139
README.md Normal file
View file

@ -0,0 +1,139 @@
# NetAnalyzer
Webex bot that provides store-level network and device health analysis by correlating data from Meraki, SIW (Store Information Warehouse), and Workspace ONE MDM.
## What it does
- `store <number>` — Full detailed report of a store's registers, printers, payment terminals, network devices, MDM inventory, and their online/offline status via Meraki clients.
- `analyze <number>` — Higher-level health summary with an overall score and prioritized issues (network, POS systems, peripherals).
The bot helps operations teams quickly understand the connectivity and device health state of a retail location.
## Architecture
```
┌─────────────────┐ ┌─────────────────────┐
│ Webex Bot │◄────────►│ NetAnalyzer Server │
│ (Webex rooms) │ Webex │ (server.js) │
└─────────────────┘ └─────────┬───────────┘
│ WebSocket (authenticated)
┌─────────────────────┐
│ Remote Agent │◄──► SIW API (Basic Auth)
│ (remoteAgent.js) │◄──► Workspace ONE MDM
└─────────────────────┘
Meraki API (direct from server)
```
**Why the remote agent?**
SIW and some MDM systems are only reachable from specific internal networks. The remote agent runs in that environment and proxies requests back to the main NetAnalyzer server over an authenticated WebSocket.
## Prerequisites
- Node.js >= 18
- A Webex bot account with access token
- Meraki API key with organization access
- Access to your organization's SIW API and Workspace ONE (AirWatch) environment
- Ability to run the remote agent on a machine that can reach SIW/MDM
## Setup
1. **Clone and install**
```bash
git clone <repo>
cd netanalyzer
npm install
```
2. **Configure environment**
```bash
cp .env.example .env
# Edit .env with your real credentials
```
Required values are documented in `.env.example`.
3. **(Optional but recommended) Rotate all secrets**
If you previously had credentials in the repository, rotate:
- Webex bot token
- Meraki API key
- SIW credentials
- Workspace ONE client secret + tenant code
- WS_TOKEN
## Running
### Main server + bot (where the Webex connection lives)
```bash
npm start
# or for development with auto-reload
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
```
Make sure `WS_URL` in its environment points to the main server with the correct `WS_TOKEN`.
## Bot Commands
In any Webex space where the bot is a member:
- `store 782` — Detailed device inventory and connectivity for store 782
- `analyze 782` — Quick health summary with score for store 782
The bot also responds to variations containing "store" or "analyze".
## Project Structure
```
.
├── bot/
│ └── handlers.js # Webex command handlers
├── config/
│ └── index.js # Centralized configuration from env
├── integrations/
│ ├── storeDetail.js # Full store report builder
│ └── storeHealth.js # Health score + summary
├── 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
├── utils/
│ ├── formatter.js
│ └── dataMerger.js # (currently unused)
├── remoteAgent.js # Lightweight proxy client
├── server.js # Main entry point (bot + ws server)
├── package.json
└── .env.example
```
## Security Notes
- **Never commit `.env`** (it is gitignored).
- The WebSocket connection between server and remote agent is protected by a shared token (`WS_TOKEN`).
- 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
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
## License
ISC

1
agents/.gitkeep Normal file
View file

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

56
bot/handlers.js Normal file
View file

@ -0,0 +1,56 @@
const { getStoreDetail } = require('../integrations/storeDetail');
async function handleStoreCommand(bot, trigger) {
const words = trigger.message.text.trim().split(/\s+/);
const storeNumber = words[1];
if (!storeNumber || isNaN(storeNumber)) {
return bot.say('markdown', 'Usage: `store <number>`\nExample: `store 305`');
}
bot.say('markdown', `🔍 Analyzing Store **${storeNumber}**...`);
try {
const fullReport = await getStoreDetail(storeNumber);
const sections = fullReport.split(/\n\n(?=\*\*)/);
for (let i = 0; i < sections.length; i++) {
const section = sections[i].trim();
if (section) {
await bot.say('markdown', section);
if (i < sections.length - 1) await new Promise(r => setTimeout(r, 400));
}
}
} catch (err) {
console.error('❌ Analysis error:', err);
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];
if (!storeNumber || isNaN(storeNumber)) {
return bot.say('markdown', 'Usage: `analyze <number>`\nExample: `analyze 305`');
}
bot.say('markdown', `🔍 Running Health Analysis for Store **${storeNumber}**...`);
try {
const health = await getStoreHealth(storeNumber);
if (health && health.summary) {
bot.say('markdown', health.summary); // ← Correct way
} else {
bot.say('markdown', '✅ Analysis completed, but no summary was generated.');
}
} catch (err) {
console.error('❌ Health analysis error:', err);
bot.say('markdown', `❌ Error analyzing store **${storeNumber}**:\n${err.message}`);
}
}
module.exports = { handleStoreCommand, handleAnalyzeCommand };

29
config/index.js Normal file
View file

@ -0,0 +1,29 @@
require('dotenv').config();
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
}
};

344
integrations/storeDetail.js Normal file
View file

@ -0,0 +1,344 @@
const { getStoreLocation, getStoreRegisters, getStorePrinters, getStorePaymentTerminals } = require('../services/siw');
const { findMerakiNetwork, getMerakiDeviceAvailabilities, getMerakiClients } = require('../services/meraki');
const { formatStoreReport } = require('../utils/formatter');
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`;
}
async function getStoreDetail(storeNumber) {
console.log(`🔍 Starting full analysis for store ${storeNumber}`);
let locationData, merakiNetwork, registers, printers, paymentTerminals, merakiClients = [], mdmDevices = [];
try {
// Phase 1: Fast lookups
[locationData, merakiNetwork] = await Promise.all([
getStoreLocation(storeNumber),
findMerakiNetwork(storeNumber)
]);
// Phase 2: Heavy parallel lookups
const parallelPromises = [
getStoreRegisters(storeNumber),
getStorePrinters(storeNumber),
getStorePaymentTerminals(storeNumber)
];
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`;
});
} else {
report += `\n⚠️ No matching Meraki network found.\n`;
}
// === 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'
);
if (filteredPrinters.length > 0) {
report += `\n**🖨️ Store Printers (${filteredPrinters.length})**\n`;
filteredPrinters.forEach(printer => {
const name = (printer.printer_name || 'Unknown').trim();
const model = printer.printer_model_name || 'N/A';
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 += `**${name}** — ${model}${status}${lastSeen}${deviceName}${connection}${link}\n`;
});
}
if (paymentTerminals && paymentTerminals.length > 0) {
report += `\n**💳 Payment Terminals (${paymentTerminals.length})**\n`;
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';
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])))
);
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 += `**${deviceName}** — ${adyenName}${model}${type}${status}${lastSeen}${recentDevice}${connection}${link}\n`;
});
}
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'));
// === Store Servers ===
if (servers.length > 0) {
report += `\n**🖥️ Store Servers (${servers.length})**\n`;
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)))
);
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`;
});
}
// === Mobile Registers ===
if (mobileRegs.length > 0) {
report += `\n**📱 Mobile Registers (${mobileRegs.length})**\n`;
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)))
);
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`;
});
}
// === Customer Displays ===
if (custDisplays.length > 0) {
report += `\n**🖥️ Customer Displays (${custDisplays.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)))
);
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;
}
module.exports = { getStoreDetail };

219
integrations/storeHealth.js Normal file
View file

@ -0,0 +1,219 @@
const { getStoreLocation } = require('../services/siw');
const { findMerakiNetwork, getMerakiDeviceAvailabilities, getMerakiClients } = require('../services/meraki');
const { getMDMDevices } = require('../services/mdm');
async function getStoreHealth(storeNumber) {
console.log(`🔍 Running health analysis for store ${storeNumber}`);
let locationData, merakiNetwork, merakiClients = [], mdmDevices = [], paymentTerminals = [], printers = [];
try {
[locationData, merakiNetwork] = await Promise.all([
getStoreLocation(storeNumber),
findMerakiNetwork(storeNumber)
]);
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);
}
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");
}
// === Core Systems ===
summary += `**🖥️ POS Systems**\n`;
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'
);
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`);
}
});
summary += `- Printers: ${onlinePrinters}/${filteredPrinters.length} online\n`;
}
// === Overall Status ===
overallScore = Math.max(0, Math.round(overallScore));
let statusEmoji = overallScore >= 90 ? '🟢' : overallScore >= 70 ? '🟡' : '🔴';
let statusText = overallScore >= 90 ? 'Good' : overallScore >= 70 ? 'Fair' : 'Needs Attention';
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`;
}
return { summary };
}
module.exports = { getStoreHealth };

1
models/.gitkeep Normal file
View file

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

12084
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

34
package.json Normal file
View file

@ -0,0 +1,34 @@
{
"name": "netanalyzer",
"version": "1.0.0",
"private": true,
"main": "server.js",
"type": "commonjs",
"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"
},
"keywords": [
"webex",
"bot",
"meraki",
"network-analysis",
"store-health"
],
"author": "",
"license": "ISC",
"engines": {
"node": ">=18"
},
"dependencies": {
"axios": "^1.16.1",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"webex-node-bot-framework": "^2.5.1",
"ws": "^8.20.1"
},
"devDependencies": {
"nodemon": "^3.1.14"
}
}

91
remoteAgent.js Normal file
View file

@ -0,0 +1,91 @@
const WebSocket = require('ws');
const axios = require('axios');
require('dotenv').config();
const WS_URL = process.env.WS_URL;
if (!WS_URL) {
console.error('❌ WS_URL is not set in .env');
process.exit(1);
}
let ws = null;
let reconnectAttempts = 0;
const INITIAL_BACKOFF = 2000; // 2 seconds
function connect() {
console.log(`🔄 Connecting to ${WS_URL}...`);
ws = new WebSocket(WS_URL);
ws.on('open', () => {
console.log('✅ Remote Agent connected to NetAnalyzer');
reconnectAttempts = 0;
});
ws.on('message', async (data) => {
try {
const request = JSON.parse(data);
if (request.action !== 'proxyRequest') return;
console.log(`🔄 Proxying ${request.method || 'GET'} ${request.url}`);
const response = await axios({
method: request.method || 'GET',
url: request.url,
headers: request.headers || {},
auth: request.auth || undefined,
data: request.body || undefined,
timeout: 30000,
});
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.on('close', (code, reason) => {
console.log(`❌ Disconnected (code: ${code}). Reconnecting...`);
scheduleReconnect();
});
ws.on('error', (err) => {
console.error('WebSocket error:', err.message);
});
}
function scheduleReconnect() {
reconnectAttempts++;
// Exponential backoff, capped at 60 seconds
const backoff = Math.min(INITIAL_BACKOFF * Math.pow(1.5, reconnectAttempts - 1), 60000);
console.log(`⏳ Reconnecting in ${Math.round(backoff/1000)}s... (attempt ${reconnectAttempts})`);
setTimeout(() => {
connect();
}, backoff);
}
// Start initial connection
connect();
// Graceful shutdown
process.on('SIGINT', () => {
console.log('🛑 Shutting down remote agent...');
if (ws) ws.close();
process.exit(0);
});

51
server.js Normal file
View file

@ -0,0 +1,51 @@
const Framework = require('webex-node-bot-framework');
const config = require('./config');
const { startWebSocketServer } = require('./services/websocket');
const { handleStoreCommand, handleAnalyzeCommand } = require('./bot/handlers');
const framework = new Framework({
token: config.webex.token,
removeWebhooksOnStart: true,
logLevel: 'debug'
});
console.log('🚀 Starting NetAnalyzer Framework...');
// 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');
// 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();
// 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!');
});
framework.on('spawn', (bot, id, addedBy) => {
console.log(`🟢 Bot spawned in room: ${bot.room.title || 'Unknown'}`);
if (addedBy) {
bot.say('NetAnalyzer is ready! Try `store <number>` or `analyze <number>`');
}
});
// 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);
});

53
services/mdm.js Normal file
View file

@ -0,0 +1,53 @@
const axios = require('axios');
const config = require('../config');
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' }
}
);
console.log('✅ MDM Token acquired');
return response.data.access_token;
} catch (err) {
console.error('❌ MDM Token failed:', err.message);
throw err;
}
}
async function getMDMDevices(storeNumber) {
const storePadded = String(storeNumber).padStart(6, '0');
console.log(`🔍 Fetching MDM devices for store ${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 devices = response.data.Devices || [];
console.log(`✅ Fetched ${devices.length} MDM devices [getMDMDevices]`);
return devices;
} catch (err) {
console.error(`❌ MDM devices fetch failed:`, err.message);
return [];
}
}
module.exports = { getMDMDevices };

146
services/meraki.js Normal file
View file

@ -0,0 +1,146 @@
const axios = require('axios');
const config = require('../config');
const merakiAxios = axios.create({
baseURL: config.meraki.baseUrl,
headers: {
'X-Cisco-Meraki-API-Key': config.meraki.apiKey,
'Content-Type': 'application/json'
}
});
// In-memory cache
let cachedNetworks = [];
let lastCacheTime = 0;
const CACHE_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
async function refreshMerakiNetworksCache() {
const start = Date.now();
console.log('🔄 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();
console.log(`✅ Cached ${cachedNetworks.length} Meraki networks (${Date.now() - start}ms)`);
} catch (err) {
console.error('❌ Failed to refresh Meraki networks cache:', err.message);
}
}
async function getMerakiNetworks(forceRefresh = false) {
const now = Date.now();
if (forceRefresh || cachedNetworks.length === 0 || (now - lastCacheTime > CACHE_TTL_MS)) {
await refreshMerakiNetworksCache();
}
return cachedNetworks;
}
async function findMerakiNetwork(storeNum) {
if (!storeNum) 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);
const networks = await getMerakiNetworks();
console.log(`🔍 Searching for store "${raw}" → 5-digit term "${searchTerm}"`);
let bestMatch = null;
let bestScore = -1;
for (const net of networks) {
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 (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 [];
}
}
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] }
}
);
return res.data || [];
} catch (err) {
console.error(`❌ Device availabilities failed:`, err.message);
return [];
}
}
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}`
);
const clients = res.data || [];
console.log(`✅ Fetched ${clients.length} Meraki clients (7-day timespan) [getMerakiClients]`);
return clients;
} catch (err) {
console.error(`❌ Failed to fetch Meraki clients:`, err.message);
return [];
}
}
module.exports = {
findMerakiNetwork,
getNetworkClients,
getMerakiClients,
getAllMerakiDevices,
getMerakiDeviceAvailabilities,
refreshMerakiNetworksCache
};

88
services/siw.js Normal file
View file

@ -0,0 +1,88 @@
const config = require('../config');
const { proxyRequest } = require('./websocket');
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;
}
async function getStoreNetwork(storeNumber) {
const authString = Buffer.from(
`${config.siw.username}:${config.siw.password}`
).toString('base64');
const requestConfig = {
method: 'GET',
url: `${config.siw.baseUrl}/StoreNetwork/${storeNumber}`,
headers: {
'Authorization': `Basic ${authString}`
}
};
const result = await proxyRequest(requestConfig);
return result.data;
}
async function getStoreRegisters(storeNumber) {
const authString = Buffer.from(
`${config.siw.username}:${config.siw.password}`
).toString('base64');
const requestConfig = {
method: 'GET',
url: `${config.siw.baseUrl}/StoreRegister/${storeNumber}`,
headers: { 'Authorization': `Basic ${authString}` }
};
const result = await proxyRequest(requestConfig);
return result.data || [];
}
async function getStorePrinters(storeNumber) {
const authString = Buffer.from(
`${config.siw.username}:${config.siw.password}`
).toString('base64');
const requestConfig = {
method: 'GET',
url: `${config.siw.baseUrl}/StorePrinter/${storeNumber}`,
headers: { 'Authorization': `Basic ${authString}` }
};
const result = await proxyRequest(requestConfig);
return result.data || [];
}
async function getStorePaymentTerminals(storeNumber) {
const authString = Buffer.from(
`${config.siw.username}:${config.siw.password}`
).toString('base64');
const requestConfig = {
method: 'GET',
url: `${config.siw.baseUrl}/StorePayment/${storeNumber}`,
headers: { 'Authorization': `Basic ${authString}` }
};
const result = await proxyRequest(requestConfig);
return result.data || [];
}
module.exports = {
getStoreLocation,
getStoreRegisters,
getStorePrinters,
getStorePaymentTerminals // ← new
};

103
services/websocket.js Normal file
View file

@ -0,0 +1,103 @@
const WebSocket = require('ws');
const config = require('../config');
let wss;
let connectedAgent = null;
const pendingRequests = new Map(); // requestId → { resolve, reject, timeout }
function startWebSocketServer() {
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');
}
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;
}
console.log('✅ Authorized Remote Agent connected');
connectedAgent = ws;
ws.on('message', (data) => {
try {
const response = JSON.parse(data);
const requestId = response.requestId;
if (requestId && pendingRequests.has(requestId)) {
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);
}
} else {
console.log('⚠️ Received response with unknown requestId:', requestId);
}
} catch (e) {
console.error('Failed to parse agent message:', e);
}
});
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; });
});
// 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
console.log(`WebSocket server running on ws://0.0.0.0:${config.ws.port}`);
}
async function proxyRequest(requestConfig) {
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)}`;
const timeout = setTimeout(() => {
pendingRequests.delete(requestId);
reject(new Error(`Proxy request timeout after 45s`));
}, 45000); // Increased timeout
pendingRequests.set(requestId, { resolve, reject, timeout });
const payload = JSON.stringify({
action: 'proxyRequest',
requestId,
...requestConfig
});
connectedAgent.send(payload);
});
}
module.exports = { startWebSocketServer, proxyRequest };

30
utils/dataMerger.js Normal file
View file

@ -0,0 +1,30 @@
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 };

15
utils/formatter.js Normal file
View file

@ -0,0 +1,15 @@
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 };