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