Add daily store phone export to Webex space
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
Weekday 10am Eastern cron exports all Webex Calling locations whose name starts with "Store" to a legacy-format CSV (+E164,Store XXXX), diffs against the previous baseline, and posts the file plus a change summary to a configured Webex space. - src/webex/storePhones.js: paginated GET /telephony/config/locations - src/services/storePhoneExport.js: CSV format, filename, diff, message - src/webex/messages.js: bot-token multipart POST /messages with file - src/jobs/storePhoneExport.js + cron in src/index.js - scripts/exportStorePhones.js for manual runs (npm run export-store-phones) - Persist baseline + archives under data/store-phone-export/ (gitignored) - Dockerfile: create /app/data for volume mount in prod - CI: align node-version with Dockerfile (20) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
3daf2fcdc5
commit
63f35027f6
14 changed files with 573 additions and 7 deletions
|
|
@ -43,3 +43,10 @@ GOOGLE_API_KEY=your-google-api-key
|
||||||
# --- Runtime ---
|
# --- Runtime ---
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# --- Daily store phone export (weekdays 10:00 AM Eastern) ---
|
||||||
|
# Webex room/space ID to post the CSV into. The bot must be a member of
|
||||||
|
# this space. Leave unset to disable the scheduled export.
|
||||||
|
# STORE_PHONE_EXPORT_ROOM_ID=your-webex-room-id
|
||||||
|
# STORE_PHONE_EXPORT_TIMEZONE=America/New_York
|
||||||
|
# STORE_PHONE_EXPORT_DATA_DIR=./data/store-phone-export
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Gitea Actions CI for wbxcallprov.
|
# Gitea Actions CI for wbxcallprov.
|
||||||
#
|
#
|
||||||
# Runs on every push and PR against main. Matches the runtime image:
|
# Runs on every push and PR against main. Matches the runtime image:
|
||||||
# node:22-alpine in Dockerfile -> node-version: 22 here.
|
# node:20-alpine in Dockerfile -> node-version: 20 here.
|
||||||
#
|
#
|
||||||
# The three checks are the same ones a developer runs locally before pushing:
|
# The three checks are the same ones a developer runs locally before pushing:
|
||||||
# npm run lint - ESLint flat config, catches unused vars / var / ==
|
# npm run lint - ESLint flat config, catches unused vars / var / ==
|
||||||
|
|
@ -25,10 +25,10 @@ jobs:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Node 22
|
- name: Set up Node 20
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '20'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -24,6 +24,9 @@ buildlogs.log
|
||||||
buildingFile.csv
|
buildingFile.csv
|
||||||
locationFile.csv
|
locationFile.csv
|
||||||
|
|
||||||
|
# Daily store phone export baseline + archives
|
||||||
|
data/
|
||||||
|
|
||||||
# Archives
|
# Archives
|
||||||
*.zip
|
*.zip
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
# --- build stage: install production dependencies only ---
|
# --- build stage: install production dependencies only ---
|
||||||
FROM node:22-alpine AS deps
|
FROM node:20-alpine AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci --omit=dev --no-audit --no-fund
|
RUN npm ci --omit=dev --no-audit --no-fund
|
||||||
|
|
||||||
# --- runtime stage: minimal image with just what's needed to run ---
|
# --- runtime stage: minimal image with just what's needed to run ---
|
||||||
FROM node:22-alpine AS runtime
|
FROM node:20-alpine AS runtime
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|
@ -22,6 +22,10 @@ COPY --chown=node:node greetings ./greetings
|
||||||
# Directory the token-refresh cron writes to. Mount a volume here in prod.
|
# Directory the token-refresh cron writes to. Mount a volume here in prod.
|
||||||
RUN mkdir -p /app/config && chown node:node /app/config
|
RUN mkdir -p /app/config && chown node:node /app/config
|
||||||
|
|
||||||
|
# Daily store phone export baseline + archives. Mount a volume at /app/data
|
||||||
|
# in prod so diffs survive container restarts.
|
||||||
|
RUN mkdir -p /app/data && chown node:node /app/data
|
||||||
|
|
||||||
# WebSocket port the on-prem SIW agent connects back to. Override at runtime
|
# WebSocket port the on-prem SIW agent connects back to. Override at runtime
|
||||||
# via -e WS_PORT and -p host:container.
|
# via -e WS_PORT and -p host:container.
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
|
||||||
49
README.md
49
README.md
|
|
@ -6,7 +6,8 @@ greetings, build auto-attendants, and clean up user licensing.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Node.js 20+ (Docker image uses `node:22-alpine`).
|
- Node.js 20+ (Docker image uses `node:20-alpine`; Node 22 breaks
|
||||||
|
`webex-node-bot-framework` because its `navigator` global is read-only).
|
||||||
- A Webex bot token, a Webex integration (service account) with the scopes
|
- A Webex bot token, a Webex integration (service account) with the scopes
|
||||||
currently used by admin API calls, a Twilio lookup account, an SIW basic-auth
|
currently used by admin API calls, a Twilio lookup account, an SIW basic-auth
|
||||||
user, and a Google API key with Address Validation + Time Zone enabled.
|
user, and a Google API key with Address Validation + Time Zone enabled.
|
||||||
|
|
@ -41,6 +42,22 @@ date from then on.
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
|
### Docker Compose (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with real values and seed config/wbxTokens.json (see above)
|
||||||
|
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
`docker-compose.yml` builds the image, loads `.env`, mounts `./config` for
|
||||||
|
token persistence, and publishes `WS_PORT` (default `8080`) so the remote SIW
|
||||||
|
agent can connect back to the bot.
|
||||||
|
|
||||||
|
### Plain Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t wbxcallprov .
|
docker build -t wbxcallprov .
|
||||||
docker run --rm --env-file .env \
|
docker run --rm --env-file .env \
|
||||||
|
|
@ -167,6 +184,33 @@ in Control Hub → `/finalizeStore 499`.
|
||||||
Card confirmations post `attachmentAction` events, dispatched in
|
Card confirmations post `attachmentAction` events, dispatched in
|
||||||
[src/commands/attachmentActions.js](src/commands/attachmentActions.js).
|
[src/commands/attachmentActions.js](src/commands/attachmentActions.js).
|
||||||
|
|
||||||
|
## Scheduled jobs
|
||||||
|
|
||||||
|
The bot runs two background tasks via `node-cron` in [src/index.js](src/index.js):
|
||||||
|
|
||||||
|
- **Token refresh** — every 60 seconds, refreshes the Webex service-account
|
||||||
|
access token when it is within 2 hours of expiry.
|
||||||
|
- **Store phone export** — weekdays at 10:00 AM `America/New_York` (configurable
|
||||||
|
via `STORE_PHONE_EXPORT_TIMEZONE`). Exports all Webex Calling locations whose
|
||||||
|
name starts with `Store` to a CSV (`+E164,Store XXXX` per line), diffs against
|
||||||
|
the previous run, and posts the file plus a change summary to a Webex space.
|
||||||
|
|
||||||
|
Configure the export in `.env`:
|
||||||
|
|
||||||
|
- `STORE_PHONE_EXPORT_ROOM_ID` — target space ID (required for the job to run).
|
||||||
|
The bot must be a member of this space.
|
||||||
|
- `STORE_PHONE_EXPORT_TIMEZONE` — cron timezone (default `America/New_York`).
|
||||||
|
- `STORE_PHONE_EXPORT_DATA_DIR` — where baseline + archive CSVs are stored
|
||||||
|
(default `./data/store-phone-export`). Mount `/app/data` as a volume in Docker
|
||||||
|
so diffs survive restarts.
|
||||||
|
|
||||||
|
Manual trigger (same code path as the cron):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run export-store-phones
|
||||||
|
# or: node scripts/exportStorePhones.js
|
||||||
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -181,7 +225,8 @@ src/
|
||||||
cards/ Adaptive Card builders
|
cards/ Adaptive Card builders
|
||||||
flows/ multi-step provisioning (stage, finalize)
|
flows/ multi-step provisioning (stage, finalize)
|
||||||
commands/ framework.hears handlers + attachmentAction dispatch
|
commands/ framework.hears handlers + attachmentAction dispatch
|
||||||
scripts/ one-off maintenance scripts (911 CSV, phone fix-up)
|
jobs/ scheduled background tasks (store phone export)
|
||||||
|
scripts/ one-off maintenance scripts (911 CSV, phone fix-up, export)
|
||||||
greetings/ WAV files uploaded as location announcements
|
greetings/ WAV files uploaded as location announcements
|
||||||
config/wbxTokens.json persistent service-account token cache (git-ignored)
|
config/wbxTokens.json persistent service-account token cache (git-ignored)
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"generate-911-csv": "node scripts/generate911Csv.js",
|
"generate-911-csv": "node scripts/generate911Csv.js",
|
||||||
"fix-store-phones": "node scripts/fixStorePhones.js",
|
"fix-store-phones": "node scripts/fixStorePhones.js",
|
||||||
|
"export-store-phones": "node scripts/exportStorePhones.js",
|
||||||
"agent:package": "./docker/remote-agent/package.sh",
|
"agent:package": "./docker/remote-agent/package.sh",
|
||||||
"agent:check": "./docker/remote-agent/package.sh --check",
|
"agent:check": "./docker/remote-agent/package.sh --check",
|
||||||
"agent:inspect": "./docker/remote-agent/inspect-bundle.sh"
|
"agent:inspect": "./docker/remote-agent/inspect-bundle.sh"
|
||||||
|
|
|
||||||
14
scripts/exportStorePhones.js
Normal file
14
scripts/exportStorePhones.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Manual trigger for the daily store phone export job.
|
||||||
|
* Useful for testing without waiting for the weekday 10am cron.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/exportStorePhones.js
|
||||||
|
*
|
||||||
|
* Requires STORE_PHONE_EXPORT_ROOM_ID (and the usual Webex credentials)
|
||||||
|
* in .env.
|
||||||
|
*/
|
||||||
|
import { runStorePhoneExport } from '../src/jobs/storePhoneExport.js';
|
||||||
|
|
||||||
|
await runStorePhoneExport();
|
||||||
|
|
@ -74,6 +74,12 @@ export const config = {
|
||||||
google: {
|
google: {
|
||||||
apiKey: required('GOOGLE_API_KEY'),
|
apiKey: required('GOOGLE_API_KEY'),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
storePhoneExport: {
|
||||||
|
roomId: optional('STORE_PHONE_EXPORT_ROOM_ID', ''),
|
||||||
|
timeZone: optional('STORE_PHONE_EXPORT_TIMEZONE', 'America/New_York'),
|
||||||
|
dataDir: resolvePath(optional('STORE_PHONE_EXPORT_DATA_DIR', './data/store-phone-export')),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|
|
||||||
13
src/index.js
13
src/index.js
|
|
@ -11,6 +11,7 @@ import { register as registerProvisionPhone } from './commands/provisionPhone.js
|
||||||
import { register as registerStoreInfo } from './commands/storeInfo.js';
|
import { register as registerStoreInfo } from './commands/storeInfo.js';
|
||||||
import { register as registerUserInfo } from './commands/userInfo.js';
|
import { register as registerUserInfo } from './commands/userInfo.js';
|
||||||
import { register as registerAttachmentActions } from './commands/attachmentActions.js';
|
import { register as registerAttachmentActions } from './commands/attachmentActions.js';
|
||||||
|
import { runStorePhoneExport } from './jobs/storePhoneExport.js';
|
||||||
|
|
||||||
const framework = new Framework(config.webexBot);
|
const framework = new Framework(config.webexBot);
|
||||||
|
|
||||||
|
|
@ -68,6 +69,18 @@ cron.schedule('0 * * * * *', async () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Weekdays at 10:00 AM Eastern: export Store phone numbers to CSV and
|
||||||
|
// post to the configured Webex space with a change summary.
|
||||||
|
cron.schedule(
|
||||||
|
'0 10 * * 1-5',
|
||||||
|
() => {
|
||||||
|
runStorePhoneExport().catch((error) => {
|
||||||
|
logger.error('Scheduled store phone export failed:', error);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ timezone: config.storePhoneExport.timeZone },
|
||||||
|
);
|
||||||
|
|
||||||
framework
|
framework
|
||||||
.start()
|
.start()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
|
|
||||||
84
src/jobs/storePhoneExport.js
Normal file
84
src/jobs/storePhoneExport.js
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { config } from '../config.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import { listStorePhoneRows } from '../webex/storePhones.js';
|
||||||
|
import { postRoomMessage } from '../webex/messages.js';
|
||||||
|
import {
|
||||||
|
diffStorePhones,
|
||||||
|
formatExportFilename,
|
||||||
|
formatExportMessage,
|
||||||
|
parseStorePhoneCsv,
|
||||||
|
rowsToCsv,
|
||||||
|
} from '../services/storePhoneExport.js';
|
||||||
|
|
||||||
|
const BASELINE_NAME = 'latest.csv';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daily store-phone export: fetch Store locations from Webex Calling,
|
||||||
|
* diff against the previous baseline, write archive + latest CSV, and
|
||||||
|
* post the file with a change summary to the configured Webex space.
|
||||||
|
*
|
||||||
|
* Safe to call from cron or a manual script — errors are logged and
|
||||||
|
* do not propagate to the caller (so a failed export never takes
|
||||||
|
* down the bot).
|
||||||
|
*/
|
||||||
|
export async function runStorePhoneExport() {
|
||||||
|
const { roomId, dataDir, timeZone } = config.storePhoneExport;
|
||||||
|
|
||||||
|
if (!roomId) {
|
||||||
|
logger.info('Store phone export skipped: STORE_PHONE_EXPORT_ROOM_ID is not configured.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
logger.info('Starting daily store phone export...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.mkdir(dataDir, { recursive: true });
|
||||||
|
|
||||||
|
const rows = await listStorePhoneRows();
|
||||||
|
const csv = rowsToCsv(rows);
|
||||||
|
const currentMap = parseStorePhoneCsv(csv);
|
||||||
|
|
||||||
|
const baselinePath = path.join(dataDir, BASELINE_NAME);
|
||||||
|
let previousMap = new Map();
|
||||||
|
let isFirstRun = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const baselineText = await fs.readFile(baselinePath, 'utf8');
|
||||||
|
previousMap = parseStorePhoneCsv(baselineText);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ENOENT') {
|
||||||
|
isFirstRun = true;
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = diffStorePhones(previousMap, currentMap);
|
||||||
|
const filename = formatExportFilename(now, timeZone);
|
||||||
|
const archivePath = path.join(dataDir, filename);
|
||||||
|
|
||||||
|
await fs.writeFile(archivePath, csv, 'utf8');
|
||||||
|
|
||||||
|
const markdown = formatExportMessage({
|
||||||
|
date: now,
|
||||||
|
timeZone,
|
||||||
|
rowCount: rows.length,
|
||||||
|
diff,
|
||||||
|
isFirstRun,
|
||||||
|
});
|
||||||
|
|
||||||
|
await postRoomMessage(roomId, markdown, [{ filename, content: csv, mimeType: 'text/csv' }]);
|
||||||
|
|
||||||
|
await fs.writeFile(baselinePath, csv, 'utf8');
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Store phone export complete: ${rows.length} rows, file ${filename}, posted to room ${roomId}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Store phone export failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
169
src/services/storePhoneExport.js
Normal file
169
src/services/storePhoneExport.js
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
// Pure helpers for the daily StorePhone CSV export: formatting,
|
||||||
|
// parsing, diffing, and filename generation. No I/O — easy to test.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize rows to the legacy StorePhone CSV format (no header):
|
||||||
|
* +14044708802,Store 7311
|
||||||
|
*/
|
||||||
|
export function rowsToCsv(rows) {
|
||||||
|
return rows.map((r) => `${r.phoneNumber},${r.name}`).join('\n') + (rows.length ? '\n' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a StorePhone CSV back into a Map<locationName, phoneNumber>.
|
||||||
|
* Ignores blank lines and malformed rows.
|
||||||
|
*/
|
||||||
|
export function parseStorePhoneCsv(text) {
|
||||||
|
const map = new Map();
|
||||||
|
for (const line of String(text ?? '').split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
const comma = trimmed.indexOf(',');
|
||||||
|
if (comma < 1) continue;
|
||||||
|
const phoneNumber = trimmed.slice(0, comma);
|
||||||
|
const name = trimmed.slice(comma + 1);
|
||||||
|
if (name) map.set(name, phoneNumber);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a filename matching the legacy convention:
|
||||||
|
* StorePhone-2026-07-15T09_10_03.455-04_00.csv
|
||||||
|
* Colons in the time and offset are replaced with underscores.
|
||||||
|
*/
|
||||||
|
export function formatExportFilename(date, timeZone = 'America/New_York') {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
fractionalSecondDigits: 3,
|
||||||
|
hour12: false,
|
||||||
|
timeZoneName: 'longOffset',
|
||||||
|
}).formatToParts(date);
|
||||||
|
|
||||||
|
const get = (type) => parts.find((p) => p.type === type)?.value ?? '';
|
||||||
|
const y = get('year');
|
||||||
|
const mo = get('month');
|
||||||
|
const d = get('day');
|
||||||
|
const h = get('hour');
|
||||||
|
const mi = get('minute');
|
||||||
|
const s = get('second');
|
||||||
|
const frac = get('fractionalSecond') || '000';
|
||||||
|
|
||||||
|
// longOffset comes back as "GMT-04:00" or "GMT+05:30" — strip prefix,
|
||||||
|
// keep sign, replace colons with underscores.
|
||||||
|
let offset = get('timeZoneName').replace(/^GMT/, '');
|
||||||
|
if (!offset || offset === 'GMT') {
|
||||||
|
offset = formatOffsetFallback(date, timeZone);
|
||||||
|
}
|
||||||
|
offset = offset.replace(/:/g, '_');
|
||||||
|
|
||||||
|
return `StorePhone-${y}-${mo}-${d}T${h}_${mi}_${s}.${frac}${offset}.csv`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fallback when formatToParts does not return a usable offset string. */
|
||||||
|
function formatOffsetFallback(date, timeZone) {
|
||||||
|
try {
|
||||||
|
const short = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone,
|
||||||
|
timeZoneName: 'shortOffset',
|
||||||
|
})
|
||||||
|
.formatToParts(date)
|
||||||
|
.find((p) => p.type === 'timeZoneName')?.value;
|
||||||
|
if (short && short !== 'GMT') {
|
||||||
|
return short.replace(/^GMT/, '');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return '+00_00';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diff two phone maps (key = full location name). Returns lists of
|
||||||
|
* added/removed/changed entries plus a count of unchanged rows.
|
||||||
|
*/
|
||||||
|
export function diffStorePhones(previous, current) {
|
||||||
|
const added = [];
|
||||||
|
const removed = [];
|
||||||
|
const changed = [];
|
||||||
|
let unchangedCount = 0;
|
||||||
|
|
||||||
|
for (const [name, phoneNumber] of current) {
|
||||||
|
if (!previous.has(name)) {
|
||||||
|
added.push({ name, phoneNumber });
|
||||||
|
} else if (previous.get(name) !== phoneNumber) {
|
||||||
|
changed.push({ name, oldPhone: previous.get(name), newPhone: phoneNumber });
|
||||||
|
} else {
|
||||||
|
unchangedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, phoneNumber] of previous) {
|
||||||
|
if (!current.has(name)) {
|
||||||
|
removed.push({ name, phoneNumber });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
added.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
|
||||||
|
removed.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
|
||||||
|
changed.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
|
||||||
|
|
||||||
|
return { added, removed, changed, unchangedCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the markdown body posted above the CSV attachment.
|
||||||
|
*/
|
||||||
|
export function formatExportMessage({ date, timeZone, rowCount, diff, isFirstRun }) {
|
||||||
|
const dateLabel = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone,
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
}).format(date);
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
`**Daily Store Phone Export** — ${dateLabel}`,
|
||||||
|
'',
|
||||||
|
`**${rowCount.toLocaleString()}** store${rowCount === 1 ? '' : 's'} exported.`,
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isFirstRun) {
|
||||||
|
lines.push('_First export — no prior baseline to compare._');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { added, removed, changed } = diff;
|
||||||
|
const hasChanges = added.length > 0 || removed.length > 0 || changed.length > 0;
|
||||||
|
|
||||||
|
if (!hasChanges) {
|
||||||
|
lines.push('_No changes since last run._');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('**Changes since last run:**');
|
||||||
|
|
||||||
|
if (added.length > 0) {
|
||||||
|
const items = added.map((r) => `${r.name} (${r.phoneNumber})`).join(', ');
|
||||||
|
lines.push(`- **Added (${added.length}):** ${items}`);
|
||||||
|
}
|
||||||
|
if (removed.length > 0) {
|
||||||
|
const items = removed.map((r) => `${r.name} (${r.phoneNumber})`).join(', ');
|
||||||
|
lines.push(`- **Removed (${removed.length}):** ${items}`);
|
||||||
|
}
|
||||||
|
if (changed.length > 0) {
|
||||||
|
const items = changed.map((r) => `${r.name}: ${r.oldPhone} → ${r.newPhone}`).join(', ');
|
||||||
|
lines.push(`- **Changed (${changed.length}):** ${items}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('', '_No other changes._');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
44
src/webex/messages.js
Normal file
44
src/webex/messages.js
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
// Post messages (with optional file attachments) to a Webex room using
|
||||||
|
// the bot token. Admin telephony reads use the service account; room
|
||||||
|
// messages require the bot because it is the space member.
|
||||||
|
|
||||||
|
import { fetchWithRateLimit } from '../http.js';
|
||||||
|
import { WEBEX_API_BASE } from '../constants.js';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post a markdown message to a room, optionally attaching one or more
|
||||||
|
* files. `files` is an array of { filename, content, mimeType? }.
|
||||||
|
*/
|
||||||
|
export async function postRoomMessage(roomId, markdown, files = []) {
|
||||||
|
if (!roomId) throw new Error('roomId required');
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('roomId', roomId);
|
||||||
|
if (markdown) form.append('markdown', markdown);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const mime = file.mimeType ?? 'text/csv';
|
||||||
|
form.append('files', new Blob([file.content], { type: mime }), file.filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetchWithRateLimit(`${WEBEX_API_BASE}/messages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${config.webexBot.token}`,
|
||||||
|
},
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text().catch(() => '');
|
||||||
|
throw new Error(
|
||||||
|
`Failed to post message to room ${roomId}: HTTP ${response.status} ${response.statusText}${errorText ? `\n${errorText}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') ?? '';
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
41
src/webex/storePhones.js
Normal file
41
src/webex/storePhones.js
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
// Store phone export helpers for the daily StorePhone CSV job.
|
||||||
|
//
|
||||||
|
// Pulls calling-line numbers from the org-wide telephony locations list
|
||||||
|
// (one paginated sweep) rather than per-location GETs.
|
||||||
|
|
||||||
|
import { webexListAll } from './client.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all Store locations with an assigned main calling-line number.
|
||||||
|
* Returns rows sorted by location name for stable CSV output and diffs.
|
||||||
|
* Locations whose name does not start with "Store" or that lack a
|
||||||
|
* callingLineId.phoneNumber are skipped (the latter is logged).
|
||||||
|
*/
|
||||||
|
export async function listStorePhoneRows() {
|
||||||
|
const locations = await webexListAll('/telephony/config/locations', {
|
||||||
|
itemsKey: 'locations',
|
||||||
|
});
|
||||||
|
|
||||||
|
const storeLocations = locations.filter((loc) => loc.name?.startsWith('Store'));
|
||||||
|
const withPhone = [];
|
||||||
|
let skippedNoPhone = 0;
|
||||||
|
|
||||||
|
for (const loc of storeLocations) {
|
||||||
|
const phoneNumber = loc.callingLineId?.phoneNumber;
|
||||||
|
if (!phoneNumber) {
|
||||||
|
skippedNoPhone++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
withPhone.push({ phoneNumber, name: loc.name });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skippedNoPhone > 0) {
|
||||||
|
logger.info(
|
||||||
|
`listStorePhoneRows: skipped ${skippedNoPhone} Store location(s) with no calling line number`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
withPhone.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
|
||||||
|
return withPhone;
|
||||||
|
}
|
||||||
135
test/storePhoneExport.test.js
Normal file
135
test/storePhoneExport.test.js
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import {
|
||||||
|
diffStorePhones,
|
||||||
|
formatExportFilename,
|
||||||
|
formatExportMessage,
|
||||||
|
parseStorePhoneCsv,
|
||||||
|
rowsToCsv,
|
||||||
|
} from '../src/services/storePhoneExport.js';
|
||||||
|
|
||||||
|
describe('rowsToCsv / parseStorePhoneCsv', () => {
|
||||||
|
it('round-trips rows with no header', () => {
|
||||||
|
const rows = [
|
||||||
|
{ phoneNumber: '+14044708802', name: 'Store 7311' },
|
||||||
|
{ phoneNumber: '+17245537090', name: 'Store 0006 - TestLab' },
|
||||||
|
];
|
||||||
|
const csv = rowsToCsv(rows);
|
||||||
|
assert.equal(csv, '+14044708802,Store 7311\n+17245537090,Store 0006 - TestLab\n');
|
||||||
|
const map = parseStorePhoneCsv(csv);
|
||||||
|
assert.equal(map.size, 2);
|
||||||
|
assert.equal(map.get('Store 7311'), '+14044708802');
|
||||||
|
assert.equal(map.get('Store 0006 - TestLab'), '+17245537090');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for no rows', () => {
|
||||||
|
assert.equal(rowsToCsv([]), '');
|
||||||
|
assert.equal(parseStorePhoneCsv('').size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores blank and malformed lines when parsing', () => {
|
||||||
|
const text = '+15551234567,Store 0499\n\nbadline\n,+1\n';
|
||||||
|
const map = parseStorePhoneCsv(text);
|
||||||
|
assert.equal(map.size, 1);
|
||||||
|
assert.equal(map.get('Store 0499'), '+15551234567');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatExportFilename', () => {
|
||||||
|
it('matches StorePhone-YYYY-MM-DDT…csv with underscores in time and offset', () => {
|
||||||
|
// Jul 15 2026 09:10:03.455 EDT = UTC-4
|
||||||
|
const date = new Date('2026-07-15T13:10:03.455Z');
|
||||||
|
const name = formatExportFilename(date, 'America/New_York');
|
||||||
|
assert.match(
|
||||||
|
name,
|
||||||
|
/^StorePhone-\d{4}-\d{2}-\d{2}T\d{2}_\d{2}_\d{2}\.\d{3}[+-]\d{2}_\d{2}\.csv$/,
|
||||||
|
);
|
||||||
|
assert.ok(name.includes('2026-07-15'), name);
|
||||||
|
assert.ok(name.includes('09_10_03'), name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('diffStorePhones', () => {
|
||||||
|
const prev = new Map([
|
||||||
|
['Store 0499', '+15551111111'],
|
||||||
|
['Store 0500', '+15552222222'],
|
||||||
|
['Store 0999', '+15559999999'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('detects added, removed, and changed rows', () => {
|
||||||
|
const curr = new Map([
|
||||||
|
['Store 0499', '+15559876543'],
|
||||||
|
['Store 0500', '+15552222222'],
|
||||||
|
['Store 7311', '+14044708802'],
|
||||||
|
]);
|
||||||
|
const diff = diffStorePhones(prev, curr);
|
||||||
|
assert.equal(diff.added.length, 1);
|
||||||
|
assert.equal(diff.added[0].name, 'Store 7311');
|
||||||
|
assert.equal(diff.removed.length, 1);
|
||||||
|
assert.equal(diff.removed[0].name, 'Store 0999');
|
||||||
|
assert.equal(diff.changed.length, 1);
|
||||||
|
assert.equal(diff.changed[0].name, 'Store 0499');
|
||||||
|
assert.equal(diff.unchangedCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports no changes when maps are identical', () => {
|
||||||
|
const diff = diffStorePhones(prev, new Map(prev));
|
||||||
|
assert.equal(diff.added.length, 0);
|
||||||
|
assert.equal(diff.removed.length, 0);
|
||||||
|
assert.equal(diff.changed.length, 0);
|
||||||
|
assert.equal(diff.unchangedCount, 3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatExportMessage', () => {
|
||||||
|
const date = new Date('2026-07-15T14:00:00.000Z');
|
||||||
|
|
||||||
|
it('notes first run when there is no baseline', () => {
|
||||||
|
const msg = formatExportMessage({
|
||||||
|
date,
|
||||||
|
timeZone: 'America/New_York',
|
||||||
|
rowCount: 1081,
|
||||||
|
diff: { added: [], removed: [], changed: [], unchangedCount: 0 },
|
||||||
|
isFirstRun: true,
|
||||||
|
});
|
||||||
|
assert.match(msg, /First export/);
|
||||||
|
assert.match(msg, /1,081/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists changes when present', () => {
|
||||||
|
const msg = formatExportMessage({
|
||||||
|
date,
|
||||||
|
timeZone: 'America/New_York',
|
||||||
|
rowCount: 3,
|
||||||
|
isFirstRun: false,
|
||||||
|
diff: {
|
||||||
|
added: [{ name: 'Store 7311', phoneNumber: '+14044708802' }],
|
||||||
|
removed: [],
|
||||||
|
changed: [
|
||||||
|
{
|
||||||
|
name: 'Store 0499',
|
||||||
|
oldPhone: '+15551111111',
|
||||||
|
newPhone: '+15559876543',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
unchangedCount: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.match(msg, /Added \(1\)/);
|
||||||
|
assert.match(msg, /Store 7311/);
|
||||||
|
assert.match(msg, /Changed \(1\)/);
|
||||||
|
assert.match(msg, /0499/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says no changes when diff is empty', () => {
|
||||||
|
const msg = formatExportMessage({
|
||||||
|
date,
|
||||||
|
timeZone: 'America/New_York',
|
||||||
|
rowCount: 10,
|
||||||
|
isFirstRun: false,
|
||||||
|
diff: { added: [], removed: [], changed: [], unchangedCount: 10 },
|
||||||
|
});
|
||||||
|
assert.match(msg, /No changes since last run/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue