feat: st command suite, Webex phone + Atlas AV integrations, dockerized remote agent

Rebrand NetAnalyzer -> StoreHealthAnalyzer and consolidate the store
reporting surface into a single `st [number]` command with focused
sub-modes.

Commands
- st [number]                - general info (SIW + brands + Meraki net link)
- st [number] network        - switches, APs, store server
- st [number] pos            - registers, payment terminals, customer display
- st [number] ios            - MDM-tracked iOS hardware
- st [number] phone          - wired 78xx + DECT basestations/handsets with
                               registration state, extensions and main DID
- st [number] av             - Atlas AMPs + MDM-tracked Apple TVs, video
                               walls, music players, LED displays
- Removed `analyze` in favor of the unified `st` surface

Integrations
- integrations/webex: Service App OAuth with rotating refresh tokens,
  seed + cleanup scripts, tokens/ storage (git-ignored)
- integrations/atlas: Xyte client + cached device discovery keyed on
  zero-padded 6-digit store numbers, cold-cache failure -> unavailable
  banner instead of a misleading empty result
- services/webexPhone, services/webexService, services/avService: shape
  raw upstream data into the report layer's contract
- utils/merakiMatcher: FQDN hostname extraction so payment terminals
  match Meraki descriptions; case-insensitive lookup
- utils/chunkReport: split long markdown replies at 7000-char boundaries

Reliability / ops
- server.js: awaited framework.stop() + 8s hard-kill timer so nodemon /
  Docker restarts don't leak WDM device registrations ("excessive device
  registrations")
- nodemon.json: SIGINT so the graceful path always runs
- scripts/cleanupWebexDevices.js: one-shot WDM cleanup utility
- Group-space routing: hears() regexes tolerate the leading @BotName
  prefix Webex prepends to mentions
- Replaced HTML-unsafe <number> placeholders with [number] in all help
  strings

Remote agent containerization
- docker/remote-agent/: multi-stage node:22-alpine image, non-root user,
  tini for signal handling, minimal deps (ws/axios/dotenv)
- docker/remote-agent/package.sh: docker buildx build defaulting to
  linux/amd64 (with override), saves image + assembles deploy/ + writes
  SHA256 + zips for offline transfer
- docker/remote-agent/deploy/: runtime docker-compose.yml, install.sh
  with platform sanity check, remote-host README
- .dockerignore + .gitignore updates for build artifacts and dist bundles
- npm run agent:package convenience script

Cleanup
- Dropped storeHealth.js / HealthReport.js and their tests/mocks in favor
  of the shared storeDetail pipeline
- Store model handles null SIW records gracefully; toSummary always
  ends with a newline so the Meraki link sits on its own line

Tests
- 144 tests across 14 suites passing; new coverage for atlasClient,
  atlasDevices, avService, avCategory classification, webexPhone,
  webexServiceAppAuth, storeDetail integration, siw, chunkReport and
  the updated meraki matcher

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Joseph McQueen 2026-07-06 09:54:41 -04:00
parent 0a46d25bd6
commit b3c37bd7df
55 changed files with 5090 additions and 737 deletions

46
.dockerignore Normal file
View file

@ -0,0 +1,46 @@
# Applied when the repo root is the Docker build context (e.g.
# `docker build -f docker/remote-agent/Dockerfile -t sha-remote-agent .`).
# Keep this file conservative — new Dockerfiles that live under docker/
# will share it.
# --- Never bake secrets into an image ---
.env
.env.*
!.env.example
tokens/
*.tokens.json
*.pem
*.key
# --- Never ship local dev artifacts ---
node_modules/
coverage/
.nyc_output/
logs/
*.log
# --- Version control / IDE noise ---
.git/
.gitignore
.gitattributes
.vscode/
.idea/
*.swp
*.swo
.DS_Store
# --- Test suites are not needed at runtime ---
tests/
# --- Docs / meta files that only matter in the repo ---
README.md
docs/
*.md
!docker/**/README.md
# --- Nested Docker artifacts (don't recursively bring in other
# containers' contexts if more are added later) ---
docker/**/node_modules/
docker/**/.env
# Packaging output (potentially large tarballs) — never send back into a build.
docker/**/dist/

View file

@ -1,5 +1,5 @@
# ============================================================================= # =============================================================================
# NetAnalyzer Environment Configuration # StoreHealthAnalyzer Environment Configuration
# Copy this file to .env and fill in your actual values. # Copy this file to .env and fill in your actual values.
# NEVER commit .env — it is gitignored. # NEVER commit .env — it is gitignored.
# ============================================================================= # =============================================================================
@ -10,7 +10,7 @@ LOG_LEVEL=info
# --- Webex Bot (required for bot functionality) --- # --- Webex Bot (required for bot functionality) ---
WEBEX_ACCESS_TOKEN=your_webex_bot_access_token_here WEBEX_ACCESS_TOKEN=your_webex_bot_access_token_here
BOT_NAME=NetAnalyzer BOT_NAME=StoreHealthAnalyzer
# --- Meraki (required for network device discovery and client status) --- # --- Meraki (required for network device discovery and client status) ---
MERAKI_API_KEY=your_meraki_api_key_here MERAKI_API_KEY=your_meraki_api_key_here
@ -35,3 +35,20 @@ WS1_TOKEN_URL=https://your-tenant.awmdm.com/api/mdm/token
WS1_CLIENT_ID=your_ws1_client_id WS1_CLIENT_ID=your_ws1_client_id
WS1_CLIENT_SECRET=your_ws1_client_secret WS1_CLIENT_SECRET=your_ws1_client_secret
WS1_TENANT_CODE=your_ws1_tenant_code WS1_TENANT_CODE=your_ws1_tenant_code
# --- Webex Service App (required for `st [number] phone` only) ---
# Client ID/secret from the Service App registration in the Webex Developer Portal.
WEBEX_CLIENT_ID=your_webex_service_app_client_id
WEBEX_CLIENT_SECRET=your_webex_service_app_client_secret
# Path to the rotating tokens JSON. Created/refreshed by `npm run webex:seed`.
# Defaults to ./tokens/webex-service-tokens.json (resolved to absolute at runtime).
WEBEX_TOKENS_PATH=./tokens/webex-service-tokens.json
# --- Atlas / Xyte (required for `st [number] av` only) ---
# Long-lived API key issued by the Atlas (hub.xyte.io) admin console. Sent
# verbatim as the Authorization header (no rotation, no "Bearer" prefix).
# Missing key surfaces as an inline banner in AV mode; MDM-tracked AV devices
# (Apple TVs, video walls, music, LED) still render.
ATLAS_AUTH_KEY=your_atlas_api_key
# Optional override; defaults to https://hub.xyte.io/core/v1 if unset.
ATLAS_BASE_URL=https://hub.xyte.io/core/v1

7
.gitignore vendored
View file

@ -6,6 +6,10 @@ node_modules/
.env.* .env.*
!.env.example !.env.example
# Webex Service App tokens (rotating; treat as secret)
tokens/
*.tokens.json
# Logs # Logs
logs/ logs/
*.log *.log
@ -28,3 +32,6 @@ coverage/
*.tgz *.tgz
tmp/ tmp/
temp/ temp/
# Docker deploy bundles (built by docker/remote-agent/package.sh)
docker/**/dist/

134
README.md
View file

@ -1,19 +1,23 @@
# NetAnalyzer # StoreHealthAnalyzer
Webex bot that provides store-level network and device health analysis by correlating data from Meraki, SIW (Store Information Warehouse), and Workspace ONE MDM. 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 ## 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. - `st [number]` — Store info pulled from SIW (location, brand, status, environment). No upstream checks.
- `analyze <number>` — Higher-level health summary with an overall score and prioritized issues (network, POS systems, peripherals). - `st [number] network` — Switch, AP, and store-server health (Meraki + MDM).
- `st [number] pos` — POS devices: registers, mobile registers, printers, payment terminals.
- `st [number] ios` — iOS devices (mainly iPhones).
- `st [number] phone` — wired Cisco 78xx IP phones (Meraki-matched by MAC) and DECT basestations + handsets via Webex Service App.
- `st [number] av` — A/V hardware snapshot: Atlas (Xyte) AMPs **plus** MDM-tracked Apple TVs, video walls (`*VW*`), music players (`*MSC*`), and LED displays (`*LED*`). Each row is MAC-matched into Meraki for the where-it's-connected line.
The bot helps operations teams quickly understand the connectivity and device health state of a retail location. The bot helps operations teams quickly understand the connectivity and device state of a retail location.
## Architecture ## Architecture
``` ```
┌─────────────────┐ ┌─────────────────────┐ ┌─────────────────┐ ┌─────────────────────┐
│ Webex Bot │◄────────►│ NetAnalyzer Server │ │ Webex Bot │◄────────►│ StoreHealthAnalyzer │
│ (Webex rooms) │ Webex │ (server.js) │ │ (Webex rooms) │ Webex │ (server.js) │
└─────────────────┘ └─────────┬───────────┘ └─────────────────┘ └─────────┬───────────┘
@ -29,7 +33,7 @@ The bot helps operations teams quickly understand the connectivity and device he
``` ```
**Why the remote agent?** **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. 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 StoreHealthAnalyzer server over an authenticated WebSocket.
## Prerequisites ## Prerequisites
@ -45,7 +49,7 @@ SIW and some MDM systems are only reachable from specific internal networks. The
```bash ```bash
git clone <repo> git clone <repo>
cd netanalyzer cd storehealthanalyzer
npm install npm install
``` ```
@ -83,6 +87,10 @@ This starts:
### Remote agent (run on a machine that can reach internal systems) ### Remote agent (run on a machine that can reach internal systems)
Two options — pick whichever fits your host.
**Option A: run directly with Node**
```bash ```bash
# On the internal machine # On the internal machine
node remoteAgent.js node remoteAgent.js
@ -90,14 +98,88 @@ node remoteAgent.js
Make sure `WS_URL` in its environment points to the main server with the correct `WS_TOKEN`. Make sure `WS_URL` in its environment points to the main server with the correct `WS_TOKEN`.
**Option B: run as a Docker container (build + run on the same host)**
Ship the agent as a standalone container instead of installing Node on the host. Full instructions live in [`docker/remote-agent/README.md`](docker/remote-agent/README.md); the short version:
```bash
# Build (from the repo root)
docker build -f docker/remote-agent/Dockerfile -t sha-remote-agent:latest .
# Configure
cp docker/remote-agent/.env.example docker/remote-agent/.env
# Edit WS_URL + WS_TOKEN
# Run (compose is the easy path)
docker compose -f docker/remote-agent/docker-compose.yml up -d --build
```
The container is small (~6080 MB), ships only `remoteAgent.js` + its three runtime deps (`ws`, `axios`, `dotenv`), and runs as the unprivileged `node` user. `tini` is PID 1 so `docker stop` closes the websocket cleanly.
**Option C: package as a portable ZIP for offline / manual transfer**
If the remote host can't reach a registry (or you just want a one-file drop), package everything into a self-contained ZIP:
```bash
# On the dev machine (from the repo root)
npm run agent:package
# → docker/remote-agent/dist/sha-remote-agent-<version>.zip
```
Then copy the ZIP to the remote host and:
```bash
unzip sha-remote-agent-<version>.zip
cd sha-remote-agent-<version>
./install.sh # loads the image, seeds .env, starts the container
```
The bundle contains the Docker image tarball, a runtime `docker-compose.yml`, a SHA-256 checksum, and an installer that verifies + loads + starts. Details in [`docker/remote-agent/deploy/README.md`](docker/remote-agent/deploy/README.md) (also included inside the ZIP as its top-level `README.md`).
### Webex Service App (only needed for `st [number] phone`)
The phone command talks to Webex APIs (people, devices, telephony) as a Service App, separate from the bot identity. Cisco rotates the refresh token on every call, so the only manual step is seeding the initial tokens once.
1. Set `WEBEX_CLIENT_ID` and `WEBEX_CLIENT_SECRET` in `.env` (from the Service App registration in the Webex Developer Portal).
2. Seed the tokens file:
```bash
# Default: imports the tokens JSON from collabFinder, verifies it via one immediate refresh
npm run webex:seed
# Or import a tokens JSON from a specific path
npm run webex:seed -- --from-file /path/to/webex-service-tokens.json
# Or seed with a raw refresh token string
npm run webex:seed -- --refresh-token <refresh-token-value>
```
The seed step performs one `grant_type=refresh_token` call against `https://webexapis.com/v1/access_token` and writes the rotated pair to `WEBEX_TOKENS_PATH` (default `./tokens/webex-service-tokens.json`, gitignored).
3. From then on, the auth singleton keeps the token fresh automatically (5-minute safety buffer ahead of the stated expiry). If the refresh token is ever revoked, `st [number] phone` degrades to a single warning banner ("Webex phone data unavailable") instead of erroring — re-seed and try again.
### Atlas / Xyte (only needed for `st [number] av`)
The AV command pulls hardware data from two places:
- **Atlas (`hub.xyte.io`)** — long-lived API key. Add `ATLAS_AUTH_KEY` (and optionally `ATLAS_BASE_URL`) to `.env`. The Atlas client maintains a 1-hour in-process cache of the org-wide device list, then filters by zero-padded store number (e.g. store 782 → `US000782*`) to find that store's AMPs.
- **MDM (Workspace ONE)** — uses the existing WebSocket-proxied MDM connection. The AV view filters MDM devices whose friendly name matches `/(VW|MSC|LED|AppleTV)/i`, mirroring the collabFinder strict-AV filter.
If `ATLAS_AUTH_KEY` is unset (or the Atlas API is unreachable), the AV report still renders the MDM-tracked devices and shows an inline "Atlas AV data unavailable" banner above them. Likewise, an MDM outage doesn't suppress the Atlas section. Both sources empty → `_No AV hardware registered for this store._`.
## Bot Commands ## Bot Commands
In any Webex space where the bot is a member: In any Webex space where the bot is a member:
- `store 782` — Detailed device inventory and connectivity for store 782 - `st 782` — store info (location, brand, status, environment)
- `analyze 782` — Quick health summary with score for store 782 - `st 782 network` — switches, APs, store server(s)
- `st 782 pos` — POS devices (registers, mobile registers, customer displays, printers, payment terminals)
- `st 782 ios` — iOS devices (iPhones)
- `st 782 phone` — wired (78xx) + DECT bases & handsets via Webex
- `st 782 av` — Atlas AMPs + MDM-tracked Apple TVs / video walls / music / LED displays
- `help` / `help st` — show this list inside Webex.
The bot also responds to variations containing "store" or "analyze". The bot answers single-message replies by default and only splits them into multiple messages when the output exceeds ~7000 characters (Webex's safe message-size cap).
## Project Structure ## Project Structure
@ -108,31 +190,55 @@ The bot also responds to variations containing "store" or "analyze".
├── config/ ├── config/
│ └── index.js # Centralized env-driven configuration + validation │ └── index.js # Centralized env-driven configuration + validation
├── integrations/ ├── integrations/
│ ├── storeDetail.js # Full store report builder (Meraki + SIW + MDM) │ ├── storeDetail.js # Per-mode store report builder (Meraki + SIW + MDM + Webex phones + Atlas AV)
│ └── storeHealth.js # Health score + summary │ ├── atlas/
│ │ ├── atlasClient.js # Atlas (hub.xyte.io) axios wrapper + AtlasUnavailableError
│ │ └── atlasDevices.js # Paginated org-device fetcher + 1h in-memory cache
│ └── webex/
│ └── WebexServiceAppAuth.js # Service App OAuth singleton w/ auto-rotating refresh
├── models/ ├── models/
│ ├── Store.js # Store domain model (normalizes SIW location) │ └── Store.js # Store domain model (normalizes SIW location + general)
│ └── HealthReport.js # Score / issue accumulator ├── scripts/
│ ├── cleanupWebexDevices.js
│ └── seedWebexTokens.js # One-shot Webex Service App token seed
├── services/ ├── services/
│ ├── meraki.js # Meraki API client (cached, retried) │ ├── meraki.js # Meraki API client (cached, retried)
│ ├── siw.js # SIW calls (proxied via the remote agent) │ ├── siw.js # SIW calls (proxied via the remote agent)
│ ├── mdm.js # Workspace ONE MDM client (retried) │ ├── mdm.js # Workspace ONE MDM client (retried)
│ ├── webexService.js # Webex API axios wrapper (auto-refresh on 401)
│ ├── webexPhone.js # Phone discovery: 78xx + DECT bases + handsets
│ ├── avService.js # AV (Atlas) device shaper for `st [number] av`
│ └── websocket.js # WS server + proxyRequest helper │ └── websocket.js # WS server + proxyRequest helper
├── tests/ ├── tests/
│ ├── *.test.js # Unit tests │ ├── *.test.js # Unit tests
│ ├── integration/ # Integration tests using mocks │ ├── integration/ # Integration tests using mocks
│ └── mocks/ # Service mocks for tests │ └── mocks/ # Service mocks for tests
├── utils/ ├── utils/
│ ├── chunkReport.js # Split markdown into <7000-char Webex messages
│ ├── logger.js # Structured JSON logger (LOG_LEVEL aware) │ ├── logger.js # Structured JSON logger (LOG_LEVEL aware)
│ ├── merakiMatcher.js # Device ↔ Meraki client matching │ ├── merakiMatcher.js # Device ↔ Meraki client matching
│ ├── retry.js # withRetry wrapper (exponential backoff) │ ├── retry.js # withRetry wrapper (exponential backoff)
│ └── validate.js # Input parsers │ └── validate.js # Input parsers
├── docker/
│ └── remote-agent/ # Standalone Docker packaging for remoteAgent.js
│ ├── Dockerfile
│ ├── docker-compose.yml # Local build/run
│ ├── package.json # Minimal deps: ws + axios + dotenv
│ ├── package.sh # Build + save + zip → dist/
│ ├── .env.example
│ ├── README.md
│ ├── deploy/ # Files bundled into the deploy ZIP
│ │ ├── docker-compose.yml # Runtime-only (references loaded image)
│ │ ├── install.sh # docker load + .env bootstrap + up
│ │ └── README.md # Remote-host instructions (also in the ZIP)
│ └── dist/ # Generated ZIPs (gitignored)
├── constants.js # Shared constants (STORE_MODES, MDM device types) ├── constants.js # Shared constants (STORE_MODES, MDM device types)
├── remoteAgent.js # Lightweight proxy client (run on internal host) ├── remoteAgent.js # Lightweight proxy client (run on internal host)
├── server.js # Main entry point (bot + WS server) ├── server.js # Main entry point (bot + WS server)
├── package.json ├── package.json
├── eslint.config.js ├── eslint.config.js
├── .prettierrc.json ├── .prettierrc.json
├── .dockerignore
└── .env.example └── .env.example
``` ```

View file

@ -1,139 +1,168 @@
const { getStoreDetail } = require('../integrations/storeDetail'); const { getStoreDetail } = require('../integrations/storeDetail');
const { getStoreHealth } = require('../integrations/storeHealth');
const { parseStoreNumber } = require('../utils/validate'); const { parseStoreNumber } = require('../utils/validate');
const { isAgentConnected } = require('../services/websocket');
const { STORE_MODES } = require('../constants'); const { STORE_MODES } = require('../constants');
const { chunkReport } = require('../utils/chunkReport');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const REMOTE_AGENT_WARNING =
'⚠️ **Remote agent is not connected.** SIW data (store info, registers, ' +
'printers, payment terminals) will be unavailable. Meraki and MDM sections ' +
'will still appear. Start `remoteAgent.js` on the internal host to restore ' +
'full data.';
/**
* Map of `st` subcommand keyword mode. Order matters only for documentation.
* `iphone` is an alias for `ios` (carried over from the old `store` command).
*/
const MODE_KEYWORDS = Object.freeze({
network: STORE_MODES.NETWORK,
pos: STORE_MODES.POS,
ios: STORE_MODES.IOS,
iphone: STORE_MODES.IOS,
phone: STORE_MODES.PHONE,
av: STORE_MODES.AV,
});
const MODE_LABELS = Object.freeze({
[STORE_MODES.INFO]: 'Info',
[STORE_MODES.NETWORK]: 'Network',
[STORE_MODES.POS]: 'POS',
[STORE_MODES.IOS]: 'iOS',
[STORE_MODES.PHONE]: 'Phone',
[STORE_MODES.AV]: 'AV',
});
/**
* Get the user-facing command text from a webex-node-bot-framework trigger,
* with the leading "@BotName " stripped in group spaces. The framework
* exposes this via `trigger.command + trigger.prompt` after a regex match;
* we fall back to the raw message text when those aren't populated (e.g.
* direct callers in tests).
*/
function getCommandText(trigger) {
const command = trigger?.command ?? '';
const prompt = trigger?.prompt ?? '';
if (command || prompt) return `${command}${prompt}`;
return trigger?.message?.text || '';
}
/**
* Parse a `st <number> [subcommand]` message. Returns the store number and
* the chosen mode. If no number is found, mode is null.
*/
function parseStoreCommand(text) { function parseStoreCommand(text) {
const storeNumber = parseStoreNumber(text); const storeNumber = parseStoreNumber(text);
if (!storeNumber) return { storeNumber: null, mode: null }; if (!storeNumber) return { storeNumber: null, mode: null };
const lower = text.toLowerCase(); const lower = ` ${text.toLowerCase()} `;
let mode = STORE_MODES.DEFAULT;
if (lower.includes(' pos')) mode = STORE_MODES.POS; let mode = STORE_MODES.INFO;
else if (lower.includes(' ios') || lower.includes(' iphone')) mode = STORE_MODES.IOS; for (const [keyword, value] of Object.entries(MODE_KEYWORDS)) {
// Match keyword as a whole word so "phone" doesn't trip on "phones".
if (new RegExp(`\\b${keyword}\\b`).test(lower)) {
mode = value;
break;
}
}
return { storeNumber, mode }; return { storeNumber, mode };
} }
function usageMessage() {
return [
'**Usage:** `st [number] [subcommand]`',
'',
'**Example:** `st 305`',
'',
'**Subcommands:**',
'- `st 305` — store info (location, brand, status, environment)',
'- `st 305 network` — switches, APs, store server(s)',
'- `st 305 pos` — POS devices (registers, mobile registers, customer displays, printers, payment terminals)',
'- `st 305 ios` — iOS devices (iPhones)',
'- `st 305 phone` — wired (78xx) + DECT bases & handsets via Webex',
'- `st 305 av` — A/V hardware (Atlas AMPs + MDM-tracked Apple TVs, video walls, music, LED)',
'',
'Type `help` for the full reference.',
].join('\n');
}
async function handleStoreCommand(bot, trigger) { async function handleStoreCommand(bot, trigger) {
const { storeNumber, mode } = parseStoreCommand(trigger.message.text); const { storeNumber, mode } = parseStoreCommand(getCommandText(trigger));
if (!storeNumber) { if (!storeNumber) {
return bot.say( return bot.say('markdown', usageMessage());
'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.'
);
} }
const modeLabel = const modeLabel = MODE_LABELS[mode] || mode;
mode === STORE_MODES.POS ? 'POS' : mode === STORE_MODES.IOS ? 'iOS' : 'Overview';
bot.say('markdown', `🔍 Analyzing Store **${storeNumber}** (${modeLabel})...`); bot.say('markdown', `🔍 Analyzing Store **${storeNumber}** (${modeLabel})...`);
// SIW depends on the remote agent. INFO and POS both need SIW data; warn up
// front if the agent is down. NETWORK / IOS / PHONE / AV don't use SIW so
// they're unaffected.
const modesThatNeedSiw = new Set([STORE_MODES.INFO, STORE_MODES.POS]);
if (modesThatNeedSiw.has(mode) && !isAgentConnected()) {
logger.warn('st command running without remote agent', { storeNumber, mode });
await bot.say('markdown', REMOTE_AGENT_WARNING);
}
try { try {
const report = await getStoreDetail(storeNumber, mode); const report = await getStoreDetail(storeNumber, mode);
const sections = report.split(/\n\n(?=\*\*)/); const chunks = chunkReport(report);
for (let i = 0; i < sections.length; i++) { if (chunks.length === 0) {
const section = sections[i].trim(); await bot.say('markdown', '_No data to display for this view._');
if (section) { return;
await bot.say('markdown', section);
if (i < sections.length - 1) await new Promise(r => setTimeout(r, 400));
} }
for (let i = 0; i < chunks.length; i++) {
await bot.say('markdown', chunks[i]);
// Small gap between multi-chunk replies so Webex keeps them in order
// visually. Single-chunk replies (the common case) have no delay.
if (i < chunks.length - 1) await new Promise(r => setTimeout(r, 300));
} }
} catch (err) { } catch (err) {
logger.error('Store analysis error', { storeNumber, error: err.message }); logger.error('Store analysis error', { storeNumber, mode, error: err.message });
bot.say('markdown', `❌ Error analyzing store **${storeNumber}**:\n${err.message}`);
}
}
async function handleAnalyzeCommand(bot, trigger) {
const { storeNumber, mode } = parseStoreCommand(trigger.message.text);
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.'
);
}
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, mode);
if (health && health.summary) {
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) {
logger.error('Store health analysis error', { storeNumber, error: err.message });
bot.say('markdown', `❌ Error analyzing store **${storeNumber}**:\n${err.message}`); bot.say('markdown', `❌ Error analyzing store **${storeNumber}**:\n${err.message}`);
} }
} }
async function handleHelpCommand(bot, trigger) { async function handleHelpCommand(bot, trigger) {
const text = trigger.message.text.toLowerCase().trim(); const text = getCommandText(trigger).toLowerCase().trim();
let response; let response;
if (text.includes('store')) { if (/\bst\b|store/.test(text) && text !== 'help') {
response = `**Store Commands** response = [
'**`st` — 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 '- `st [number]` — store info (location, brand, status, environment)',
\`store <number> ios\` — All iOS devices '- `st [number] network` — switches, APs, store server(s)',
'- `st [number] pos` — POS devices (registers, mobile registers, customer displays, printers, payment terminals)',
**Tip:** Type the command without a number for usage examples.`; '- `st [number] ios` — iOS devices (mainly iPhones; alias: `iphone`)',
} else if (text.includes('analyze')) { '- `st [number] phone` — wired (78xx) and DECT bases + handsets via Webex Service App',
response = `**Analyze Commands** (shows only broken components for sub-modes) '- `st [number] av` — A/V hardware: Atlas AMPs + MDM-tracked Apple TVs, video walls, music, LED displays',
'',
\`analyze <number>\` — Overall health summary '**Tip:** type `st` without a number for a quick usage example.',
\`analyze <number> pos\` — POS health (only issues) ].join('\n');
\`analyze <number> ios\` — iOS health (only issues)
**Tip:** Type the command without a number for usage examples.`;
} else { } else {
response = `**NetAnalyzer Help** response = [
'**StoreHealthAnalyzer — Help**',
Available Commands: '',
'**Store Commands**',
**Store Commands:** '- `st [number]` — store info (location, brand, status, environment)',
\`store <number>\` — Store info/details, active network devices, and the store server '- `st [number] network` — switches, APs, store server(s)',
\`store <number> pos\` — Store server, registers, mobile registers, printers, and payment terminals '- `st [number] pos` — POS devices (registers, mobile registers, customer displays, printers, payment terminals)',
\`store <number> ios\` — All iOS devices '- `st [number] ios` — iOS devices (mainly iPhones)',
'- `st [number] phone` — wired (78xx) + DECT bases & handsets via Webex',
**Analyze Commands:** '- `st [number] av` — A/V hardware (Atlas AMPs + MDM Apple TVs / VW / MSC / LED)',
\`analyze <number>\` — Overall health summary '',
\`analyze <number> pos\` — POS health (broken components only) '**Tips**',
\`analyze <number> ios\` — iOS health (broken components only) '- Works in both group spaces and 1:1 chats.',
'- Type `st` without a number for usage examples.',
**Tips:** '- Use `help st` for command-specific help.',
Most commands work in both group spaces and 1:1 chats. '- Try `st 782` to get started.',
Type a command without a number for usage examples. ].join('\n');
Use \`help store\` or \`help analyze\` for more details.
Try \`store 782\` or \`analyze 782\` to get started.`;
} }
bot.say('markdown', response); bot.say('markdown', response);
@ -141,7 +170,8 @@ Available Commands:
module.exports = { module.exports = {
handleStoreCommand, handleStoreCommand,
handleAnalyzeCommand,
handleHelpCommand, handleHelpCommand,
parseStoreCommand, parseStoreCommand,
getCommandText,
MODE_LABELS,
}; };

View file

@ -20,6 +20,13 @@ const RECOMMENDED_ENV_VARS = [
'WS1_CLIENT_ID', 'WS1_CLIENT_ID',
'WS1_CLIENT_SECRET', 'WS1_CLIENT_SECRET',
'WS1_TENANT_CODE', 'WS1_TENANT_CODE',
// Webex Service App (only needed for `st <store> phone`). Missing values
// surface as an unavailable banner in phone mode rather than a fatal error.
'WEBEX_CLIENT_ID',
'WEBEX_CLIENT_SECRET',
// Atlas / Xyte (only needed for `st <store> av`). Missing key surfaces as
// an inline banner in AV mode; MDM-side AV devices still render.
'ATLAS_AUTH_KEY',
]; ];
function validateEnvironment() { function validateEnvironment() {
@ -59,7 +66,7 @@ module.exports = {
logLevel: process.env.LOG_LEVEL || 'info', logLevel: process.env.LOG_LEVEL || 'info',
webex: { webex: {
token: process.env.WEBEX_ACCESS_TOKEN, token: process.env.WEBEX_ACCESS_TOKEN,
name: process.env.BOT_NAME || 'NetAnalyzer', name: process.env.BOT_NAME || 'StoreHealthAnalyzer',
}, },
meraki: { meraki: {
baseUrl: 'https://api.meraki.com/api/v1', baseUrl: 'https://api.meraki.com/api/v1',
@ -82,4 +89,17 @@ module.exports = {
clientSecret: process.env.WS1_CLIENT_SECRET, clientSecret: process.env.WS1_CLIENT_SECRET,
tenantCode: process.env.WS1_TENANT_CODE, tenantCode: process.env.WS1_TENANT_CODE,
}, },
webexServiceApp: {
clientId: process.env.WEBEX_CLIENT_ID,
clientSecret: process.env.WEBEX_CLIENT_SECRET,
// Absolute path resolved by WebexServiceAppAuth at construction time.
tokensPath: process.env.WEBEX_TOKENS_PATH || './tokens/webex-service-tokens.json',
},
atlas: {
// Long-lived API key; sent verbatim as the Authorization header value
// (no Bearer prefix). The Atlas client throws AtlasUnavailableError
// when this is unset so the AV renderer can show a banner.
authKey: process.env.ATLAS_AUTH_KEY,
baseUrl: process.env.ATLAS_BASE_URL || 'https://hub.xyte.io/core/v1',
},
}; };

View file

@ -3,9 +3,12 @@
*/ */
const STORE_MODES = Object.freeze({ const STORE_MODES = Object.freeze({
DEFAULT: 'default', // store info + active network devices + store server INFO: 'info', // bare `st <n>` — just the Store header (location + general)
NETWORK: 'network', // switches, APs, and store server(s)
POS: 'pos', // store server + registers + mobile registers + printers + payment terminals POS: 'pos', // store server + registers + mobile registers + printers + payment terminals
IOS: 'ios', // all iOS devices IOS: 'ios', // iOS devices (iPhones)
PHONE: 'phone', // placeholder — simplified phone-device view (CollabSupport-style)
AV: 'av', // placeholder — simplified A/V-device view (CollabSupport-style)
}); });
/** /**
@ -19,6 +22,21 @@ const MDM_DEVICE_TYPES = Object.freeze({
IPHONE: 'IPH', IPHONE: 'IPH',
}); });
/**
* MDM-side AV hardware buckets. Names are matched (case-insensitive) against
* the device's friendly name. AppleTV is checked first because the others
* are short substrings that could appear inside an Apple TV's name.
* Mirrors collabFinder `STRICT_AV_PATTERN` (services/enrichment/filters.js).
*/
const AV_CATEGORIES = Object.freeze({
APPLE_TV: 'AppleTV',
VIDEO_WALL: 'VW',
MUSIC: 'MSC',
LED: 'LED',
});
const AV_FRIENDLY_NAME_PATTERN = /(VW|MSC|LED|AppleTV)/i;
/** /**
* Return the canonical device-name string used to classify an MDM device. * Return the canonical device-name string used to classify an MDM device.
*/ */
@ -35,9 +53,29 @@ function filterMdmByType(devices, marker) {
return devices.filter(d => mdmDeviceName(d).includes(marker)); return devices.filter(d => mdmDeviceName(d).includes(marker));
} }
/**
* Classify an MDM device into one of the AV categories, or null if it is
* not an AV device. Order of checks matters: AppleTV is most specific, so
* if a device name accidentally contains both "AppleTV" and one of the
* short markers, it's still classified as an Apple TV.
*/
function classifyMdmAvDevice(deviceOrName) {
const name = typeof deviceOrName === 'string' ? deviceOrName : mdmDeviceName(deviceOrName);
if (!name) return null;
if (/AppleTV/i.test(name)) return AV_CATEGORIES.APPLE_TV;
if (/VW/.test(name)) return AV_CATEGORIES.VIDEO_WALL;
if (/MSC/.test(name)) return AV_CATEGORIES.MUSIC;
if (/LED/.test(name)) return AV_CATEGORIES.LED;
return null;
}
module.exports = { module.exports = {
STORE_MODES, STORE_MODES,
MDM_DEVICE_TYPES, MDM_DEVICE_TYPES,
AV_CATEGORIES,
AV_FRIENDLY_NAME_PATTERN,
mdmDeviceName, mdmDeviceName,
filterMdmByType, filterMdmByType,
classifyMdmAvDevice,
}; };

View file

@ -0,0 +1,17 @@
# =============================================================================
# StoreHealthAnalyzer Remote Agent — Environment
# Copy this file to `.env` (next to docker-compose.yml) and fill in the
# values. NEVER commit .env — the top-level .dockerignore already excludes
# it from the image and .gitignore excludes it from git.
# =============================================================================
# Websocket URL of the main StoreHealthAnalyzer server. Use `wss://` if
# the server is reverse-proxied through TLS; `ws://host:port` for direct.
# Leave any legacy `?token=...` query parameter off — the WS_TOKEN below is
# sent as an Authorization: Bearer header instead.
WS_URL=wss://storehealthanalyzer.example.com/ws
# Shared secret the main server accepts. Must match the WS_TOKEN configured
# on the server side. Generate a strong random value once and rotate it if
# you suspect it's been exposed.
WS_TOKEN=change_me_to_a_long_random_value

View file

@ -0,0 +1,66 @@
# syntax=docker/dockerfile:1.7
# ============================================================================
# StoreHealthAnalyzer — Remote Agent
# ----------------------------------------------------------------------------
# The remote agent is a tiny WebSocket client that proxies HTTP requests
# (SIW / MDM / anything else the main bot needs from an internal network)
# back to the main StoreHealthAnalyzer server. It ships as a standalone
# container so it can run inside the segmented network where SIW/MDM live.
#
# Build context is the REPOSITORY ROOT so we can pull in `remoteAgent.js`
# from the source tree. Everything else (bot framework, express, config/,
# services/) is intentionally excluded — the agent doesn't need any of it.
#
# Build:
# docker build -f docker/remote-agent/Dockerfile -t sha-remote-agent:latest .
#
# Run:
# docker run --rm -it \
# --env-file docker/remote-agent/.env \
# --name sha-remote-agent \
# sha-remote-agent:latest
# ============================================================================
# ---- Stage 1: dependencies ------------------------------------------------
FROM node:22-alpine AS deps
WORKDIR /app
# Only copy the minimal package manifest (ws + axios + dotenv). Using
# `npm install --omit=dev` because this package.json intentionally has no
# lockfile — the three-dep footprint is small and stable enough that the
# extra file adds more maintenance than reproducibility.
COPY docker/remote-agent/package.json ./package.json
RUN npm install --omit=dev --no-audit --no-fund && npm cache clean --force
# ---- Stage 2: runtime -----------------------------------------------------
FROM node:22-alpine AS runtime
# tini is a tiny init that reaps zombies and forwards signals correctly, so
# `docker stop` reaches Node's SIGTERM handler for a clean websocket close.
RUN apk add --no-cache tini
# The `node` user ships preconfigured in the official image (uid 1000).
# Running unprivileged is a sane default for a container that just makes
# outbound HTTP calls.
WORKDIR /app
USER node
# Bring in the pre-installed node_modules from the deps stage, then the
# single application file. Both are owned by `node` so they can be read at
# runtime without extra chmod steps.
COPY --chown=node:node --from=deps /app/node_modules ./node_modules
COPY --chown=node:node docker/remote-agent/package.json ./package.json
COPY --chown=node:node remoteAgent.js ./remoteAgent.js
ENV NODE_ENV=production
# Documented, not enforced — the agent is a WebSocket CLIENT, so it doesn't
# listen on any port. Leaving this uncommented would be misleading, so we
# just skip EXPOSE entirely.
# tini as PID 1 → signals reach node → agent's SIGTERM/SIGINT handler runs
# → websocket closes cleanly → process exits 0.
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "remoteAgent.js"]

View file

@ -0,0 +1,222 @@
# StoreHealthAnalyzer Remote Agent — Docker
Standalone container for the remote agent that proxies SIW / MDM requests
from an internal network back to the main StoreHealthAnalyzer server over an
authenticated WebSocket.
## What ships in the image
- `node:22-alpine` runtime with [`tini`](https://github.com/krallin/tini) as
PID 1 so `docker stop` reaches Node's SIGTERM handler and the websocket
closes cleanly.
- Just the agent script (`remoteAgent.js`) and its three runtime deps
(`ws`, `axios`, `dotenv`). No bot framework, no Express, no test tooling.
- Runs as the unprivileged `node` user.
Final image size is small (roughly 6080 MB depending on architecture),
compared to ~180 MB if the root `package.json` were installed.
## Files in this folder
| File | Purpose |
| --- | --- |
| `Dockerfile` | Two-stage build (`deps` → `runtime`). Uses the repo root as the build context so it can pull in `remoteAgent.js`. |
| `package.json` | Minimal manifest: `ws`, `axios`, `dotenv`. |
| `docker-compose.yml` | Convenience wrapper for **local** builds; run from the repo root. |
| `.env.example` | Copy to `.env`, fill in `WS_URL` + `WS_TOKEN`. |
| `package.sh` | Builds the image and produces a self-contained deploy ZIP under `dist/`. |
| `deploy/` | Files that get bundled into the deploy ZIP (runtime compose, `install.sh`, remote README). |
| `dist/` | Generated ZIPs (gitignored). |
## Prerequisites
- Docker 24+ (BuildKit is default and required for the `syntax=` line).
- The main StoreHealthAnalyzer server reachable from the host that will run
this container (outbound only — the agent doesn't listen on any port).
- A shared `WS_TOKEN` value matching the one configured on the server.
## Build
Always build from the **repository root** — the Dockerfile expects that
context so it can copy `remoteAgent.js`:
```bash
# From the repo root
docker build \
-f docker/remote-agent/Dockerfile \
-t sha-remote-agent:latest \
.
```
Tag with a version too if you plan to ship it to a registry:
```bash
docker tag sha-remote-agent:latest ghcr.io/<owner>/sha-remote-agent:1.0.0
docker push ghcr.io/<owner>/sha-remote-agent:1.0.0
```
## Configure
```bash
cp docker/remote-agent/.env.example docker/remote-agent/.env
$EDITOR docker/remote-agent/.env
```
Required values:
- `WS_URL` — websocket URL of the main server (e.g. `wss://sha.example.com/ws`).
- `WS_TOKEN` — shared secret matching the server's `WS_TOKEN`.
Both `.env` and `.env.*` are excluded by the top-level `.dockerignore`, so
the file is never baked into the image.
## Run
### Docker CLI
```bash
docker run --rm -it \
--name sha-remote-agent \
--env-file docker/remote-agent/.env \
sha-remote-agent:latest
```
Add `-d` for detached mode and `--restart unless-stopped` if you want it to
auto-recover on host reboots.
### Docker Compose (recommended)
```bash
# From the repo root
docker compose -f docker/remote-agent/docker-compose.yml up -d --build
# Tail logs
docker compose -f docker/remote-agent/docker-compose.yml logs -f
# Stop
docker compose -f docker/remote-agent/docker-compose.yml down
```
Compose sets `restart: unless-stopped` and 10 MB / 3-file JSON log rotation
so the container survives host restarts and doesn't fill the disk with
reconnect chatter.
## Deploy elsewhere (ZIP bundle — recommended)
For hosts you can't reach with a registry, use the packaging script — it
produces a single ZIP with the image, a runtime compose file, an installer,
and a checksum:
```bash
# From the repo root — defaults to building for linux/amd64
npm run agent:package
# or, equivalently:
./docker/remote-agent/package.sh
```
Output lands in `docker/remote-agent/dist/sha-remote-agent-<version>.zip`
(the folder is gitignored). Transfer that one file to the remote host and:
```bash
unzip sha-remote-agent-<version>.zip
cd sha-remote-agent-<version>
./install.sh # loads the image, seeds .env, starts the container
```
Full remote-host instructions ship inside the ZIP as `README.md` and are
also visible here for reference: [`deploy/README.md`](deploy/README.md).
The script tags the image both `sha-remote-agent:<version>` and
`sha-remote-agent:latest`, so local `docker compose` still works after
packaging.
### Target-platform selection (very important on Apple Silicon)
Docker images are architecture-specific. If you build on an Apple Silicon
Mac with `docker build`, you get an `arm64` image — which will **fail to
start** on a typical x86_64 Linux server (RHEL, Rocky, CentOS, Ubuntu)
with `exec /sbin/tini: exec format error`. The packaging script uses
`docker buildx build --platform ...` to avoid that.
The default target is `linux/amd64`. Override with `--platform` when your
remote host is different:
```bash
# x86_64 Linux (the default — Linux RH / Rocky / CentOS / Ubuntu on Intel/AMD)
./docker/remote-agent/package.sh --platform linux/amd64
# ARM Linux (Raspberry Pi 4/5, Ampere servers, etc.)
./docker/remote-agent/package.sh --platform linux/arm64
# For local testing on Apple Silicon
./docker/remote-agent/package.sh --platform linux/arm64
```
`install.sh` on the remote host also detects `image_arch != host_arch` and
refuses to start with a clear message pointing at the right rebuild command,
so a wrong-arch ZIP fails fast instead of after `docker run`.
Cross-building requires `docker buildx` — Docker Desktop ships it by
default; on Linux install the `docker-buildx-plugin` package if it isn't
already there.
### Manual export (without the packaging script)
If you'd rather do it by hand:
```bash
# Export from the build host
docker save sha-remote-agent:latest | gzip > sha-remote-agent.tar.gz
# Import on the target host
gunzip -c sha-remote-agent.tar.gz | docker load
# On the target: only .env is needed; no source tree required
docker run --rm -d \
--name sha-remote-agent \
--restart unless-stopped \
--env-file /path/to/remote-agent.env \
sha-remote-agent:latest
```
## Networking
The agent is a **websocket client** — nothing listens inside the container,
so there's no port to publish. You just need outbound network access from
the container to:
- The main StoreHealthAnalyzer server (`WS_URL`).
- Whatever internal APIs the agent proxies for (SIW, MDM, ...).
If the internal APIs live only on the container host's network (e.g. a
private VLAN accessible only from the host), uncomment `network_mode: host`
in `docker-compose.yml` (Linux only). On Docker Desktop for macOS/Windows,
prefer running the container on a user-defined bridge network that has route
access to the required endpoints.
## Verifying it works
Startup logs from a healthy agent look like:
```
🔄 Connecting to wss://sha.example.com/ws...
✅ Remote Agent connected to StoreHealthAnalyzer
```
On the main server side you should see a matching `Remote agent connected`
log line. From then on, `st [number]` commands that need SIW data will
succeed instead of degrading to the "Remote agent is not connected" banner.
## Signals and shutdown
The agent handles `SIGTERM` and `SIGINT` explicitly (see
`remoteAgent.js`), closing the websocket before exiting. Because we run
`tini` as PID 1, `docker stop` (which sends `SIGTERM` then kills after the
grace period) reaches Node correctly and the exit is clean.
## Rebuilding after code changes
Because `remoteAgent.js` is copied in during the runtime stage, changing
the script requires a rebuild (`--build` with compose, or a fresh
`docker build`). The `deps` stage is cached whenever `package.json` is
unchanged, so incremental rebuilds are fast.

View file

@ -0,0 +1,119 @@
# StoreHealthAnalyzer Remote Agent — Deploy Bundle
This ZIP is a self-contained deployment bundle for the StoreHealthAnalyzer
remote agent. Extract it, run `install.sh`, fill in your `.env`, and the
agent will start as a Docker container.
## What's in the bundle
| File | Purpose |
| --- | --- |
| `sha-remote-agent-<version>.tar.gz` | The Docker image, saved via `docker save`. |
| `docker-compose.yml` | Runtime-only compose file (no build step; references the loaded image). |
| `install.sh` | Verifies checksum, loads the image, seeds `.env`, starts the container. |
| `.env.example` | Template — copied to `.env` on first run for you to fill in. |
| `SHA256SUMS` | Integrity check for the image tarball. |
| `VERSION` | Plain-text version marker used by `install.sh` and `docker-compose.yml`. |
| `README.md` | This file. |
## Prerequisites (on the remote host)
- Docker 20.10+ with the daemon running.
- Docker Compose — either the modern `docker compose` plugin (v2) or the
legacy `docker-compose` binary. `install.sh` auto-detects.
- Whichever user runs `install.sh` needs permission to talk to the Docker
daemon (member of the `docker` group, or run under `sudo`).
- Outbound network access from the host to:
- The main StoreHealthAnalyzer server (`WS_URL`).
- The internal APIs the agent proxies for (SIW, MDM, etc.).
## Install / start
```bash
unzip sha-remote-agent-<version>.zip
cd sha-remote-agent-<version>
./install.sh
```
On the first run `install.sh` will:
1. Verify the SHA-256 of the image tarball against `SHA256SUMS`.
2. Load the image into Docker (skipped on subsequent runs if the image is
already present).
3. Copy `.env.example``.env` and stop, asking you to fill it in.
Fill in `.env`:
```bash
vi .env # set WS_URL and WS_TOKEN
```
Then re-run:
```bash
./install.sh
```
That last run will start the container (`docker compose up -d`) and print
the log-tail command.
## Day-to-day operations
```bash
docker compose logs -f # tail the agent logs
docker compose ps # show container status
docker compose restart # cycle it
docker compose down # stop and remove the container
docker compose up -d # bring it back up
```
Healthy startup looks like:
```
🔄 Connecting to wss://.../ws...
✅ Remote Agent connected to StoreHealthAnalyzer
```
## Upgrading
When you receive a newer ZIP:
```bash
# Optional: back up your existing config
cp -a <old-version-folder>/.env ./sha-remote-agent-<new-version>-env.bak
# Stop the old container
cd <old-version-folder> && docker compose down && cd ..
# Extract and start the new one
unzip sha-remote-agent-<new-version>.zip
cp <old-version-folder>/.env sha-remote-agent-<new-version>/.env
cd sha-remote-agent-<new-version>
./install.sh
```
The old image stays in Docker's local cache until you `docker image prune`
it — handy if you need to roll back quickly.
## Troubleshooting
- **"Cannot talk to the Docker daemon"** — either Docker isn't running or
your user isn't in the `docker` group. Try `sudo ./install.sh` or add
yourself to the group: `sudo usermod -aG docker $USER` and log back in.
- **"Checksum verification FAILED"** — the ZIP was corrupted in transit.
Re-transfer.
- **"exec /sbin/tini: exec format error"** or **"Image architecture
does not match this host"** — the ZIP was built for the wrong CPU
architecture (typically an Apple Silicon Mac produced an `arm64` image
for an `x86_64` Linux host). `install.sh` catches this and prints the
exact rebuild command; ask your build operator to run:
```
./docker/remote-agent/package.sh --platform linux/amd64
```
(or `linux/arm64` if this host is ARM — run `uname -m` to check:
`x86_64``linux/amd64`, `aarch64``linux/arm64`.)
- **Agent connects, then disconnects immediately**`WS_TOKEN` doesn't
match the server. Fix in `.env`, then `docker compose restart`.
- **Agent never connects** — check `WS_URL` (correct hostname, correct
scheme `ws://` vs `wss://`) and that there's no firewall between this
host and the server.

View file

@ -0,0 +1,26 @@
# Runtime-only compose file that ships inside the deploy ZIP.
# Unlike the build-time compose in the repo root, this one does NOT try to
# build anything — it references the image loaded from the tarball
# (`sha-remote-agent:__VERSION__`, replaced at package time).
#
# Run:
# ./install.sh # first-time setup (loads image, seeds .env, starts)
# docker compose up -d # subsequent starts once installed
# docker compose logs -f # tail logs
# docker compose down # stop
services:
remote-agent:
image: sha-remote-agent:__VERSION__
container_name: sha-remote-agent
restart: unless-stopped
env_file:
- .env
# The agent is a websocket CLIENT — no ports to publish.
stop_signal: SIGTERM
stop_grace_period: 10s
logging:
driver: json-file
options:
max-size: '10m'
max-file: '3'

View file

@ -0,0 +1,118 @@
#!/usr/bin/env bash
#
# StoreHealthAnalyzer Remote Agent — install / (re)start on a remote host.
#
# Run this after extracting the deploy ZIP:
# unzip sha-remote-agent-<version>.zip
# cd sha-remote-agent-<version>
# ./install.sh
#
# On first run: verifies the image tarball, loads it into Docker, and drops
# a starter .env so you can fill in WS_URL / WS_TOKEN. Re-runs are safe —
# the script is idempotent.
set -euo pipefail
# Move to the script's own directory so relative paths work regardless of
# where the user invoked it from.
cd "$(dirname "$0")"
RED=$'\033[0;31m'
GRN=$'\033[0;32m'
YLW=$'\033[1;33m'
RST=$'\033[0m'
log() { printf '%s[install]%s %s\n' "$GRN" "$RST" "$*"; }
warn() { printf '%s[install]%s %s\n' "$YLW" "$RST" "$*"; }
die() { printf '%s[install]%s %s\n' "$RED" "$RST" "$*" >&2; exit 1; }
# --- 1. Preflight ----------------------------------------------------------
command -v docker >/dev/null 2>&1 || die "Docker not found on PATH."
docker info >/dev/null 2>&1 \
|| die "Cannot talk to the Docker daemon. Is it running / do you have permission?"
# Detect either `docker compose` (v2 plugin) or the legacy `docker-compose`.
if docker compose version >/dev/null 2>&1; then
COMPOSE=(docker compose)
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE=(docker-compose)
else
die "Neither 'docker compose' nor 'docker-compose' is available. Install Docker Compose."
fi
[[ -f VERSION ]] || die "VERSION file missing from bundle — is this a valid deploy ZIP?"
VERSION="$(cat VERSION)"
IMAGE_TARBALL="sha-remote-agent-${VERSION}.tar.gz"
[[ -f "$IMAGE_TARBALL" ]] || die "Image tarball not found: $IMAGE_TARBALL"
# --- 2. Verify checksum (optional; skip gracefully if no shasum tool) -----
if [[ -f SHA256SUMS ]]; then
if command -v sha256sum >/dev/null 2>&1; then
log "Verifying SHA256 checksum..."
sha256sum -c SHA256SUMS >/dev/null || die "Checksum verification FAILED."
elif command -v shasum >/dev/null 2>&1; then
log "Verifying SHA256 checksum (macOS shasum)..."
shasum -a 256 -c SHA256SUMS >/dev/null || die "Checksum verification FAILED."
else
warn "No sha256sum/shasum tool found — skipping integrity check."
fi
log "Checksum OK."
else
warn "No SHA256SUMS file in bundle — skipping integrity check."
fi
# --- 3. Load the image -----------------------------------------------------
IMAGE_TAG="sha-remote-agent:${VERSION}"
if docker image inspect "$IMAGE_TAG" >/dev/null 2>&1; then
log "Image $IMAGE_TAG already present — skipping load."
else
log "Loading Docker image from $IMAGE_TARBALL..."
docker load -i "$IMAGE_TARBALL"
fi
# --- 3a. Platform sanity check --------------------------------------------
# If the image was built for a different CPU architecture than this host,
# Docker will let it "run" but tini (and node) fail with cryptic errors
# like "exec format error". Catch that up front with a clear message.
IMAGE_ARCH="$(docker image inspect --format '{{.Architecture}}' "$IMAGE_TAG" 2>/dev/null || true)"
HOST_ARCH_RAW="$(uname -m)"
case "$HOST_ARCH_RAW" in
x86_64|amd64) HOST_ARCH="amd64" ;;
aarch64|arm64) HOST_ARCH="arm64" ;;
armv7l) HOST_ARCH="arm" ;;
*) HOST_ARCH="$HOST_ARCH_RAW" ;;
esac
if [[ -n "$IMAGE_ARCH" && "$IMAGE_ARCH" != "$HOST_ARCH" ]]; then
warn "Image architecture ($IMAGE_ARCH) does not match this host ($HOST_ARCH)."
warn "The container will fail to start with 'exec format error'."
die "Rebuild on the dev host with: ./docker/remote-agent/package.sh --platform linux/${HOST_ARCH}"
fi
# --- 4. Bootstrap .env -----------------------------------------------------
if [[ ! -f .env ]]; then
if [[ -f .env.example ]]; then
cp .env.example .env
warn ".env did not exist — copied .env.example into place."
warn "EDIT .env now to set WS_URL and WS_TOKEN, then re-run this script."
exit 0
else
die ".env is missing and no .env.example is bundled. Cannot proceed."
fi
fi
# --- 5. Start the container -----------------------------------------------
log "Starting sha-remote-agent (version ${VERSION})..."
"${COMPOSE[@]}" up -d
log "Done. Tail logs with:"
log " ${COMPOSE[*]} logs -f"
log "Stop with:"
log " ${COMPOSE[*]} down"

View file

@ -0,0 +1,43 @@
# Compose file for the StoreHealthAnalyzer remote agent.
#
# Run from the REPOSITORY ROOT so the build context can pick up
# remoteAgent.js:
#
# docker compose -f docker/remote-agent/docker-compose.yml up -d --build
#
# Environment values come from docker/remote-agent/.env (copy the .env.example
# next to it). Set WS_URL to the main StoreHealthAnalyzer server's public
# websocket endpoint, and WS_TOKEN to the shared secret.
services:
remote-agent:
build:
context: ../..
dockerfile: docker/remote-agent/Dockerfile
image: sha-remote-agent:latest
container_name: sha-remote-agent
restart: unless-stopped
env_file:
- .env
# The agent is a websocket client — it doesn't listen on any port, so
# there's nothing to publish. It just needs outbound network access to:
# - the main StoreHealthAnalyzer server (WS_URL)
# - the internal APIs it proxies for (SIW, MDM, whatever else).
#
# If those live on the host's Docker network, uncomment `network_mode:
# host` (Linux only) or attach to a shared user-defined network.
#
# network_mode: host
# Stop signal + timeout tuning: the agent handles SIGTERM cleanly via
# tini, so the default 10s grace period is plenty.
stop_signal: SIGTERM
stop_grace_period: 10s
# Send stdout/stderr to json-file with sensible rotation so a long-lived
# container doesn't fill the disk with reconnect chatter.
logging:
driver: json-file
options:
max-size: '10m'
max-file: '3'

View file

@ -0,0 +1,19 @@
{
"name": "storehealthanalyzer-remote-agent",
"version": "1.0.0",
"private": true,
"description": "Standalone container for the StoreHealthAnalyzer remote agent (WebSocket proxy for SIW/MDM/etc. from an internal network).",
"main": "remoteAgent.js",
"type": "commonjs",
"scripts": {
"start": "node remoteAgent.js"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"axios": "^1.16.1",
"dotenv": "^17.4.2",
"ws": "^8.20.1"
}
}

193
docker/remote-agent/package.sh Executable file
View file

@ -0,0 +1,193 @@
#!/usr/bin/env bash
#
# Package the StoreHealthAnalyzer remote agent into a self-contained ZIP
# for offline / manual transfer to a remote Docker host.
#
# What this script does:
# 1. Reads the version from docker/remote-agent/package.json.
# 2. Builds `sha-remote-agent:<version>` from the local source tree.
# 3. `docker save`s the image, gzip-compressed, into a temp staging dir.
# 4. Copies deploy/docker-compose.yml, deploy/install.sh, deploy/README.md,
# and .env.example into the staging dir. Rewrites the compose file's
# __VERSION__ placeholder to match the built image tag.
# 5. Writes VERSION and SHA256SUMS files for identification / integrity.
# 6. Zips the whole staging dir into docker/remote-agent/dist/.
#
# Usage:
# ./docker/remote-agent/package.sh # tag=package.json, platform=linux/amd64
# ./docker/remote-agent/package.sh --tag 1.0.1 # override tag
# ./docker/remote-agent/package.sh --platform linux/arm64 # ARM Linux target
# ./docker/remote-agent/package.sh --platform linux/amd64 # explicit default (Linux RH/Rocky/CentOS/Ubuntu on Intel)
#
# The image is ALWAYS built for the target platform via `docker buildx
# build --platform ...` so the tarball you ship matches the remote host.
# Default is linux/amd64 because that's the overwhelmingly common Linux
# server architecture; override with --platform if your remote host is
# something else (e.g. linux/arm64 for a Raspberry Pi or ARM-based server).
#
# Requires: docker (with buildx), zip, node (for reading package.json),
# sha256sum OR shasum (macOS ships shasum by default).
set -euo pipefail
# --- Locations --------------------------------------------------------------
# Absolute path to this script's directory; then one level up is the repo root.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
DIST_DIR="$SCRIPT_DIR/dist"
DEPLOY_DIR="$SCRIPT_DIR/deploy"
DOCKERFILE="$SCRIPT_DIR/Dockerfile"
# --- Colors -----------------------------------------------------------------
GRN=$'\033[0;32m'
YLW=$'\033[1;33m'
RED=$'\033[0;31m'
RST=$'\033[0m'
log() { printf '%s[package]%s %s\n' "$GRN" "$RST" "$*"; }
warn() { printf '%s[package]%s %s\n' "$YLW" "$RST" "$*"; }
die() { printf '%s[package]%s %s\n' "$RED" "$RST" "$*" >&2; exit 1; }
# --- Argument parsing -------------------------------------------------------
VERSION=""
# Default target platform. Overwhelming majority of Linux server hosts
# (RHEL, Rocky, CentOS, Ubuntu, Debian) run on x86_64. Override with
# --platform for ARM Linux (linux/arm64) or anything else.
PLATFORM="linux/amd64"
while [[ $# -gt 0 ]]; do
case "$1" in
--tag)
shift
VERSION="${1:-}"
shift || true
;;
--platform)
shift
PLATFORM="${1:-}"
shift || true
;;
-h|--help)
grep '^#' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
*)
die "Unknown argument: $1 (try --help)"
;;
esac
done
[[ -n "$PLATFORM" ]] || die "--platform requires a value (e.g. linux/amd64, linux/arm64)"
# --- Preflight --------------------------------------------------------------
command -v docker >/dev/null 2>&1 || die "docker not found on PATH."
command -v zip >/dev/null 2>&1 || die "zip not found on PATH."
command -v node >/dev/null 2>&1 || die "node not found on PATH."
# buildx is required so we can cross-build for a specific target platform
# on any host (e.g. build linux/amd64 from an Apple Silicon Mac).
docker buildx version >/dev/null 2>&1 \
|| die "docker buildx not available. Install Docker Desktop or the buildx plugin."
if [[ -z "$VERSION" ]]; then
VERSION="$(node -p "require('$SCRIPT_DIR/package.json').version")"
fi
[[ -n "$VERSION" ]] || die "Could not determine version."
log "Packaging sha-remote-agent version: ${VERSION}"
log "Target platform: ${PLATFORM}"
IMAGE_TAG="sha-remote-agent:${VERSION}"
BUNDLE_NAME="sha-remote-agent-${VERSION}"
STAGING_DIR="$(mktemp -d)"
STAGING_ROOT="$STAGING_DIR/$BUNDLE_NAME"
mkdir -p "$STAGING_ROOT"
# Guarantee cleanup even on error.
cleanup() { rm -rf "$STAGING_DIR"; }
trap cleanup EXIT
# --- 1. Build the image -----------------------------------------------------
log "Building Docker image ${IMAGE_TAG} for ${PLATFORM} (context = ${REPO_ROOT})..."
# buildx with --load emits the image straight into the local Docker daemon
# so `docker save` in the next step picks it up. --load only supports one
# platform at a time, which matches our "one ZIP per target" workflow.
docker buildx build \
--platform "$PLATFORM" \
--load \
-f "$DOCKERFILE" \
-t "$IMAGE_TAG" \
-t "sha-remote-agent:latest" \
"$REPO_ROOT"
# --- 2. Save the image to a gzipped tarball --------------------------------
IMAGE_TARBALL="${BUNDLE_NAME}.tar.gz"
log "Saving image to ${IMAGE_TARBALL}..."
docker save "$IMAGE_TAG" | gzip > "$STAGING_ROOT/$IMAGE_TARBALL"
TAR_SIZE_MB="$(du -m "$STAGING_ROOT/$IMAGE_TARBALL" | cut -f1)"
log "Image tarball size: ${TAR_SIZE_MB} MB"
# --- 3. Copy deploy assets --------------------------------------------------
log "Copying deploy assets into bundle..."
cp "$SCRIPT_DIR/.env.example" "$STAGING_ROOT/.env.example"
cp "$DEPLOY_DIR/install.sh" "$STAGING_ROOT/install.sh"
cp "$DEPLOY_DIR/README.md" "$STAGING_ROOT/README.md"
# Template the version into the runtime compose file so it references the
# specific image tag we just built.
sed "s|__VERSION__|${VERSION}|g" \
"$DEPLOY_DIR/docker-compose.yml" > "$STAGING_ROOT/docker-compose.yml"
chmod +x "$STAGING_ROOT/install.sh"
# --- 4. VERSION + SHA256SUMS -----------------------------------------------
printf '%s\n' "$VERSION" > "$STAGING_ROOT/VERSION"
log "Computing SHA-256 checksum for the image tarball..."
pushd "$STAGING_ROOT" >/dev/null
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$IMAGE_TARBALL" > SHA256SUMS
elif command -v shasum >/dev/null 2>&1; then
# macOS: shasum -a 256 emits the same "<hash> <filename>" format
# `sha256sum -c` understands.
shasum -a 256 "$IMAGE_TARBALL" > SHA256SUMS
else
warn "No sha256sum/shasum available — skipping checksum file."
fi
popd >/dev/null
# --- 5. Zip -----------------------------------------------------------------
mkdir -p "$DIST_DIR"
ZIP_PATH="$DIST_DIR/${BUNDLE_NAME}.zip"
rm -f "$ZIP_PATH"
log "Creating ZIP: ${ZIP_PATH}"
# Zip from inside the temp dir so the archive contains the top-level folder
# with the bundle name (matching what install.sh expects when unzipped).
(cd "$STAGING_DIR" && zip -qr "$ZIP_PATH" "$BUNDLE_NAME")
ZIP_SIZE_MB="$(du -m "$ZIP_PATH" | cut -f1)"
# --- 6. Done ---------------------------------------------------------------
log ""
log "=========================================================="
log " Bundle ready:"
log " $ZIP_PATH"
log " (${ZIP_SIZE_MB} MB, built for ${PLATFORM})"
log "=========================================================="
log ""
log "Transfer to the remote host, then:"
log " unzip ${BUNDLE_NAME}.zip"
log " cd ${BUNDLE_NAME}"
log " ./install.sh"

View file

@ -0,0 +1,107 @@
/**
* Atlas (Xyte) HTTP client.
*
* Atlas is the SaaS that monitors AV hardware (AMPs, displays, etc.) for the
* organization. Auth is a single long-lived API key sent verbatim in the
* `Authorization` header no OAuth/rotation, in contrast to the Webex
* Service App. Configure with `ATLAS_AUTH_KEY`.
*
* `atlasGet` returns the response body and wraps transient failures via
* withRetry. Missing-key conditions throw `AtlasUnavailableError` so the
* upstream renderer can surface a clean banner instead of a stack trace.
*/
const axios = require('axios');
const { withRetry } = require('../../utils/retry');
const logger = require('../../utils/logger');
const DEFAULT_BASE_URL = 'https://hub.xyte.io/core/v1';
const REQUEST_TIMEOUT_MS = 15000;
const RETRY_OPTS = { retries: 2, initialDelayMs: 500 };
class AtlasUnavailableError extends Error {
constructor(message) {
super(message);
this.name = 'AtlasUnavailableError';
}
}
// We construct the axios instance lazily so process.env changes between test
// cases are picked up, and so importing this module never throws when the
// key is absent (the renderer prefers a banner over a startup failure).
let _client = null;
function getClient() {
if (_client) return _client;
const authKey = process.env.ATLAS_AUTH_KEY;
if (!authKey) {
throw new AtlasUnavailableError(
'ATLAS_AUTH_KEY is not set. Add it to the environment and restart the bot.'
);
}
_client = axios.create({
baseURL: process.env.ATLAS_BASE_URL || DEFAULT_BASE_URL,
timeout: REQUEST_TIMEOUT_MS,
headers: {
'Content-Type': 'application/json',
// Atlas accepts the raw key in the Authorization header (no "Bearer ").
Authorization: authKey,
},
});
return _client;
}
/**
* GET against the Atlas API. Returns `response.data` (or `{}`). Wraps
* transient failures via withRetry.
*
* Throws AtlasUnavailableError when ATLAS_AUTH_KEY is missing, or a plain
* Error with status context on hard transport failures.
*/
async function atlasGet(endpoint, params = {}) {
const client = getClient();
const path = String(endpoint).replace(/^\/+/, '');
try {
const res = await withRetry(
() =>
client.get(`/${path}`, {
params,
// Validate manually so 4xx don't burn retry budget.
validateStatus: status => status >= 200 && status < 500,
}),
RETRY_OPTS
);
if (res.status >= 400) {
const detail =
res.data?.message || res.data?.error || `${res.status} ${res.statusText || ''}`.trim();
throw new Error(`Atlas GET /${path} failed: ${detail}`);
}
return res.data ?? {};
} catch (err) {
if (err instanceof AtlasUnavailableError) throw err;
logger.error('Atlas request failed', {
endpoint: path,
error: err.message,
status: err.response?.status,
});
throw err;
}
}
function resetClientForTests() {
_client = null;
}
module.exports = {
atlasGet,
AtlasUnavailableError,
resetClientForTests,
// Exposed so tests can assert defaults without poking the cached client.
DEFAULT_BASE_URL,
};

View file

@ -0,0 +1,177 @@
/**
* Atlas (Xyte) device discovery.
*
* The Atlas org-wide device list endpoint is paginated and not free, so we
* maintain a 1-hour in-process cache (same TTL the collabFinder reference
* uses). `getAtlasDeviceList()` lazily populates the cache; everything else
* filters on top of it.
*
* Store matching mirrors the collabFinder convention: device names follow
* `US<6-digit padded store>` (e.g. `US000782AMP`), so we zero-pad the store
* number before doing a case-insensitive substring match. Padding is
* deliberate a raw "782" would also substring-match unrelated devices like
* `US007820AMP`.
*/
const { atlasGet, AtlasUnavailableError } = require('./atlasClient');
const logger = require('../../utils/logger');
const PAGE_SIZE = 100;
const PAGE_THROTTLE_MS = 80;
const HARD_PAGE_CAP = 100;
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
let _cachedDevices = [];
let _lastCacheTime = 0;
function resetCacheForTests() {
_cachedDevices = [];
_lastCacheTime = 0;
}
/**
* Walk the paginated `/organization/devices` endpoint and replace the cache.
* Bails out early on auth/transport failure and preserves the previous cache
* (so a single bad refresh doesn't blank the bot's data view).
*/
async function refreshAtlasDevicesCache() {
const start = Date.now();
logger.debug('Refreshing Atlas device cache');
let allItems = [];
let page = 1;
while (page <= HARD_PAGE_CAP) {
let data;
try {
data = await atlasGet('organization/devices', { page, per_page: PAGE_SIZE });
} catch (err) {
if (err instanceof AtlasUnavailableError) throw err;
logger.error('Atlas device cache refresh failed mid-pagination', {
page,
error: err.message,
});
// If we have a previously populated cache, keep serving it so a
// transient outage doesn't blank the AV view. With nothing cached, the
// caller has no fallback — re-throw so getAtlasDevicesForStore can
// surface the unavailable banner.
if (_cachedDevices.length === 0) throw err;
return;
}
const items = Array.isArray(data?.items) ? data.items : [];
allItems = allItems.concat(items);
const nextPage = data?.next_page;
// Stop on a short page or a missing/falsy `next_page`. Either signal
// means we've exhausted the list.
if (!nextPage || items.length < PAGE_SIZE) break;
page = Number(nextPage) || page + 1;
await new Promise(r => setTimeout(r, PAGE_THROTTLE_MS));
}
_cachedDevices = allItems;
_lastCacheTime = Date.now();
logger.info('Atlas device cache refreshed', {
count: _cachedDevices.length,
elapsedMs: Date.now() - start,
});
}
/**
* Return the cached device list, refreshing on first call or TTL expiry.
* Pass `forceRefresh: true` to ignore the TTL.
*/
async function getAtlasDeviceList(forceRefresh = false) {
const stale = Date.now() - _lastCacheTime > CACHE_TTL_MS;
if (forceRefresh || _cachedDevices.length === 0 || stale) {
await refreshAtlasDevicesCache();
}
return _cachedDevices;
}
/**
* Find devices whose name contains the zero-padded store number.
* Padding to 6 digits matches Atlas's `US<NNNNNN>` naming.
*/
async function findAtlasDevicesForStore(storeNumber) {
const padded = String(storeNumber).trim().padStart(6, '0');
const devices = await getAtlasDeviceList();
const matches = devices.filter(dev =>
String(dev.name || '')
.toUpperCase()
.includes(padded)
);
logger.debug('Atlas devices matched for store', {
storeNumber,
padded,
matched: matches.length,
});
return matches;
}
/**
* Fetch detail for a single device. Returns null on failure rather than
* throwing most callers iterate a small list and want best-effort enrichment.
*/
async function getAtlasDeviceDetail(deviceId) {
if (!deviceId) return null;
try {
return await atlasGet(`organization/devices/${deviceId}`);
} catch (err) {
if (err instanceof AtlasUnavailableError) throw err;
logger.warn('Atlas device detail fetch failed', { deviceId, error: err.message });
return null;
}
}
/**
* Convenience: find devices by store and fetch detail for each in parallel.
* Returns `{ devices: [...], unavailable, reason }` so the caller has the
* same shape phone uses.
*/
async function getAtlasDevicesForStore(storeNumber) {
let candidates;
try {
candidates = await findAtlasDevicesForStore(storeNumber);
} catch (err) {
if (err instanceof AtlasUnavailableError) {
return { devices: [], unavailable: true, reason: err.message };
}
return {
devices: [],
unavailable: true,
reason: `Atlas lookup failed: ${err.message}`,
};
}
if (candidates.length === 0) {
return { devices: [] };
}
const detailResults = await Promise.allSettled(candidates.map(c => getAtlasDeviceDetail(c.id)));
// Stitch detail back over the list summary so we don't lose the name when
// detail returned null. Detail wins for everything it does provide.
const devices = candidates.map((summary, idx) => {
const detail = detailResults[idx];
const detailValue = detail.status === 'fulfilled' ? detail.value : null;
return { ...summary, ...(detailValue || {}) };
});
return { devices };
}
module.exports = {
refreshAtlasDevicesCache,
getAtlasDeviceList,
findAtlasDevicesForStore,
getAtlasDeviceDetail,
getAtlasDevicesForStore,
resetCacheForTests,
PAGE_SIZE,
HARD_PAGE_CAP,
CACHE_TTL_MS,
};

View file

@ -1,5 +1,6 @@
const { const {
getStoreLocation, getStoreLocation,
getStoreGeneral,
getStoreRegisters, getStoreRegisters,
getStorePrinters, getStorePrinters,
getStorePaymentTerminals, getStorePaymentTerminals,
@ -10,48 +11,122 @@ const {
getMerakiClients, getMerakiClients,
} = require('../services/meraki'); } = require('../services/meraki');
const { getMDMDevices } = require('../services/mdm'); const { getMDMDevices } = require('../services/mdm');
const { collectPhoneStatus } = require('../services/webexPhone');
const { collectAvStatus } = require('../services/avService');
const { createStore } = require('../models/Store'); const { createStore } = require('../models/Store');
const { const {
findMatchingClient, findMatchingClient,
getClientStatus, getClientStatus,
formatLastSeen, formatLastSeen,
buildMerakiClientLink, buildMerakiClientLink,
extractHostname,
} = require('../utils/merakiMatcher'); } = require('../utils/merakiMatcher');
const { STORE_MODES, MDM_DEVICE_TYPES, filterMdmByType } = require('../constants'); const {
STORE_MODES,
MDM_DEVICE_TYPES,
AV_CATEGORIES,
AV_FRIENDLY_NAME_PATTERN,
filterMdmByType,
classifyMdmAvDevice,
mdmDeviceName,
} = require('../constants');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
function shouldFetchForMode(mode, category) { /**
if (mode === STORE_MODES.IOS) { * What each mode needs from the upstream services. Used to gate parallel
return ['mdm', 'meraki', 'merakiClients'].includes(category); * fetches so we don't hit MDM/Meraki for an INFO-only request.
} */
if (mode === STORE_MODES.POS) { const MODE_FETCH_PLAN = {
return ['siw', 'mdm', 'meraki', 'merakiClients'].includes(category); [STORE_MODES.INFO]: {
} siw: false,
return true; // default fetches most things header: true,
} meraki: false,
mdm: false,
phone: false,
av: false,
},
[STORE_MODES.NETWORK]: {
siw: false,
header: false,
meraki: true,
mdm: true,
phone: false,
av: false,
},
[STORE_MODES.POS]: {
siw: true,
header: false,
meraki: true,
mdm: true,
phone: false,
av: false,
},
[STORE_MODES.IOS]: {
siw: false,
header: false,
meraki: true,
mdm: true,
phone: false,
av: false,
},
// PHONE needs Meraki clients (for MAC matching) + the Webex Service App
// phone service.
[STORE_MODES.PHONE]: {
siw: false,
header: false,
meraki: true,
mdm: false,
phone: true,
av: false,
},
// AV pulls Atlas (AMPs) AND MDM (Apple TVs, video walls, music, LED),
// then MAC-matches each into Meraki for the where-connected line.
[STORE_MODES.AV]: {
siw: false,
header: false,
meraki: true,
mdm: true,
phone: false,
av: true,
},
};
async function getStoreDetail(storeNumber, mode = STORE_MODES.DEFAULT) { const PLACEHOLDER_MESSAGES = {};
async function getStoreDetail(storeNumber, mode = STORE_MODES.INFO) {
logger.info('Starting store analysis', { storeNumber, mode }); logger.info('Starting store analysis', { storeNumber, mode });
// Future-mode placeholders short-circuit before any upstream calls.
if (PLACEHOLDER_MESSAGES[mode]) {
return PLACEHOLDER_MESSAGES[mode];
}
const plan = MODE_FETCH_PLAN[mode] || MODE_FETCH_PLAN[STORE_MODES.INFO];
let locationData = null, let locationData = null,
generalData = null,
merakiNetwork = null, merakiNetwork = null,
registers = [], registers = [],
printers = [], printers = [],
paymentTerminals = [], paymentTerminals = [],
merakiClients = [], merakiClients = [],
mdmDevices = []; mdmDevices = [],
phoneData = null,
avData = null;
try { try {
// Phase 1: basic lookups // Phase 1: header data (location + general) and Meraki network discovery
[locationData, merakiNetwork] = await Promise.all([ // so phase 2 has a network ID to query clients against.
shouldFetchForMode(mode, 'location') ? getStoreLocation(storeNumber) : Promise.resolve(null), [locationData, generalData, merakiNetwork] = await Promise.all([
shouldFetchForMode(mode, 'meraki') ? findMerakiNetwork(storeNumber) : Promise.resolve(null), plan.header ? getStoreLocation(storeNumber) : Promise.resolve(null),
plan.header ? getStoreGeneral(storeNumber) : Promise.resolve(null),
plan.meraki ? findMerakiNetwork(storeNumber) : Promise.resolve(null),
]); ]);
// Phase 2: heavy data, fanned out in parallel // Phase 2: heavy data, fanned out in parallel.
const dataPromises = []; const dataPromises = [];
if (shouldFetchForMode(mode, 'siw')) { if (plan.siw) {
dataPromises.push( dataPromises.push(
getStoreRegisters(storeNumber), getStoreRegisters(storeNumber),
getStorePrinters(storeNumber), getStorePrinters(storeNumber),
@ -62,21 +137,44 @@ async function getStoreDetail(storeNumber, mode = STORE_MODES.DEFAULT) {
} }
dataPromises.push( dataPromises.push(
merakiNetwork && shouldFetchForMode(mode, 'merakiClients') merakiNetwork && plan.meraki ? getMerakiClients(merakiNetwork.id) : Promise.resolve([])
? getMerakiClients(merakiNetwork.id)
: Promise.resolve([])
); );
dataPromises.push(plan.mdm ? getMDMDevices(storeNumber) : Promise.resolve([]));
// Webex phone data is independent of SIW/MDM — collectPhoneStatus catches
// its own errors and yields { unavailable: true, reason } on failure.
dataPromises.push( dataPromises.push(
shouldFetchForMode(mode, 'mdm') ? getMDMDevices(storeNumber) : Promise.resolve([]) plan.phone
? collectPhoneStatus(storeNumber).catch(err => ({
unavailable: true,
reason: err.message,
}))
: Promise.resolve(null)
); );
const [reg, prn, pay, clients, mdm] = await Promise.all(dataPromises); // Atlas AV data is similarly independent — collectAvStatus already
// returns { unavailable, reason } on failure, but we still defend
// against unexpected throws so a transport hiccup can't take the whole
// report down (MDM-side AV devices should still render).
dataPromises.push(
plan.av
? collectAvStatus(storeNumber).catch(err => ({
devices: [],
unavailable: true,
reason: err.message,
}))
: Promise.resolve(null)
);
const [reg, prn, pay, clients, mdm, phone, av] = await Promise.all(dataPromises);
registers = reg || []; registers = reg || [];
printers = prn || []; printers = prn || [];
paymentTerminals = pay || []; paymentTerminals = pay || [];
merakiClients = clients || []; merakiClients = clients || [];
mdmDevices = mdm || []; mdmDevices = mdm || [];
phoneData = phone || null;
avData = av || null;
logger.info('Store data fetched', { mode }); logger.info('Store data fetched', { mode });
} catch (err) { } catch (err) {
@ -85,21 +183,30 @@ async function getStoreDetail(storeNumber, mode = STORE_MODES.DEFAULT) {
let report = ''; let report = '';
if (mode === STORE_MODES.DEFAULT) { if (mode === STORE_MODES.INFO) {
report += createStore(locationData, storeNumber).toSummary(); // INFO needs location to render anything — fetch StoreGeneral too for
// brand/status/environment. The Store model handles missing data
// gracefully.
report += createStore(locationData, storeNumber, { general: generalData }).toSummary();
} else if (mode === STORE_MODES.NETWORK) {
report += await buildActiveNetworkDevices(merakiNetwork); report += await buildActiveNetworkDevices(merakiNetwork);
report += buildStoreServers(mdmDevices, merakiClients, merakiNetwork); report += buildStoreServers(mdmDevices, merakiClients, merakiNetwork);
} else if (mode === STORE_MODES.POS) { } else if (mode === STORE_MODES.POS) {
report += buildStoreServers(mdmDevices, merakiClients, merakiNetwork); report += buildStoreServers(mdmDevices, merakiClients, merakiNetwork);
report += buildRegisters(registers, merakiClients, merakiNetwork); report += buildRegisters(registers, merakiClients, merakiNetwork);
report += buildMobileRegisters(mdmDevices, merakiClients, merakiNetwork); report += buildMobileRegisters(mdmDevices, merakiClients, merakiNetwork);
report += buildCustomerDisplays(mdmDevices, merakiClients, merakiNetwork);
report += buildPrinters(printers, merakiClients, merakiNetwork); report += buildPrinters(printers, merakiClients, merakiNetwork);
report += buildPaymentTerminals(paymentTerminals, merakiClients, merakiNetwork); report += buildPaymentTerminals(paymentTerminals, merakiClients, merakiNetwork);
} else if (mode === STORE_MODES.IOS) { } else if (mode === STORE_MODES.IOS) {
report += buildIOSDevices(mdmDevices, merakiClients, merakiNetwork); report += buildIOSDevices(mdmDevices, merakiClients, merakiNetwork);
} else if (mode === STORE_MODES.PHONE) {
report += buildPhoneReport(phoneData, merakiClients, merakiNetwork);
} else if (mode === STORE_MODES.AV) {
report += buildAvReport(avData, mdmDevices, merakiClients, merakiNetwork);
} }
return report || 'No matching data for this view.'; return report.trim() || '_No matching data for this view._';
} }
// === Section Builders === // === Section Builders ===
@ -121,9 +228,12 @@ function renderClientLine({ prefixParts, identifiers, merakiClients, merakiNetwo
} }
async function buildActiveNetworkDevices(merakiNetwork) { async function buildActiveNetworkDevices(merakiNetwork) {
if (!merakiNetwork) return '\n⚠️ No matching Meraki network found.\n'; if (!merakiNetwork) return '\n\n⚠️ No matching Meraki network found.\n';
let out = `**🌐 [${merakiNetwork.name}](${merakiNetwork.url})**\n\n`; // Leading \n\n forces the section splitter (\n\n followed by **) to break
// this header onto its own message instead of gluing it to the previous
// section's last line.
let out = `\n\n**🌐 [${merakiNetwork.name}](${merakiNetwork.url})**\n\n`;
let activeDevices = []; let activeDevices = [];
try { try {
@ -225,6 +335,20 @@ function buildMobileRegisters(mdmDevices, merakiClients, merakiNetwork) {
}); });
} }
// Customer-facing displays (MDM naming convention: `US<store>CD##`,
// e.g. `US000782CD01`). Meraki advertises them with the same short hostname
// as the client description, so the default name-based match path works.
function buildCustomerDisplays(mdmDevices, merakiClients, merakiNetwork) {
return buildMdmSection({
devices: mdmDevices,
marker: MDM_DEVICE_TYPES.CUSTOMER_DISPLAY,
title: '📟 Customer Displays',
fallbackName: 'Unknown Customer Display',
merakiClients,
merakiNetwork,
});
}
function buildIOSDevices(mdmDevices, merakiClients, merakiNetwork) { function buildIOSDevices(mdmDevices, merakiClients, merakiNetwork) {
return buildMdmSection({ return buildMdmSection({
devices: mdmDevices, devices: mdmDevices,
@ -290,9 +414,311 @@ function buildPaymentTerminals(paymentTerminals, merakiClients, merakiNetwork) {
const adyenName = term.adyen_device_name || 'N/A'; const adyenName = term.adyen_device_name || 'N/A';
const model = term.device_model_name || 'N/A'; const model = term.device_model_name || 'N/A';
const type = term.device_type_name || 'N/A'; const type = term.device_type_name || 'N/A';
// SIW stores the FQDN in `ip_address`; the hostname before the first dot
// is what Meraki uses as the client description (e.g. "VFI-807-005-168").
const hostname = extractHostname(term.ip_address);
out += renderClientLine({ out += renderClientLine({
prefixParts: [`**${deviceName}**`, adyenName, model, type], prefixParts: [`**${deviceName}**`, adyenName, model, type],
identifiers: { deviceName, adyenName, ip_address: term.ip_address }, identifiers: {
name: hostname,
deviceName,
adyenName,
ip_address: term.ip_address,
},
merakiClients,
merakiNetwork,
});
});
return out;
}
// === Phone (Webex Service App) ===
const WEBEX_UNAVAILABLE_BANNER = reason =>
'\n\n**⚠️ Webex phone data unavailable**\n\n' +
`_${reason || 'Service App is not configured or tokens are missing.'}_\n\n` +
'Run `npm run webex:seed` on the host to bootstrap or re-seed the Service ' +
'App tokens, then try again.\n';
/**
* Build the full PHONE-mode report: a header (Webex location + store DID),
* wired 78xx phones, DECT basestations with their currently-registered
* handsets nested underneath, and a trailing "Unregistered Handsets" section
* for anything that doesn't have a recent registration or doesn't map to a
* known base.
*/
function buildPhoneReport(phoneData, merakiClients, merakiNetwork) {
if (!phoneData) {
return WEBEX_UNAVAILABLE_BANNER('Webex Service App not configured.');
}
if (phoneData.unavailable) {
return WEBEX_UNAVAILABLE_BANNER(phoneData.reason);
}
let out = '';
out += buildPhoneHeader(phoneData);
out += buildWiredPhones(phoneData.phones, merakiClients, merakiNetwork);
out += buildDectSection(phoneData, merakiClients, merakiNetwork);
if (!out.trim()) {
return '\n\n_No Webex phones, DECT basestations, or handsets registered for this store._';
}
return out;
}
function buildPhoneHeader(phoneData) {
const locationName = phoneData?.dectNetwork?.locationName;
const mainNumber = phoneData?.locationMainNumber;
if (!locationName && !mainNumber) return '';
const parts = [];
if (locationName) parts.push(`**📍 ${locationName}**`);
if (mainNumber) parts.push(`📞 Main: **${mainNumber}**`);
return `\n\n${parts.join(' — ')}\n`;
}
function buildWiredPhones(phones, merakiClients, merakiNetwork) {
if (!phones || phones.length === 0) return '';
let out = `\n\n**📞 Wired Phones (${phones.length})**\n`;
phones.forEach(p => {
const model = p.model || 'Cisco IP Phone';
const extPart = p.extension ? `ext ${p.extension}` : null;
const prefixParts = [`**${p.name}**`, model];
if (extPart) prefixParts.push(extPart);
out += renderClientLine({
prefixParts,
identifiers: { mac: p.mac, name: p.name },
merakiClients,
merakiNetwork,
});
});
return out;
}
// Handsets don't have an IP presence Webex can poll — they're DECT radio
// devices that only register through their basestation. So `handset.status`
// from the Webex list endpoint is unreliable (frequently empty/"unknown").
// Instead, we treat the line's `lastRegistrationTime` as the health signal:
// a fresh registration means the handset is talking to its base right now.
const HANDSET_FRESH_REG_WINDOW_MS = 24 * 60 * 60 * 1000; // 24h
function isHandsetRegistered(h) {
if (!h.lastRegistrationTime) return false;
const lastRegMs = new Date(h.lastRegistrationTime).getTime();
return Number.isFinite(lastRegMs) && Date.now() - lastRegMs <= HANDSET_FRESH_REG_WINDOW_MS;
}
/**
* Compose the user-facing handset name. Webex stores the meaningful slot
* index separately from the extension/access-code, so we render the
* "<index>-<extension>" form operators recognise (e.g. "1-50782", "2-50782")
* when both are present, falling back to whatever displayName we got.
*/
function formatHandsetName(h) {
if (h.index != null && h.extension) {
return `${h.index}-${h.extension}`;
}
if (h.extension) return String(h.extension);
return h.name || `Handset ${h.index ?? '?'}`;
}
function buildDectSection(phoneData, merakiClients, merakiNetwork) {
const bases = phoneData.basestations || [];
const handsets = phoneData.handsets || [];
if (bases.length === 0 && handsets.length === 0) return '';
let out = `\n\n**📡 DECT Network**`;
if (phoneData.dectNetwork?.name) {
out += `${phoneData.dectNetwork.name}`;
}
out += `\n`;
// Partition handsets:
// - "registered + assigned to a known base" → nested under that base.
// - everything else (stale registration, no registration data, or a
// baseStationId that doesn't map to a current base) → trailing
// "Unregistered Handsets" section.
const baseIds = new Set(bases.map(b => b.id));
const handsetsByBase = new Map();
const unregisteredHandsets = [];
handsets.forEach(h => {
if (isHandsetRegistered(h) && h.baseStationId && baseIds.has(h.baseStationId)) {
const list = handsetsByBase.get(h.baseStationId) || [];
list.push(h);
handsetsByBase.set(h.baseStationId, list);
} else {
unregisteredHandsets.push(h);
}
});
bases.forEach(base => {
const baseHandsets = handsetsByBase.get(base.id) || [];
const firmware = base.firmware ? ` fw ${base.firmware}` : '';
const lines = base.linesRegistered ? `${base.linesRegistered} lines registered` : '';
out += '\n';
out += renderClientLine({
prefixParts: [`**🛰️ ${base.name}**`, `${base.model || 'DECT Base'}${firmware}${lines}`],
identifiers: { mac: base.mac, name: base.name },
merakiClients,
merakiNetwork,
});
if (baseHandsets.length === 0) {
out += ` _no registered handsets_\n`;
} else {
baseHandsets.forEach(h => {
out += ` - ${formatHandsetLine(h)}\n`;
});
}
});
if (unregisteredHandsets.length > 0) {
out += `\n**📵 Unregistered Handsets (${unregisteredHandsets.length})**\n`;
unregisteredHandsets.forEach(h => {
out += `- ${formatHandsetLine(h)}\n`;
});
}
return out;
}
function formatHandsetLine(h) {
const displayName = formatHandsetName(h);
const lastReg = h.lastRegistrationTime ? new Date(h.lastRegistrationTime) : null;
const ago = lastReg ? formatLastSeen(lastReg) : null;
let presence;
if (!lastReg) {
presence = '❓ No registration data';
} else if (Date.now() - lastReg.getTime() <= HANDSET_FRESH_REG_WINDOW_MS) {
presence = `✅ Registered — last sync ${ago}`;
} else {
presence = `⚠️ Last registered ${ago}`;
}
return `**${displayName}** — ${presence}`;
}
// === AV (Atlas + MDM hardware) ===
// Ordered so the report always renders Atlas first, then the MDM categories
// in a consistent (and visually grouped) sequence. Keep this aligned with
// `AV_SUBSECTIONS` below.
const AV_SUBSECTIONS = [
{ category: AV_CATEGORIES.APPLE_TV, title: '📺 Apple TVs', fallbackName: 'Unknown Apple TV' },
{
category: AV_CATEGORIES.VIDEO_WALL,
title: '🖼️ Video Walls',
fallbackName: 'Unknown Video Wall',
},
{
category: AV_CATEGORIES.MUSIC,
title: '🎵 Music Players',
fallbackName: 'Unknown Music Player',
},
{ category: AV_CATEGORIES.LED, title: '💡 LED Displays', fallbackName: 'Unknown LED Display' },
];
const ATLAS_UNAVAILABLE_BANNER = reason =>
'\n\n**⚠️ Atlas AV data unavailable**\n\n' +
`_${reason || 'Atlas is not configured (set ATLAS_AUTH_KEY).'}_\n`;
function buildAvReport(avData, mdmDevices, merakiClients, merakiNetwork) {
let out = '';
// Atlas section first (with inline banner on failure so MDM hardware
// below still renders). Treat a missing avData payload as "Atlas wasn't
// attempted" — defensive in case the plan fetch shape changes.
if (avData && avData.unavailable) {
out += ATLAS_UNAVAILABLE_BANNER(avData.reason);
} else if (avData && Array.isArray(avData.devices) && avData.devices.length > 0) {
out += buildAtlasAmpSection(avData.devices, merakiClients, merakiNetwork);
}
// Then each MDM-derived AV category.
for (const sub of AV_SUBSECTIONS) {
out += buildMdmAvSection({
mdmDevices,
category: sub.category,
title: sub.title,
fallbackName: sub.fallbackName,
merakiClients,
merakiNetwork,
});
}
if (!out.trim()) {
return '\n\n_No AV hardware registered for this store._';
}
return out;
}
function buildAtlasAmpSection(devices, merakiClients, merakiNetwork) {
let out = `\n\n**📡 Atlas AMP (${devices.length})**\n`;
devices.forEach(dev => {
const prefixParts = [`**${dev.name}**`];
if (dev.model) prefixParts.push(dev.model);
prefixParts.push(formatAtlasPresence(dev));
out += renderClientLine({
prefixParts,
identifiers: { mac: dev.mac, name: dev.name, ip_address: dev.ip },
merakiClients,
merakiNetwork,
});
});
return out;
}
function formatAtlasPresence(dev) {
const lastSeen = dev.lastSeen ? new Date(dev.lastSeen) : null;
const lastSeenStr = lastSeen ? formatLastSeen(lastSeen) : null;
const suffix = lastSeenStr ? ` — last seen ${lastSeenStr}` : '';
if (dev.online === true) return `✅ Online${suffix}`;
if (dev.online === false) return `⚠️ Offline${suffix}`;
return `❓ Unknown${suffix}`;
}
function buildMdmAvSection({
mdmDevices,
category,
title,
fallbackName,
merakiClients,
merakiNetwork,
}) {
const filtered = (mdmDevices || []).filter(d => {
const name = mdmDeviceName(d);
return AV_FRIENDLY_NAME_PATTERN.test(name) && classifyMdmAvDevice(name) === category;
});
if (filtered.length === 0) return '';
let out = `\n\n**${title} (${filtered.length})**\n`;
filtered.forEach(dev => {
const name = dev.DeviceFriendlyName || dev.UserName || fallbackName;
const model = dev.Model || dev.ModelId || null;
const prefixParts = [`**${name}**`];
if (model) prefixParts.push(model);
out += renderClientLine({
prefixParts,
identifiers: {
UserName: dev.UserName,
DeviceFriendlyName: dev.DeviceFriendlyName,
mac: dev.MacAddress,
name,
},
merakiClients, merakiClients,
merakiNetwork, merakiNetwork,
}); });

View file

@ -1,243 +0,0 @@
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');
// 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
};
async function getStoreHealth(storeNumber, _mode = 'default') {
logger.info('Running health analysis', { 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),
getStorePaymentTerminals(storeNumber),
getStorePrinters(storeNumber),
]);
}
} catch (err) {
logger.error('Health analysis error', { error: err.message });
}
const healthReport = createHealthReport(storeNumber, locationData?.name || 'Unknown Store');
// === Network Infrastructure ===
if (merakiNetwork) {
healthReport.addSection(
'**🌐 Network Infrastructure**',
`- 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;
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');
}
// === Core Systems ===
healthReport.addSection('**🖥️ POS Systems**', '');
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);
// 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`);
// 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

@ -0,0 +1,184 @@
/**
* Webex Service App OAuth singleton.
*
* Mirrors the collabFinder reference but ported to CommonJS + this project's
* structured logger. Once a tokens file exists at `tokensFilePath`, this
* singleton transparently refreshes the access token (Cisco rotates the
* refresh token on every refresh, so we always write back the new pair).
*
* Bootstrap is handled out-of-band by scripts/seedWebexTokens.js this
* singleton refuses to call Webex without a refresh token already on disk.
*/
const axios = require('axios');
const fs = require('fs').promises;
const path = require('path');
const logger = require('../../utils/logger');
const TOKEN_URL = 'https://webexapis.com/v1/access_token';
const SAFETY_BUFFER_MS = 5 * 60 * 1000; // refresh 5 min before stated expiry
class WebexServiceAppAuth {
static #instance = null;
/**
* Resolve a singleton bound to the given config. Subsequent calls ignore
* the args and return the original instance caller controls the lifetime
* by calling resetForTests() between unit tests.
*/
static getInstance(opts = {}) {
if (!WebexServiceAppAuth.#instance) {
WebexServiceAppAuth.#instance = new WebexServiceAppAuth(opts);
}
return WebexServiceAppAuth.#instance;
}
static resetForTests() {
WebexServiceAppAuth.#instance = null;
}
constructor({
clientId = process.env.WEBEX_CLIENT_ID,
clientSecret = process.env.WEBEX_CLIENT_SECRET,
tokensFilePath = process.env.WEBEX_TOKENS_PATH ||
path.join(process.cwd(), 'tokens', 'webex-service-tokens.json'),
httpClient = axios,
} = {}) {
if (!clientId) {
throw new Error('WEBEX_CLIENT_ID is required (set it in environment variables)');
}
if (!clientSecret) {
throw new Error('WEBEX_CLIENT_SECRET is required (set it in environment variables)');
}
this.clientId = clientId;
this.clientSecret = clientSecret;
// Resolve to an absolute path so logs and fs ops are unambiguous whether
// running on the host or inside Docker.
this.tokensFilePath = path.resolve(tokensFilePath);
this.http = httpClient;
this.accessToken = null;
this.refreshToken = null;
this.expiresAt = 0;
logger.debug('WebexServiceAppAuth initialized', { tokensFilePath: this.tokensFilePath });
}
async loadTokens() {
try {
const raw = await fs.readFile(this.tokensFilePath, 'utf8');
const tokens = JSON.parse(raw);
this.accessToken = tokens.accessToken || null;
this.refreshToken = tokens.refreshToken || null;
this.expiresAt = tokens.expiresAt || 0;
logger.info('Webex tokens loaded from file', { tokensFilePath: this.tokensFilePath });
} catch (err) {
if (err.code === 'ENOENT') {
logger.warn('No Webex tokens file found — run `npm run webex:seed` to bootstrap', {
tokensFilePath: this.tokensFilePath,
});
} else {
logger.error('Failed to load Webex tokens file', {
tokensFilePath: this.tokensFilePath,
error: err.message,
});
}
throw err;
}
}
async saveTokens() {
const payload = {
accessToken: this.accessToken,
refreshToken: this.refreshToken,
expiresAt: this.expiresAt,
updatedAt: new Date().toISOString(),
};
await fs.mkdir(path.dirname(this.tokensFilePath), { recursive: true });
await fs.writeFile(this.tokensFilePath, JSON.stringify(payload, null, 2), 'utf8');
logger.debug('Webex tokens saved', { tokensFilePath: this.tokensFilePath });
}
/**
* Exchange the current refresh token for a fresh pair. Persists the result.
* Throws if no refresh token is available.
*/
async refresh() {
if (!this.refreshToken) {
throw new Error(
'No refresh token available. ' + 'Bootstrap initial tokens first via `npm run webex:seed`.'
);
}
const params = new URLSearchParams({
grant_type: 'refresh_token',
client_id: this.clientId,
client_secret: this.clientSecret,
refresh_token: this.refreshToken,
});
try {
const response = await this.http.post(TOKEN_URL, params.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
timeout: 10000,
});
const data = response.data || {};
this.accessToken = data.access_token;
this.refreshToken = data.refresh_token || this.refreshToken;
this.expiresAt = Date.now() + Number(data.expires_in || 0) * 1000 - SAFETY_BUFFER_MS;
await this.saveTokens();
logger.info('Webex tokens refreshed', { expiresInSec: data.expires_in });
return this.accessToken;
} catch (err) {
const status = err.response?.status;
const detail = err.response?.data ? JSON.stringify(err.response.data) : err.message;
logger.error('Webex token refresh failed', { status, detail });
if (status === 400 || status === 401) {
throw new Error(
'Webex refresh token rejected (400/401). ' +
'It may be expired or revoked — re-seed via `npm run webex:seed`.',
{ cause: err }
);
}
throw err;
}
}
/**
* Return a currently valid access token. Lazy-loads the tokens file on
* first use and refreshes automatically when within the safety buffer.
*/
async getAccessToken() {
if (!this.accessToken && !this.refreshToken) {
await this.loadTokens();
}
if (!this.accessToken || Date.now() >= this.expiresAt) {
logger.debug('Webex access token missing or expired — refreshing');
return this.refresh();
}
return this.accessToken;
}
async forceRefresh() {
logger.warn('Forcing Webex token refresh');
return this.refresh();
}
clearTokens() {
this.accessToken = null;
this.refreshToken = null;
this.expiresAt = 0;
}
}
module.exports = WebexServiceAppAuth;

View file

@ -1,73 +0,0 @@
/**
* 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 };

View file

@ -1,22 +1,124 @@
/** /**
* Domain model for a Store. * Domain model for a Store.
* Normalizes location data coming from SIW. * Normalizes data coming from SIW's /StoreLocation and /StoreGeneral endpoints.
*/ */
const MAX_BRANDS = 3;
/**
* Extract a deduplicated list of brand names from a SIW general payload.
*
* Known shapes (most common first):
* 1. /StoreGeneral/{n}: `pimary_brand_name` (sic typo in the upstream API,
* kept here for safety), `primary_brand_name`, `secondary_brand_name`,
* `tertiary_brand_name`.
* 2. Array-of-strings or array-of-objects: `brands`, `brand_names`,
* `brand_list`, `brand_display_names`.
* 3. Numbered `brand_1..N` (also `brand1`, `brand_name_1`,
* `brand_display_name_1`).
* 4. Single-brand fields: `brand_display_name`, `brand_name`, `brand`,
* `primary_brand`.
*
* Returns at most MAX_BRANDS entries (case-insensitively deduplicated).
*/
function extractBrands(data) {
if (!data) return [];
const brands = [];
const pushBrand = value => {
if (value == null) return;
const str = String(value).trim();
if (!str) return;
if (!brands.some(b => b.toLowerCase() === str.toLowerCase())) {
brands.push(str);
}
};
const pushFromArray = arr => {
if (!Array.isArray(arr)) return false;
let pushed = false;
arr.forEach(b => {
if (typeof b === 'string') {
pushBrand(b);
pushed = true;
} else if (b && typeof b === 'object') {
pushBrand(b.brand_display_name || b.brand_name || b.name || b.display_name);
pushed = true;
}
});
return pushed;
};
// 1) Named primary/secondary/tertiary (the real SIW /StoreGeneral shape).
// Note: upstream typo `pimary_brand_name` is real — handle both.
pushBrand(data.primary_brand_name || data.pimary_brand_name);
pushBrand(data.secondary_brand_name);
pushBrand(data.tertiary_brand_name);
if (brands.length > 0) return brands.slice(0, MAX_BRANDS);
// 2) Array forms.
for (const key of ['brands', 'brand_names', 'brand_list', 'brand_display_names']) {
if (pushFromArray(data[key])) break;
}
// 3) Numbered single-value fields.
if (brands.length === 0) {
for (let i = 1; i <= MAX_BRANDS + 2; i++) {
pushBrand(
data[`brand_${i}`] ||
data[`brand${i}`] ||
data[`brand_name_${i}`] ||
data[`brand_display_name_${i}`]
);
}
}
// 4) Single brand fallback.
if (brands.length === 0) {
pushBrand(data.brand_display_name || data.brand_name || data.brand || data.primary_brand);
}
return brands.slice(0, MAX_BRANDS);
}
class Store { class Store {
constructor(data = {}, storeNumber) { /**
this.number = String(storeNumber || data.store_number || '').trim(); * @param {object|null} locationData /StoreLocation/{n} payload (address etc.)
this.name = data.name || data.store_name || `Store ${this.number}`; * @param {string|number} storeNumber
this.address = data.address || ''; * @param {object} [options]
this.address2 = data.address2 || ''; * @param {object|null} [options.general] /StoreGeneral/{n} payload
this.address3 = data.address3 || ''; * (brands, status, environment, etc.)
this.city = data.city || ''; */
this.state = data.state || ''; constructor(locationData, storeNumber, options = {}) {
this.postalCode = data.postal_code || ''; // ES default params only trigger on `undefined`, not `null`. SIW returns
this.countryCode = data.country_code || 'US'; // null for stores it has no record of, so we have to coerce here.
this.phone = data.phone || ''; const d = locationData || {};
this.districtId = data.district_id || null; const g = options.general || {};
this.regionId = data.region_id || null;
this.number = String(storeNumber || d.store_number || g.store_number || '').trim();
this.name = d.name || d.store_name || `Store ${this.number}`;
// Brand / status / environment come from /StoreGeneral; fall back to
// location data only if a flat object happened to carry them.
this.brands = extractBrands(g).length > 0 ? extractBrands(g) : extractBrands(d);
this.status = g.store_status_name || d.store_status_name || null;
this.environment = g.environment_name || d.environment_name || null;
this.address = d.address || '';
this.address2 = d.address2 || '';
this.address3 = d.address3 || '';
this.city = d.city || '';
this.state = d.state || '';
this.postalCode = d.postal_code || '';
this.countryCode = d.country_code || 'US';
this.phone = d.phone || '';
this.districtId = d.district_id || null;
this.regionId = d.region_id || null;
this.hasLocationData = !!locationData;
this.hasGeneralData = !!options.general;
} }
get fullAddress() { get fullAddress() {
@ -28,12 +130,43 @@ class Store {
} }
toSummary() { 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'}`; if (!this.hasLocationData && !this.hasGeneralData) {
// Trailing \n\n so the section splitter in bot/handlers.js can cleanly
// separate this from whatever section comes next.
return `### Store ${this.number}\n\n_⚠ No SIW record found for this store._\n\n`;
}
const lines = [`### Store ${this.number} - ${this.name}`, '', '** Details**'];
if (this.brands.length > 0) {
const label = this.brands.length === 1 ? 'Brand' : 'Brands';
lines.push(`${label}: ${this.brands.join(', ')}`);
}
// Status + Environment on one line for compactness; only render if at
// least one is known.
if (this.status || this.environment) {
const bits = [];
if (this.status) bits.push(`Status: ${this.status}`);
if (this.environment) bits.push(`Environment: ${this.environment}`);
lines.push(bits.join(' | '));
}
if (this.fullAddress) {
lines.push(this.fullAddress);
}
lines.push(`District ID: ${this.districtId || 'N/A'} Region ID: ${this.regionId || 'N/A'}`);
// Trailing blank line so the next section (Meraki network header) is
// separated by \n\n** which the section splitter understands.
lines.push('');
return lines.join('\n') + '\n';
} }
} }
function createStore(data, storeNumber) { function createStore(locationData, storeNumber, options) {
return new Store(data, storeNumber); return new Store(locationData, storeNumber, options);
} }
module.exports = { Store, createStore }; module.exports = { Store, createStore, extractBrands };

17
nodemon.json Normal file
View file

@ -0,0 +1,17 @@
{
"watch": [
"server.js",
"remoteAgent.js",
"constants.js",
"bot",
"config",
"integrations",
"models",
"services",
"utils"
],
"ignore": ["node_modules/", "tests/", "coverage/", "logs/", "*.test.js", "scripts/"],
"ext": "js,json",
"delay": 750,
"signal": "SIGINT"
}

View file

@ -1,10 +1,10 @@
{ {
"name": "netanalyzer", "name": "storehealthanalyzer",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"main": "server.js", "main": "server.js",
"type": "commonjs", "type": "commonjs",
"description": "NetAnalyzer - Webex bot for store network and device health analysis using Meraki, SIW, and MDM integrations", "description": "StoreHealthAnalyzer — Webex bot for store network and device health analysis using Meraki, SIW, and MDM integrations",
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",
"agent": "node remoteAgent.js", "agent": "node remoteAgent.js",
@ -15,7 +15,11 @@
"format": "prettier --write \"**/*.{js,json,md}\"", "format": "prettier --write \"**/*.{js,json,md}\"",
"format:check": "prettier --check \"**/*.{js,json,md}\"", "format:check": "prettier --check \"**/*.{js,json,md}\"",
"test": "jest", "test": "jest",
"test:watch": "jest --watch" "test:watch": "jest --watch",
"webex:cleanup": "node scripts/cleanupWebexDevices.js",
"webex:cleanup:delete": "node scripts/cleanupWebexDevices.js --delete",
"webex:seed": "node scripts/seedWebexTokens.js",
"agent:package": "bash docker/remote-agent/package.sh"
}, },
"keywords": [ "keywords": [
"webex", "webex",

View file

@ -34,7 +34,7 @@ function connect() {
ws = new WebSocket(WS_URL, buildClientOptions()); ws = new WebSocket(WS_URL, buildClientOptions());
ws.on('open', () => { ws.on('open', () => {
console.log('✅ Remote Agent connected to NetAnalyzer'); console.log('✅ Remote Agent connected to StoreHealthAnalyzer');
reconnectAttempts = 0; reconnectAttempts = 0;
}); });

View file

@ -0,0 +1,142 @@
#!/usr/bin/env node
/**
* Cleanup stale Webex Device Manager (WDM) registrations for the bot token.
*
* Webex caps each user/bot at a fixed number of device registrations
* (currently ~100). Every time webex-node-bot-framework starts, it registers
* a new device; if the process is killed before framework.stop() runs (e.g.
* by nodemon SIGKILL, OOM, or a crash), the registration is orphaned.
* Once you hit the cap, new logins fail with:
* "User has excessive device registrations"
*
* Usage:
* node scripts/cleanupWebexDevices.js # dry-run (lists only)
* node scripts/cleanupWebexDevices.js --delete # actually delete them
* node scripts/cleanupWebexDevices.js --delete --keep-newest=1
*
* Requires WEBEX_ACCESS_TOKEN in .env.
*/
require('dotenv').config();
const axios = require('axios');
const WDM_BASE = 'https://wdm-a.wbx2.com/wdm/api/v1';
const TOKEN = process.env.WEBEX_ACCESS_TOKEN;
const args = process.argv.slice(2);
const doDelete = args.includes('--delete');
const keepNewestArg = args.find(a => a.startsWith('--keep-newest='));
const keepNewest = keepNewestArg ? parseInt(keepNewestArg.split('=')[1], 10) || 0 : 0;
if (!TOKEN) {
console.error('❌ WEBEX_ACCESS_TOKEN is not set in .env');
process.exit(1);
}
const api = axios.create({
baseURL: WDM_BASE,
headers: { Authorization: `Bearer ${TOKEN}` },
timeout: 15000,
});
function fmtDate(s) {
if (!s) return 'unknown';
try {
return new Date(s).toISOString();
} catch (_e) {
return String(s);
}
}
async function listDevices() {
try {
const res = await api.get('/devices');
const devices = res.data?.devices || res.data || [];
return Array.isArray(devices) ? devices : [];
} catch (err) {
const status = err.response?.status;
const body = err.response?.data;
console.error('❌ Failed to list devices', { status, error: err.message, body });
process.exit(1);
}
}
async function deleteDevice(device) {
// The WDM API returns either a `url` (full URL) or a `deviceUrl`. Prefer the
// explicit URL; otherwise fall back to /devices/{id}.
const url = device.url || device.deviceUrl;
try {
if (url) {
await axios.delete(url, { headers: { Authorization: `Bearer ${TOKEN}` }, timeout: 15000 });
} else if (device.id) {
await api.delete(`/devices/${device.id}`);
} else {
throw new Error('device has no url or id; skipping');
}
return { ok: true };
} catch (err) {
return { ok: false, error: err.response?.data || err.message };
}
}
(async function main() {
const devices = await listDevices();
console.log(`Found ${devices.length} device registration(s) for this token.\n`);
if (devices.length === 0) {
console.log('Nothing to clean up. ✅');
return;
}
// Sort newest → oldest by modificationTime / creationTime so --keep-newest
// keeps the most recently-touched registrations.
const sorted = [...devices].sort((a, b) => {
const ta = new Date(a.modificationTime || a.creationTime || 0).getTime();
const tb = new Date(b.modificationTime || b.creationTime || 0).getTime();
return tb - ta;
});
sorted.forEach((d, i) => {
console.log(
[
`[${i}]`,
d.deviceType || 'unknown-type',
`name=${d.name || d.userAgent || 'n/a'}`,
`created=${fmtDate(d.creationTime)}`,
`modified=${fmtDate(d.modificationTime)}`,
`id=${d.id || (d.url || '').split('/').pop()}`,
].join(' ')
);
});
const toDelete = sorted.slice(keepNewest);
if (!doDelete) {
console.log(`\nDry run — would delete ${toDelete.length} device(s).`);
console.log(`Re-run with --delete to actually remove them.`);
if (keepNewest > 0) {
console.log(`(Keeping the ${keepNewest} newest registration(s).)`);
}
return;
}
console.log(`\nDeleting ${toDelete.length} device(s)...`);
let okCount = 0;
let failCount = 0;
for (const d of toDelete) {
const result = await deleteDevice(d);
const tag = `[${d.id || (d.url || '').split('/').pop()}]`;
if (result.ok) {
okCount++;
console.log(` ✅ deleted ${tag}`);
} else {
failCount++;
console.log(` ❌ failed ${tag} ${JSON.stringify(result.error)}`);
}
}
console.log(`\nDone. Deleted ${okCount}, failed ${failCount}.`);
if (keepNewest > 0) {
console.log(`(Kept the ${keepNewest} newest registration(s).)`);
}
})();

140
scripts/seedWebexTokens.js Normal file
View file

@ -0,0 +1,140 @@
#!/usr/bin/env node
/**
* Seed the Webex Service App tokens file from an existing source.
*
* Two seed paths, both end with one immediate refresh against
* https://webexapis.com/v1/access_token. That verifies the seed actually
* works, rotates to a fresh pair (Cisco rotates refresh tokens), and writes
* the rotated pair to this project's WEBEX_TOKENS_PATH.
*
* Usage:
* npm run webex:seed # uses default --from-file path (collabFinder)
* npm run webex:seed -- --from-file /path/to/tokens.json
* npm run webex:seed -- --refresh-token <refresh-token-string>
*
* Requires WEBEX_CLIENT_ID + WEBEX_CLIENT_SECRET in .env.
*/
require('dotenv').config();
const fs = require('fs').promises;
const path = require('path');
const axios = require('axios');
const TOKEN_URL = 'https://webexapis.com/v1/access_token';
const DEFAULT_SOURCE = '/Volumes/jmcqueen/Docker/collabFinder/config/webex-service-tokens.json';
const SAFETY_BUFFER_MS = 5 * 60 * 1000;
const args = process.argv.slice(2);
function flag(name) {
const idx = args.indexOf(name);
if (idx >= 0 && idx + 1 < args.length) return args[idx + 1];
const inline = args.find(a => a.startsWith(`${name}=`));
if (inline) return inline.split('=').slice(1).join('=');
return null;
}
async function readSeedRefreshToken() {
const refreshTokenFlag = flag('--refresh-token');
if (refreshTokenFlag) {
return { source: 'refresh-token flag', refreshToken: refreshTokenFlag.trim() };
}
const fromFile = flag('--from-file') || DEFAULT_SOURCE;
console.log(`Seeding from file: ${fromFile}`);
let raw;
try {
raw = await fs.readFile(fromFile, 'utf8');
} catch (err) {
if (err.code === 'ENOENT') {
throw new Error(
`Source tokens file not found: ${fromFile}\n` +
'Pass --from-file <path> or --refresh-token <token>.',
{ cause: err }
);
}
throw err;
}
let parsed;
try {
parsed = JSON.parse(raw);
} catch (err) {
throw new Error(`Source tokens file is not valid JSON (${fromFile}): ${err.message}`, {
cause: err,
});
}
const refreshToken = parsed.refreshToken || parsed.refresh_token;
if (!refreshToken) {
throw new Error(`Source tokens file has no refreshToken / refresh_token field (${fromFile})`);
}
return { source: fromFile, refreshToken };
}
async function exchangeRefreshToken(refreshToken) {
const clientId = process.env.WEBEX_CLIENT_ID;
const clientSecret = process.env.WEBEX_CLIENT_SECRET;
if (!clientId || !clientSecret) {
throw new Error('WEBEX_CLIENT_ID and WEBEX_CLIENT_SECRET must be set in .env');
}
const params = new URLSearchParams({
grant_type: 'refresh_token',
client_id: clientId,
client_secret: clientSecret,
refresh_token: refreshToken,
});
try {
const res = await axios.post(TOKEN_URL, params.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
timeout: 15000,
});
return res.data;
} catch (err) {
const status = err.response?.status;
const detail = err.response?.data ? JSON.stringify(err.response.data) : err.message;
throw new Error(`Token refresh failed (status=${status || 'n/a'}): ${detail}`, {
cause: err,
});
}
}
async function writeTokens(targetPath, data) {
const payload = {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt: Date.now() + Number(data.expires_in || 0) * 1000 - SAFETY_BUFFER_MS,
updatedAt: new Date().toISOString(),
};
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.writeFile(targetPath, JSON.stringify(payload, null, 2), 'utf8');
return payload;
}
(async () => {
try {
const targetPath = path.resolve(
process.env.WEBEX_TOKENS_PATH ||
path.join(process.cwd(), 'tokens', 'webex-service-tokens.json')
);
const { source, refreshToken } = await readSeedRefreshToken();
console.log(`Seed source: ${source}`);
console.log(`Target path: ${targetPath}`);
console.log('Performing initial refresh against Webex...');
const data = await exchangeRefreshToken(refreshToken);
const payload = await writeTokens(targetPath, data);
console.log('Webex tokens seeded successfully.');
console.log(` expiresIn: ${data.expires_in}s (buffered)`);
console.log(` expiresAt: ${new Date(payload.expiresAt).toISOString()}`);
console.log(` updatedAt: ${payload.updatedAt}`);
process.exit(0);
} catch (err) {
console.error('Seed failed:', err.message);
process.exit(1);
}
})();

View file

@ -1,7 +1,7 @@
const Framework = require('webex-node-bot-framework'); const Framework = require('webex-node-bot-framework');
const config = require('./config'); const config = require('./config');
const { startWebSocketServer, stopWebSocketServer } = require('./services/websocket'); const { startWebSocketServer, stopWebSocketServer } = require('./services/websocket');
const { handleStoreCommand, handleAnalyzeCommand, handleHelpCommand } = require('./bot/handlers'); const { handleStoreCommand, handleHelpCommand, getCommandText } = require('./bot/handlers');
const logger = require('./utils/logger'); const logger = require('./utils/logger');
const framework = new Framework({ const framework = new Framework({
@ -10,35 +10,36 @@ const framework = new Framework({
logLevel: config.logLevel, logLevel: config.logLevel,
}); });
logger.info('Starting NetAnalyzer Framework', { botName: config.webex.name }); logger.info('Starting StoreHealthAnalyzer framework', { botName: config.webex.name });
// Register command handlers (anchored at the start of the message so // Register command handlers. The `(?:\S+\s+)?` prefix allows the leading
// "please analyze store 305" doesn't fire both store and analyze handlers). // "@BotName " that Webex prepends to group-space mentions, while still
// anchoring at the start (so "please run st 305" or "stop"/"start" don't
// trigger). DMs (no bot-name prefix) are matched by the empty optional group.
framework.hears( framework.hears(
/^\s*help\b/i, /^(?:\S+\s+)?help\b/i,
handleHelpCommand, handleHelpCommand,
'Show available commands (try: help, help store, help analyze)' 'Show available commands (try: help, help st)'
); );
framework.hears( framework.hears(
/^\s*store\b/i, /^(?:\S+\s+)?st\b/i,
handleStoreCommand, handleStoreCommand,
'store <number> — info + network + server\nstore <number> pos — POS devices\nstore <number> ios — iOS devices' 'st [number] — info | st [number] network — switches/APs/server | st [number] pos — POS devices | st [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)'
); );
// Friendly fallback for anything that didn't match the commands above. // Friendly fallback for anything that didn't match the commands above.
framework.hears( framework.hears(
/.*/, /.*/,
(bot, trigger) => { (bot, trigger) => {
logger.info('Unhandled message', { text: trigger.message.text }); const heard = getCommandText(trigger);
logger.info('Unhandled message', { text: heard });
bot.say( bot.say(
'I heard: ' + 'markdown',
trigger.message.text + [
'\n\nTry `store <number>`, `store <number> pos`, `analyze <number>`, or `help` for commands.' `I heard: _${heard}_`,
'',
'Try `st [number]`, `st [number] network`, `st [number] pos`, `st [number] ios`, or `help` for commands.',
].join('\n')
); );
}, },
99999 99999
@ -52,7 +53,8 @@ framework.on('spawn', (bot, _id, addedBy) => {
logger.info('Bot spawned in room', { room: bot.room.title || 'Unknown' }); logger.info('Bot spawned in room', { room: bot.room.title || 'Unknown' });
if (addedBy) { if (addedBy) {
bot.say( bot.say(
'NetAnalyzer is ready!\n\nTry `store 782`, `store 782 pos`, `analyze 782`, or type `help` for all commands.' 'markdown',
'StoreHealthAnalyzer is ready! Try `st 782`, `st 782 network`, `st 782 pos`, `st 782 ios`, or type `help`.'
); );
} }
}); });
@ -69,7 +71,13 @@ framework
}); });
// ==================== Graceful Shutdown ==================== // ==================== Graceful Shutdown ====================
// IMPORTANT: framework.stop() unregisters this process's WDM device with
// Webex. If we don't await it, those devices accumulate and Webex eventually
// rejects new registrations with "User has excessive device registrations".
// nodemon.json sets signal=SIGINT for the same reason.
let isShuttingDown = false; let isShuttingDown = false;
const SHUTDOWN_HARD_TIMEOUT_MS = 8000;
async function shutdown(signal) { async function shutdown(signal) {
if (isShuttingDown) return; if (isShuttingDown) return;
@ -77,18 +85,36 @@ async function shutdown(signal) {
logger.info('Shutting down gracefully', { signal }); logger.info('Shutting down gracefully', { signal });
// Belt-and-suspenders: if cleanup hangs (e.g. the Webex websocket is stuck),
// force-exit so nodemon/Docker can move on. SIGKILL from the orchestrator
// would skip the device unregister and leak a WDM registration.
const hardKill = setTimeout(() => {
logger.error('Shutdown exceeded hard timeout; forcing exit', {
timeoutMs: SHUTDOWN_HARD_TIMEOUT_MS,
});
process.exit(1);
}, SHUTDOWN_HARD_TIMEOUT_MS);
hardKill.unref();
try { try {
stopWebSocketServer(); stopWebSocketServer();
// webex-node-bot-framework may not expose a clean stop; do what we can.
if (framework && typeof framework.stop === 'function') { if (framework && typeof framework.stop === 'function') {
await framework.stop().catch(() => {}); try {
await framework.stop();
logger.info('Webex framework stopped (device registration released)');
} catch (err) {
logger.error('framework.stop() failed; device registration may leak', {
error: err.message,
});
}
} }
logger.info('Cleanup complete. Exiting.'); logger.info('Cleanup complete. Exiting.');
} catch (err) { } catch (err) {
logger.error('Error during shutdown', { error: err.message }); logger.error('Error during shutdown', { error: err.message });
} finally { } finally {
clearTimeout(hardKill);
process.exit(0); process.exit(0);
} }
} }

110
services/avService.js Normal file
View file

@ -0,0 +1,110 @@
/**
* AV (Atlas) device-discovery service.
*
* Thin shim over integrations/atlas: pulls the Atlas devices belonging to a
* store and shapes each into a renderer-friendly object the storeDetail AV
* report consumes. Mirrors the contract of services/webexPhone.js on any
* unrecoverable failure (missing ATLAS_AUTH_KEY, transport error) it returns
* `{ devices: [], unavailable: true, reason }` rather than throwing, so the
* bot can render a banner alongside whatever other AV data (MDM Apple TVs
* etc.) is still available.
*
* Atlas field names vary subtly across device types; `shapeAtlasDevice`
* picks defensively from the locations the collabFinder reference touched
* (`state.IpAddress`, `state.MacAddress`, `connection_status`, `status`,
* etc.). The renderer treats `online === null` as "unknown" so an unfamiliar
* payload degrades gracefully.
*/
const { getAtlasDevicesForStore } = require('../integrations/atlas/atlasDevices');
const logger = require('../utils/logger');
const ONLINE_KEYWORDS = new Set(['online', 'connected', 'active', 'up']);
const OFFLINE_KEYWORDS = new Set(['offline', 'disconnected', 'inactive', 'down']);
function deriveOnline(dev) {
// Try the most specific fields first; fall back to top-level `status`.
const candidates = [
dev?.connection_status,
dev?.state?.connection_status,
dev?.state?.status,
dev?.status,
];
for (const c of candidates) {
if (typeof c === 'boolean') return c;
if (typeof c !== 'string') continue;
const lower = c.trim().toLowerCase();
if (!lower) continue;
if (ONLINE_KEYWORDS.has(lower)) return true;
if (OFFLINE_KEYWORDS.has(lower)) return false;
}
return null;
}
function pickFirst(...values) {
for (const v of values) {
if (v !== undefined && v !== null && v !== '') return v;
}
return null;
}
function shapeAtlasDevice(dev) {
const state = dev?.state || {};
const model = pickFirst(
typeof dev?.model === 'string' ? dev.model : null,
dev?.model?.name,
dev?.model_name,
dev?.product
);
const firmware = pickFirst(dev?.firmware?.version, dev?.firmware_version, state?.firmware);
return {
id: dev?.id || null,
name: dev?.name || dev?.displayName || 'Unknown AV Device',
model,
firmware,
mac: pickFirst(dev?.mac, state?.MacAddress, state?.macAddress, dev?.mac_address),
ip: pickFirst(state?.IpAddress, state?.ipAddress, dev?.ip, dev?.ip_address),
online: deriveOnline(dev),
lastSeen: pickFirst(
dev?.last_connection,
dev?.last_seen,
state?.lastSeen,
state?.last_seen,
dev?.updated_at
),
};
}
/**
* Collect AV devices for the given store. Always resolves with the
* unavailable banner contract never throws so the storeDetail layer can
* compose this alongside MDM and Meraki output without try/catch noise.
*/
async function collectAvStatus(storeNumber) {
logger.debug('collectAvStatus start', { storeNumber });
let result;
try {
result = await getAtlasDevicesForStore(storeNumber);
} catch (err) {
logger.error('Atlas lookup threw unexpectedly', { storeNumber, error: err.message });
return { devices: [], unavailable: true, reason: err.message };
}
if (result.unavailable) {
return { devices: [], unavailable: true, reason: result.reason };
}
const devices = (result.devices || []).map(shapeAtlasDevice);
logger.info('collectAvStatus done', { storeNumber, count: devices.length });
return { devices };
}
module.exports = {
collectAvStatus,
// Exposed for tests
shapeAtlasDevice,
deriveOnline,
};

View file

@ -1,6 +1,7 @@
const config = require('../config'); const config = require('../config');
const { proxyRequest } = require('./websocket'); const { proxyRequest } = require('./websocket');
const { parseStoreNumber } = require('../utils/validate'); const { parseStoreNumber } = require('../utils/validate');
const logger = require('../utils/logger');
function getSiwAuthHeaders() { function getSiwAuthHeaders() {
const { username, password } = config.siw; const { username, password } = config.siw;
@ -22,40 +23,77 @@ function buildSiwRequest(path) {
}; };
} }
/**
* Perform a SIW request and gracefully degrade when the remote agent isn't
* connected. Returns `fallback` (default: null) so downstream code can keep
* rendering Meraki/MDM sections without crashing.
*
* Any other error is propagated so the integration layer can decide whether
* to swallow or surface it.
*/
async function siwGet(path, { fallback = null, storeNumber } = {}) {
try {
const result = await proxyRequest(buildSiwRequest(path));
return result?.data ?? fallback;
} catch (err) {
if (err.code === 'AGENT_NOT_CONNECTED') {
logger.warn('SIW request skipped — remote agent not connected', { path, storeNumber });
return fallback;
}
throw err;
}
}
async function getStoreLocation(storeNumber) { async function getStoreLocation(storeNumber) {
const normalized = parseStoreNumber(storeNumber); const normalized = parseStoreNumber(storeNumber);
if (!normalized) throw new Error('Invalid store number'); if (!normalized) throw new Error('Invalid store number');
const result = await proxyRequest(buildSiwRequest(`/StoreLocation/${normalized}`)); const data = await siwGet(`/StoreLocation/${normalized}`, { storeNumber: normalized });
return result.data; if (!data) {
logger.warn('SIW returned no location record', { storeNumber: normalized });
}
return data;
}
/**
* /StoreGeneral/{n} returns brand info, environment, POS type, status,
* open/close dates, etc. Distinct from /StoreLocation which is the address.
*/
async function getStoreGeneral(storeNumber) {
const normalized = parseStoreNumber(storeNumber);
if (!normalized) throw new Error('Invalid store number');
const data = await siwGet(`/StoreGeneral/${normalized}`, { storeNumber: normalized });
if (!data) {
logger.warn('SIW returned no general record', { storeNumber: normalized });
}
return data;
} }
async function getStoreRegisters(storeNumber) { async function getStoreRegisters(storeNumber) {
const normalized = parseStoreNumber(storeNumber); const normalized = parseStoreNumber(storeNumber);
if (!normalized) throw new Error('Invalid store number'); if (!normalized) throw new Error('Invalid store number');
const result = await proxyRequest(buildSiwRequest(`/StoreRegister/${normalized}`)); return siwGet(`/StoreRegister/${normalized}`, { fallback: [], storeNumber: normalized });
return result.data || [];
} }
async function getStorePrinters(storeNumber) { async function getStorePrinters(storeNumber) {
const normalized = parseStoreNumber(storeNumber); const normalized = parseStoreNumber(storeNumber);
if (!normalized) throw new Error('Invalid store number'); if (!normalized) throw new Error('Invalid store number');
const result = await proxyRequest(buildSiwRequest(`/StorePrinter/${normalized}`)); return siwGet(`/StorePrinter/${normalized}`, { fallback: [], storeNumber: normalized });
return result.data || [];
} }
async function getStorePaymentTerminals(storeNumber) { async function getStorePaymentTerminals(storeNumber) {
const normalized = parseStoreNumber(storeNumber); const normalized = parseStoreNumber(storeNumber);
if (!normalized) throw new Error('Invalid store number'); if (!normalized) throw new Error('Invalid store number');
const result = await proxyRequest(buildSiwRequest(`/StorePayment/${normalized}`)); return siwGet(`/StorePayment/${normalized}`, { fallback: [], storeNumber: normalized });
return result.data || [];
} }
module.exports = { module.exports = {
getStoreLocation, getStoreLocation,
getStoreGeneral,
getStoreRegisters, getStoreRegisters,
getStorePrinters, getStorePrinters,
getStorePaymentTerminals, getStorePaymentTerminals,

328
services/webexPhone.js Normal file
View file

@ -0,0 +1,328 @@
/**
* Webex phone-discovery service.
*
* Focused subset of the collabFinder phoneService.js:
* - resolves a store number to its `ae<5digit>@ae.com` person
* - lists the wired desk phones registered to that person (filtered to
* the two Cisco IP Phones in store use: 7821, 7841)
* - lists the person's DECT network and its basestations + handsets
* (handsets carry baseStationId so the caller can group them under
* their parent base)
* - fetches the store's main DID number (callingLineId on the DECT
* network's location)
*
* The shape returned is intentionally flat so renderers in
* integrations/storeDetail.js can iterate without diving through nested
* status wrappers. Graceful-degradation contract: any unrecoverable failure
* (e.g. Service App not configured, tokens missing) yields
* `{ unavailable: true, reason }` rather than throwing, so the bot can
* print a single warning banner the same way it does for SIW.
*/
const webex = require('./webexService');
const logger = require('../utils/logger');
// Cisco IP Phone 7821 / 7841 — the only wired desk phones we care about in
// store mode. Pattern is intentionally loose (some product strings use
// "Cisco 7841", others "CP-7841-K9", etc.).
const WIRED_PHONE_PATTERN = /78(21|41)/;
function storeEmail(storeNumber) {
const padded = String(storeNumber).trim().padStart(5, '0');
return `ae${padded}@ae.com`;
}
async function getPersonIdByEmail(email) {
if (!email) return null;
try {
const res = await webex.request('GET', 'people', null, { email });
const items = res.items || [];
if (items.length === 0) {
logger.warn('Webex person lookup empty', { email });
return null;
}
return items[0].id;
} catch (err) {
logger.error('Webex person lookup failed', { email, error: err.message });
return null;
}
}
/**
* Pull the phone extension assigned to this person. Webex Calling exposes it
* either as a top-level `extension` field on the person, or in `phoneNumbers`
* with type `work_extension`. Returns null if neither is present.
*/
async function getPersonExtension(personId) {
if (!personId) return null;
try {
const person = await webex.request('GET', `people/${personId}`);
if (person?.extension) return String(person.extension);
const fromNumbers = (person?.phoneNumbers || []).find(p =>
String(p.type || '')
.toLowerCase()
.includes('extension')
);
return fromNumbers?.value ? String(fromNumbers.value) : null;
} catch (err) {
logger.warn('Webex person extension lookup failed', {
personId,
error: err.message,
});
return null;
}
}
async function getDevicesForPerson(personId) {
if (!personId) return [];
const all = [];
let next = null;
try {
do {
const params = { personId, max: 100 };
if (next) params.next = next;
const res = await webex.request('GET', 'devices', null, params);
const items = res.items || [];
all.push(...items);
next = res.next || null;
} while (next);
return all;
} catch (err) {
logger.error('Webex device list failed', { personId, error: err.message });
return [];
}
}
async function getDectNetworksForPerson(personId) {
if (!personId) return [];
try {
const res = await webex.request('GET', `telephony/config/people/${personId}/dectNetworks`);
const networks = res.dectNetworks || [];
return networks.map(net => ({
id: net.id,
name: net.name || 'Unknown',
handsetsCount: net.numberOfHandsetsAssigned || 0,
locationName: net.location?.name || null,
locationId: net.location?.id || null,
}));
} catch (err) {
logger.error('Webex DECT networks lookup failed', { personId, error: err.message });
return [];
}
}
async function getDectBasestations(locationId, networkId) {
if (!locationId || !networkId) return [];
try {
const res = await webex.request(
'GET',
`telephony/config/locations/${locationId}/dectNetworks/${networkId}/baseStations`
);
const items = res.items || res.baseStations || [];
return items.map(b => ({
id: b.id,
mac: b.mac || b.macAddress || b.baseMac || null,
name: b.displayName || `Basestation ${b.mac || b.macAddress || 'Unknown'}`,
status: b.status || 'unknown',
lastSeen: b.lastSeen || null,
firmware: b.softwareVersion || null,
model: b.model || null,
ipAddress: b.ip || null,
linesRegistered: b.numberOfLinesRegistered || 0,
}));
} catch (err) {
logger.error('Webex DECT basestations lookup failed', {
locationId,
networkId,
error: err.message,
});
return [];
}
}
async function getDectHandsets(locationId, networkId) {
if (!locationId || !networkId) return [];
try {
const res = await webex.request(
'GET',
`telephony/config/locations/${locationId}/dectNetworks/${networkId}/handsets`
);
const items = res.items || res.handsets || [];
return items.map(h => ({
id: h.id,
// The handset's slot index in the DECT network (1, 2, 3, ...). Used by
// the renderer to compose the "<index>-<extension>" display name.
index: h.index ?? null,
name: h.defaultDisplayName || h.displayName || `Handset ${h.index || ''}`,
status: h.status || 'unknown',
lastSeen: h.lastSeen || null,
mac: h.mac || null,
firmware: h.softwareVersion || null,
model: h.model || null,
extension: h.accessCode || h.lines?.[0]?.esn || null,
// baseStationId is only present on the detail endpoint; the per-handset
// detail fetch below fills it in.
baseStationId: h.baseStationId || null,
}));
} catch (err) {
logger.error('Webex DECT handsets lookup failed', {
locationId,
networkId,
error: err.message,
});
return [];
}
}
async function getDectHandsetDetail(locationId, networkId, handsetId) {
if (!locationId || !networkId || !handsetId) return null;
try {
const h = await webex.request(
'GET',
`telephony/config/locations/${locationId}/dectNetworks/${networkId}/handsets/${handsetId}`
);
return {
id: h.id,
index: h.index ?? null,
baseStationId: h.baseStationId || null,
lastRegistrationTime: h.lines?.[0]?.lastRegistrationTime || null,
extension: h.lines?.[0]?.extension || null,
};
} catch (err) {
logger.warn('Webex DECT handset detail failed', {
handsetId,
error: err.message,
});
return null;
}
}
async function getLocationMainNumber(locationId) {
if (!locationId) return null;
try {
const loc = await webex.request('GET', `telephony/config/locations/${locationId}`);
return loc?.callingLineId?.phoneNumber || loc?.phoneNumber || null;
} catch (err) {
logger.warn('Webex location main-number lookup failed', {
locationId,
error: err.message,
});
return null;
}
}
function shapeWiredPhone(dev, extension = null) {
return {
mac: dev.mac || null,
name: dev.displayName || dev.product || 'Unknown Phone',
model: dev.product || dev.model || null,
firmware: dev.software || dev.softwareVersion || null,
status: dev.connectionStatus || dev.status || 'unknown',
lastSeen: dev.lastSeen || null,
ipAddress: dev.ip || dev.ipAddress || null,
// All wired phones in store mode belong to the store service-account
// person, so they share that person's primary extension. Worth surfacing
// because the device itself doesn't carry it.
extension,
};
}
/**
* Collect every phone artefact we render for a store. Returns
* `{ unavailable: true, reason }` if the Service App is not configured or
* the bootstrap tokens file is missing callers should surface the reason
* as a banner rather than treating it as an error.
*/
async function collectPhoneStatus(storeNumber) {
const email = storeEmail(storeNumber);
logger.debug('collectPhoneStatus start', { storeNumber, email });
let personId;
try {
personId = await getPersonIdByEmail(email);
} catch (err) {
// getPersonIdByEmail catches its own errors so this only fires when
// request setup fails (e.g. missing client id / missing tokens file).
return { unavailable: true, reason: err.message };
}
if (!personId) {
return {
unavailable: true,
reason:
`No Webex person found for ${email}. ` +
'Confirm the store has a service account provisioned in Webex.',
};
}
const [devicesRes, networksRes, extensionRes] = await Promise.allSettled([
getDevicesForPerson(personId),
getDectNetworksForPerson(personId),
getPersonExtension(personId),
]);
const allDevices = devicesRes.status === 'fulfilled' ? devicesRes.value : [];
const dectNetworks = networksRes.status === 'fulfilled' ? networksRes.value : [];
const dectNetwork = dectNetworks[0] || null;
const personExtension = extensionRes.status === 'fulfilled' ? extensionRes.value : null;
const wiredPhones = allDevices
.filter(d => WIRED_PHONE_PATTERN.test(String(d.product || d.model || '')))
.map(d => shapeWiredPhone(d, personExtension));
let basestations = [];
let handsets = [];
let locationMainNumber = null;
if (dectNetwork?.locationId && dectNetwork?.id) {
const [basesRes, handsetsRes, mainNumRes] = await Promise.allSettled([
getDectBasestations(dectNetwork.locationId, dectNetwork.id),
getDectHandsets(dectNetwork.locationId, dectNetwork.id),
getLocationMainNumber(dectNetwork.locationId),
]);
basestations = basesRes.status === 'fulfilled' ? basesRes.value : [];
const rawHandsets = handsetsRes.status === 'fulfilled' ? handsetsRes.value : [];
locationMainNumber = mainNumRes.status === 'fulfilled' ? mainNumRes.value : null;
// Per-handset detail pulls in baseStationId / lastRegistrationTime so we
// can group handsets under their parent basestation.
handsets = await Promise.all(
rawHandsets.map(async h => {
const detail = await getDectHandsetDetail(dectNetwork.locationId, dectNetwork.id, h.id);
return { ...h, ...(detail || {}) };
})
);
}
logger.info('collectPhoneStatus done', {
storeNumber,
wired: wiredPhones.length,
bases: basestations.length,
handsets: handsets.length,
});
return {
phones: wiredPhones,
basestations,
handsets,
dectNetwork,
locationMainNumber,
};
}
module.exports = {
collectPhoneStatus,
// Exposed for unit tests:
storeEmail,
WIRED_PHONE_PATTERN,
getPersonIdByEmail,
getPersonExtension,
getDevicesForPerson,
getDectNetworksForPerson,
getDectBasestations,
getDectHandsets,
getDectHandsetDetail,
getLocationMainNumber,
};

86
services/webexService.js Normal file
View file

@ -0,0 +1,86 @@
/**
* Thin axios wrapper for the Webex Service App.
*
* - Pulls the current access token from WebexServiceAppAuth on each call.
* - Retries transient failures via utils/retry.withRetry.
* - On a 401 response (token revoked between cache and call), forces a single
* refresh and retries once. Repeated 401s after that surface as errors so
* the caller can degrade gracefully.
*/
const axios = require('axios');
const config = require('../config');
const WebexServiceAppAuth = require('../integrations/webex/WebexServiceAppAuth');
const { withRetry } = require('../utils/retry');
const logger = require('../utils/logger');
const BASE_URL = 'https://webexapis.com/v1';
const RETRY_OPTS = { retries: 2, initialDelayMs: 500 };
let _auth = null;
function auth() {
if (!_auth) {
_auth = WebexServiceAppAuth.getInstance({
clientId: config.webexServiceApp.clientId,
clientSecret: config.webexServiceApp.clientSecret,
tokensFilePath: config.webexServiceApp.tokensPath,
});
}
return _auth;
}
function resetAuthForTests() {
_auth = null;
}
/**
* Issue a Webex Service App request. Returns response.data (or an empty
* object) on success; throws axios errors on hard failures.
*
* @param {('GET'|'POST'|'PUT'|'DELETE'|'PATCH')} method
* @param {string} pathSuffix - Webex API path relative to /v1 (e.g. "people").
* @param {object|null} body - JSON body for non-GET methods.
* @param {object|null} params - querystring params.
*/
async function request(method, pathSuffix, body = null, params = null) {
const url = `${BASE_URL}/${String(pathSuffix).replace(/^\/+/, '')}`;
const a = auth();
const doRequest = async (forceRefresh = false) => {
const token = forceRefresh ? await a.forceRefresh() : await a.getAccessToken();
return axios({
method,
url,
data: body || undefined,
params: params || undefined,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
timeout: 15000,
});
};
try {
const res = await withRetry(() => doRequest(false), RETRY_OPTS);
return res.data ?? {};
} catch (err) {
if (err.response?.status === 401) {
logger.warn('Webex returned 401 — refreshing and retrying once', { url });
try {
const retry = await doRequest(true);
return retry.data ?? {};
} catch (retryErr) {
logger.error('Webex request failed after forced refresh', {
url,
status: retryErr.response?.status,
error: retryErr.message,
});
throw retryErr;
}
}
throw err;
}
}
module.exports = { request, resetAuthForTests, BASE_URL };

View file

@ -160,10 +160,22 @@ function stopWebSocketServer() {
rejectPending('Server shutting down'); rejectPending('Server shutting down');
} }
function isAgentConnected() {
return !!connectedAgent && connectedAgent.readyState === WebSocket.OPEN;
}
class AgentNotConnectedError extends Error {
constructor(message = 'No remote agent connected') {
super(message);
this.name = 'AgentNotConnectedError';
this.code = 'AGENT_NOT_CONNECTED';
}
}
async function proxyRequest(requestConfig) { async function proxyRequest(requestConfig) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!connectedAgent || connectedAgent.readyState !== WebSocket.OPEN) { if (!isAgentConnected()) {
return reject(new Error('No remote agent connected')); return reject(new AgentNotConnectedError());
} }
if (pendingRequests.size >= MAX_PENDING_REQUESTS) { if (pendingRequests.size >= MAX_PENDING_REQUESTS) {
@ -195,4 +207,10 @@ async function proxyRequest(requestConfig) {
}); });
} }
module.exports = { startWebSocketServer, stopWebSocketServer, proxyRequest }; module.exports = {
startWebSocketServer,
stopWebSocketServer,
proxyRequest,
isAgentConnected,
AgentNotConnectedError,
};

76
tests/atlasClient.test.js Normal file
View file

@ -0,0 +1,76 @@
jest.mock('../config', () => ({ logLevel: 'error' }));
jest.mock('axios', () => {
const get = jest.fn();
const create = jest.fn(() => ({ get }));
return { __esModule: true, default: { create }, create, __get: get };
});
const axios = require('axios');
const atlasClientModule = require('../integrations/atlas/atlasClient');
const { atlasGet, AtlasUnavailableError, resetClientForTests } = atlasClientModule;
describe('atlasClient', () => {
const originalKey = process.env.ATLAS_AUTH_KEY;
beforeEach(() => {
jest.clearAllMocks();
resetClientForTests();
});
afterAll(() => {
if (originalKey === undefined) delete process.env.ATLAS_AUTH_KEY;
else process.env.ATLAS_AUTH_KEY = originalKey;
});
it('throws AtlasUnavailableError when ATLAS_AUTH_KEY is missing', async () => {
delete process.env.ATLAS_AUTH_KEY;
await expect(atlasGet('organization/devices')).rejects.toBeInstanceOf(AtlasUnavailableError);
});
it('creates an axios instance with the env-provided Authorization header (no Bearer prefix)', async () => {
process.env.ATLAS_AUTH_KEY = 'secret-token-123';
axios.__get.mockResolvedValue({ status: 200, data: { ok: true } });
await atlasGet('organization/devices', { page: 1 });
expect(axios.create).toHaveBeenCalledTimes(1);
const cfg = axios.create.mock.calls[0][0];
expect(cfg.headers.Authorization).toBe('secret-token-123');
expect(cfg.baseURL).toBe('https://hub.xyte.io/core/v1');
});
it('honors ATLAS_BASE_URL override', async () => {
process.env.ATLAS_AUTH_KEY = 'k';
process.env.ATLAS_BASE_URL = 'https://atlas.test/api/v2';
axios.__get.mockResolvedValue({ status: 200, data: {} });
await atlasGet('organization/devices');
expect(axios.create.mock.calls[0][0].baseURL).toBe('https://atlas.test/api/v2');
delete process.env.ATLAS_BASE_URL;
});
it('returns response.data on success and strips leading slash from path', async () => {
process.env.ATLAS_AUTH_KEY = 'k';
axios.__get.mockResolvedValue({ status: 200, data: { items: [1, 2] } });
const data = await atlasGet('/organization/devices', { page: 2 });
expect(data).toEqual({ items: [1, 2] });
expect(axios.__get).toHaveBeenCalledWith(
'/organization/devices',
expect.objectContaining({ params: { page: 2 } })
);
});
it('throws with status detail on 4xx response', async () => {
process.env.ATLAS_AUTH_KEY = 'k';
axios.__get.mockResolvedValue({
status: 403,
statusText: 'Forbidden',
data: { message: 'invalid key' },
});
await expect(atlasGet('organization/devices')).rejects.toThrow(/invalid key/);
});
});

143
tests/atlasDevices.test.js Normal file
View file

@ -0,0 +1,143 @@
jest.mock('../config', () => ({ logLevel: 'error' }));
jest.mock('../integrations/atlas/atlasClient', () => {
const actual = jest.requireActual('../integrations/atlas/atlasClient');
return {
...actual,
atlasGet: jest.fn(),
};
});
const { atlasGet, AtlasUnavailableError } = require('../integrations/atlas/atlasClient');
const devices = require('../integrations/atlas/atlasDevices');
function pageOf(items, nextPage = null) {
return { items, next_page: nextPage };
}
describe('atlasDevices pagination', () => {
beforeEach(() => {
jest.clearAllMocks();
devices.resetCacheForTests();
});
it('stops paginating when a short page is returned', async () => {
// 100 items + next_page=2 → keep going; 5 items on page 2 → stop.
const page1 = Array.from({ length: devices.PAGE_SIZE }, (_, i) => ({ id: `d${i}`, name: 'x' }));
const page2 = Array.from({ length: 5 }, (_, i) => ({ id: `d${100 + i}`, name: 'x' }));
atlasGet.mockResolvedValueOnce(pageOf(page1, 2)).mockResolvedValueOnce(pageOf(page2, 3));
const list = await devices.getAtlasDeviceList();
expect(list).toHaveLength(105);
expect(atlasGet).toHaveBeenCalledTimes(2);
});
it('stops paginating when next_page is missing even on a full page', async () => {
const page1 = Array.from({ length: devices.PAGE_SIZE }, (_, i) => ({ id: `d${i}`, name: 'x' }));
atlasGet.mockResolvedValueOnce(pageOf(page1, null));
const list = await devices.getAtlasDeviceList();
expect(list).toHaveLength(devices.PAGE_SIZE);
expect(atlasGet).toHaveBeenCalledTimes(1);
});
it('preserves the previous cache when a mid-pagination call fails', async () => {
// First refresh succeeds with one device.
atlasGet.mockResolvedValueOnce(pageOf([{ id: 'd1', name: 'US000782AMP' }], null));
await devices.getAtlasDeviceList(true);
// Force a refresh that fails on page 1 → cache should not be wiped.
atlasGet.mockRejectedValueOnce(new Error('boom'));
const list = await devices.getAtlasDeviceList(true);
expect(list).toHaveLength(1);
expect(list[0].id).toBe('d1');
});
it('propagates AtlasUnavailableError so callers can render a banner', async () => {
atlasGet.mockRejectedValueOnce(new AtlasUnavailableError('ATLAS_AUTH_KEY is not set'));
await expect(devices.getAtlasDeviceList(true)).rejects.toBeInstanceOf(AtlasUnavailableError);
});
});
describe('findAtlasDevicesForStore', () => {
beforeEach(() => {
jest.clearAllMocks();
devices.resetCacheForTests();
});
it('matches the zero-padded store number against device names', async () => {
atlasGet.mockResolvedValueOnce(
pageOf(
[
{ id: '1', name: 'US000782AMP' }, // match
{ id: '2', name: 'us000782DSP' }, // match (case-insensitive)
{ id: '3', name: 'US007820AMP' }, // NOT — 782 unpadded would false-positive
{ id: '4', name: 'US000305AMP' },
],
null
)
);
const matches = await devices.findAtlasDevicesForStore('782');
expect(matches.map(m => m.id).sort()).toEqual(['1', '2']);
});
it('handles already-padded store numbers and whitespace', async () => {
atlasGet.mockResolvedValueOnce(pageOf([{ id: '1', name: 'US000305AMP' }], null));
const matches = await devices.findAtlasDevicesForStore(' 000305 ');
expect(matches).toHaveLength(1);
});
});
describe('getAtlasDevicesForStore', () => {
beforeEach(() => {
jest.clearAllMocks();
devices.resetCacheForTests();
});
it('returns { devices: [] } when no matches', async () => {
atlasGet.mockResolvedValueOnce(pageOf([{ id: '1', name: 'US000305AMP' }], null));
const result = await devices.getAtlasDevicesForStore('999');
expect(result).toEqual({ devices: [] });
});
it('fetches detail per matched device and merges over the list summary', async () => {
atlasGet
// Initial list call.
.mockResolvedValueOnce(pageOf([{ id: 'd1', name: 'US000782AMP', status: 'unknown' }], null))
// Detail call for d1 — adds richer state.
.mockResolvedValueOnce({
id: 'd1',
name: 'US000782AMP',
status: 'online',
state: { IpAddress: '10.0.0.5', MacAddress: 'aa:bb:cc:dd:ee:ff' },
});
const result = await devices.getAtlasDevicesForStore('782');
expect(result.devices).toHaveLength(1);
expect(result.devices[0]).toEqual(
expect.objectContaining({
id: 'd1',
name: 'US000782AMP',
status: 'online',
state: { IpAddress: '10.0.0.5', MacAddress: 'aa:bb:cc:dd:ee:ff' },
})
);
});
it('returns { unavailable: true } with the auth reason when the key is missing', async () => {
atlasGet.mockRejectedValueOnce(new AtlasUnavailableError('ATLAS_AUTH_KEY is not set'));
const result = await devices.getAtlasDevicesForStore('782');
expect(result.unavailable).toBe(true);
expect(result.reason).toMatch(/ATLAS_AUTH_KEY/);
expect(result.devices).toEqual([]);
});
it('returns { unavailable: true } with the transport reason on other lookup failures', async () => {
atlasGet.mockRejectedValueOnce(new Error('ECONNRESET'));
const result = await devices.getAtlasDevicesForStore('782');
expect(result.unavailable).toBe(true);
expect(result.reason).toMatch(/Atlas lookup failed/);
});
});

45
tests/avCategory.test.js Normal file
View file

@ -0,0 +1,45 @@
const { classifyMdmAvDevice, AV_CATEGORIES, AV_FRIENDLY_NAME_PATTERN } = require('../constants');
describe('AV_FRIENDLY_NAME_PATTERN', () => {
it('matches the four AV markers (case-insensitive for AppleTV)', () => {
expect(AV_FRIENDLY_NAME_PATTERN.test('US000782AppleTV01')).toBe(true);
expect(AV_FRIENDLY_NAME_PATTERN.test('US000782appletv01')).toBe(true);
expect(AV_FRIENDLY_NAME_PATTERN.test('US000782VW1')).toBe(true);
expect(AV_FRIENDLY_NAME_PATTERN.test('US000782MSC1')).toBe(true);
expect(AV_FRIENDLY_NAME_PATTERN.test('US000782LED1')).toBe(true);
});
it('does not match non-AV markers', () => {
expect(AV_FRIENDLY_NAME_PATTERN.test('US000782IPH04')).toBe(false);
expect(AV_FRIENDLY_NAME_PATTERN.test('US000782SRV01')).toBe(false);
expect(AV_FRIENDLY_NAME_PATTERN.test('US000782MR03')).toBe(false);
expect(AV_FRIENDLY_NAME_PATTERN.test('US000782CD01')).toBe(false);
});
});
describe('classifyMdmAvDevice', () => {
it('classifies AppleTV first (most specific)', () => {
expect(classifyMdmAvDevice('US000782AppleTV01')).toBe(AV_CATEGORIES.APPLE_TV);
});
it('classifies video walls / music / LED based on the marker', () => {
expect(classifyMdmAvDevice('US000782VW1')).toBe(AV_CATEGORIES.VIDEO_WALL);
expect(classifyMdmAvDevice('US000782MSC1')).toBe(AV_CATEGORIES.MUSIC);
expect(classifyMdmAvDevice('US000782LED1')).toBe(AV_CATEGORIES.LED);
});
it('returns null for non-AV MDM names', () => {
expect(classifyMdmAvDevice('US000782IPH04')).toBeNull();
expect(classifyMdmAvDevice('US000782SRV01')).toBeNull();
expect(classifyMdmAvDevice('US000782CD01')).toBeNull();
expect(classifyMdmAvDevice('')).toBeNull();
expect(classifyMdmAvDevice(null)).toBeNull();
});
it('accepts either a raw friendly-name string or an MDM device object', () => {
expect(classifyMdmAvDevice({ DeviceFriendlyName: 'US000782VW2' })).toBe(
AV_CATEGORIES.VIDEO_WALL
);
expect(classifyMdmAvDevice({ UserName: 'US000782AppleTV03' })).toBe(AV_CATEGORIES.APPLE_TV);
});
});

119
tests/avService.test.js Normal file
View file

@ -0,0 +1,119 @@
jest.mock('../config', () => ({ logLevel: 'error' }));
jest.mock('../integrations/atlas/atlasDevices', () => ({
getAtlasDevicesForStore: jest.fn(),
}));
const { getAtlasDevicesForStore } = require('../integrations/atlas/atlasDevices');
const { collectAvStatus, shapeAtlasDevice, deriveOnline } = require('../services/avService');
describe('deriveOnline', () => {
it('returns true for common "online" keywords', () => {
expect(deriveOnline({ connection_status: 'online' })).toBe(true);
expect(deriveOnline({ connection_status: 'CONNECTED' })).toBe(true);
expect(deriveOnline({ state: { connection_status: 'active' } })).toBe(true);
expect(deriveOnline({ status: 'up' })).toBe(true);
});
it('returns false for common "offline" keywords', () => {
expect(deriveOnline({ connection_status: 'offline' })).toBe(false);
expect(deriveOnline({ connection_status: 'Disconnected' })).toBe(false);
expect(deriveOnline({ status: 'inactive' })).toBe(false);
});
it('returns null when no recognisable status field is present', () => {
expect(deriveOnline({})).toBeNull();
expect(deriveOnline({ status: 'idk' })).toBeNull();
expect(deriveOnline({ status: '' })).toBeNull();
});
it('honors explicit booleans', () => {
expect(deriveOnline({ connection_status: true })).toBe(true);
expect(deriveOnline({ connection_status: false })).toBe(false);
});
});
describe('shapeAtlasDevice', () => {
it('promotes state.IpAddress / state.MacAddress to top-level ip / mac', () => {
const shaped = shapeAtlasDevice({
id: 'd1',
name: 'US000782AMP',
state: { IpAddress: '10.0.0.5', MacAddress: 'AA:BB:CC:DD:EE:FF' },
status: 'online',
});
expect(shaped.ip).toBe('10.0.0.5');
expect(shaped.mac).toBe('AA:BB:CC:DD:EE:FF');
expect(shaped.online).toBe(true);
});
it('handles model as a string OR an object with .name', () => {
expect(shapeAtlasDevice({ model: 'Cisco AMP' }).model).toBe('Cisco AMP');
expect(shapeAtlasDevice({ model: { name: 'Cisco AMP' } }).model).toBe('Cisco AMP');
});
it('falls back to a placeholder name when none is present', () => {
expect(shapeAtlasDevice({}).name).toBe('Unknown AV Device');
});
});
describe('collectAvStatus', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('shapes each Atlas device returned by the integration', async () => {
getAtlasDevicesForStore.mockResolvedValue({
devices: [
{
id: 'd1',
name: 'US000782AMP',
model: 'Cisco AMP',
state: { IpAddress: '10.0.0.5', MacAddress: 'aa:bb:cc:dd:ee:ff' },
status: 'online',
},
],
});
const result = await collectAvStatus('782');
expect(result.unavailable).toBeFalsy();
expect(result.devices).toHaveLength(1);
expect(result.devices[0]).toEqual(
expect.objectContaining({
id: 'd1',
name: 'US000782AMP',
model: 'Cisco AMP',
ip: '10.0.0.5',
mac: 'aa:bb:cc:dd:ee:ff',
online: true,
})
);
});
it('propagates an unavailable payload from the integration layer', async () => {
getAtlasDevicesForStore.mockResolvedValue({
devices: [],
unavailable: true,
reason: 'ATLAS_AUTH_KEY is not set',
});
const result = await collectAvStatus('782');
expect(result.unavailable).toBe(true);
expect(result.reason).toMatch(/ATLAS_AUTH_KEY/);
expect(result.devices).toEqual([]);
});
it('catches unexpected throws and returns the banner contract', async () => {
getAtlasDevicesForStore.mockRejectedValue(new Error('boom'));
const result = await collectAvStatus('782');
expect(result.unavailable).toBe(true);
expect(result.reason).toBe('boom');
expect(result.devices).toEqual([]);
});
it('returns an empty list when the store has no Atlas devices', async () => {
getAtlasDevicesForStore.mockResolvedValue({ devices: [] });
const result = await collectAvStatus('782');
expect(result.unavailable).toBeFalsy();
expect(result.devices).toEqual([]);
});
});

56
tests/chunkReport.test.js Normal file
View file

@ -0,0 +1,56 @@
const { chunkReport } = require('../utils/chunkReport');
describe('chunkReport', () => {
it('returns [] for empty input', () => {
expect(chunkReport('')).toEqual([]);
expect(chunkReport(null)).toEqual([]);
});
it('returns a single chunk when under the limit', () => {
const report = '**🌐 Network**\n- Switch online\n- AP online';
expect(chunkReport(report, 1000)).toEqual([report]);
});
it('trims surrounding whitespace from the single-chunk case', () => {
const report = '\n\n**A**\nhello\n\n';
expect(chunkReport(report, 1000)).toEqual(['**A**\nhello']);
});
it('splits on section boundaries when over the limit', () => {
const a = '**A** ' + 'x'.repeat(60);
const b = '**B** ' + 'y'.repeat(60);
const c = '**C** ' + 'z'.repeat(60);
const report = `${a}\n\n${b}\n\n${c}`;
const chunks = chunkReport(report, 100);
// Each section is ~66 chars so two sections per chunk is just over the
// limit. Expect 3 chunks, one per section.
expect(chunks).toHaveLength(3);
expect(chunks[0]).toContain('**A**');
expect(chunks[1]).toContain('**B**');
expect(chunks[2]).toContain('**C**');
chunks.forEach(c => expect(c.length).toBeLessThanOrEqual(100));
});
it('packs multiple small sections into one chunk when they fit', () => {
const sections = ['**A** short', '**B** short', '**C** short', '**D** short'];
const report = sections.join('\n\n');
const chunks = chunkReport(report, 1000);
expect(chunks).toHaveLength(1);
expect(chunks[0]).toBe(report);
});
it('hard-splits when a single section exceeds the limit', () => {
const huge = '**Huge** ' + 'x'.repeat(500);
const chunks = chunkReport(huge, 100);
chunks.forEach(c => expect(c.length).toBeLessThanOrEqual(100));
expect(chunks.join('')).toBe(huge);
});
it('keeps each chunk under the default 7000-char limit', () => {
const section = '**Section ' + 'x'.repeat(50) + '**\n' + 'y'.repeat(3500);
const report = Array.from({ length: 5 }, () => section).join('\n\n');
const chunks = chunkReport(report);
chunks.forEach(c => expect(c.length).toBeLessThanOrEqual(7000));
});
});

View file

@ -1,41 +1,119 @@
const { parseStoreCommand } = require('../bot/handlers'); const { parseStoreCommand, getCommandText } = require('../bot/handlers');
const { STORE_MODES } = require('../constants'); const { STORE_MODES } = require('../constants');
// The two phrase regexes registered in server.js, kept in lock-step here so
// the test suite documents the routing contract (and fails fast if either
// drifts).
const HELP_PHRASE = /^(?:\S+\s+)?help\b/i;
const ST_PHRASE = /^(?:\S+\s+)?st\b/i;
describe('parseStoreCommand', () => { describe('parseStoreCommand', () => {
it('parses a default store command', () => { it('defaults to INFO mode for a bare st <number>', () => {
expect(parseStoreCommand('store 305')).toEqual({ expect(parseStoreCommand('st 305')).toEqual({
storeNumber: '305', storeNumber: '305',
mode: STORE_MODES.DEFAULT, mode: STORE_MODES.INFO,
}); });
}); });
it('detects pos mode', () => { it('recognises the network subcommand', () => {
expect(parseStoreCommand('store 305 pos')).toEqual({ expect(parseStoreCommand('st 305 network')).toEqual({
storeNumber: '305',
mode: STORE_MODES.NETWORK,
});
});
it('recognises the pos subcommand', () => {
expect(parseStoreCommand('st 305 pos')).toEqual({
storeNumber: '305', storeNumber: '305',
mode: STORE_MODES.POS, mode: STORE_MODES.POS,
}); });
}); });
it('detects ios mode (ios or iphone)', () => { it('treats ios and iphone as the same mode', () => {
expect(parseStoreCommand('store 305 ios')).toEqual({ expect(parseStoreCommand('st 305 ios').mode).toBe(STORE_MODES.IOS);
storeNumber: '305', expect(parseStoreCommand('st 305 iphone').mode).toBe(STORE_MODES.IOS);
mode: STORE_MODES.IOS,
});
expect(parseStoreCommand('analyze 305 iphone')).toEqual({
storeNumber: '305',
mode: STORE_MODES.IOS,
});
}); });
it('returns null storeNumber for non-numeric input', () => { it('recognises the phone and av placeholder subcommands', () => {
expect(parseStoreCommand('store')).toEqual({ storeNumber: null, mode: null }); expect(parseStoreCommand('st 305 phone').mode).toBe(STORE_MODES.PHONE);
expect(parseStoreCommand('analyze something')).toEqual({ storeNumber: null, mode: null }); expect(parseStoreCommand('st 305 av').mode).toBe(STORE_MODES.AV);
}); });
it('parses analyze commands the same way', () => { it('does not match "phone" inside another word', () => {
expect(parseStoreCommand('analyze 782')).toEqual({ // "phones" should not pick up PHONE mode — must be a whole word.
storeNumber: '782', expect(parseStoreCommand('st 305 phones').mode).toBe(STORE_MODES.INFO);
mode: STORE_MODES.DEFAULT,
}); });
it('returns null storeNumber when no digits are present', () => {
expect(parseStoreCommand('st')).toEqual({ storeNumber: null, mode: null });
expect(parseStoreCommand('st network')).toEqual({ storeNumber: null, mode: null });
});
});
describe('getCommandText', () => {
it('prefers trigger.command + trigger.prompt (framework-cleaned text)', () => {
// What the framework gives us in a group space after stripping the bot
// mention: command is the matched phrase, prompt is everything after.
const trigger = {
command: 'st',
prompt: ' 782 network',
message: { text: 'devStoreHealthAnalyzer st 782 network' },
};
expect(getCommandText(trigger)).toBe('st 782 network');
});
it('handles a DM message (no bot-name prefix) the same way', () => {
const trigger = {
command: 'st',
prompt: ' 782 pos',
message: { text: 'st 782 pos' },
};
expect(getCommandText(trigger)).toBe('st 782 pos');
});
it('falls back to message.text when command/prompt are absent', () => {
expect(getCommandText({ message: { text: 'st 305' } })).toBe('st 305');
});
it('returns an empty string for an empty trigger', () => {
expect(getCommandText({})).toBe('');
expect(getCommandText(null)).toBe('');
});
});
describe('route phrase regexes', () => {
// Direct-message form (no bot name).
it('matches bare DM commands', () => {
expect(ST_PHRASE.test('st 782')).toBe(true);
expect(ST_PHRASE.test('st 782 network')).toBe(true);
expect(HELP_PHRASE.test('help')).toBe(true);
expect(HELP_PHRASE.test('help st')).toBe(true);
});
// Group-mention form: Webex prepends "BotName " to mentioned messages.
it('matches group-mention commands with a leading bot-name prefix', () => {
expect(ST_PHRASE.test('devStoreHealthAnalyzer st 782')).toBe(true);
expect(ST_PHRASE.test('devStoreHealthAnalyzer st 782 ios')).toBe(true);
expect(HELP_PHRASE.test('devStoreHealthAnalyzer help')).toBe(true);
});
// Don't trigger on similar-looking words.
it('does not match unrelated words containing "st"', () => {
expect(ST_PHRASE.test('stop the build')).toBe(false);
expect(ST_PHRASE.test('start now')).toBe(false);
expect(ST_PHRASE.test('fast 305')).toBe(false);
});
// Don't trigger when the command is buried mid-sentence in a DM.
it('does not match commands buried more than one word deep', () => {
expect(ST_PHRASE.test('please run st 305')).toBe(false);
expect(HELP_PHRASE.test('I really need help')).toBe(false);
});
// Disambiguation: "BotName help st" should hit help only, not st.
it('routes "help st" to help only', () => {
const msg = 'devStoreHealthAnalyzer help st';
expect(HELP_PHRASE.test(msg)).toBe(true);
expect(ST_PHRASE.test(msg)).toBe(false);
}); });
}); });

View file

@ -1,52 +0,0 @@
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

@ -1,29 +0,0 @@
/**
* 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

@ -3,6 +3,8 @@ const {
getClientStatus, getClientStatus,
formatLastSeen, formatLastSeen,
buildMerakiClientLink, buildMerakiClientLink,
extractHostname,
normalizeMac,
} = require('../utils/merakiMatcher'); } = require('../utils/merakiMatcher');
describe('merakiMatcher', () => { describe('merakiMatcher', () => {
@ -44,6 +46,103 @@ describe('merakiMatcher', () => {
expect(findMatchingClient([], { name: 'foo' })).toBeNull(); expect(findMatchingClient([], { name: 'foo' })).toBeNull();
expect(findMatchingClient(sampleClients, { name: 'nonexistent' })).toBeNull(); expect(findMatchingClient(sampleClients, { name: 'nonexistent' })).toBeNull();
}); });
// Payment-terminal regression: SIW reports the FQDN in `ip_address`
// (e.g. "VFI-807-005-168.us000782.stores.ae.com") and Meraki advertises
// only the lowercase hostname before the first dot. Before the fix the
// matcher compared an upper-case ipPrefix against a lower-cased desc and
// missed every payment terminal.
it('matches a Meraki client when SIW gives an FQDN ip_address (case-insensitive)', () => {
const clients = [
{ id: 'm1', description: 'vfi-807-005-168', status: 'Online', lastSeen: null },
];
const match = findMatchingClient(clients, {
deviceName: 'Terminal 11',
adyenName: 'P400Plus-807005168',
ip_address: 'VFI-807-005-168.us000782.stores.ae.com',
});
expect(match).toBe(clients[0]);
});
});
describe('normalizeMac', () => {
it('strips common separators and lowercases', () => {
expect(normalizeMac('AA:BB:CC:11:22:33')).toBe('aabbcc112233');
expect(normalizeMac('aa-bb-cc-11-22-33')).toBe('aabbcc112233');
expect(normalizeMac('aabb.cc11.2233')).toBe('aabbcc112233');
expect(normalizeMac('AABBCC112233')).toBe('aabbcc112233');
});
it('returns null for non-12-hex input', () => {
expect(normalizeMac('')).toBeNull();
expect(normalizeMac(null)).toBeNull();
expect(normalizeMac('aa:bb:cc')).toBeNull();
expect(normalizeMac('not a mac at all')).toBeNull();
expect(normalizeMac('aabbcc1122334455')).toBeNull();
});
});
describe('findMatchingClient — MAC strategy', () => {
const clients = [
{
id: 'client-name-match',
description: 'VFI-807-005-168',
mac: '11:22:33:44:55:66',
status: 'Offline',
},
{
id: 'client-mac-match',
description: 'something-totally-different',
mac: 'AA-BB-CC-DD-EE-FF',
status: 'Online',
},
];
it('matches by MAC when present (deterministic, case/separator insensitive)', () => {
const match = findMatchingClient(clients, { mac: 'aabb.ccdd.eeff' });
expect(match?.id).toBe('client-mac-match');
});
it('lets MAC win even when a name strategy could also match', () => {
// The first client has a description that would match by name, but we
// pass a MAC for the second one — MAC should take priority.
const match = findMatchingClient(clients, {
mac: 'aabbccddeeff',
name: 'VFI-807-005-168',
});
expect(match?.id).toBe('client-mac-match');
});
it('falls back to name strategy when MAC is missing or unmatched', () => {
const match = findMatchingClient(clients, {
mac: 'ffffffffffff',
name: 'VFI-807-005-168',
});
expect(match?.id).toBe('client-name-match');
});
it('returns null when nothing matches and MAC is invalid', () => {
const match = findMatchingClient(clients, { mac: 'not-a-mac' });
expect(match).toBeNull();
});
});
describe('extractHostname', () => {
it('returns the lowercase short hostname from an FQDN', () => {
expect(extractHostname('VFI-807-005-168.us000782.stores.ae.com')).toBe('vfi-807-005-168');
});
it('passes a bare IP through (no dot-split semantics for our matching)', () => {
// Bare IPs still split on the first dot — that's fine because the
// matcher only uses this as a "starts-with" hint anyway.
expect(extractHostname('192.168.10.45')).toBe('192');
});
it('returns null for falsy input', () => {
expect(extractHostname(null)).toBeNull();
expect(extractHostname(undefined)).toBeNull();
expect(extractHostname('')).toBeNull();
});
}); });
describe('getClientStatus', () => { describe('getClientStatus', () => {

View file

@ -1,13 +0,0 @@
/**
* 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 };

View file

@ -1,42 +0,0 @@
/**
* 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,
};

View file

@ -1,44 +0,0 @@
/**
* 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,
};

55
tests/siw.test.js Normal file
View file

@ -0,0 +1,55 @@
jest.mock('../config', () => ({
siw: { baseUrl: 'https://siw.example.com/api', username: 'u', password: 'p' },
logLevel: 'error',
}));
const { AgentNotConnectedError } = require('../services/websocket');
jest.mock('../services/websocket', () => {
class AgentNotConnectedError extends Error {
constructor() {
super('No remote agent connected');
this.code = 'AGENT_NOT_CONNECTED';
}
}
return {
proxyRequest: jest.fn(),
isAgentConnected: () => false,
AgentNotConnectedError,
};
});
const { proxyRequest } = require('../services/websocket');
const siw = require('../services/siw');
describe('siw service degrades gracefully when remote agent is offline', () => {
beforeEach(() => {
proxyRequest.mockReset();
proxyRequest.mockRejectedValue(new AgentNotConnectedError());
});
it('getStoreLocation returns null without throwing', async () => {
await expect(siw.getStoreLocation('305')).resolves.toBeNull();
});
it('getStoreGeneral returns null without throwing', async () => {
await expect(siw.getStoreGeneral('305')).resolves.toBeNull();
});
it('getStoreRegisters returns []', async () => {
await expect(siw.getStoreRegisters('305')).resolves.toEqual([]);
});
it('getStorePrinters returns []', async () => {
await expect(siw.getStorePrinters('305')).resolves.toEqual([]);
});
it('getStorePaymentTerminals returns []', async () => {
await expect(siw.getStorePaymentTerminals('305')).resolves.toEqual([]);
});
it('rethrows non-agent errors', async () => {
proxyRequest.mockRejectedValue(new Error('boom'));
await expect(siw.getStoreRegisters('305')).rejects.toThrow('boom');
});
});

View file

@ -1,4 +1,4 @@
const { createStore, Store } = require('../models/Store'); const { createStore, Store, extractBrands } = require('../models/Store');
describe('Store model', () => { describe('Store model', () => {
it('uses store_number from data when no explicit number given', () => { it('uses store_number from data when no explicit number given', () => {
@ -20,6 +20,21 @@ describe('Store model', () => {
expect(summary).toContain('District ID: N/A'); expect(summary).toContain('District ID: N/A');
}); });
it('handles null location data without throwing (regression: store 2477)', () => {
const s = createStore(null, 2477);
expect(s.hasLocationData).toBe(false);
expect(s.name).toBe('Store 2477');
const summary = s.toSummary();
expect(summary).toContain('Store 2477');
expect(summary).toContain('No SIW record found');
});
it('handles undefined location data without throwing', () => {
const s = createStore(undefined, 305);
expect(s.hasLocationData).toBe(false);
expect(() => s.toSummary()).not.toThrow();
});
it('builds a full address from optional parts', () => { it('builds a full address from optional parts', () => {
const s = new Store( const s = new Store(
{ {
@ -38,4 +53,154 @@ describe('Store model', () => {
expect(addr).toContain('Anytown, CA 90210'); expect(addr).toContain('Anytown, CA 90210');
expect(addr).toContain('Phone: 555-1234'); expect(addr).toContain('Phone: 555-1234');
}); });
it('summary ends with a blank line so the section splitter works', () => {
const s = createStore({ name: 'Foo' }, '305');
// The Meraki section starts with `\n\n**🌐...`. Combined with toSummary's
// trailing newline this gives a clean \n\n** boundary.
expect(s.toSummary()).toMatch(/\n$/);
});
});
describe('extractBrands', () => {
it('returns [] for null / missing data', () => {
expect(extractBrands(null)).toEqual([]);
expect(extractBrands({})).toEqual([]);
});
it('reads an array of plain strings', () => {
expect(extractBrands({ brands: ['AEO', 'Aerie', 'Offline'] })).toEqual([
'AEO',
'Aerie',
'Offline',
]);
});
it('reads an array of objects with brand_display_name', () => {
expect(
extractBrands({
brands: [{ brand_display_name: 'AEO' }, { brand_name: 'Aerie' }, { name: 'Offline' }],
})
).toEqual(['AEO', 'Aerie', 'Offline']);
});
it('reads numbered brand_1..brand_3 fields', () => {
expect(extractBrands({ brand_1: 'AEO', brand_2: 'Aerie', brand_3: 'Offline' })).toEqual([
'AEO',
'Aerie',
'Offline',
]);
});
it('reads brand_display_name_1..3 fields', () => {
expect(
extractBrands({
brand_display_name_1: 'AEO',
brand_display_name_2: 'Aerie',
})
).toEqual(['AEO', 'Aerie']);
});
it('falls back to a single brand field', () => {
expect(extractBrands({ brand_display_name: 'AEO' })).toEqual(['AEO']);
expect(extractBrands({ brand: 'AEO' })).toEqual(['AEO']);
});
it('deduplicates case-insensitively and caps at 3', () => {
expect(
extractBrands({ brands: ['AEO', 'aeo', 'Aerie', 'Offline', 'Todd Snyder', 'Unsubsidiary'] })
).toEqual(['AEO', 'Aerie', 'Offline']);
});
it('Store.brands is populated and rendered in the summary', () => {
const s = createStore(
{ name: 'Mall Store', brand_1: 'AEO', brand_2: 'Aerie', address: '1 Main' },
'2477'
);
expect(s.brands).toEqual(['AEO', 'Aerie']);
const summary = s.toSummary();
expect(summary).toContain('Brands: AEO, Aerie');
});
it('uses singular "Brand:" label when only one brand is present', () => {
const s = createStore({ name: 'X', brand: 'AEO' }, '1');
expect(s.toSummary()).toContain('Brand: AEO');
});
it('reads brands from the real SIW /StoreGeneral shape (incl. `pimary` typo)', () => {
const general = {
pimary_brand_name: 'American Eagle Outfitters',
secondary_brand_name: 'Aerie',
tertiary_brand_name: 'Offline',
};
expect(extractBrands(general)).toEqual(['American Eagle Outfitters', 'Aerie', 'Offline']);
});
it('also reads brands from the spelled-correctly `primary_brand_name` form', () => {
const general = {
primary_brand_name: 'AEO',
secondary_brand_name: 'Aerie',
};
expect(extractBrands(general)).toEqual(['AEO', 'Aerie']);
});
it('dedupes when primary and tertiary are the same brand (real store 782 case)', () => {
const general = {
pimary_brand_name: 'American Eagle Outfitters',
secondary_brand_name: null,
tertiary_brand_name: 'American Eagle Outfitters',
};
expect(extractBrands(general)).toEqual(['American Eagle Outfitters']);
});
});
describe('Store with /StoreGeneral data', () => {
const location = {
name: 'AEO #782',
address: '1 Mall Way',
city: 'Pittsburgh',
state: 'PA',
postal_code: '15222',
};
const general = {
store_number: 782,
brand_code: 'AE',
pimary_brand_name: 'American Eagle Outfitters',
secondary_brand_name: null,
tertiary_brand_name: 'American Eagle Outfitters',
environment_name: 'Prd',
store_status_name: 'Live',
};
it('exposes brands, status, and environment from the general payload', () => {
const s = createStore(location, 782, { general });
expect(s.brands).toEqual(['American Eagle Outfitters']);
expect(s.status).toBe('Live');
expect(s.environment).toBe('Prd');
expect(s.hasGeneralData).toBe(true);
});
it('renders Status and Environment on a single line in the summary', () => {
const s = createStore(location, 782, { general });
const summary = s.toSummary();
expect(summary).toContain('Brand: American Eagle Outfitters');
expect(summary).toMatch(/Status: Live\s+\|\s+Environment: Prd/);
});
it('skips the status/env line when both are missing', () => {
const s = createStore(location, 782, { general: {} });
const summary = s.toSummary();
expect(summary).not.toContain('Status:');
expect(summary).not.toContain('Environment:');
});
it('still renders when only general data is available (no location)', () => {
const s = createStore(null, 782, { general });
expect(s.hasLocationData).toBe(false);
expect(s.hasGeneralData).toBe(true);
const summary = s.toSummary();
expect(summary).toContain('Store 782');
expect(summary).toContain('Brand: American Eagle Outfitters');
expect(summary).toContain('Status: Live');
});
}); });

491
tests/storeDetail.test.js Normal file
View file

@ -0,0 +1,491 @@
jest.mock('../config', () => ({
siw: { baseUrl: 'https://siw.example.com/api', username: 'u', password: 'p' },
meraki: { apiKey: 'k', orgId: 'o' },
mdm: { url: 'https://mdm.example.com', username: 'u', password: 'p', tenantCode: 't' },
logLevel: 'error',
}));
jest.mock('../services/siw', () => ({
getStoreLocation: jest.fn(),
getStoreGeneral: jest.fn(),
getStoreRegisters: jest.fn(),
getStorePrinters: jest.fn(),
getStorePaymentTerminals: jest.fn(),
}));
jest.mock('../services/meraki', () => ({
findMerakiNetwork: jest.fn(),
getMerakiDeviceAvailabilities: jest.fn(),
getMerakiClients: jest.fn(),
}));
jest.mock('../services/mdm', () => ({
getMDMDevices: jest.fn(),
}));
jest.mock('../services/webexPhone', () => ({
collectPhoneStatus: jest.fn(),
}));
jest.mock('../services/avService', () => ({
collectAvStatus: jest.fn(),
}));
const { STORE_MODES, MDM_DEVICE_TYPES, filterMdmByType } = require('../constants');
const siw = require('../services/siw');
const meraki = require('../services/meraki');
const mdm = require('../services/mdm');
const webexPhone = require('../services/webexPhone');
const avService = require('../services/avService');
const { getStoreDetail } = require('../integrations/storeDetail');
describe('filterMdmByType with Customer Display marker', () => {
it('matches CD devices and ignores SRV/MR/IPH', () => {
const devices = [
{ UserName: 'US000782SRV01' },
{ UserName: 'US000782MR03' },
{ UserName: 'US000782CD01' },
{ UserName: 'US000782CD02' },
{ UserName: 'US000782IPH04' },
];
const cds = filterMdmByType(devices, MDM_DEVICE_TYPES.CUSTOMER_DISPLAY);
expect(cds).toHaveLength(2);
expect(cds.map(d => d.UserName)).toEqual(['US000782CD01', 'US000782CD02']);
});
it('does not false-positive when name contains no CD substring', () => {
const devices = [
{ UserName: 'US000782SRV01' },
{ UserName: 'US000782MR03' },
{ UserName: 'US000782IPH04' },
];
expect(filterMdmByType(devices, MDM_DEVICE_TYPES.CUSTOMER_DISPLAY)).toEqual([]);
});
});
describe('getStoreDetail POS mode — Customer Displays section', () => {
beforeEach(() => {
jest.clearAllMocks();
// Minimal Meraki + SIW fixtures so the report can be built.
meraki.findMerakiNetwork.mockResolvedValue({
id: 'N_1',
name: 'AEO - 00782 - Standalone',
url: 'https://n976.dashboard.meraki.com/AEO-00782/n/ABC/manage',
});
meraki.getMerakiClients.mockResolvedValue([
// Customer Display advertised as the same hostname Meraki sees.
{ id: 'cd1-meraki', description: 'US000782CD01', status: 'Online', lastSeen: null },
]);
meraki.getMerakiDeviceAvailabilities.mockResolvedValue([]);
siw.getStoreRegisters.mockResolvedValue([]);
siw.getStorePrinters.mockResolvedValue([]);
siw.getStorePaymentTerminals.mockResolvedValue([]);
});
it('includes a Customer Displays section listing every CD device with its Meraki status', async () => {
mdm.getMDMDevices.mockResolvedValue([
{ UserName: 'US000782SRV01', DeviceFriendlyName: 'Store Server 01' },
{ UserName: 'US000782CD01', DeviceFriendlyName: 'Customer Display 01' },
]);
const report = await getStoreDetail('782', STORE_MODES.POS);
expect(report).toContain('**📟 Customer Displays (1)**');
expect(report).toContain('US000782CD01');
// The match through Meraki should report Online status.
expect(report).toMatch(/US000782CD01.*Online/);
});
it('omits the Customer Displays section when no CD devices exist', async () => {
mdm.getMDMDevices.mockResolvedValue([
{ UserName: 'US000782SRV01', DeviceFriendlyName: 'Store Server 01' },
]);
const report = await getStoreDetail('782', STORE_MODES.POS);
expect(report).not.toContain('Customer Displays');
});
it('places Customer Displays after Mobile Registers in the POS report', async () => {
mdm.getMDMDevices.mockResolvedValue([
{ UserName: 'US000782MR01' },
{ UserName: 'US000782CD01' },
]);
const report = await getStoreDetail('782', STORE_MODES.POS);
const mrIdx = report.indexOf('Mobile Registers');
const cdIdx = report.indexOf('Customer Displays');
expect(mrIdx).toBeGreaterThan(-1);
expect(cdIdx).toBeGreaterThan(-1);
expect(cdIdx).toBeGreaterThan(mrIdx);
});
});
describe('getStoreDetail PHONE mode', () => {
beforeEach(() => {
jest.clearAllMocks();
meraki.findMerakiNetwork.mockResolvedValue({
id: 'N_1',
name: 'AEO - 00782 - Standalone',
url: 'https://n976.dashboard.meraki.com/AEO-00782/n/ABC/manage',
});
// The Meraki client list contains the matching MAC for our wired phone.
meraki.getMerakiClients.mockResolvedValue([
{
id: 'cli-phone',
description: 'PHN-7841-FRONT',
mac: '11:22:33:44:55:66',
status: 'Online',
lastSeen: new Date().toISOString(),
},
{
id: 'cli-base',
description: 'DECT-BASE-01',
mac: 'BA:5E:01:00:00:01',
status: 'Online',
lastSeen: new Date().toISOString(),
},
]);
});
it('renders the location/main-number header, wired phones (Meraki-matched by MAC, with extension), and a registered handset nested under its base with <index>-<ext> naming', async () => {
webexPhone.collectPhoneStatus.mockResolvedValue({
phones: [
{
mac: '11:22:33:44:55:66',
name: 'Front Desk Phone',
model: 'Cisco 7841',
firmware: '12.0',
extension: '50782',
status: 'connected',
},
],
basestations: [
{
id: 'base-1',
mac: 'BA:5E:01:00:00:01',
name: 'Base 1',
model: 'DBS-110',
firmware: '1.2.3',
linesRegistered: 1,
},
],
handsets: [
{
id: 'h-1',
index: 1,
name: '50782', // Webex sometimes returns the extension as the display name.
extension: '50782',
status: 'unknown', // Webex returns this for DECT handsets; should NOT propagate to the report.
baseStationId: 'base-1',
lastRegistrationTime: new Date().toISOString(),
},
],
dectNetwork: { id: 'dn-1', name: 'Store 0782', locationName: 'Store 0782' },
locationMainNumber: '+14123694426',
});
const report = await getStoreDetail('782', STORE_MODES.PHONE);
// Header at the top: location + store DID together, no trailing footer.
expect(report).toContain('**📍 Store 0782**');
expect(report).toContain('📞 Main: **+14123694426**');
expect(report).not.toContain('Store Main Number');
// The header sits ahead of all other sections.
expect(report.indexOf('Store 0782')).toBeLessThan(report.indexOf('Wired Phones'));
expect(report).toContain('**📞 Wired Phones (1)**');
expect(report).toContain('Front Desk Phone');
// Extension rendered, firmware suppressed.
expect(report).toContain('ext 50782');
expect(report).not.toContain('fw 12.0');
// MAC-strategy match through to Meraki should yield an Online status line.
expect(report).toMatch(/Front Desk Phone.*Online/);
expect(report).toContain('**📡 DECT Network**');
expect(report).toContain('Base 1');
expect(report).toMatch(/Base 1.*Online/);
// Handset name uses the <index>-<extension> form, NOT the bare extension.
expect(report).toContain('1-50782');
// Handset is nested under its base.
const baseIdx = report.indexOf('Base 1');
const handsetIdx = report.indexOf('1-50782');
expect(handsetIdx).toBeGreaterThan(baseIdx);
// Handset presence is derived from last-registration recency.
expect(report).toMatch(/1-50782.*✅ Registered/);
expect(report).not.toMatch(/1-50782.*unknown/);
// Registered + assigned handsets should NOT appear in the trailing
// "Unregistered Handsets" section.
expect(report).not.toContain('Unregistered Handsets');
});
it('moves stale, never-registered, and orphan handsets into the trailing Unregistered Handsets section', async () => {
const staleStamp = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
webexPhone.collectPhoneStatus.mockResolvedValue({
phones: [],
basestations: [{ id: 'base-1', mac: 'BA:5E:01:00:00:01', name: 'Base 1' }],
handsets: [
{
id: 'h-fresh',
index: 1,
extension: '50782',
baseStationId: 'base-1',
lastRegistrationTime: new Date().toISOString(),
},
{
id: 'h-stale',
index: 2,
extension: '50782',
baseStationId: 'base-1',
lastRegistrationTime: staleStamp,
},
{
id: 'h-never',
index: 3,
extension: '50782',
baseStationId: 'base-1',
lastRegistrationTime: null,
},
{
id: 'h-orphan',
index: 4,
extension: '50782',
baseStationId: 'base-removed',
lastRegistrationTime: new Date().toISOString(),
},
],
dectNetwork: { id: 'dn-1', name: 'Store 0782', locationName: 'Store 0782' },
locationMainNumber: null,
});
const report = await getStoreDetail('782', STORE_MODES.PHONE);
expect(report).toContain('**📵 Unregistered Handsets (3)**');
// Fresh handset is nested under its base, BEFORE the unregistered section.
const baseIdx = report.indexOf('Base 1');
const unregIdx = report.indexOf('Unregistered Handsets');
const freshIdx = report.indexOf('1-50782');
expect(freshIdx).toBeGreaterThan(baseIdx);
expect(freshIdx).toBeLessThan(unregIdx);
// The three problem handsets all appear in the trailing section, each with
// the right presence label.
const trail = report.slice(unregIdx);
expect(trail).toMatch(/2-50782.*⚠️ Last registered/);
expect(trail).toMatch(/3-50782.*❓ No registration data/);
expect(trail).toContain('4-50782');
});
it('renders an unavailable banner with the supplied reason', async () => {
webexPhone.collectPhoneStatus.mockResolvedValue({
unavailable: true,
reason: 'No Webex person found for ae00782@ae.com.',
});
const report = await getStoreDetail('782', STORE_MODES.PHONE);
expect(report).toContain('Webex phone data unavailable');
expect(report).toContain('No Webex person found for ae00782@ae.com.');
expect(report).toContain('npm run webex:seed');
});
it('renders the unavailable banner when collectPhoneStatus throws unexpectedly', async () => {
webexPhone.collectPhoneStatus.mockRejectedValue(new Error('WEBEX_CLIENT_ID is required'));
const report = await getStoreDetail('782', STORE_MODES.PHONE);
expect(report).toContain('Webex phone data unavailable');
expect(report).toContain('WEBEX_CLIENT_ID is required');
});
});
describe('getStoreDetail AV mode', () => {
beforeEach(() => {
jest.clearAllMocks();
meraki.findMerakiNetwork.mockResolvedValue({
id: 'N_1',
name: 'AEO - 00782 - Standalone',
url: 'https://n976.dashboard.meraki.com/AEO-00782/n/ABC/manage',
});
meraki.getMerakiClients.mockResolvedValue([
{
id: 'cli-amp',
description: 'US000782AMP',
mac: 'AA:BB:CC:00:00:01',
status: 'Online',
lastSeen: new Date().toISOString(),
},
{
id: 'cli-atv1',
description: 'US000782AppleTV01',
mac: 'AA:BB:CC:00:00:02',
status: 'Online',
lastSeen: new Date().toISOString(),
},
{
id: 'cli-vw',
description: 'US000782VW1',
mac: 'AA:BB:CC:00:00:03',
status: 'Online',
lastSeen: new Date().toISOString(),
},
]);
meraki.getMerakiDeviceAvailabilities.mockResolvedValue([]);
});
it('renders Atlas AMP + every MDM AV subsection in fixed order with correct counts', async () => {
avService.collectAvStatus.mockResolvedValue({
devices: [
{
id: 'd1',
name: 'US000782AMP',
model: 'Cisco AMP',
mac: 'AA:BB:CC:00:00:01',
ip: '10.0.0.5',
online: true,
lastSeen: new Date().toISOString(),
},
],
});
mdm.getMDMDevices.mockResolvedValue([
// Two Apple TVs.
{
UserName: 'US000782AppleTV01',
DeviceFriendlyName: 'US000782AppleTV01',
MacAddress: 'AA:BB:CC:00:00:02',
Model: 'Apple TV 4K',
},
{ UserName: 'US000782AppleTV02', DeviceFriendlyName: 'US000782AppleTV02' },
// Video wall.
{
UserName: 'US000782VW1',
DeviceFriendlyName: 'US000782VW1',
MacAddress: 'AA:BB:CC:00:00:03',
Model: 'Samsung VW',
},
// Music + LED.
{ UserName: 'US000782MSC1', DeviceFriendlyName: 'US000782MSC1' },
{ UserName: 'US000782LED1', DeviceFriendlyName: 'US000782LED1' },
// Non-AV devices must be ignored.
{ UserName: 'US000782IPH04' },
{ UserName: 'US000782SRV01' },
]);
const report = await getStoreDetail('782', STORE_MODES.AV);
expect(report).toContain('**📡 Atlas AMP (1)**');
expect(report).toContain('**📺 Apple TVs (2)**');
expect(report).toContain('**🖼️ Video Walls (1)**');
expect(report).toContain('**🎵 Music Players (1)**');
expect(report).toContain('**💡 LED Displays (1)**');
// Fixed render order: Atlas first, then Apple TVs, VW, Music, LED.
const idxAtlas = report.indexOf('Atlas AMP');
const idxAppleTV = report.indexOf('Apple TVs');
const idxVW = report.indexOf('Video Walls');
const idxMusic = report.indexOf('Music Players');
const idxLED = report.indexOf('LED Displays');
expect(idxAtlas).toBeLessThan(idxAppleTV);
expect(idxAppleTV).toBeLessThan(idxVW);
expect(idxVW).toBeLessThan(idxMusic);
expect(idxMusic).toBeLessThan(idxLED);
// Online indicator + Meraki match should appear for the AMP.
expect(report).toMatch(/US000782AMP.*Cisco AMP.*✅ Online.*Online.*Meraki Client/);
// AppleTV with MAC also matches Meraki.
expect(report).toMatch(/US000782AppleTV01.*Apple TV 4K.*Online.*Meraki Client/);
// Non-AV devices must NOT leak into the report.
expect(report).not.toContain('US000782IPH04');
expect(report).not.toContain('US000782SRV01');
});
it('shows only Atlas when MDM returns no AV devices (no empty subsection headers)', async () => {
avService.collectAvStatus.mockResolvedValue({
devices: [
{
id: 'd1',
name: 'US000782AMP',
model: 'Cisco AMP',
mac: 'AA:BB:CC:00:00:01',
online: true,
},
],
});
mdm.getMDMDevices.mockResolvedValue([
{ UserName: 'US000782IPH04' },
{ UserName: 'US000782SRV01' },
]);
const report = await getStoreDetail('782', STORE_MODES.AV);
expect(report).toContain('Atlas AMP (1)');
expect(report).not.toContain('Apple TVs');
expect(report).not.toContain('Video Walls');
expect(report).not.toContain('Music Players');
expect(report).not.toContain('LED Displays');
});
it('shows the Atlas-unavailable banner above the MDM sections when Atlas is down', async () => {
avService.collectAvStatus.mockResolvedValue({
devices: [],
unavailable: true,
reason: 'ATLAS_AUTH_KEY is not set',
});
mdm.getMDMDevices.mockResolvedValue([
{ UserName: 'US000782AppleTV01', DeviceFriendlyName: 'US000782AppleTV01' },
]);
const report = await getStoreDetail('782', STORE_MODES.AV);
expect(report).toContain('Atlas AV data unavailable');
expect(report).toContain('ATLAS_AUTH_KEY is not set');
expect(report).toContain('Apple TVs (1)');
// Atlas section header should NOT render when there are no Atlas devices.
expect(report).not.toContain('Atlas AMP (');
// Banner appears above the MDM subsections.
expect(report.indexOf('Atlas AV data unavailable')).toBeLessThan(report.indexOf('Apple TVs'));
});
it('shows the Atlas-unavailable banner even when collectAvStatus throws unexpectedly', async () => {
avService.collectAvStatus.mockRejectedValue(new Error('ECONNRESET'));
mdm.getMDMDevices.mockResolvedValue([]);
const report = await getStoreDetail('782', STORE_MODES.AV);
expect(report).toContain('Atlas AV data unavailable');
expect(report).toContain('ECONNRESET');
});
it('renders the empty-state message when neither source has any devices', async () => {
avService.collectAvStatus.mockResolvedValue({ devices: [] });
mdm.getMDMDevices.mockResolvedValue([]);
const report = await getStoreDetail('782', STORE_MODES.AV);
expect(report).toContain('No AV hardware registered for this store.');
});
it('reflects Atlas online=false as ⚠️ Offline in the prefix', async () => {
avService.collectAvStatus.mockResolvedValue({
devices: [{ id: 'd1', name: 'US000782AMP', model: 'Cisco AMP', online: false }],
});
mdm.getMDMDevices.mockResolvedValue([]);
const report = await getStoreDetail('782', STORE_MODES.AV);
expect(report).toMatch(/US000782AMP.*⚠️ Offline/);
});
it('renders ❓ Unknown for Atlas devices with no derivable status', async () => {
avService.collectAvStatus.mockResolvedValue({
devices: [{ id: 'd1', name: 'US000782AMP', model: 'Cisco AMP', online: null }],
});
mdm.getMDMDevices.mockResolvedValue([]);
const report = await getStoreDetail('782', STORE_MODES.AV);
expect(report).toMatch(/US000782AMP.*❓ Unknown/);
});
});

202
tests/webexPhone.test.js Normal file
View file

@ -0,0 +1,202 @@
jest.mock('../config', () => ({
webexServiceApp: { clientId: 'c', clientSecret: 's', tokensPath: '/tmp/x.json' },
logLevel: 'error',
}));
jest.mock('../services/webexService', () => ({
request: jest.fn(),
}));
const webex = require('../services/webexService');
const phone = require('../services/webexPhone');
function setRouteResponses(routes) {
webex.request.mockReset();
webex.request.mockImplementation(async (method, pathSuffix, _body, _params) => {
const key = `${method} ${pathSuffix}`;
if (key in routes) {
const v = routes[key];
return typeof v === 'function' ? v() : v;
}
throw new Error(`Unmocked Webex request: ${key}`);
});
}
describe('webexPhone.storeEmail', () => {
it('pads to 5 digits and uses the @ae.com domain', () => {
expect(phone.storeEmail(782)).toBe('ae00782@ae.com');
expect(phone.storeEmail('00305')).toBe('ae00305@ae.com');
expect(phone.storeEmail(' 12345 ')).toBe('ae12345@ae.com');
});
});
describe('webexPhone.WIRED_PHONE_PATTERN', () => {
it('matches 7821 / 7841 and rejects everything else', () => {
expect(phone.WIRED_PHONE_PATTERN.test('Cisco IP Phone 7821')).toBe(true);
expect(phone.WIRED_PHONE_PATTERN.test('CP-7841-K9')).toBe(true);
expect(phone.WIRED_PHONE_PATTERN.test('Cisco 8851')).toBe(false);
expect(phone.WIRED_PHONE_PATTERN.test('Webex Desk Pro')).toBe(false);
});
});
describe('collectPhoneStatus', () => {
it('returns the full flat shape and associates handsets with their base', async () => {
setRouteResponses({
'GET people': { items: [{ id: 'person-1' }] },
'GET people/person-1': {
id: 'person-1',
displayName: 'AE Store 782',
extension: '50782',
},
'GET devices': {
items: [
{ mac: '11:22:33:44:55:66', product: 'Cisco 7841', displayName: 'Front Desk Phone' },
{
mac: 'aa:bb:cc:dd:ee:ff',
product: 'Cisco 8851',
displayName: 'Should be filtered out',
},
{ mac: '99:88:77:66:55:44', product: 'CP-7821-K9' },
],
},
'GET telephony/config/people/person-1/dectNetworks': {
dectNetworks: [
{
id: 'dn-1',
name: 'Store 0782',
location: { id: 'loc-1', name: 'Store 0782' },
numberOfHandsetsAssigned: 2,
},
],
},
'GET telephony/config/locations/loc-1/dectNetworks/dn-1/baseStations': {
items: [
{ id: 'base-1', mac: 'BA:5E:01:00:00:01', displayName: 'Base 1', status: 'online' },
{ id: 'base-2', mac: 'BA:5E:02:00:00:02', displayName: 'Base 2', status: 'online' },
],
},
'GET telephony/config/locations/loc-1/dectNetworks/dn-1/handsets': {
items: [
{ id: 'h-1', index: 1, defaultDisplayName: 'Handset 1', status: 'online' },
{ id: 'h-2', index: 2, defaultDisplayName: 'Handset 2', status: 'offline' },
{ id: 'h-3', index: 3, defaultDisplayName: 'Handset 3 (orphan)', status: 'unknown' },
],
},
'GET telephony/config/locations/loc-1/dectNetworks/dn-1/handsets/h-1': {
id: 'h-1',
index: 1,
baseStationId: 'base-1',
lines: [{ extension: '1001' }],
},
'GET telephony/config/locations/loc-1/dectNetworks/dn-1/handsets/h-2': {
id: 'h-2',
index: 2,
baseStationId: 'base-2',
lines: [{ extension: '1002' }],
},
'GET telephony/config/locations/loc-1/dectNetworks/dn-1/handsets/h-3': {
id: 'h-3',
index: 3,
baseStationId: null, // orphan
lines: [],
},
'GET telephony/config/locations/loc-1': {
callingLineId: { phoneNumber: '+15555550100' },
},
});
const result = await phone.collectPhoneStatus('782');
expect(result.unavailable).toBeFalsy();
expect(result.phones).toHaveLength(2); // 7841 + 7821, 8851 filtered out
expect(result.phones.map(p => p.model)).toEqual(['Cisco 7841', 'CP-7821-K9']);
// Every wired phone inherits the store-person extension.
expect(result.phones.every(p => p.extension === '50782')).toBe(true);
expect(result.basestations).toHaveLength(2);
expect(result.handsets).toHaveLength(3);
// Handset → base association via the detail endpoint
const h1 = result.handsets.find(h => h.id === 'h-1');
const h2 = result.handsets.find(h => h.id === 'h-2');
const h3 = result.handsets.find(h => h.id === 'h-3');
expect(h1.baseStationId).toBe('base-1');
expect(h2.baseStationId).toBe('base-2');
expect(h3.baseStationId).toBeNull();
expect(h1.extension).toBe('1001');
// The handset's slot index flows through from both list and detail
// endpoints so the renderer can display "<index>-<extension>".
expect(h1.index).toBe(1);
expect(h2.index).toBe(2);
expect(h3.index).toBe(3);
expect(result.locationMainNumber).toBe('+15555550100');
expect(result.dectNetwork?.id).toBe('dn-1');
});
it('returns unavailable=true when no person exists for the store email', async () => {
setRouteResponses({
'GET people': { items: [] },
});
const result = await phone.collectPhoneStatus('999');
expect(result.unavailable).toBe(true);
expect(result.reason).toMatch(/ae00999@ae\.com/);
});
it('returns unavailable=true when the underlying request layer throws on first call', async () => {
webex.request.mockReset();
webex.request.mockRejectedValue(new Error('WEBEX_CLIENT_ID is required'));
const result = await phone.collectPhoneStatus('782');
// collectPhoneStatus swallows getPersonIdByEmail errors and returns
// "no person" rather than crashing — equivalent surface to "unavailable".
expect(result.unavailable).toBe(true);
});
it('handles a store with no DECT network gracefully (wired phones only)', async () => {
setRouteResponses({
'GET people': { items: [{ id: 'person-2' }] },
'GET people/person-2': { id: 'person-2', extension: '50305' },
'GET devices': { items: [{ mac: '11:22:33:44:55:66', product: 'Cisco 7841' }] },
'GET telephony/config/people/person-2/dectNetworks': { dectNetworks: [] },
});
const result = await phone.collectPhoneStatus('305');
expect(result.phones).toHaveLength(1);
expect(result.phones[0].extension).toBe('50305');
expect(result.basestations).toEqual([]);
expect(result.handsets).toEqual([]);
expect(result.dectNetwork).toBeNull();
expect(result.locationMainNumber).toBeNull();
});
describe('getPersonExtension', () => {
it('returns extension from the top-level extension field', async () => {
setRouteResponses({
'GET people/p1': { id: 'p1', extension: '50782' },
});
expect(await phone.getPersonExtension('p1')).toBe('50782');
});
it('falls back to phoneNumbers entry with extension-like type', async () => {
setRouteResponses({
'GET people/p2': {
id: 'p2',
phoneNumbers: [
{ type: 'work', value: '+14123694426' },
{ type: 'work_extension', value: '50305' },
],
},
});
expect(await phone.getPersonExtension('p2')).toBe('50305');
});
it('returns null when nothing extension-like is present', async () => {
setRouteResponses({
'GET people/p3': { id: 'p3', phoneNumbers: [{ type: 'work', value: '+1...' }] },
});
expect(await phone.getPersonExtension('p3')).toBeNull();
});
});
});

View file

@ -0,0 +1,194 @@
const fs = require('fs').promises;
const os = require('os');
const path = require('path');
const WebexServiceAppAuth = require('../integrations/webex/WebexServiceAppAuth');
function makeMockAxios(impl) {
return { post: jest.fn(impl) };
}
describe('WebexServiceAppAuth', () => {
let tmpFile;
beforeEach(async () => {
WebexServiceAppAuth.resetForTests();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'webex-auth-'));
tmpFile = path.join(tmpDir, 'tokens.json');
});
afterEach(async () => {
WebexServiceAppAuth.resetForTests();
try {
await fs.rm(path.dirname(tmpFile), { recursive: true, force: true });
} catch (_e) {
/* tmp cleanup failures shouldn't fail the suite */
}
});
it('refuses to construct without client id / secret', () => {
WebexServiceAppAuth.resetForTests();
expect(() => new WebexServiceAppAuth({ clientId: '', clientSecret: 's' })).toThrow(
/WEBEX_CLIENT_ID/
);
expect(() => new WebexServiceAppAuth({ clientId: 'c', clientSecret: '' })).toThrow(
/WEBEX_CLIENT_SECRET/
);
});
it('round-trips tokens through the tokens file', async () => {
const auth = new WebexServiceAppAuth({
clientId: 'c',
clientSecret: 's',
tokensFilePath: tmpFile,
});
auth.accessToken = 'at-1';
auth.refreshToken = 'rt-1';
auth.expiresAt = 1234567890000;
await auth.saveTokens();
const auth2 = new WebexServiceAppAuth({
clientId: 'c',
clientSecret: 's',
tokensFilePath: tmpFile,
});
await auth2.loadTokens();
expect(auth2.accessToken).toBe('at-1');
expect(auth2.refreshToken).toBe('rt-1');
expect(auth2.expiresAt).toBe(1234567890000);
});
it('loadTokens rejects with ENOENT when file is missing', async () => {
const auth = new WebexServiceAppAuth({
clientId: 'c',
clientSecret: 's',
tokensFilePath: path.join(path.dirname(tmpFile), 'no-such-file.json'),
});
await expect(auth.loadTokens()).rejects.toMatchObject({ code: 'ENOENT' });
});
it('refresh() persists rotated tokens and applies the 5-min safety buffer', async () => {
const expiresIn = 3600; // 1 hour
const mockHttp = makeMockAxios(async () => ({
data: {
access_token: 'new-access',
refresh_token: 'new-refresh',
expires_in: expiresIn,
},
}));
const auth = new WebexServiceAppAuth({
clientId: 'c',
clientSecret: 's',
tokensFilePath: tmpFile,
httpClient: mockHttp,
});
auth.refreshToken = 'old-refresh';
const beforeMs = Date.now();
const token = await auth.refresh();
const afterMs = Date.now();
expect(token).toBe('new-access');
expect(auth.refreshToken).toBe('new-refresh');
expect(mockHttp.post).toHaveBeenCalledTimes(1);
// Buffer = 5 minutes early ⇒ expiresAt ≈ now + expiresIn*1000 - 5min.
const expectedLow = beforeMs + expiresIn * 1000 - 5 * 60 * 1000;
const expectedHigh = afterMs + expiresIn * 1000 - 5 * 60 * 1000;
expect(auth.expiresAt).toBeGreaterThanOrEqual(expectedLow);
expect(auth.expiresAt).toBeLessThanOrEqual(expectedHigh);
// And it persisted on disk:
const raw = await fs.readFile(tmpFile, 'utf8');
expect(JSON.parse(raw)).toMatchObject({
accessToken: 'new-access',
refreshToken: 'new-refresh',
});
});
it('refresh() throws a re-seed hint on 400/401 from Webex', async () => {
const mockHttp = makeMockAxios(async () => {
const err = new Error('Bad Request');
err.response = { status: 400, data: { error: 'invalid_grant' } };
throw err;
});
const auth = new WebexServiceAppAuth({
clientId: 'c',
clientSecret: 's',
tokensFilePath: tmpFile,
httpClient: mockHttp,
});
auth.refreshToken = 'old';
await expect(auth.refresh()).rejects.toThrow(/webex:seed/);
});
it('refresh() throws clearly when no refresh token is available', async () => {
const auth = new WebexServiceAppAuth({
clientId: 'c',
clientSecret: 's',
tokensFilePath: tmpFile,
});
await expect(auth.refresh()).rejects.toThrow(/No refresh token/);
});
it('getAccessToken() refreshes when expiresAt is past', async () => {
const mockHttp = makeMockAxios(async () => ({
data: { access_token: 'refreshed', refresh_token: 'rt2', expires_in: 3600 },
}));
const auth = new WebexServiceAppAuth({
clientId: 'c',
clientSecret: 's',
tokensFilePath: tmpFile,
httpClient: mockHttp,
});
auth.accessToken = 'stale';
auth.refreshToken = 'old';
auth.expiresAt = Date.now() - 1000; // already expired
const token = await auth.getAccessToken();
expect(token).toBe('refreshed');
expect(mockHttp.post).toHaveBeenCalledTimes(1);
});
it('getAccessToken() returns the cached token when not expired', async () => {
const mockHttp = makeMockAxios(async () => {
throw new Error('should not be called');
});
const auth = new WebexServiceAppAuth({
clientId: 'c',
clientSecret: 's',
tokensFilePath: tmpFile,
httpClient: mockHttp,
});
auth.accessToken = 'fresh';
auth.refreshToken = 'rt';
auth.expiresAt = Date.now() + 60 * 1000;
const token = await auth.getAccessToken();
expect(token).toBe('fresh');
expect(mockHttp.post).not.toHaveBeenCalled();
});
it('getInstance() returns a singleton until resetForTests is called', () => {
const a1 = WebexServiceAppAuth.getInstance({
clientId: 'c',
clientSecret: 's',
tokensFilePath: tmpFile,
});
const a2 = WebexServiceAppAuth.getInstance({
clientId: 'other',
clientSecret: 'x',
tokensFilePath: tmpFile,
});
expect(a2).toBe(a1);
WebexServiceAppAuth.resetForTests();
const a3 = WebexServiceAppAuth.getInstance({
clientId: 'c',
clientSecret: 's',
tokensFilePath: tmpFile,
});
expect(a3).not.toBe(a1);
});
});

View file

@ -5,12 +5,23 @@ jest.mock('../config', () => ({
logLevel: 'error', logLevel: 'error',
})); }));
const { proxyRequest } = require('../services/websocket'); const { proxyRequest, isAgentConnected, AgentNotConnectedError } = require('../services/websocket');
describe('proxyRequest', () => { describe('websocket service', () => {
it('rejects when no remote agent is connected', async () => { describe('isAgentConnected', () => {
await expect(proxyRequest({ method: 'GET', url: 'http://x' })).rejects.toThrow( it('returns false when no agent is connected', () => {
'No remote agent connected' expect(isAgentConnected()).toBe(false);
});
});
describe('proxyRequest', () => {
it('rejects with AgentNotConnectedError when no agent is connected', async () => {
await expect(proxyRequest({ method: 'GET', url: 'http://x' })).rejects.toBeInstanceOf(
AgentNotConnectedError
); );
await expect(proxyRequest({ method: 'GET', url: 'http://x' })).rejects.toMatchObject({
code: 'AGENT_NOT_CONNECTED',
});
});
}); });
}); });

55
utils/chunkReport.js Normal file
View file

@ -0,0 +1,55 @@
/**
* Split a markdown report into Webex-message-sized chunks.
*
* Webex caps message bodies at ~7439 chars; we round down to 7000 to leave
* headroom for the bot framework and any wrapping the client may add.
*
* Strategy:
* 1. If the whole report fits, return it as a single chunk.
* 2. Otherwise, prefer breaking on section boundaries (`\n\n` immediately
* followed by `**`, which is how every section header in our reports is
* delimited). This keeps markdown intact across chunks.
* 3. If a single section is itself larger than the limit, fall back to a
* hard slice on the limit so we never lose data.
*/
const DEFAULT_LIMIT = 7000;
function chunkReport(report, maxLen = DEFAULT_LIMIT) {
if (!report) return [];
const trimmed = report.trim();
if (trimmed.length <= maxLen) return [trimmed];
// Split on `\n\n**` but keep the `**` (consume only the leading `\n\n`).
const sections = trimmed.split(/\n\n(?=\*\*)/);
const sectionChunks = [];
let current = '';
for (const section of sections) {
const candidate = current ? `${current}\n\n${section}` : section;
if (candidate.length > maxLen && current) {
sectionChunks.push(current);
current = section;
} else {
current = candidate;
}
}
if (current) sectionChunks.push(current);
// Hard-split any chunk still over the limit (rare — only happens if one
// section by itself exceeds maxLen, e.g. a store with hundreds of clients).
const finalChunks = [];
for (const c of sectionChunks) {
if (c.length <= maxLen) {
finalChunks.push(c);
} else {
for (let i = 0; i < c.length; i += maxLen) {
finalChunks.push(c.slice(i, i + maxLen));
}
}
}
return finalChunks;
}
module.exports = { chunkReport, DEFAULT_LIMIT };

View file

@ -6,11 +6,22 @@
/** /**
* Find the best matching Meraki client for a device using multiple strategies. * Find the best matching Meraki client for a device using multiple strategies.
* *
* identifiers can contain: name, deviceName, adyenName, UserName, DeviceFriendlyName, ip * identifiers can contain: mac, name, deviceName, adyenName, UserName,
* DeviceFriendlyName, ip / ip_address. MAC, when present, is checked first
* (and wins outright) because it's deterministic useful for phones/DECT
* basestations whose display names rarely line up with Meraki descriptions.
*/ */
function findMatchingClient(merakiClients = [], identifiers = {}) { function findMatchingClient(merakiClients = [], identifiers = {}) {
if (!merakiClients.length) return null; if (!merakiClients.length) return null;
// Strategy 0: MAC. Deterministic; runs ahead of any name/IP heuristic.
const targetMac = normalizeMac(identifiers.mac);
if (targetMac) {
for (const client of merakiClients) {
if (normalizeMac(client?.mac) === targetMac) return client;
}
}
const names = [ const names = [
identifiers.name, identifiers.name,
identifiers.deviceName, identifiers.deviceName,
@ -23,11 +34,10 @@ function findMatchingClient(merakiClients = [], identifiers = {}) {
.filter(Boolean) .filter(Boolean)
.map(n => String(n).toLowerCase().trim()); .map(n => String(n).toLowerCase().trim());
const ipPrefix = identifiers.ip_address // SIW often stores an FQDN in the `ip_address` field
? String(identifiers.ip_address).split('.')[0] // (e.g. "VFI-807-005-168.us000782.stores.ae.com"). The hostname portion
: identifiers.ip // before the first dot is what Meraki uses as its client `description`.
? String(identifiers.ip).split('.')[0] const ipPrefix = extractHostname(identifiers.ip_address || identifiers.ip);
: null;
for (const client of merakiClients) { for (const client of merakiClients) {
if (!client?.description) continue; if (!client?.description) continue;
@ -38,7 +48,7 @@ function findMatchingClient(merakiClients = [], identifiers = {}) {
return client; return client;
} }
// Strategy 2: IP prefix fallback // Strategy 2: FQDN hostname / IP prefix fallback
if (ipPrefix && desc.includes(ipPrefix)) { if (ipPrefix && desc.includes(ipPrefix)) {
return client; return client;
} }
@ -55,6 +65,31 @@ function findMatchingClient(merakiClients = [], identifiers = {}) {
return null; return null;
} }
/**
* Normalise a MAC address for equality comparison: strip every non-hex
* character, lowercase, then validate length === 12. Returns null on bad
* input so callers can safely use the result as a Map key.
*/
function normalizeMac(value) {
if (!value) return null;
const stripped = String(value)
.replace(/[^0-9a-fA-F]/g, '')
.toLowerCase();
return stripped.length === 12 ? stripped : null;
}
/**
* Pull the hostname out of an "ip_address" value. SIW endpoints frequently
* return an FQDN (e.g. "VFI-807-005-168.us000782.stores.ae.com") in the
* ip_address field; the portion before the first dot is the device's short
* hostname, which is what Meraki advertises as the client description.
* Returns null for empty/missing input.
*/
function extractHostname(value) {
if (!value) return null;
return String(value).split('.')[0].toLowerCase();
}
function getClientStatus(client) { function getClientStatus(client) {
if (!client) return '❓ Unknown'; if (!client) return '❓ Unknown';
return client.status === 'Online' ? '✅ Online' : '❌ Offline'; return client.status === 'Online' ? '✅ Online' : '❌ Offline';
@ -97,4 +132,6 @@ module.exports = {
getClientStatus, getClientStatus,
formatLastSeen, formatLastSeen,
buildMerakiClientLink, buildMerakiClientLink,
extractHostname,
normalizeMac,
}; };

View file

@ -1,5 +1,5 @@
/** /**
* Validation helpers for NetAnalyzer * Validation helpers for StoreHealthAnalyzer
*/ */
/** /**