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>
172 lines
5.7 KiB
JavaScript
172 lines
5.7 KiB
JavaScript
/**
|
||
* Domain model for a Store.
|
||
* 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 {
|
||
/**
|
||
* @param {object|null} locationData /StoreLocation/{n} payload (address etc.)
|
||
* @param {string|number} storeNumber
|
||
* @param {object} [options]
|
||
* @param {object|null} [options.general] /StoreGeneral/{n} payload
|
||
* (brands, status, environment, etc.)
|
||
*/
|
||
constructor(locationData, storeNumber, options = {}) {
|
||
// ES default params only trigger on `undefined`, not `null`. SIW returns
|
||
// null for stores it has no record of, so we have to coerce here.
|
||
const d = locationData || {};
|
||
const g = options.general || {};
|
||
|
||
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() {
|
||
const parts = [this.address, this.address2, this.address3].filter(Boolean);
|
||
const cityState = `${this.city}, ${this.state} ${this.postalCode}`.trim();
|
||
return [...parts, cityState, this.phone ? `Phone: ${this.phone}` : '']
|
||
.filter(Boolean)
|
||
.join('\n');
|
||
}
|
||
|
||
toSummary() {
|
||
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(locationData, storeNumber, options) {
|
||
return new Store(locationData, storeNumber, options);
|
||
}
|
||
|
||
module.exports = { Store, createStore, extractBrands };
|