netanalyzer/models/Store.js
Joseph McQueen b3c37bd7df 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>
2026-07-06 09:54:41 -04:00

172 lines
5.7 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 };