Initial commit: ServChan Webex bot for ServiceChannel work orders.
ServiceChannel webhook processor, proposal approval cards, attachment auto-post, CollabSupport commands, and Docker deployment configuration. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
c2e98d105e
61 changed files with 22415 additions and 0 deletions
28
.dockerignore
Normal file
28
.dockerignore
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
data
|
||||||
|
logs
|
||||||
|
src/logs
|
||||||
|
|
||||||
|
# Non-runtime directories (keeps prod images small and clean)
|
||||||
|
archive
|
||||||
|
downloads
|
||||||
|
tools
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# Any legacy secrets files — the app reads all credentials from env now.
|
||||||
|
# Redacted config.json is still built; anything with secrets is excluded.
|
||||||
|
config/config-test.json
|
||||||
|
config/*.local.json
|
||||||
|
config/secrets.json
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
credentials*
|
||||||
|
secrets*
|
||||||
102
.env.example
Normal file
102
.env.example
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
# === ServChan Environment Variables (All Secrets) ===
|
||||||
|
# Copy this file to .env and fill in the real values.
|
||||||
|
# Never commit the real .env file.
|
||||||
|
#
|
||||||
|
# The application now loads ALL secrets exclusively from environment variables.
|
||||||
|
# config/config.json should no longer contain any credentials.
|
||||||
|
|
||||||
|
# --- Required for core bot operation ---
|
||||||
|
WEBEX_BOT_TOKEN=your-production-or-dev-bot-token
|
||||||
|
SC_CLIENT_ID=your-servicechannel-client-id
|
||||||
|
SC_CLIENT_SECRET=your-servicechannel-client-secret
|
||||||
|
SC_USERNAME=your-servicechannel-username
|
||||||
|
SC_PASSWORD=your-servicechannel-password
|
||||||
|
|
||||||
|
# --- xAI (Grok) ---
|
||||||
|
XAI_TOKEN=your-xai-api-key
|
||||||
|
XAI_MODEL=grok-4-1-fast-reasoning
|
||||||
|
|
||||||
|
# --- Optional / Integration specific ---
|
||||||
|
WEBEX_BASE_URL=https://webexapis.com/v1
|
||||||
|
WEBEX_BOT_PERSON_ID=your-bot-person-id # Used by space cleanup (see note below)
|
||||||
|
# CollabSupport (collabFinder) HTTP API — required for /woSummary, /woHistory,
|
||||||
|
# /avStatus, and /woAttachments.
|
||||||
|
#
|
||||||
|
# Host / smoke tests (public URL behind nginx):
|
||||||
|
CS_API_BASE=https://bot.joesjavajoint.com/CollabSupport
|
||||||
|
#
|
||||||
|
# Docker (set in docker-compose — do not put in .env unless overriding):
|
||||||
|
# CS_API_BASE_INTERNAL=http://collabfinder:1800
|
||||||
|
# Requires collabFinder running and servchan-bot on network collabfinder_collabnet.
|
||||||
|
|
||||||
|
# RED, Meraki, Atlas, Optisign — only needed if you use the related features
|
||||||
|
# RED_CLIENT_ID=...
|
||||||
|
# RED_API_KEY=...
|
||||||
|
# RED_COMPANY_IDS=...
|
||||||
|
# MERAKI_API_KEY=...
|
||||||
|
# MERAKI_ORG_ID=...
|
||||||
|
# ATLAS_AUTH_KEY=...
|
||||||
|
# OPTISIGN_API_KEY=...
|
||||||
|
|
||||||
|
# --- Admin endpoints (/cleanup-test, /stale-workorders) ---
|
||||||
|
# Required in production. If unset in NODE_ENV=production the endpoints refuse
|
||||||
|
# requests with 503. In dev (NODE_ENV!=production) unset means "allow" with a
|
||||||
|
# warning in the log.
|
||||||
|
ADMIN_TOKEN=change-me-to-a-long-random-string
|
||||||
|
|
||||||
|
# --- Webhook authentication (optional, off by default) ---
|
||||||
|
# ServiceChannel signs every webhook per their docs:
|
||||||
|
# https://developer.servicechannel.com/guides/wh/receive-events-and-respond/
|
||||||
|
# Sign-Type: HMACSHA256
|
||||||
|
# Sign-Data: <base64(HMAC-SHA256(rawBody, signingKey))>
|
||||||
|
#
|
||||||
|
# Setup:
|
||||||
|
# 1. Fetch the Signing Key with:
|
||||||
|
# GET /v3/NotificationSubscriptions/SigningKey
|
||||||
|
# or copy it from the ServiceChannel UI.
|
||||||
|
# 2. Paste it into SC_WEBHOOK_SIGNING_SECRET below and set
|
||||||
|
# SC_WEBHOOK_AUTH_MODE=log
|
||||||
|
# to observe verification results without rejecting anything.
|
||||||
|
# 3. Once the log shows repeated OK lines for real webhooks, flip to
|
||||||
|
# SC_WEBHOOK_AUTH_MODE=enforce
|
||||||
|
#
|
||||||
|
# See src/server/webhookAuth.js for full docs.
|
||||||
|
|
||||||
|
SC_WEBHOOK_AUTH_MODE=off # off | log | enforce
|
||||||
|
# SC_WEBHOOK_SIGNING_SECRET=paste-the-servicechannel-signing-key-here
|
||||||
|
|
||||||
|
# The defaults below match ServiceChannel's format exactly — for SC you should
|
||||||
|
# NOT need to override any of them. They exist for other webhook sources.
|
||||||
|
# SC_WEBHOOK_SIGNATURE_HEADER=sign-data # SC's default: "Sign-Data"
|
||||||
|
# SC_WEBHOOK_SIGNATURE_ENCODING=base64 # SC uses base64; auto also works
|
||||||
|
# SC_WEBHOOK_SIGNATURE_ALGO=sha256 # SC uses HMAC-SHA256
|
||||||
|
# SC_WEBHOOK_SIGNATURE_PREFIX= # SC has no prefix; leave empty
|
||||||
|
|
||||||
|
# Alternative to HMAC signing: a static shared-secret header. Not used by
|
||||||
|
# ServiceChannel — only relevant if you're proxying webhooks through something
|
||||||
|
# else that adds a bearer-style token.
|
||||||
|
# SC_WEBHOOK_TOKEN=some-long-random-value
|
||||||
|
# SC_WEBHOOK_TOKEN_HEADER=x-webhook-token
|
||||||
|
|
||||||
|
# --- Proposal approval (optional) ---
|
||||||
|
# RejectReasonCodeId used when auto-rejecting a superseded approved proposal.
|
||||||
|
# Fetch valid values via GET /proposals/RejectionReasons in ServiceChannel.
|
||||||
|
# If unset, ServChan picks the first reason matching "revised"/"superseded"/etc.
|
||||||
|
# SC_PROPOSAL_REJECT_REASON_ID=7
|
||||||
|
|
||||||
|
# --- Attachment auto-post (optional) ---
|
||||||
|
# When true (default), ServChan posts SC photos/invoices to the Webex WO room
|
||||||
|
# on room creation and when WorkOrderNoteAdded webhooks include AttachmentIds.
|
||||||
|
# Set to false to disable automatic posting (manual /woAttachments still works).
|
||||||
|
# AUTO_POST_ATTACHMENTS=true
|
||||||
|
|
||||||
|
# --- Runtime configuration (non-secret) ---
|
||||||
|
DB_PATH=./data/webex_sc_mappings.db
|
||||||
|
# Directory for daily *.log files. Every logger in the app resolves to this
|
||||||
|
# single location (structured logs, webhook payload archive, cleanup cron).
|
||||||
|
# In Docker the container's WORKDIR is /app, so ./logs → /app/logs, which is
|
||||||
|
# where docker-compose mounts the host ./logs volume. Change this only if
|
||||||
|
# you're intentionally routing logs elsewhere (e.g. /var/log/servchan).
|
||||||
|
LOG_DIR=./logs
|
||||||
|
PORT=1458
|
||||||
|
NODE_ENV=production
|
||||||
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Environment & Secrets
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
config/config.json
|
||||||
|
config/config-test.json
|
||||||
|
|
||||||
|
# Database files (production data)
|
||||||
|
*.db
|
||||||
|
data/*.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS / Editor
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Archive / old code
|
||||||
|
archive/
|
||||||
|
|
||||||
|
# Temporary / test artifacts
|
||||||
|
dev_webex_sc_mappings.db
|
||||||
|
test-*.db
|
||||||
|
|
||||||
|
# Large data files from tools
|
||||||
|
*.csv
|
||||||
|
*.json
|
||||||
|
!package-lock.json
|
||||||
|
!package.json
|
||||||
50
Dockerfile
Normal file
50
Dockerfile
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# ------------------- Base Stage -------------------
|
||||||
|
FROM node:20-slim AS base
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install build dependencies for sqlite3 native module
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
python3 \
|
||||||
|
make \
|
||||||
|
g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# ------------------- Development Stage -------------------
|
||||||
|
FROM base AS development
|
||||||
|
|
||||||
|
ENV NODE_ENV=development
|
||||||
|
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 1458
|
||||||
|
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
|
|
||||||
|
# ------------------- Production Stage -------------------
|
||||||
|
FROM base AS production
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
RUN npm ci --only=production && \
|
||||||
|
npm rebuild sqlite3 --build-from-source
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create runtime dirs owned by the non-root 'node' user (uid 1000).
|
||||||
|
# This ensures /app/logs and /app/data exist inside the image with correct perms
|
||||||
|
# even if no volume is mounted at start. Volumes will override at runtime.
|
||||||
|
RUN mkdir -p /app/logs /app/data && chown -R node:node /app/logs /app/data
|
||||||
|
|
||||||
|
EXPOSE 1458
|
||||||
|
|
||||||
|
# Run as non-root user (node user in the image has uid 1000).
|
||||||
|
# On Linux hosts you may need to ensure the host data/logs dirs are owned by 1000:1000
|
||||||
|
# or use user namespaces / volume permissions.
|
||||||
|
USER node
|
||||||
|
|
||||||
|
CMD ["npm", "start"]
|
||||||
264
README.md
Normal file
264
README.md
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
# ServChan
|
||||||
|
|
||||||
|
Service Channel + Webex Bot for retail facilities coordination.
|
||||||
|
|
||||||
|
ServChan automatically creates dedicated Webex collaboration spaces for every ServiceChannel work order, posts rich updates (including AI-summarized descriptions), and provides useful slash commands for technicians and support teams.
|
||||||
|
|
||||||
|
The goal of this document is to help you understand how the system actually works — both architecturally (how data and actions flow) and operationally (how to run, monitor, and debug it).
|
||||||
|
|
||||||
|
## How the System Works (High-Level Flow)
|
||||||
|
|
||||||
|
### 1. Webhook Path (ServiceChannel → Webex Spaces)
|
||||||
|
|
||||||
|
This is the core automated behavior:
|
||||||
|
|
||||||
|
1. ServiceChannel sends a webhook to `POST /webhook` (handled in `src/server/app.js`).
|
||||||
|
2. The route immediately calls `webhookProcessor.processWebhook(payload)`.
|
||||||
|
3. `webhookProcessor` (src/services/webhookProcessor.js) does the following:
|
||||||
|
- Uses a **per-workOrderId Mutex + queue** to safely handle multiple events for the same work order arriving at the same time.
|
||||||
|
- Checks the SQLite DB (`src/db/mappings.js`) to see if a Webex room already exists for this work order.
|
||||||
|
- If no room exists:
|
||||||
|
- Calls `webexService.createWorkOrderRoom(...)` to create a new group space under the configured team.
|
||||||
|
- Adds the default list of members.
|
||||||
|
- Stores the `workOrderId ↔ roomId` mapping in the database.
|
||||||
|
- Builds a message based on the `EventType` (`WorkOrderCreated`, `WorkOrderNoteAdded`, etc.).
|
||||||
|
- For `WorkOrderCreated`, it calls `summarizeTicketDescription()` from `src/integrations/xai/client.js` to turn the messy ServiceChannel description into something readable.
|
||||||
|
- Posts the message using `webexService.sendMarkdown(...)`.
|
||||||
|
|
||||||
|
4. `webexService` (src/services/webexService.js) is a thin wrapper. It currently delegates to `botClient` but gives us a place to add ServChan-specific logic or change the underlying client later without touching the processor.
|
||||||
|
|
||||||
|
### 2. Command Path (User → Bot)
|
||||||
|
|
||||||
|
Users interact with the bot via mentions in Webex:
|
||||||
|
|
||||||
|
- The Webex Framework is initialized in `src/bot/index.js`.
|
||||||
|
- All commands are routed through a single `Framework.hears(...)` handler.
|
||||||
|
- Commands are dispatched to handlers in `src/commands/` (`help.js`, `avStatus.js`, `woSummary.js`, etc.).
|
||||||
|
- Many of these handlers call the external CollabSupport service (configured via `CS_API_BASE`) rather than doing heavy work locally.
|
||||||
|
|
||||||
|
### Key Classes / Modules and Their Responsibilities
|
||||||
|
|
||||||
|
| Module / Class | Role |
|
||||||
|
|-----------------------------------|------|
|
||||||
|
| `index.js` | Thin bootstrap. Creates DB connection, instantiates `webhookProcessor` + `webexService`, starts the bot and Express server. |
|
||||||
|
| `webhookProcessor.js` | The heart of automation. Owns concurrency control (mutex/queue), room lifecycle decisions, and message construction. |
|
||||||
|
| `webexService.js` | Thin service layer on top of the Webex client. Preferred interface for anything that needs to talk to Webex. |
|
||||||
|
| `botClient.js` (in `integrations/webex/`) | Low-level Webex bot operations using the bot token (create rooms, post messages, add members). |
|
||||||
|
| `adminClient.js` (in `integrations/webex/`) | Separate client for privileged operations (DECT, phone lookups, etc.) that require different scopes. |
|
||||||
|
| `src/integrations/xai/client.js` | All Grok/xAI calls. Contains both the short initial-description summarizer and the more detailed ticket summarizer. |
|
||||||
|
| `src/db/mappings.js` + `path.js` | Database access for work order ↔ room mappings. Path resolution is centralized here for safety. |
|
||||||
|
| `src/server/app.js` | Express routes (`/health`, `/webhook`, `/cleanup-test`, etc.). |
|
||||||
|
| `src/commands/*.js` | Individual slash command implementations. |
|
||||||
|
|
||||||
|
### Data & Action Flow Summary
|
||||||
|
|
||||||
|
- **Incoming webhook** → `server` → `webhookProcessor` → (DB + `webexService` + `xai`) → Webex room
|
||||||
|
- **User command** → Webex Framework (`bot/`) → specific command handler → (often external CollabSupport API) → response back to Webex
|
||||||
|
- **Space cleanup** → `cleanup-test` or scheduled job → `spaceCleanupService` → reads DB + ServiceChannel status → acts via `botClient`
|
||||||
|
|
||||||
|
All persistent state for work order rooms lives in the SQLite database (see "Database Constraint" below).
|
||||||
|
|
||||||
|
### What Happens When a Work Order Is Created (End-to-End Walkthrough)
|
||||||
|
|
||||||
|
Here is a concrete trace of what occurs when ServiceChannel sends a `WorkOrderCreated` webhook:
|
||||||
|
|
||||||
|
1. **Webhook arrives**
|
||||||
|
ServiceChannel calls `POST /webhook` with a payload containing `EventType: "WorkOrderCreated"` and the full work order object.
|
||||||
|
|
||||||
|
2. **Route hands off immediately**
|
||||||
|
`src/server/app.js` receives the request, logs it, returns `200 OK` quickly, and calls:
|
||||||
|
```js
|
||||||
|
webhookProcessor.processWebhook(payload)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Concurrency protection**
|
||||||
|
`src/services/webhookProcessor.js` looks up (or creates) a Mutex for this specific `workOrderId`. It also maintains a small queue so multiple rapid events for the same work order are processed in order.
|
||||||
|
|
||||||
|
4. **Room lookup**
|
||||||
|
It queries the database (`src/db/mappings.js`) to see if a Webex room already exists for this work order.
|
||||||
|
|
||||||
|
5. **Room creation (first time only)**
|
||||||
|
If no room exists:
|
||||||
|
- Calls `webexService.createWorkOrderRoom(workOrder, teamId)`
|
||||||
|
- `webexService` calls `botClient.createRoom(...)` with the standard title format:
|
||||||
|
`ServChan WO-123456 | Store 2477 | Store Name`
|
||||||
|
- Adds the default members (the list currently hardcoded in `index.js`)
|
||||||
|
- Saves the mapping (`workOrderId → roomId`) to the database
|
||||||
|
|
||||||
|
6. **Description summarization**
|
||||||
|
Because this is a `WorkOrderCreated` event, it calls:
|
||||||
|
```js
|
||||||
|
summarizeTicketDescription(description, xaiToken)
|
||||||
|
```
|
||||||
|
from `src/integrations/xai/client.js`. This sends a focused prompt to Grok to turn the often messy ServiceChannel description into a clean 2–6 sentence summary.
|
||||||
|
|
||||||
|
7. **Message construction**
|
||||||
|
The processor builds a rich Markdown message containing:
|
||||||
|
- Link to the work order in ServiceChannel
|
||||||
|
- Store and trade information
|
||||||
|
- Priority, category, problem code
|
||||||
|
- Status
|
||||||
|
- The AI-generated description summary
|
||||||
|
|
||||||
|
8. **Message posted**
|
||||||
|
Calls `webexService.sendMarkdown(roomId, text)`, which ultimately calls `botClient.sendMarkdown(...)`.
|
||||||
|
|
||||||
|
9. **Cleanup**
|
||||||
|
The mutex is released. If the queue for this work order is now empty, the mutex and queue objects are removed from memory.
|
||||||
|
|
||||||
|
The result is a new, populated Webex space ready for the team, usually within a few seconds of the work order being created in ServiceChannel.
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
ServChan/
|
||||||
|
├── index.js # Thin bootstrap / orchestrator
|
||||||
|
├── src/
|
||||||
|
│ ├── bot/ # Webex Framework + command routing
|
||||||
|
│ ├── server/ # Express app (routes + handlers)
|
||||||
|
│ ├── services/ # Core business logic (webhookProcessor, webexService, etc.)
|
||||||
|
│ ├── integrations/ # External system clients (webex, serviceChannel, xai)
|
||||||
|
│ ├── db/ # Database access + safe path resolution
|
||||||
|
│ ├── commands/ # Individual slash command implementations
|
||||||
|
│ ├── legacy/ # Old code kept during transition (avoid editing)
|
||||||
|
│ ├── experimental/ # Superseded device code (mostly unused now)
|
||||||
|
│ └── utils/
|
||||||
|
├── tools/ # Standalone operational scripts
|
||||||
|
├── config/ # Non-secret config (secrets still here too)
|
||||||
|
├── data/ # SQLite database (handle with care)
|
||||||
|
├── Dockerfile
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── REFACTOR-LOG.md # History of major changes (reference only)
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Operational Considerations
|
||||||
|
|
||||||
|
### The Database Constraint (Very Important)
|
||||||
|
|
||||||
|
The SQLite database file used by the live production bot must **never** be moved, renamed, or have a second copy accidentally created.
|
||||||
|
|
||||||
|
- All code resolves the DB path through `src/db/path.js`, which respects `DB_PATH` from your environment.
|
||||||
|
- The main application creates **one** connection in `index.js` and passes that instance around.
|
||||||
|
- When debugging, always verify which database file is actually being used (check logs at startup — it prints the path).
|
||||||
|
|
||||||
|
If you ever see two different `.db` files being touched, stop and investigate immediately.
|
||||||
|
|
||||||
|
### Remote Service Dependency
|
||||||
|
|
||||||
|
Many user-facing commands (`/avStatus`, `/woSummary`, `/woAttachments`, `/woHistory`) do **not** do the heavy lifting locally. They call an external service via the `CS_API_BASE` environment variable (currently pointing at a CollabSupport instance).
|
||||||
|
|
||||||
|
If those commands start failing but the webhook automation still works, the problem is usually with the remote service or network connectivity to it.
|
||||||
|
|
||||||
|
### Webex Token Separation
|
||||||
|
|
||||||
|
- Normal bot actions (creating rooms for work orders, posting messages, adding members) use the bot token via `botClient`.
|
||||||
|
- Advanced lookups (DECT networks, phones assigned to stores, etc.) require a different privileged token and go through `adminClient`.
|
||||||
|
|
||||||
|
Keep these two concerns separate. Do not mix the tokens.
|
||||||
|
|
||||||
|
### Logging & Observability
|
||||||
|
|
||||||
|
- Application logs are written daily to `./logs/YYYYMMDD.log`.
|
||||||
|
- The good structured logger lives in `src/utils/logger.js`.
|
||||||
|
- Some older legacy paths still use simpler console + file logging.
|
||||||
|
- The `/health` endpoint is your best friend for runtime state (DB connectivity, memory, uptime).
|
||||||
|
|
||||||
|
### Space Cleanup
|
||||||
|
|
||||||
|
Automated (or manual via `/cleanup-test`) removal of old completed work order spaces is handled by `spaceCleanupService.js`.
|
||||||
|
|
||||||
|
It reads work order status from ServiceChannel and then acts on the Webex rooms via the bot client. It is intentionally conservative.
|
||||||
|
|
||||||
|
## Running the Application
|
||||||
|
|
||||||
|
### Local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker (recommended for consistency)
|
||||||
|
|
||||||
|
**Development** (live reload, source mounted, node --watch):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
**Production** (optimized image, no source mount, persistent logs volume):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
After the first build you can usually omit `--build`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
See [docker-compose.prod.yml](docker-compose.prod.yml) for the production-specific settings.
|
||||||
|
|
||||||
|
**Important for production images**:
|
||||||
|
- All secrets must come from `.env` (via `env_file`). The application now enforces this via `src/config/secrets.js`.
|
||||||
|
- `config/config.json` must **not** contain real credentials (it can still hold non-secret values like server name/port or legacy non-token webex fields for compatibility).
|
||||||
|
- The production stage runs as the non-root `node` user (uid 1000). On Linux hosts ensure your `./data` and `./logs` host directories are writable by uid 1000 if you see permission errors.
|
||||||
|
|
||||||
|
### Production Considerations
|
||||||
|
|
||||||
|
- Use the production compose files for any long-lived or customer-facing deployment.
|
||||||
|
- Logs and the SQLite DB are the only things that need to persist across container restarts/recreates. Everything else is in the image.
|
||||||
|
- The `/health` and `/healthz` endpoints are suitable for container orchestrators (Kubernetes liveness/readiness, etc.).
|
||||||
|
- Consider adding resource limits, secrets management (Docker secrets / Vault), and log rotation outside the container for full prod ops.
|
||||||
|
- Always run `docker compose ... config` to validate before deploying.
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
|
||||||
|
- `GET /healthz` → Simple liveness (used by Docker healthcheck)
|
||||||
|
- `GET /health` → Detailed JSON report including database connectivity and memory usage
|
||||||
|
|
||||||
|
### Common Debugging Commands
|
||||||
|
|
||||||
|
- Check which DB file is in use → Look at the very first logs after startup.
|
||||||
|
- Test space cleanup logic → `GET /cleanup-test?dryRun=true`
|
||||||
|
- Force a fresh xAI summary on a new work order → Create a test work order in ServiceChannel.
|
||||||
|
|
||||||
|
## Bot Commands
|
||||||
|
|
||||||
|
| Command | Context | Notes |
|
||||||
|
|------------------|----------------------------------|-------|
|
||||||
|
| `/help` | Anywhere | Context-aware |
|
||||||
|
| `/avStatus` | In WO space or with store number | Often calls remote service |
|
||||||
|
| `/woSummary` | In WO space or with WO number | Often calls remote service |
|
||||||
|
| `/woAttachments` | In WO space or with WO number | Often calls remote service |
|
||||||
|
| `/woHistory` | With store number | Often calls remote service |
|
||||||
|
| `/woApprove` | In WO space (or with WO#) | Manually posts proposal approval Adaptive Card (when status is IN PROGRESS \| WAITING FOR APPROVAL). Auto-triggered on relevant webhooks too. |
|
||||||
|
|
||||||
|
**Approval Cards (new)**: When a webhook arrives with `IN PROGRESS | WAITING FOR APPROVAL` (typically with a "Proposal created" note), ServChan automatically posts an Adaptive Card v1.3 in the room. The card shows proposal details/costs, prepopulates the suggested new NTE (current + proposal sums), and allows submit to PATCH the NTE in ServiceChannel + record approver attribution from the Webex user. Use `/woApprove` to re-trigger.
|
||||||
|
|
||||||
|
## Development Guidelines
|
||||||
|
|
||||||
|
When making changes:
|
||||||
|
|
||||||
|
- Prefer dependency injection (especially passing the `db` instance) over creating new connections.
|
||||||
|
- If you need to talk to Webex for normal bot operations, go through `webexService` rather than calling `botClient` directly.
|
||||||
|
- All AI summarization should live in or be called from `src/integrations/xai/`.
|
||||||
|
- Respect the production database path resolution — never hardcode a `.db` path.
|
||||||
|
- If you're touching webhook behavior, be extremely careful with concurrency (the mutex/queue pattern exists for a reason).
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Purpose | Notes |
|
||||||
|
|-------------------------|----------------------------------------------|-------|
|
||||||
|
| `DB_PATH` | SQLite database location | Critical — production uses this |
|
||||||
|
| `WEBEX_BOT_TOKEN` | Token for normal bot operations | Used by botClient |
|
||||||
|
| `CS_API_BASE` | Base URL for remote CollabSupport service | Powers many commands |
|
||||||
|
| `WEBEX_BOT_PERSON_ID` | Used to identify the bot during cleanup | Prevents removing itself |
|
||||||
|
| `WEBEX_ADMIN_TOKEN` (or similar) | Token for privileged Webex calls | Used by adminClient (if configured) |
|
||||||
|
|
||||||
|
Secrets must be provided exclusively via environment variables (loaded by `src/config/secrets.js` and required at startup for core functionality). `config/config.json` should contain **only non-secret values**. The code no longer depends on secrets living in the JSON file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
This document is meant to be living. Update it when you add significant new behavior or change how major flows work.
|
||||||
89
REFACTOR-LOG.md
Normal file
89
REFACTOR-LOG.md
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
# ServChan Refactor Log
|
||||||
|
|
||||||
|
**Date**: 2026-05-28
|
||||||
|
**Operator**: Grok (assisting jmcqueen)
|
||||||
|
**Phase**: Initial cleanup & modularization (pre-security hardening)
|
||||||
|
|
||||||
|
## Backup Performed
|
||||||
|
|
||||||
|
**Primary backup (filesystem tarball)**:
|
||||||
|
- Location: `~/backups/ServChan-pre-refactor-20260528-122917.tar.gz`
|
||||||
|
- Size: 22 MB
|
||||||
|
- Exclusions: `node_modules/`, `.git/` (none existed), `logs/`, `*.csv`, `*.json` (large data files), `AppleTVEnriched*`
|
||||||
|
- Created with: `tar -czf ...` from `/Users/jmcqueen/Docker`
|
||||||
|
|
||||||
|
**Note on version control**:
|
||||||
|
- At the time of backup, this directory was **not** a git repository (`fatal: not a git repository`).
|
||||||
|
- The tarball is the authoritative rollback artifact for this refactor.
|
||||||
|
|
||||||
|
**Rollback instructions**:
|
||||||
|
1. Stop any running containers: `docker compose down`
|
||||||
|
2. `cd /Users/jmcqueen/Docker`
|
||||||
|
3. `rm -rf ServChan/` (or move the current tree aside)
|
||||||
|
4. `tar -xzf ~/backups/ServChan-pre-refactor-20260528-122917.tar.gz`
|
||||||
|
5. `cd ServChan && docker compose up --build`
|
||||||
|
|
||||||
|
## Goals of This Refactor Pass
|
||||||
|
|
||||||
|
- Make `src/` the single source of truth with a clean, logical structure.
|
||||||
|
- Thin `index.js` to pure bootstrap.
|
||||||
|
- Rationalize the multiple overlapping Webex clients (bot operations vs. privileged admin/telephony operations).
|
||||||
|
- Remove dead/broken references to local device integrations that have moved to the remote CollabSupport service.
|
||||||
|
- Stabilize the core webhook → per-WO room flow.
|
||||||
|
- Add minimal but useful documentation (README + this log).
|
||||||
|
- Create a solid base before touching high-risk security items (secrets in config.json, etc.).
|
||||||
|
|
||||||
|
## Key Decisions Captured During Planning
|
||||||
|
|
||||||
|
- **Webex clients**: Keep a deliberate split. `botClient.js` for standard ServChan bot actions (messages, rooms, memberships using the bot token). Separate `adminClient.js` (or telephony client) for DECT/phone lookups that require a different privileged token/scope.
|
||||||
|
- **Remote services**: `avStatus`, `woSummary`, `woHistory`, `woAttachments` (and phone status) now primarily delegate to the external CollabSupport service (`CS_API_BASE`). Local heavy device libraries (meraki, mdm, red, atlas, optisigns, deviceService, phoneService) are obsolete for current operations and will be removed or isolated.
|
||||||
|
- **No git history** at start of work — rely on the tarball above.
|
||||||
|
|
||||||
|
## Work Status
|
||||||
|
|
||||||
|
(Will be updated as the refactor progresses)
|
||||||
|
|
||||||
|
- [x] Full backup created and verified
|
||||||
|
- [x] Started core webhook logic extraction (webhookProcessor)
|
||||||
|
- [x] Wired new webhookProcessor into the live /webhook route in index.js (old functions marked LEGACY but left in place for safety during transition)
|
||||||
|
- [x] Moved the entire remaining legacy block (createWebexSpace, addMember, postToWebexSpace, old processWebhook, summarizeTicketDescription, cleanupOldLogs, etc.) into src/legacy/old-index-helpers.js. index.js is now a thin ~108-line orchestrator.
|
||||||
|
|
||||||
|
### Step 4 - Thin webexService Wrapper (2026-05-28)
|
||||||
|
- Created `src/services/webexService.js` — a thin service layer over `botClient`.
|
||||||
|
- Provides the same low-level interface (`createRoom`, `addMember`, `sendMarkdown`) for drop-in compatibility with the current `webhookProcessor`.
|
||||||
|
- Also includes higher-level ServChan-specific helpers (`createWorkOrderRoom`, `addDefaultMembers`, `postWorkOrderMessage`).
|
||||||
|
- Wired into `index.js`: the `webhookProcessor` now receives a `WebexService` instance instead of raw `botClient`.
|
||||||
|
- This improves isolation, testability, and gives a clear place for future Webex business logic.
|
||||||
|
|
||||||
|
### Step 3 - Consolidate Summarizer (2026-05-28)
|
||||||
|
- Moved the initial WorkOrderCreated description summarizer (`summarizeTicketDescription`) from the legacy file into `src/integrations/xai/client.js` as a proper, well-documented export.
|
||||||
|
- Updated `index.js` to import the summarizer from the canonical xAI integration module.
|
||||||
|
- Cleaned up `src/legacy/old-index-helpers.js` (removed the summarizer and updated its header).
|
||||||
|
- The legacy file is now significantly smaller and closer to being deletable.
|
||||||
|
- All summarization logic for the bot now lives under `src/integrations/xai/`.
|
||||||
|
|
||||||
|
### Step 2 - Safe DB Layer Improvements (2026-05-28)
|
||||||
|
- Created `src/db/path.js` — centralized, safe `getDbPath()` that respects `process.env.DB_PATH` with the exact same fallback the original production code used.
|
||||||
|
- Updated `src/db/mappings.js` to use the centralized path resolver instead of hardcoding `./webex_sc_mappings.db`. This was the biggest risk for accidentally creating/connecting to the wrong database file.
|
||||||
|
- Enhanced `runSpaceCleanup` to accept an optional `{ db }` instance so callers can explicitly pass the production connection.
|
||||||
|
- Updated `src/server/app.js` to forward the real `db` instance to the cleanup test endpoint.
|
||||||
|
- **No database files were moved, renamed, or had new ones created.** The production DB remains exactly where it was.
|
||||||
|
|
||||||
|
All changes were designed to reduce the chance of the codebase accidentally using a different SQLite file than the live production bot.
|
||||||
|
- [ ] DB path handling: **Hard constraint** — production bot actively uses the current DB file. No moves, renames, or changes to the physical DB file/location during this refactor phase. Code changes only (env-driven resolution where safe, without altering the file itself).
|
||||||
|
|
||||||
|
### DB Constraint (2026-05-28)
|
||||||
|
The SQLite database backing the live production ServChan bot must remain untouched.
|
||||||
|
- Do not `mv`, `cp`, or rename any `.db` file.
|
||||||
|
- Do not change the default fallback path in a way that would cause a new DB file to be created in a different location.
|
||||||
|
- All DB-related work in this phase must be read-only from the code perspective (improving how we resolve the path from `DB_PATH` env when possible) while the actual file stays exactly where production expects it.
|
||||||
|
|
||||||
|
## Post-Refactor Notes
|
||||||
|
|
||||||
|
(Added after completion)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**If anything feels off after changes, restore from the tarball above immediately.** Do not attempt manual fixes on a broken refactor state without the backup in hand.
|
||||||
|
|
||||||
|
Last updated: 2026-05-28 (start of cleanup)
|
||||||
75
dev-start.sh
Executable file
75
dev-start.sh
Executable file
|
|
@ -0,0 +1,75 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# ServChan DEV Instance Startup Script
|
||||||
|
# ============================================================
|
||||||
|
#
|
||||||
|
# Purpose:
|
||||||
|
# Safely run a development/test instance of the bot without
|
||||||
|
# affecting the production instance.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./dev-start.sh
|
||||||
|
#
|
||||||
|
# Configuration (edit these values as needed):
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
|
# === DEV INSTANCE CONFIGURATION ===
|
||||||
|
# Never commit real tokens.
|
||||||
|
#
|
||||||
|
# The application requires ALL secrets via environment variables.
|
||||||
|
# Example:
|
||||||
|
# WEBEX_BOT_TOKEN="dev-..." \
|
||||||
|
# SC_CLIENT_ID="..." SC_CLIENT_SECRET="..." \
|
||||||
|
# SC_USERNAME="..." SC_PASSWORD="..." \
|
||||||
|
# XAI_TOKEN="..." \
|
||||||
|
# ./dev-start.sh
|
||||||
|
#
|
||||||
|
# The application now respects WEBEX_BOT_TOKEN (and other *_TOKEN / SC_* variables)
|
||||||
|
# even if they exist in .env or config/config.json.
|
||||||
|
|
||||||
|
if [ -z "$WEBEX_BOT_TOKEN" ]; then
|
||||||
|
echo "ERROR: WEBEX_BOT_TOKEN is not set."
|
||||||
|
echo "Please run as: WEBEX_BOT_TOKEN=\"your-dev-token\" ./dev-start.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use a different port so it doesn't conflict with production (which uses 1458)
|
||||||
|
export PORT=1460
|
||||||
|
|
||||||
|
# Use a completely separate database file for testing
|
||||||
|
export DB_PATH="./data/dev_webex_sc_mappings.db"
|
||||||
|
|
||||||
|
# Set environment for clarity in logs
|
||||||
|
export NODE_ENV=development
|
||||||
|
|
||||||
|
# You can override CS_API_BASE here if you want to point the dev bot
|
||||||
|
# at a different backend. Leaving it unset will use whatever is in .env
|
||||||
|
# or the default.
|
||||||
|
# export CS_API_BASE="https://bot.joesjavajoint.com/CollabSupport"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
echo "=============================================================="
|
||||||
|
echo " Starting ServChan DEV instance"
|
||||||
|
echo "=============================================================="
|
||||||
|
echo ""
|
||||||
|
echo " Port: $PORT"
|
||||||
|
echo " Database: $DB_PATH"
|
||||||
|
echo " Environment: $NODE_ENV"
|
||||||
|
echo " Bot Token: (provided via WEBEX_BOT_TOKEN env var)"
|
||||||
|
echo ""
|
||||||
|
echo " Health checks will be available at:"
|
||||||
|
echo " http://localhost:$PORT/healthz"
|
||||||
|
echo " http://localhost:$PORT/health"
|
||||||
|
echo ""
|
||||||
|
echo " To test commands, mention the DEV bot in Webex."
|
||||||
|
echo ""
|
||||||
|
echo "=============================================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Ensure the data directory exists
|
||||||
|
mkdir -p "$(dirname "$DB_PATH")"
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
npm run dev
|
||||||
107
discover-note-timestamps.js
Normal file
107
discover-note-timestamps.js
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
/**
|
||||||
|
* Temporary discovery script.
|
||||||
|
* Goal: Inspect the shape of notes returned by the ServiceChannel API
|
||||||
|
* to determine the correct timestamp field name.
|
||||||
|
*
|
||||||
|
* Run with: node discover-note-timestamps.js
|
||||||
|
*
|
||||||
|
* This script now loads credentials from environment variables (via .env)
|
||||||
|
* — same as the main app — so there's no plaintext credential in this file.
|
||||||
|
* Feel free to delete once you're done using it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'dotenv/config';
|
||||||
|
import axios from 'axios';
|
||||||
|
import qs from 'node:querystring';
|
||||||
|
|
||||||
|
const SC = {
|
||||||
|
clientId: process.env.SC_CLIENT_ID,
|
||||||
|
clientSecret: process.env.SC_CLIENT_SECRET,
|
||||||
|
username: process.env.SC_USERNAME,
|
||||||
|
password: process.env.SC_PASSWORD,
|
||||||
|
baseUrl: process.env.SC_BASE_URL || 'https://api.servicechannel.com/v3',
|
||||||
|
oauthUrl: process.env.SC_OAUTH_URL || 'https://login.servicechannel.com/oauth/token',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!SC.clientId || !SC.clientSecret || !SC.username || !SC.password) {
|
||||||
|
console.error('Missing ServiceChannel credentials. Set SC_CLIENT_ID, SC_CLIENT_SECRET, SC_USERNAME, SC_PASSWORD in .env.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getToken() {
|
||||||
|
const basicAuth = Buffer.from(`${SC.clientId}:${SC.clientSecret}`).toString('base64');
|
||||||
|
|
||||||
|
const response = await axios.post(
|
||||||
|
SC.oauthUrl,
|
||||||
|
qs.stringify({
|
||||||
|
grant_type: 'password',
|
||||||
|
username: SC.username,
|
||||||
|
password: SC.password,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${basicAuth}`,
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
timeout: 15000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const workOrderId = Number(process.argv[2] || 352088878);
|
||||||
|
|
||||||
|
console.log(`Discovering note structure for work order ${workOrderId}...\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await getToken();
|
||||||
|
|
||||||
|
const notesRes = await axios.get(
|
||||||
|
`${SC.baseUrl}/workorders/${workOrderId}/notes`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
timeout: 30000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const notes = notesRes.data?.Notes || notesRes.data || [];
|
||||||
|
|
||||||
|
console.log(`Found ${notes.length} notes.\n`);
|
||||||
|
|
||||||
|
if (notes.length === 0) {
|
||||||
|
console.log("No notes found on this work order.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstNote = notes[0];
|
||||||
|
console.log("Keys on the first note object:");
|
||||||
|
console.log(Object.keys(firstNote));
|
||||||
|
console.log("\n");
|
||||||
|
|
||||||
|
console.log("=== First note (full object) ===");
|
||||||
|
console.dir(firstNote, { depth: null, colors: true });
|
||||||
|
|
||||||
|
console.log("\n=== Last 3 notes - looking for timestamp fields ===");
|
||||||
|
const lastNotes = notes.slice(-3).reverse();
|
||||||
|
lastNotes.forEach((note, i) => {
|
||||||
|
console.log(`\nNote ${i + 1}:`);
|
||||||
|
const possibleTimestampFields = Object.keys(note).filter(k =>
|
||||||
|
/time|date|stamp|created|updated/i.test(k)
|
||||||
|
);
|
||||||
|
console.log(" Possible timestamp fields:", possibleTimestampFields);
|
||||||
|
possibleTimestampFields.forEach(field => {
|
||||||
|
console.log(` ${field}: ${note[field]}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error:", err.response?.data || err.message);
|
||||||
|
if (err.response) {
|
||||||
|
console.error("Status:", err.response.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
74
docker-compose.prod.yml
Normal file
74
docker-compose.prod.yml
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
# docker-compose.prod.yml
|
||||||
|
#
|
||||||
|
# Production override / standalone compose file for ServChan.
|
||||||
|
#
|
||||||
|
# Usage (recommended - merges with base for any common settings):
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||||
|
#
|
||||||
|
# Or standalone (also works):
|
||||||
|
# docker compose -f docker-compose.prod.yml up -d --build
|
||||||
|
#
|
||||||
|
# Key differences from dev:
|
||||||
|
# - Uses the production stage of the Dockerfile (optimized, non-root, no dev deps/watch)
|
||||||
|
# - NO full source bind mount (code is baked into the image at build time)
|
||||||
|
# - Dedicated persistent volume for logs (separate from any source tree)
|
||||||
|
# - NODE_ENV=production
|
||||||
|
# - Suitable for long-running detached production use
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# - .env must contain all required secrets (WEBEX_BOT_TOKEN, SC_*, XAI_TOKEN, etc.)
|
||||||
|
# - ./data and ./logs directories will be created on the host as needed
|
||||||
|
# - For best security, ensure config/config.json contains NO real credentials
|
||||||
|
# (the code now loads secrets exclusively from environment variables)
|
||||||
|
#
|
||||||
|
# After first run you can drop --build: docker compose -f ... up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
bot:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: production
|
||||||
|
container_name: servchan-bot
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "1458:1458"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
# Persistent storage for the SQLite database (the "sacred" production DB).
|
||||||
|
# The exact file referenced by DB_PATH in .env must never be moved or renamed.
|
||||||
|
- ./data:/app/data
|
||||||
|
# Persistent logs (written by both legacy and structured loggers).
|
||||||
|
# Separate volume so logs survive container recreation without source mount.
|
||||||
|
- ./logs:/app/logs
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- CS_API_BASE_INTERNAL=http://collabfinder:1800
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- collabnet
|
||||||
|
|
||||||
|
# Healthcheck hits /health so DB connectivity is part of the signal.
|
||||||
|
# /healthz still exists as a pure liveness probe if you want to fall back.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:1458/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\""]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
# Example production resource constraints (uncomment and tune):
|
||||||
|
# deploy:
|
||||||
|
# resources:
|
||||||
|
# limits:
|
||||||
|
# cpus: '1.0'
|
||||||
|
# memory: 512M
|
||||||
|
# reservations:
|
||||||
|
# cpus: '0.25'
|
||||||
|
# memory: 128M
|
||||||
|
|
||||||
|
networks:
|
||||||
|
collabnet:
|
||||||
|
external: true
|
||||||
|
name: collabfinder_collabnet
|
||||||
55
docker-compose.yml
Normal file
55
docker-compose.yml
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
# docker-compose.yml
|
||||||
|
#
|
||||||
|
# Development compose file for ServChan.
|
||||||
|
# Recommended for local development and testing (live code reload via bind mount + node --watch).
|
||||||
|
#
|
||||||
|
# Start dev:
|
||||||
|
# docker compose up --build
|
||||||
|
#
|
||||||
|
# For PRODUCTION use the override file instead:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||||
|
#
|
||||||
|
# See docker-compose.prod.yml for details on the production setup
|
||||||
|
# (production stage, no source mount, dedicated logs volume, NODE_ENV=production).
|
||||||
|
|
||||||
|
services:
|
||||||
|
bot:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: development
|
||||||
|
container_name: servchan-bot
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "1458:1458"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- /app/node_modules
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=development
|
||||||
|
# Reach collabFinder on the Docker network (see networks below).
|
||||||
|
# Overrides CS_API_BASE from .env — the public URL often fails inside
|
||||||
|
# containers when bot.joesjavajoint.com resolves to this host (hairpin).
|
||||||
|
- CS_API_BASE_INTERNAL=http://collabfinder:1800
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- collabnet
|
||||||
|
|
||||||
|
# Healthcheck hits /health so DB connectivity is part of the signal.
|
||||||
|
# /healthz still exists as a pure liveness probe if you want to fall back.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:1458/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\""]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
# Shared with collabFinder (docker compose in ../collabFinder).
|
||||||
|
# Create it by starting collabFinder first: cd ../collabFinder && docker compose up -d
|
||||||
|
networks:
|
||||||
|
collabnet:
|
||||||
|
external: true
|
||||||
|
name: collabfinder_collabnet
|
||||||
BIN
downloads/INVOICE_85282.pdf
Normal file
BIN
downloads/INVOICE_85282.pdf
Normal file
Binary file not shown.
BIN
downloads/Store 2511 Audio Rack.png
Normal file
BIN
downloads/Store 2511 Audio Rack.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 MiB |
BIN
downloads/Store 2511 Volume Control.png
Normal file
BIN
downloads/Store 2511 Volume Control.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.1 MiB |
161
index.js
Normal file
161
index.js
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
import 'dotenv/config';
|
||||||
|
import sqlite3 from 'sqlite3';
|
||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
import { runSpaceCleanup } from './src/services/spaceCleanupService.js';
|
||||||
|
import { createWebhookProcessor } from './src/services/webhookProcessor.js';
|
||||||
|
import { initializeBot, stopBot } from './src/bot/index.js';
|
||||||
|
import { createApp, setupCron } from './src/server/app.js';
|
||||||
|
import { summarizeTicketDescription } from './src/integrations/xai/client.js';
|
||||||
|
import { WebexService } from './src/services/webexService.js';
|
||||||
|
import { getStaleWorkOrdersReport } from './src/services/staleWorkOrderReportService.js';
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
// CONFIGURATION
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Non-secret config only (server name/port, etc.). Secrets come exclusively from env.
|
||||||
|
// See src/config/index.js and src/config/secrets.js.
|
||||||
|
import nonSecretConfig from './src/config/index.js';
|
||||||
|
import { loadSecrets } from './src/config/secrets.js';
|
||||||
|
import { getDbPath } from './src/db/path.js';
|
||||||
|
import { ensureLogDir } from './src/utils/logPath.js';
|
||||||
|
import { getCollabSupportBase } from './src/integrations/collabSupport/client.js';
|
||||||
|
|
||||||
|
const secrets = loadSecrets();
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || nonSecretConfig.server?.port || 1458;
|
||||||
|
// Use the shared resolver so index.js, src/db/path.js, and every downstream
|
||||||
|
// module agree on which file to open. Previously the fallback here (bot.db)
|
||||||
|
// disagreed with .env.example (webex_sc_mappings.db), which meant a missing
|
||||||
|
// DB_PATH env var would silently spawn a brand-new empty database.
|
||||||
|
const DB_PATH = getDbPath();
|
||||||
|
|
||||||
|
// Ensure runtime directories exist early. Log dir goes through the shared
|
||||||
|
// resolver (LOG_DIR env, default ./logs) so mounts, cleanup, and every logger
|
||||||
|
// call agree on one location.
|
||||||
|
const dataDir = path.dirname(DB_PATH);
|
||||||
|
if (!fs.existsSync(dataDir)) {
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
const logsDir = ensureLogDir();
|
||||||
|
console.log(`[boot] Logs → ${logsDir}`);
|
||||||
|
const csBase = getCollabSupportBase();
|
||||||
|
if (csBase) {
|
||||||
|
console.log(`[boot] CollabSupport → ${csBase}${process.env.CS_API_BASE_INTERNAL ? ' (internal)' : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build webexConfig for the Framework:
|
||||||
|
// - Token (and baseUrl) ALWAYS come from secrets/env (never baked into config.json or image).
|
||||||
|
// - Other fields (name, email, wbx_base_url) can still come from a (clean) config.json for compatibility.
|
||||||
|
const baseWebex = nonSecretConfig.auth?.webex || nonSecretConfig.webex || {};
|
||||||
|
const webexConfig = {
|
||||||
|
...baseWebex,
|
||||||
|
token: secrets.webex.token, // force from env
|
||||||
|
baseUrl: secrets.webex.baseUrl || baseWebex.wbx_base_url || baseWebex.baseUrl || 'https://webexapis.com/v1',
|
||||||
|
wbx_base_url: baseWebex.wbx_base_url || secrets.webex.baseUrl || 'https://webexapis.com/v1',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
// SQLite Database
|
||||||
|
// (Production DB file location is sacred — see REFACTOR-LOG.md)
|
||||||
|
const db = new sqlite3.Database(DB_PATH);
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS mappings (
|
||||||
|
workOrderId INTEGER PRIMARY KEY,
|
||||||
|
roomId TEXT UNIQUE NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS posted_attachments (
|
||||||
|
workOrderId INTEGER NOT NULL,
|
||||||
|
attachmentId INTEGER NOT NULL,
|
||||||
|
postedAt TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (workOrderId, attachmentId)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS pending_approval_cards (
|
||||||
|
workOrderId INTEGER PRIMARY KEY,
|
||||||
|
roomId TEXT NOT NULL,
|
||||||
|
messageId TEXT NOT NULL,
|
||||||
|
proposalId INTEGER,
|
||||||
|
postedAt TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
console.log(`[DB] Connected to ${DB_PATH}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Core Webhook Processor (extracted logic — see src/services/webhookProcessor.js)
|
||||||
|
// We pass the existing db instance so we never create a second connection or touch
|
||||||
|
// the production DB file location.
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
const hardcodedTeamId = 'Y2lzY29zcGFyazovL3VzL1RFQU0vMmI3MTJhZjAtZjc5NS0xMWYwLTk4MDYtYjczNjhlY2UzNjQx';
|
||||||
|
const defaultMembers = [
|
||||||
|
"mcqueenj@ae.com",
|
||||||
|
"bollandd@ae.com",
|
||||||
|
"ferrerij@ae.com",
|
||||||
|
"wagurakj@ae.com",
|
||||||
|
"karpuszkav@ae.com"
|
||||||
|
];
|
||||||
|
|
||||||
|
// Wrapper for the initial description summarizer used on WorkOrderCreated events.
|
||||||
|
// Prefer token passed in or from secrets (xai); fall back to legacy config only for transition.
|
||||||
|
async function summarizeForNewWO(rawDescription, token, opts = {}) {
|
||||||
|
const xaiToken = token || secrets.xai?.token || baseWebex.xai?.token;
|
||||||
|
return summarizeTicketDescription(rawDescription, xaiToken, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Webex service provides a thin, ServChan-aware layer over the raw bot client.
|
||||||
|
// This improves isolation and testability of the webhook processor.
|
||||||
|
const webexService = new WebexService();
|
||||||
|
|
||||||
|
const webhookProcessor = createWebhookProcessor({
|
||||||
|
db,
|
||||||
|
webex: webexService,
|
||||||
|
summarizeDescription: summarizeForNewWO,
|
||||||
|
teamId: hardcodedTeamId,
|
||||||
|
defaultMembers,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Webex Bot (Framework + commands)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
const Framework = initializeBot({
|
||||||
|
webexConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Cron + Express App (extracted)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
setupCron();
|
||||||
|
|
||||||
|
const app = createApp({
|
||||||
|
db,
|
||||||
|
DB_PATH,
|
||||||
|
webhookProcessor,
|
||||||
|
runSpaceCleanup,
|
||||||
|
Framework,
|
||||||
|
getStaleWorkOrdersReport: (db) => getStaleWorkOrdersReport(db),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Start server (thin bootstrap)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`${nonSecretConfig.server?.name || 'ServChan'} → Webex webhook receiver running on port ${PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown (handles both local Ctrl-C and Docker/K8s SIGTERM)
|
||||||
|
function shutdown(signal) {
|
||||||
|
console.log(`[shutdown] Received ${signal}, stopping...`);
|
||||||
|
stopBot(Framework).then(() => {
|
||||||
|
try { db.close(); } catch (_) {}
|
||||||
|
process.exit(0);
|
||||||
|
}).catch(() => process.exit(0));
|
||||||
|
}
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
14344
package-lock.json
generated
Normal file
14344
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
31
package.json
Normal file
31
package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"name": "servchan",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Service Channel + Webex Bot",
|
||||||
|
"type": "module",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node --watch index.js",
|
||||||
|
"start": "node index.js",
|
||||||
|
"lint": "echo \"No linter configured\"",
|
||||||
|
"test": "echo \"No tests configured\"",
|
||||||
|
"test:collab": "node scripts/smoke-collab-support.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"async-mutex": "^0.5.0",
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"body-parser": "^2.2.2",
|
||||||
|
"crypto": "^1.0.1",
|
||||||
|
"csv-parser": "^3.2.0",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"formdata-node": "^6.0.3",
|
||||||
|
"graphql-request": "^7.4.0",
|
||||||
|
"heic-convert": "^2.1.0",
|
||||||
|
"json2csv": "^6.0.0-alpha.2",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
|
"p-limit": "^7.3.0",
|
||||||
|
"sqlite3": "^5.1.7",
|
||||||
|
"webex-node-bot-framework": "^2.5.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
153
scripts/smoke-collab-support.js
Normal file
153
scripts/smoke-collab-support.js
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Smoke-test CollabSupport HTTP endpoints used by ServChan.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* CS_API_BASE=https://bot.joesjavajoint.com/CollabSupport \
|
||||||
|
* SMOKE_WO_ID=356369551 SMOKE_STORE_NUM=2254 \
|
||||||
|
* node scripts/smoke-collab-support.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
const base = (process.env.CS_API_BASE_INTERNAL || process.env.CS_API_BASE || '').replace(/\/+$/, '');
|
||||||
|
const woId = process.env.SMOKE_WO_ID || '356369551';
|
||||||
|
const storeNum = process.env.SMOKE_STORE_NUM || '2254';
|
||||||
|
const timeoutMs = Number(process.env.SMOKE_TIMEOUT_MS) || 30_000;
|
||||||
|
|
||||||
|
const USAGE_MARKERS = [
|
||||||
|
'**Work Order Summary Usage:**',
|
||||||
|
'**Work Order History Usage:**',
|
||||||
|
'Please provide a 2–4 digit store number.',
|
||||||
|
];
|
||||||
|
|
||||||
|
function looksLikeUsage(text) {
|
||||||
|
return USAGE_MARKERS.some((m) => text.includes(m));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCheck(label, url, { expectJson = false, validate, expectStatus } = {}) {
|
||||||
|
const started = Date.now();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: expectJson ? { Accept: 'application/json' } : undefined,
|
||||||
|
});
|
||||||
|
const elapsed = Date.now() - started;
|
||||||
|
const body = expectJson ? await res.json() : await res.text();
|
||||||
|
|
||||||
|
const issues = [];
|
||||||
|
if (expectStatus !== undefined) {
|
||||||
|
if (res.status !== expectStatus) issues.push(`expected HTTP ${expectStatus}, got ${res.status}`);
|
||||||
|
} else if (!res.ok) {
|
||||||
|
issues.push(`HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
if (expectStatus === undefined && !expectJson && typeof body === 'string' && body.trim().length === 0) {
|
||||||
|
issues.push('empty markdown body');
|
||||||
|
}
|
||||||
|
if (expectStatus === undefined && !expectJson && typeof body === 'string' && looksLikeUsage(body)) {
|
||||||
|
issues.push('looks like usage/help text, not data');
|
||||||
|
}
|
||||||
|
if (validate) {
|
||||||
|
const v = validate(body, res);
|
||||||
|
if (v) issues.push(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pass = issues.length === 0;
|
||||||
|
console.log(`${pass ? 'PASS' : 'FAIL'} ${label}`);
|
||||||
|
console.log(` ${url}`);
|
||||||
|
console.log(` → ${res.status} (${elapsed}ms)${issues.length ? ` — ${issues.join('; ')}` : ''}`);
|
||||||
|
return pass;
|
||||||
|
} catch (err) {
|
||||||
|
const elapsed = Date.now() - started;
|
||||||
|
const msg = err.name === 'AbortError' ? 'timeout' : err.message;
|
||||||
|
console.log(`FAIL ${label}`);
|
||||||
|
console.log(` ${url}`);
|
||||||
|
console.log(` → error (${elapsed}ms): ${msg}`);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!base) {
|
||||||
|
console.error('CS_API_BASE is required (e.g. https://bot.joesjavajoint.com/CollabSupport)');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`CollabSupport smoke test → ${base}`);
|
||||||
|
console.log(`WO=${woId} store=${storeNum}\n`);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
results.push(await fetchCheck(
|
||||||
|
'wosummary',
|
||||||
|
`${base}/wosummary?woId=${encodeURIComponent(woId)}`,
|
||||||
|
{
|
||||||
|
validate: (body) => body.includes('Work Order Summary') ? null : 'missing summary header',
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
results.push(await fetchCheck(
|
||||||
|
'wohistory (summary)',
|
||||||
|
`${base}/wohistory?storeNum=${encodeURIComponent(storeNum)}`,
|
||||||
|
{
|
||||||
|
validate: (body) => body.includes('Work Order History') ? null : 'missing history header',
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
results.push(await fetchCheck(
|
||||||
|
'wohistory (detailed)',
|
||||||
|
`${base}/wohistory?storeNum=${encodeURIComponent(storeNum)}&mode=detailed`,
|
||||||
|
{
|
||||||
|
validate: (body) => body.includes('Work Order History') ? null : 'missing history header',
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
results.push(await fetchCheck(
|
||||||
|
'avStatus (summary)',
|
||||||
|
`${base}/avStatus?storeNum=${encodeURIComponent(storeNum)}`,
|
||||||
|
{
|
||||||
|
validate: (body) => (body.length > 50 ? null : 'response too short'),
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
results.push(await fetchCheck(
|
||||||
|
'avStatus (detailed)',
|
||||||
|
`${base}/avStatus?storeNum=${encodeURIComponent(storeNum)}&mode=detailed`,
|
||||||
|
{
|
||||||
|
validate: (body) => (body.length > 50 ? null : 'response too short'),
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
results.push(await fetchCheck(
|
||||||
|
'woAttachments (JSON)',
|
||||||
|
`${base}/woAttachments?woId=${encodeURIComponent(woId)}`,
|
||||||
|
{
|
||||||
|
expectJson: true,
|
||||||
|
validate: (data) => {
|
||||||
|
if (typeof data.success !== 'boolean') return 'missing success field';
|
||||||
|
if (typeof data.count !== 'number') return 'missing count field';
|
||||||
|
if (!Array.isArray(data.attachments)) return 'missing attachments array';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
// Validation endpoint — should 400
|
||||||
|
results.push(await fetchCheck(
|
||||||
|
'wosummary invalid (expect 400)',
|
||||||
|
`${base}/wosummary?woId=abc`,
|
||||||
|
{ expectStatus: 400 }
|
||||||
|
));
|
||||||
|
|
||||||
|
const passed = results.filter(Boolean).length;
|
||||||
|
const total = results.length;
|
||||||
|
console.log(`\n${passed}/${total} checks passed`);
|
||||||
|
|
||||||
|
process.exit(passed === total ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
155
src/bot/index.js
Normal file
155
src/bot/index.js
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
/**
|
||||||
|
* src/bot/index.js
|
||||||
|
*
|
||||||
|
* Webex bot initialization and command registration.
|
||||||
|
*
|
||||||
|
* Extracted during the 2026-05-28 refactor to make index.js a thin bootstrap.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import framework from 'webex-node-bot-framework';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
import { handleHelp } from '../commands/help.js';
|
||||||
|
import { handleUnknown } from '../commands/unknownCommand.js';
|
||||||
|
import { handleWoSummary } from '../commands/woSummary.js';
|
||||||
|
import { handleWoAttachments } from '../commands/woAttachments.js';
|
||||||
|
import { handleWoHistory } from '../commands/woHistory.js';
|
||||||
|
import { handleAvStatus } from '../commands/avStatus.js';
|
||||||
|
import { handleWoApprove } from '../commands/woApprove.js';
|
||||||
|
import approvalService from '../services/approvalService.js';
|
||||||
|
import { installMercuryGuard } from './mercuryGuard.js';
|
||||||
|
import db from '../db/mappings.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize and start the Webex bot framework.
|
||||||
|
* @param {object} options
|
||||||
|
* @param {object} options.webexConfig - The webex auth section from config
|
||||||
|
* @returns {object} The Framework instance
|
||||||
|
*/
|
||||||
|
export function initializeBot({ webexConfig }) {
|
||||||
|
if (!webexConfig?.token) {
|
||||||
|
throw new Error('webexConfig.token is required to initialize the bot');
|
||||||
|
}
|
||||||
|
|
||||||
|
const Framework = new framework({
|
||||||
|
...webexConfig,
|
||||||
|
spawn: false,
|
||||||
|
mentionOnly: true,
|
||||||
|
removeME: false,
|
||||||
|
suppressDirectSpaceSpawnFailures: true
|
||||||
|
});
|
||||||
|
|
||||||
|
Framework.start();
|
||||||
|
|
||||||
|
// Prevent Mercury websocket INVALID_STATE_ERROR from crashing Node (see
|
||||||
|
// src/bot/mercuryGuard.js for details). Must be installed before events flow.
|
||||||
|
installMercuryGuard(Framework);
|
||||||
|
|
||||||
|
Framework.on("initialized", () => {
|
||||||
|
logger('bot', "Webex Framework is all fired up! [Press CTRL-C to quit]");
|
||||||
|
});
|
||||||
|
|
||||||
|
Framework.on('spawn', (bot, id) => {
|
||||||
|
// Optional detailed logging
|
||||||
|
// logger('bot', `Bot spawned in room: ${bot.room?.title || id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
Framework.on('error', (err) => {
|
||||||
|
if (err.message && !err.message.includes('Could not find a room')) {
|
||||||
|
logger('bot:error', err.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// NEW: Handle Adaptive Card submissions (e.g. proposal approvals)
|
||||||
|
Framework.on('attachmentAction', async (bot, trigger) => {
|
||||||
|
try {
|
||||||
|
await approvalService.handleApprovalSubmit(bot, trigger, { db });
|
||||||
|
} catch (e) {
|
||||||
|
logger('bot:approvalAction', `Handler error: ${e.message}`, 'error');
|
||||||
|
try {
|
||||||
|
await bot.say(`⚠️ Approval action failed: ${e.message}. Please try again or approve in ServiceChannel.`);
|
||||||
|
} catch (sayErr) {
|
||||||
|
logger('bot:approvalAction', `Could not reply in room (Mercury may be disconnected): ${sayErr.message}`, 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Command registration - single .hears handler
|
||||||
|
Framework.hears(/.*/, async (bot, trigger) => {
|
||||||
|
try {
|
||||||
|
let rawText = trigger.text?.trim() || '';
|
||||||
|
|
||||||
|
// Strip common bot mention patterns
|
||||||
|
const botMentionPatterns = [
|
||||||
|
/^ServiceChannel\s+/i,
|
||||||
|
/^@ServiceChannel\s+/i,
|
||||||
|
/^ServiceChannel@webex\.bot\s+/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of botMentionPatterns) {
|
||||||
|
rawText = rawText.replace(pattern, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = rawText.split(/\s+/);
|
||||||
|
const command = parts[0]?.toLowerCase().replace(/^\//, '') || '';
|
||||||
|
const args = parts.slice(1);
|
||||||
|
|
||||||
|
// Attach args for command handlers that expect them
|
||||||
|
trigger.args = args;
|
||||||
|
|
||||||
|
switch (command) {
|
||||||
|
case 'test':
|
||||||
|
await bot.say(`[TEST] Echo: "${rawText}"`);
|
||||||
|
break;
|
||||||
|
case 'help':
|
||||||
|
await handleHelp(bot, trigger);
|
||||||
|
break;
|
||||||
|
case 'avstatus':
|
||||||
|
await handleAvStatus(bot, trigger);
|
||||||
|
break;
|
||||||
|
case 'wosummary':
|
||||||
|
await handleWoSummary(bot, trigger);
|
||||||
|
break;
|
||||||
|
case 'woattachments':
|
||||||
|
await handleWoAttachments(bot, trigger);
|
||||||
|
break;
|
||||||
|
case 'wohistory':
|
||||||
|
await handleWoHistory(bot, trigger);
|
||||||
|
break;
|
||||||
|
case 'woapprove':
|
||||||
|
case 'approve':
|
||||||
|
case 'requestapproval':
|
||||||
|
await handleWoApprove(bot, trigger);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
await handleUnknown(bot, trigger);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger('bot:command', `Command handler error: ${e.message}`, 'error');
|
||||||
|
try {
|
||||||
|
await bot.say(`⚠️ Command failed: ${e.message}`);
|
||||||
|
} catch (_) {
|
||||||
|
logger('bot:command', 'Could not send error reply (Mercury may be disconnected)', 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, null, 1);
|
||||||
|
|
||||||
|
return Framework;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Graceful shutdown helper for the bot framework.
|
||||||
|
*/
|
||||||
|
export async function stopBot(Framework) {
|
||||||
|
if (Framework && typeof Framework.stop === 'function') {
|
||||||
|
try {
|
||||||
|
await Framework.stop();
|
||||||
|
logger('bot', 'Framework stopped cleanly');
|
||||||
|
} catch (err) {
|
||||||
|
logger('bot:error', `Error stopping Framework: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { initializeBot, stopBot };
|
||||||
170
src/bot/mercuryGuard.js
Normal file
170
src/bot/mercuryGuard.js
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
/**
|
||||||
|
* src/bot/mercuryGuard.js
|
||||||
|
*
|
||||||
|
* Prevents the Webex Mercury WebSocket plugin from crashing the Node process.
|
||||||
|
*
|
||||||
|
* Root cause (confirmed from production stack trace 2026-08-04):
|
||||||
|
* @webex/internal-plugin-mercury socket-base.js onmessage → _acknowledge → send()
|
||||||
|
* rejects with Error('INVALID_STATE_ERROR') when readyState !== OPEN (1).
|
||||||
|
* That promise is never caught inside the plugin, so it becomes an
|
||||||
|
* unhandledRejection / uncaughtException and terminates Node.
|
||||||
|
*
|
||||||
|
* This module:
|
||||||
|
* 1. Catches Mercury INVALID_STATE_ERROR at the process level (no crash).
|
||||||
|
* 2. Debounces a Framework.restart() to rebuild the websocket listeners.
|
||||||
|
* 3. Hooks mercury offline/online events for proactive recovery.
|
||||||
|
* 4. Periodically logs connection health.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
let guardInstalled = false;
|
||||||
|
let restartInFlight = false;
|
||||||
|
let restartTimer = null;
|
||||||
|
|
||||||
|
/** True when err is the Mercury socket ack-on-closed-socket failure. */
|
||||||
|
export function isMercuryInvalidStateError(err) {
|
||||||
|
if (!err) return false;
|
||||||
|
const msg = String(err.message || err);
|
||||||
|
if (msg !== 'INVALID_STATE_ERROR') return false;
|
||||||
|
const stack = String(err.stack || '');
|
||||||
|
return (
|
||||||
|
stack.includes('mercury') ||
|
||||||
|
stack.includes('socket-base') ||
|
||||||
|
stack.includes('internal-plugin-mercury') ||
|
||||||
|
stack.includes('plugin-mercury')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read mercury connection snapshot without throwing. */
|
||||||
|
export function getMercuryState(Framework) {
|
||||||
|
try {
|
||||||
|
const mercury = Framework?.webex?.internal?.mercury;
|
||||||
|
const socket = mercury?.socket;
|
||||||
|
return {
|
||||||
|
connected: Boolean(mercury?.connected),
|
||||||
|
connecting: Boolean(mercury?.connecting),
|
||||||
|
readyState: socket?.readyState ?? null,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleFrameworkRestart(Framework, reason) {
|
||||||
|
if (restartTimer) clearTimeout(restartTimer);
|
||||||
|
restartTimer = setTimeout(async () => {
|
||||||
|
if (restartInFlight) return;
|
||||||
|
restartInFlight = true;
|
||||||
|
try {
|
||||||
|
logger('mercuryGuard', `Restarting Webex Framework (${reason})`, 'warn');
|
||||||
|
if (Framework && typeof Framework.restart === 'function') {
|
||||||
|
await Framework.restart();
|
||||||
|
logger('mercuryGuard', 'Framework restart completed');
|
||||||
|
} else {
|
||||||
|
logger('mercuryGuard', 'Framework.restart() unavailable — skipping', 'warn');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger('mercuryGuard', `Framework restart failed: ${err.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
restartInFlight = false;
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachMercuryListeners(Framework) {
|
||||||
|
try {
|
||||||
|
const mercury = Framework?.webex?.internal?.mercury;
|
||||||
|
if (!mercury?.on) {
|
||||||
|
logger('mercuryGuard', 'Mercury plugin not available for event hooks', 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mercury.on('offline', (event) => {
|
||||||
|
logger('mercuryGuard', `Mercury offline (${event?.type || 'unknown'})`, 'warn');
|
||||||
|
scheduleFrameworkRestart(Framework, `mercury offline (${event?.type || 'unknown'})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
mercury.on('online', () => {
|
||||||
|
logger('mercuryGuard', 'Mercury online');
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger('mercuryGuard', `Could not attach mercury listeners: ${err.message}`, 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startHealthPoll(Framework) {
|
||||||
|
const intervalMs = Number(process.env.MERCURY_HEALTH_POLL_MS) || 5 * 60 * 1000;
|
||||||
|
setInterval(() => {
|
||||||
|
const state = getMercuryState(Framework);
|
||||||
|
if (!state) return;
|
||||||
|
// readyState: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED
|
||||||
|
if (!state.connected && !state.connecting && state.readyState !== 1 && state.readyState !== 0) {
|
||||||
|
logger(
|
||||||
|
'mercuryGuard',
|
||||||
|
`Mercury appears disconnected (connected=${state.connected}, readyState=${state.readyState})`,
|
||||||
|
'warn'
|
||||||
|
);
|
||||||
|
scheduleFrameworkRestart(Framework, `health poll (readyState=${state.readyState})`);
|
||||||
|
}
|
||||||
|
}, intervalMs).unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install process-level guards and mercury lifecycle hooks.
|
||||||
|
* Safe to call once at boot; subsequent calls are ignored.
|
||||||
|
*
|
||||||
|
* @param {object} Framework - webex-node-bot-framework instance
|
||||||
|
*/
|
||||||
|
export function installMercuryGuard(Framework) {
|
||||||
|
if (guardInstalled) return;
|
||||||
|
guardInstalled = true;
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
if (!isMercuryInvalidStateError(reason)) return;
|
||||||
|
const state = getMercuryState(Framework);
|
||||||
|
logger(
|
||||||
|
'mercuryGuard',
|
||||||
|
`Intercepted unhandled Mercury INVALID_STATE_ERROR (readyState=${state?.readyState ?? '?'}) — process kept alive`,
|
||||||
|
'warn'
|
||||||
|
);
|
||||||
|
scheduleFrameworkRestart(Framework, 'INVALID_STATE_ERROR (unhandledRejection)');
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('uncaughtException', (err) => {
|
||||||
|
if (isMercuryInvalidStateError(err)) {
|
||||||
|
const state = getMercuryState(Framework);
|
||||||
|
logger(
|
||||||
|
'mercuryGuard',
|
||||||
|
`Intercepted uncaught Mercury INVALID_STATE_ERROR (readyState=${state?.readyState ?? '?'}) — process kept alive`,
|
||||||
|
'warn'
|
||||||
|
);
|
||||||
|
scheduleFrameworkRestart(Framework, 'INVALID_STATE_ERROR (uncaughtException)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger('process', `Fatal uncaught exception: ${err.message}`, 'error');
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
Framework.on('initialized', () => {
|
||||||
|
attachMercuryListeners(Framework);
|
||||||
|
startHealthPoll(Framework);
|
||||||
|
logger('mercuryGuard', 'Mercury crash guard installed');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optional dev/test hook: set SIMULATE_MERCURY_CRASH=1 to verify the guard
|
||||||
|
// without waiting for a real websocket blip.
|
||||||
|
if (process.env.SIMULATE_MERCURY_CRASH === '1') {
|
||||||
|
setTimeout(() => {
|
||||||
|
const fakeErr = new Error('INVALID_STATE_ERROR');
|
||||||
|
fakeErr.stack =
|
||||||
|
'Error: INVALID_STATE_ERROR\n' +
|
||||||
|
' at /app/node_modules/@webex/internal-plugin-mercury/dist/socket/socket-base.js:321:25\n' +
|
||||||
|
' at Socket.send (/app/node_modules/@webex/internal-plugin-mercury/dist/socket/socket-base.js:319:14)\n' +
|
||||||
|
' at Socket._acknowledge (/app/node_modules/@webex/internal-plugin-mercury/dist/socket/socket-base.js:346:19)';
|
||||||
|
Promise.reject(fakeErr);
|
||||||
|
}, 5000).unref?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { installMercuryGuard, isMercuryInvalidStateError, getMercuryState };
|
||||||
52
src/commands/avStatus.js
Normal file
52
src/commands/avStatus.js
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
// src/commands/avStatus.js
|
||||||
|
import botClient from '../integrations/webex/botClient.js';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
import {
|
||||||
|
assertCollabSupportConfigured,
|
||||||
|
fetchCollabMarkdown,
|
||||||
|
} from '../integrations/collabSupport/client.js';
|
||||||
|
|
||||||
|
export async function handleAvStatus(bot, trigger) {
|
||||||
|
logger('av:status', 'HANDLER ENTERED');
|
||||||
|
|
||||||
|
const args = trigger.args || [];
|
||||||
|
const mode = args[1]?.toLowerCase() === 'detailed' ? 'detailed' : 'summary';
|
||||||
|
|
||||||
|
let storeNum = (args[0] || '').trim();
|
||||||
|
if (!storeNum && trigger.message?.roomType === 'group') {
|
||||||
|
storeNum = await botClient.getStoreByRoom(trigger.message.roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!storeNum || !/^\d{2,4}$/.test(storeNum)) {
|
||||||
|
await bot.say('Please provide a 2–4 digit store number after `/avStatus`\n(e.g. `/avStatus 2477 [detailed]`).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
assertCollabSupportConfigured();
|
||||||
|
} catch {
|
||||||
|
logger('av:status', 'CS_API_BASE not configured', 'warn');
|
||||||
|
await bot.say('The AV status service is not configured on this bot (missing `CS_API_BASE`).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = { storeNum };
|
||||||
|
if (mode === 'detailed') params.mode = 'detailed';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetchCollabMarkdown('/avStatus', params, { logTag: 'av:status' });
|
||||||
|
|
||||||
|
if (result.status === 400 || result.isUsageResponse) {
|
||||||
|
await bot.say('Invalid store number format. Please use 2-4 digits.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new Error(`HTTP ${result.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await bot.say({ markdown: result.markdown });
|
||||||
|
} catch (err) {
|
||||||
|
logger('av:status', `Failed for ${storeNum}: ${err.message}`, 'error');
|
||||||
|
await bot.say(`Sorry, I couldn't fetch device status for store **${storeNum}** right now. (${err.message})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/commands/help.js
Normal file
24
src/commands/help.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
// src/commands/help.js
|
||||||
|
export async function handleHelp(bot, trigger) {
|
||||||
|
const isGroup = trigger.message.roomType === 'group';
|
||||||
|
|
||||||
|
let text = `### ServiceChannel Bot Help\n\n`;
|
||||||
|
|
||||||
|
if (isGroup) {
|
||||||
|
text += `- **/woSummary** — status of the workorder linked to this space\n`;
|
||||||
|
text += `- **/woHistory** - History of AV issues.\n`;
|
||||||
|
text += `- **/woAttachments** — download attachments for the workorder in this space\n`;
|
||||||
|
text += `- **/avStatus** — AV device status for the store linked to this space\n`;
|
||||||
|
text += `- **/woApprove** — (re)post proposal approval card for current WO (when WAITING FOR APPROVAL)\n`;
|
||||||
|
} else {
|
||||||
|
text += `- **/woSummary <WO-number>** — status of any work order\n`;
|
||||||
|
text += `- **/woHistory <store-number>** — History of AV issues.\n`;
|
||||||
|
text += `- **/woAttachments <WO-number>** — download attachments for any work order\n`;
|
||||||
|
text += `- **/avStatus <store-number>** — AV device status for any store\n`;
|
||||||
|
text += `- **/woApprove <WO-number>** — post proposal approval card (manual)\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
text += `\nType **/help** again in a 1:1 or group space for context-specific commands.`;
|
||||||
|
|
||||||
|
await bot.say('markdown', text);
|
||||||
|
}
|
||||||
31
src/commands/index.js
Normal file
31
src/commands/index.js
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
// src/commands/index.js – FIXED: no hard-coded Framework
|
||||||
|
|
||||||
|
import { handleAvStatus } from './avStatus.js';
|
||||||
|
import { handleWoStatus } from './woStatus.js';
|
||||||
|
import { handleAttachments } from './attachments.js';
|
||||||
|
import { handleHelp } from './help.js';
|
||||||
|
import { handleUnknown } from './unknownCommand.js';
|
||||||
|
|
||||||
|
const PRIORITY = {
|
||||||
|
DEVICE_STATUS: 3,
|
||||||
|
WO_STATUS: 2,
|
||||||
|
ATTACHMENTS: 3,
|
||||||
|
HELP: 4,
|
||||||
|
UNKNOWN: 99999,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function registerAllCommands(framework) {
|
||||||
|
// Less strict start anchor + allow optional space after command
|
||||||
|
framework.hears(/woStatus/i, handleWoStatus, "**/woStatus** [WO#] - Work order status", 2);
|
||||||
|
|
||||||
|
//framework.hears(/^\s*\/attachments\b/i, handleAttachments, "**/attachments** [WO#] - Attachments", 3);
|
||||||
|
|
||||||
|
//framework.hears(/^\s*\/help\b/i, handleHelp, "**/help** - Show commands", 4);
|
||||||
|
|
||||||
|
// Temporarily disable deviceStatus until we confirm others work
|
||||||
|
//framework.hears(/^\s*\/deviceStatus\b/i, handleDeviceStatus, "**/deviceStatus** - Device status", 3);
|
||||||
|
|
||||||
|
framework.hears(/.*/gim, handleUnknown, null, 99999);
|
||||||
|
|
||||||
|
console.log("Commands registered with relaxed start regex.");
|
||||||
|
}
|
||||||
8
src/commands/unknownCommand.js
Normal file
8
src/commands/unknownCommand.js
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
// In src/commands/unknownCommand.js
|
||||||
|
import { handleHelp } from "./help.js";
|
||||||
|
export async function handleUnknown(bot, trigger) {
|
||||||
|
console.log('[COMMAND] handleUnknown triggered for text:', trigger.message.text);
|
||||||
|
await bot.say({ markdown: `Sorry, I don't understand **${trigger.message.text}**.`});
|
||||||
|
handleHelp(bot, trigger)
|
||||||
|
|
||||||
|
}
|
||||||
50
src/commands/woApprove.js
Normal file
50
src/commands/woApprove.js
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
// src/commands/woApprove.js
|
||||||
|
// Manual trigger for posting the proposal approval package (markdown + card) in the current WO room.
|
||||||
|
// Useful for testing, re-sending, or if webhook was missed.
|
||||||
|
|
||||||
|
import botClient from '../integrations/webex/botClient.js';
|
||||||
|
import approvalService from '../services/approvalService.js';
|
||||||
|
import db from '../db/mappings.js';
|
||||||
|
import { getWorkOrderForNte } from '../integrations/serviceChannel/client.js';
|
||||||
|
|
||||||
|
export async function handleWoApprove(bot, trigger) {
|
||||||
|
console.log('[WO-APPROVE] HANDLER ENTERED');
|
||||||
|
|
||||||
|
let woId = null;
|
||||||
|
|
||||||
|
if (trigger.message && trigger.message.roomType === 'group') {
|
||||||
|
woId = await botClient.getWOByRoom(trigger.message.roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!woId && trigger.args && trigger.args[0]) {
|
||||||
|
woId = String(trigger.args[0]).replace(/\D/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!woId) {
|
||||||
|
await bot.say('Could not determine Work Order from this space. Use `/woApprove 123456` or ensure you are in a ServChan WO room.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomId = trigger.message ? trigger.message.roomId : null;
|
||||||
|
if (!roomId) {
|
||||||
|
await bot.say('No room context for card post.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await bot.say(`Fetching proposals and building approval package for WO-${woId}...`);
|
||||||
|
|
||||||
|
const woDetails = await getWorkOrderForNte(woId);
|
||||||
|
if (!woDetails) {
|
||||||
|
await bot.say(`Could not load WO-${woId} from ServiceChannel.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await approvalService.postApprovalPackage(botClient, roomId, woDetails, null, { skipDedup: true, db });
|
||||||
|
|
||||||
|
await bot.say('Approval package posted (itemized summary + card). Use the card to approve.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[WO-APPROVE] Error:', err);
|
||||||
|
await bot.say(`Error preparing approval package for WO-${woId}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/commands/woAttachments.js
Normal file
76
src/commands/woAttachments.js
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
// src/commands/woAttachments.js
|
||||||
|
import botClient from '../integrations/webex/botClient.js';
|
||||||
|
import webexService from '../services/webexService.js';
|
||||||
|
import db from '../db/mappings.js';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
import { postWorkOrderAttachments } from '../services/attachmentService.js';
|
||||||
|
|
||||||
|
function lookupWorkOrderIdByRoom(roomId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get(
|
||||||
|
'SELECT workOrderId FROM mappings WHERE roomId = ?',
|
||||||
|
[roomId],
|
||||||
|
(err, row) => (err ? reject(err) : resolve(row?.workOrderId ?? null))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleWoAttachments(bot, trigger) {
|
||||||
|
logger('wo:attachments', 'HANDLER ENTERED');
|
||||||
|
|
||||||
|
const args = trigger.args || [];
|
||||||
|
const roomId = trigger.message?.roomId;
|
||||||
|
|
||||||
|
let woNumber = (args[0] || '').trim();
|
||||||
|
if (!woNumber && trigger.message?.roomType === 'group') {
|
||||||
|
woNumber = await botClient.getWOByRoom(roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!woNumber) {
|
||||||
|
await bot.say('Please provide a valid work order number (e.g. `/woAttachments 356369551`).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!roomId) {
|
||||||
|
await bot.say('Could not determine the current Webex room.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let workOrderId = woNumber;
|
||||||
|
try {
|
||||||
|
const mappedId = await lookupWorkOrderIdByRoom(roomId);
|
||||||
|
if (mappedId) workOrderId = mappedId;
|
||||||
|
} catch (err) {
|
||||||
|
logger('wo:attachments', `Room mapping lookup failed: ${err.message}`, 'warn');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await bot.say({ markdown: `🔍 Looking up attachments for work order **${woNumber}**...` });
|
||||||
|
|
||||||
|
const { posted, skipped, links = 0 } = await postWorkOrderAttachments({
|
||||||
|
db,
|
||||||
|
webex: webexService,
|
||||||
|
roomId,
|
||||||
|
workOrderId,
|
||||||
|
woNumber,
|
||||||
|
skipDedup: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (posted === 0 && skipped === 0) {
|
||||||
|
await bot.say({ markdown: `No attachments found for WO **${woNumber}**.` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileCount = posted - links;
|
||||||
|
let summary = `✅ Finished for WO **${woNumber}**: **${fileCount}** file(s) uploaded`;
|
||||||
|
if (links > 0) {
|
||||||
|
summary += `, **${links}** link-only (HEIC conversion unavailable — rebuild container with \`docker compose down -v && docker compose up --build\`)`;
|
||||||
|
}
|
||||||
|
if (skipped > 0) summary += `, **${skipped}** skipped`;
|
||||||
|
summary += '.';
|
||||||
|
await bot.say({ markdown: summary });
|
||||||
|
} catch (err) {
|
||||||
|
logger('wo:attachments', `Error for WO ${woNumber}: ${err.message}`, 'error');
|
||||||
|
await bot.say(`❌ Error fetching attachments for WO **${woNumber}**: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/commands/woHistory.js
Normal file
52
src/commands/woHistory.js
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
// src/commands/woHistory.js
|
||||||
|
import botClient from '../integrations/webex/botClient.js';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
import {
|
||||||
|
assertCollabSupportConfigured,
|
||||||
|
fetchCollabMarkdown,
|
||||||
|
} from '../integrations/collabSupport/client.js';
|
||||||
|
|
||||||
|
export async function handleWoHistory(bot, trigger) {
|
||||||
|
logger('wo:history', 'HANDLER ENTERED');
|
||||||
|
|
||||||
|
const args = trigger.args || [];
|
||||||
|
const mode = args[1]?.toLowerCase() === 'detailed' ? 'detailed' : 'summary';
|
||||||
|
|
||||||
|
let storeNum = (args[0] || '').trim();
|
||||||
|
if (!storeNum && trigger.message?.roomType === 'group') {
|
||||||
|
storeNum = await botClient.getStoreByRoom(trigger.message.roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!storeNum || !/^\d{2,4}$/.test(storeNum)) {
|
||||||
|
await bot.say('Please provide a 2–4 digit store number after `/woHistory`\n(e.g. `/woHistory 2477 [detailed]`).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
assertCollabSupportConfigured();
|
||||||
|
} catch {
|
||||||
|
logger('wo:history', 'CS_API_BASE not configured', 'warn');
|
||||||
|
await bot.say('The work-order history service is not configured on this bot (missing `CS_API_BASE`).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = { storeNum };
|
||||||
|
if (mode === 'detailed') params.mode = 'detailed';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetchCollabMarkdown('/wohistory', params, { logTag: 'wo:history' });
|
||||||
|
|
||||||
|
if (result.status === 400 || result.isUsageResponse) {
|
||||||
|
await bot.say('Invalid store number format. Please use 2-4 digits.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new Error(`HTTP ${result.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await bot.say({ markdown: result.markdown });
|
||||||
|
} catch (err) {
|
||||||
|
logger('wo:history', `Failed for ${storeNum}: ${err.message}`, 'error');
|
||||||
|
await bot.say(`Sorry, I couldn't fetch WO history for store **${storeNum}** right now. (${err.message})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/commands/woSummary.js
Normal file
48
src/commands/woSummary.js
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
// src/commands/woSummary.js
|
||||||
|
import botClient from '../integrations/webex/botClient.js';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
import {
|
||||||
|
assertCollabSupportConfigured,
|
||||||
|
fetchCollabMarkdown,
|
||||||
|
} from '../integrations/collabSupport/client.js';
|
||||||
|
|
||||||
|
export async function handleWoSummary(bot, trigger) {
|
||||||
|
logger('wo:summary', 'HANDLER ENTERED');
|
||||||
|
|
||||||
|
const args = trigger.args || [];
|
||||||
|
|
||||||
|
let woId = (args[0] || '').trim();
|
||||||
|
if (!woId && trigger.message?.roomType === 'group') {
|
||||||
|
woId = await botClient.getWOByRoom(trigger.message.roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!woId) {
|
||||||
|
await bot.say('Please provide a valid work order number (e.g. `/woSummary 356369551`).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
assertCollabSupportConfigured();
|
||||||
|
} catch {
|
||||||
|
logger('wo:summary', 'CS_API_BASE not configured', 'warn');
|
||||||
|
await bot.say('The work-order summary service is not configured on this bot (missing `CS_API_BASE`).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetchCollabMarkdown('/wosummary', { woId }, { logTag: 'wo:summary' });
|
||||||
|
|
||||||
|
if (result.status === 400 || result.isUsageResponse) {
|
||||||
|
await bot.say(`Invalid Work Order ID: **${woId}**.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new Error(`HTTP ${result.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await bot.say({ markdown: result.markdown });
|
||||||
|
} catch (err) {
|
||||||
|
logger('wo:summary', `Failed for ${woId}: ${err.message}`, 'error');
|
||||||
|
await bot.say(`Sorry, I couldn't fetch the summary for WO **${woId}** right now. (${err.message})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/config/index.js
Normal file
28
src/config/index.js
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
// src/config/index.js
|
||||||
|
//
|
||||||
|
// This module should ONLY load NON-SENSITIVE configuration.
|
||||||
|
// All secrets must come from environment variables (see src/config/secrets.js).
|
||||||
|
//
|
||||||
|
// Do NOT put real credentials in any file under /config.
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
// This file should contain only non-secret settings (server name, default port, etc.)
|
||||||
|
const configPath = path.join(__dirname, '../../config/config.json');
|
||||||
|
|
||||||
|
let configData;
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(configPath, 'utf8');
|
||||||
|
configData = JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to load config from ${configPath}:`, err.message);
|
||||||
|
// Non-secret config is optional — continue with empty object
|
||||||
|
configData = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default configData;
|
||||||
72
src/config/secrets.js
Normal file
72
src/config/secrets.js
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
/**
|
||||||
|
* src/config/secrets.js
|
||||||
|
*
|
||||||
|
* Centralized loader for all sensitive credentials.
|
||||||
|
*
|
||||||
|
* Philosophy:
|
||||||
|
* - All secrets come from environment variables only.
|
||||||
|
* - config.json must never contain real secrets.
|
||||||
|
* - Only WEBEX_BOT_TOKEN is strictly required at startup (core bot functionality).
|
||||||
|
* - Other services (ServiceChannel, xAI, etc.) are optional. Features that need
|
||||||
|
* them will fail with a clear error when actually used.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function requireEnv(name) {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`Missing required environment variable: ${name}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalEnv(name) {
|
||||||
|
return process.env[name] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSecrets() {
|
||||||
|
return {
|
||||||
|
// Core requirement — the bot cannot function without this
|
||||||
|
webex: {
|
||||||
|
token: requireEnv('WEBEX_BOT_TOKEN'),
|
||||||
|
baseUrl: optionalEnv('WEBEX_BASE_URL') || 'https://webexapis.com/v1',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ServiceChannel credentials — required only when ServiceChannel features are used
|
||||||
|
serviceChannel: {
|
||||||
|
clientId: optionalEnv('SC_CLIENT_ID'),
|
||||||
|
clientSecret: optionalEnv('SC_CLIENT_SECRET'),
|
||||||
|
username: optionalEnv('SC_USERNAME'),
|
||||||
|
password: optionalEnv('SC_PASSWORD'),
|
||||||
|
baseUrl: optionalEnv('SC_BASE_URL') || 'https://api.servicechannel.com/v3',
|
||||||
|
oauthUrl: optionalEnv('SC_OAUTH_URL') || 'https://login.servicechannel.com/oauth/token',
|
||||||
|
},
|
||||||
|
|
||||||
|
// xAI — required only when summarization features are used
|
||||||
|
xai: {
|
||||||
|
token: optionalEnv('XAI_TOKEN'),
|
||||||
|
model: optionalEnv('XAI_MODEL') || 'grok-4-1-fast-reasoning',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Secondary integrations — all optional
|
||||||
|
red: {
|
||||||
|
clientId: optionalEnv('RED_CLIENT_ID'),
|
||||||
|
apiKey: optionalEnv('RED_API_KEY'),
|
||||||
|
companyIDs: optionalEnv('RED_COMPANY_IDS') ? optionalEnv('RED_COMPANY_IDS').split(',') : [],
|
||||||
|
},
|
||||||
|
|
||||||
|
meraki: {
|
||||||
|
apiKey: optionalEnv('MERAKI_API_KEY'),
|
||||||
|
orgId: optionalEnv('MERAKI_ORG_ID'),
|
||||||
|
},
|
||||||
|
|
||||||
|
atlas: {
|
||||||
|
authKey: optionalEnv('ATLAS_AUTH_KEY'),
|
||||||
|
},
|
||||||
|
|
||||||
|
optisign: {
|
||||||
|
apiKey: optionalEnv('OPTISIGN_API_KEY'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default loadSecrets;
|
||||||
34
src/db/mappings.js
Normal file
34
src/db/mappings.js
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
// src/db/mappings.js
|
||||||
|
//
|
||||||
|
// Fallback SQLite handle used when a caller doesn't inject its own connection.
|
||||||
|
//
|
||||||
|
// SAFETY NOTE (Production Constraint - 2026-05-28):
|
||||||
|
// The live production database file must never be moved or renamed. The
|
||||||
|
// canonical path is resolved by `getDbPath()` in ./path.js; both this module
|
||||||
|
// and the main entrypoint call that helper so they always agree.
|
||||||
|
//
|
||||||
|
// The primary bot process (index.js) opens the DB once and injects it into
|
||||||
|
// every service that needs it. This module exists so utilities that can be
|
||||||
|
// invoked outside the main process (or in isolation, e.g. via a script) still
|
||||||
|
// have a working handle.
|
||||||
|
//
|
||||||
|
// The mappings table itself is created by the main entrypoint at startup —
|
||||||
|
// we intentionally do NOT run CREATE TABLE here anymore, to avoid schema
|
||||||
|
// drift (an earlier version of this file declared a `storeNumber` column that
|
||||||
|
// the production DB never had, which caused inserts to fail silently).
|
||||||
|
|
||||||
|
import sqlite3 from 'sqlite3';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
import { getDbPath } from './path.js';
|
||||||
|
|
||||||
|
const resolvedDbPath = getDbPath();
|
||||||
|
|
||||||
|
const db = new sqlite3.Database(resolvedDbPath, (err) => {
|
||||||
|
if (err) {
|
||||||
|
logger('db:mappings', `Failed to open SQLite DB at ${resolvedDbPath}: ${err.message}`, 'error');
|
||||||
|
} else {
|
||||||
|
logger('db:mappings', `SQLite DB connected/opened at ${resolvedDbPath}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default db;
|
||||||
47
src/db/path.js
Normal file
47
src/db/path.js
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
/**
|
||||||
|
* src/db/path.js
|
||||||
|
*
|
||||||
|
* Centralized, safe resolution of the SQLite database file path.
|
||||||
|
*
|
||||||
|
* CRITICAL PRODUCTION CONSTRAINT (as of 2026-05-28 refactor):
|
||||||
|
* The database file used by the live production ServChan bot must never be
|
||||||
|
* moved, renamed, or have a new file created in a different location.
|
||||||
|
*
|
||||||
|
* This module exists to make every part of the codebase use the exact same
|
||||||
|
* logic when resolving the DB path:
|
||||||
|
* 1. Respect process.env.DB_PATH if set (this is how production configures it)
|
||||||
|
* 2. Fall back to the same default the original production index.js used
|
||||||
|
*
|
||||||
|
* DO NOT change the fallback path in this file without very careful coordination.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
// SINGLE SOURCE OF TRUTH for the DB fallback path. Do not change without
|
||||||
|
// migrating any environment that relies on the fallback. Every module that
|
||||||
|
// resolves a DB path should call getDbPath() rather than hard-coding this.
|
||||||
|
export const DEFAULT_DB_PATH = './data/webex_sc_mappings.db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the SQLite database file path that should be used.
|
||||||
|
*
|
||||||
|
* Order of precedence:
|
||||||
|
* 1. process.env.DB_PATH (how production configures it)
|
||||||
|
* 2. DEFAULT_DB_PATH (matches what .env.example documents)
|
||||||
|
*/
|
||||||
|
export function getDbPath() {
|
||||||
|
if (process.env.DB_PATH) return process.env.DB_PATH;
|
||||||
|
return DEFAULT_DB_PATH;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the directory that should contain the database file.
|
||||||
|
*/
|
||||||
|
export function getDbDirectory() {
|
||||||
|
return path.dirname(getDbPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getDbPath,
|
||||||
|
getDbDirectory,
|
||||||
|
};
|
||||||
146
src/integrations/collabSupport/client.js
Normal file
146
src/integrations/collabSupport/client.js
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
/**
|
||||||
|
* src/integrations/collabSupport/client.js
|
||||||
|
*
|
||||||
|
* Shared HTTP client for CollabSupport (collabFinder) read endpoints.
|
||||||
|
* Used by /woSummary, /woHistory, /avStatus, and /woAttachments.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
|
/** Usage-markdown prefixes returned by collabFinder before HTTP 400 alignment. */
|
||||||
|
const USAGE_MARKERS = [
|
||||||
|
'**Work Order Summary Usage:**',
|
||||||
|
'**Work Order History Usage:**',
|
||||||
|
'Please provide a 2–4 digit store number.',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getCollabSupportBase() {
|
||||||
|
// CS_API_BASE_INTERNAL is for Docker: reach collabFinder on the compose
|
||||||
|
// network (http://collabfinder:1800) instead of the public URL, which often
|
||||||
|
// fails from inside a container (hairpin NAT / DNS to the host's public IP).
|
||||||
|
const raw = process.env.CS_API_BASE_INTERNAL || process.env.CS_API_BASE;
|
||||||
|
if (!raw) return null;
|
||||||
|
return raw.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrl(path, params = {}) {
|
||||||
|
const base = getCollabSupportBase();
|
||||||
|
if (!base) {
|
||||||
|
throw new Error('CS_API_BASE is not configured');
|
||||||
|
}
|
||||||
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
const url = new URL(`${base}${normalizedPath}`);
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
url.searchParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFetchError(err) {
|
||||||
|
if (err.name === 'AbortError') return 'Request timed out';
|
||||||
|
const cause = err.cause;
|
||||||
|
const causeMsg = cause && (cause.message || cause.code);
|
||||||
|
if (causeMsg && causeMsg !== err.message) {
|
||||||
|
return `${err.message} (${causeMsg})`;
|
||||||
|
}
|
||||||
|
return err.message || String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithTimeout(url, options = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
return await fetch(url, { ...options, signal: controller.signal });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeUsageMarkdown(text) {
|
||||||
|
if (!text || typeof text !== 'string') return false;
|
||||||
|
return USAGE_MARKERS.some((marker) => text.includes(marker));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET a markdown endpoint from CollabSupport.
|
||||||
|
* @returns {{ ok: boolean, status: number, markdown: string, elapsedMs: number, isUsageResponse?: boolean }}
|
||||||
|
*/
|
||||||
|
export async function fetchCollabMarkdown(path, params = {}, { logTag = 'collabSupport', timeoutMs } = {}) {
|
||||||
|
const url = buildUrl(path, params);
|
||||||
|
const started = Date.now();
|
||||||
|
logger(logTag, `GET ${url}`);
|
||||||
|
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetchWithTimeout(url, {}, timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
||||||
|
} catch (err) {
|
||||||
|
const elapsedMs = Date.now() - started;
|
||||||
|
const msg = formatFetchError(err);
|
||||||
|
logger(logTag, `GET ${url} → error (${elapsedMs}ms): ${msg}`, 'error');
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsedMs = Date.now() - started;
|
||||||
|
const markdown = await response.text();
|
||||||
|
logger(logTag, `GET ${url} → ${response.status} (${elapsedMs}ms)`);
|
||||||
|
|
||||||
|
const isUsageResponse = response.ok && looksLikeUsageMarkdown(markdown);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: response.ok,
|
||||||
|
status: response.status,
|
||||||
|
markdown,
|
||||||
|
elapsedMs,
|
||||||
|
isUsageResponse,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET a JSON endpoint from CollabSupport.
|
||||||
|
* @returns {{ ok: boolean, status: number, data: object, elapsedMs: number }}
|
||||||
|
*/
|
||||||
|
export async function fetchCollabJson(path, params = {}, { logTag = 'collabSupport', timeoutMs } = {}) {
|
||||||
|
const url = buildUrl(path, params);
|
||||||
|
const started = Date.now();
|
||||||
|
logger(logTag, `GET ${url}`);
|
||||||
|
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetchWithTimeout(url, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
}, timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
||||||
|
} catch (err) {
|
||||||
|
const elapsedMs = Date.now() - started;
|
||||||
|
const msg = formatFetchError(err);
|
||||||
|
logger(logTag, `GET ${url} → error (${elapsedMs}ms): ${msg}`, 'error');
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsedMs = Date.now() - started;
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await response.json();
|
||||||
|
} catch {
|
||||||
|
data = {};
|
||||||
|
}
|
||||||
|
logger(logTag, `GET ${url} → ${response.status} (${elapsedMs}ms)`);
|
||||||
|
|
||||||
|
return { ok: response.ok, status: response.status, data, elapsedMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertCollabSupportConfigured() {
|
||||||
|
if (!getCollabSupportBase()) {
|
||||||
|
throw new Error('CS_API_BASE is not configured');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getCollabSupportBase,
|
||||||
|
fetchCollabMarkdown,
|
||||||
|
fetchCollabJson,
|
||||||
|
assertCollabSupportConfigured,
|
||||||
|
};
|
||||||
132
src/integrations/serviceChannel/attachments.js
Normal file
132
src/integrations/serviceChannel/attachments.js
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
/**
|
||||||
|
* ServiceChannel work order attachment helpers (OData).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fetchWithRetry } from './client.js';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
|
||||||
|
const ATTACHMENT_SELECT =
|
||||||
|
'Id,Name,Uri,Description,TimeStamp,IsInvoiceDigitalCopy,NoteId';
|
||||||
|
|
||||||
|
export function getContentTypeFromFilename(filename) {
|
||||||
|
if (!filename) return 'application/octet-stream';
|
||||||
|
const lower = filename.toLowerCase();
|
||||||
|
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
|
||||||
|
if (lower.endsWith('.png')) return 'image/png';
|
||||||
|
if (lower.endsWith('.gif')) return 'image/gif';
|
||||||
|
if (lower.endsWith('.bmp')) return 'image/bmp';
|
||||||
|
if (lower.endsWith('.webp')) return 'image/webp';
|
||||||
|
if (lower.endsWith('.heic')) return 'image/heic';
|
||||||
|
if (lower.endsWith('.heif')) return 'image/heif';
|
||||||
|
if (lower.endsWith('.pdf')) return 'application/pdf';
|
||||||
|
if (lower.endsWith('.txt')) return 'text/plain';
|
||||||
|
if (lower.endsWith('.doc')) return 'application/msword';
|
||||||
|
if (lower.endsWith('.docx')) {
|
||||||
|
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||||
|
}
|
||||||
|
if (lower.endsWith('.xls')) return 'application/vnd.ms-excel';
|
||||||
|
if (lower.endsWith('.xlsx')) {
|
||||||
|
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||||
|
}
|
||||||
|
if (lower.endsWith('.ppt')) return 'application/vnd.ms-powerpoint';
|
||||||
|
if (lower.endsWith('.pptx')) {
|
||||||
|
return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
|
||||||
|
}
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List attachments for a work order.
|
||||||
|
* @returns {Promise<Array<object>>}
|
||||||
|
*/
|
||||||
|
export async function listWorkOrderAttachments(workOrderId) {
|
||||||
|
if (!workOrderId) return [];
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const res = await fetchWithRetry(
|
||||||
|
`/odata/workorders(${workOrderId})/attachments`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
$select: ATTACHMENT_SELECT,
|
||||||
|
$orderby: 'TimeStamp desc',
|
||||||
|
},
|
||||||
|
timeout: 30000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const attachments = res.data?.value || [];
|
||||||
|
logger(
|
||||||
|
'sc:listAttachments',
|
||||||
|
`Found ${attachments.length} for WO ${workOrderId} (${Date.now() - start}ms)`
|
||||||
|
);
|
||||||
|
return attachments;
|
||||||
|
} catch (err) {
|
||||||
|
logger('sc:listAttachments', `Failed for WO ${workOrderId}: ${err.message}`, 'warn');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single attachment by id.
|
||||||
|
* @returns {Promise<object|null>}
|
||||||
|
*/
|
||||||
|
export async function getWorkOrderAttachment(workOrderId, attachmentId) {
|
||||||
|
if (!workOrderId || attachmentId == null) return null;
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const res = await fetchWithRetry(
|
||||||
|
`/odata/workorders(${workOrderId})/attachments`,
|
||||||
|
{
|
||||||
|
params: { $filter: `Id eq ${attachmentId}` },
|
||||||
|
timeout: 30000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const att = res.data?.value?.[0] || null;
|
||||||
|
if (att) {
|
||||||
|
logger(
|
||||||
|
'sc:getAttachment',
|
||||||
|
`Fetched attachment ${attachmentId} for WO ${workOrderId} (${Date.now() - start}ms)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return att;
|
||||||
|
} catch (err) {
|
||||||
|
logger(
|
||||||
|
'sc:getAttachment',
|
||||||
|
`Failed ${attachmentId} for WO ${workOrderId}: ${err.message}`,
|
||||||
|
'warn'
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download attachment bytes from a storage URI.
|
||||||
|
* @returns {Promise<{ buffer: Buffer, contentType: string, fileName: string }>}
|
||||||
|
*/
|
||||||
|
export async function downloadAttachment(uri, fileName = 'attachment') {
|
||||||
|
if (!uri) throw new Error('Missing attachment URI');
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
const safeName = fileName || 'attachment';
|
||||||
|
|
||||||
|
const fileRes = await fetch(uri);
|
||||||
|
if (!fileRes.ok) {
|
||||||
|
throw new Error(`Download failed: HTTP ${fileRes.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerType = fileRes.headers.get('content-type')?.split(';')[0]?.trim();
|
||||||
|
const contentType = (headerType && headerType !== 'application/octet-stream')
|
||||||
|
? headerType
|
||||||
|
: getContentTypeFromFilename(safeName);
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await fileRes.arrayBuffer());
|
||||||
|
logger(
|
||||||
|
'sc:downloadAttachment',
|
||||||
|
`Downloaded ${safeName} (${buffer.length} bytes, ${Date.now() - start}ms)`
|
||||||
|
);
|
||||||
|
|
||||||
|
return { buffer, contentType, fileName: safeName };
|
||||||
|
}
|
||||||
686
src/integrations/serviceChannel/client.js
Normal file
686
src/integrations/serviceChannel/client.js
Normal file
|
|
@ -0,0 +1,686 @@
|
||||||
|
// src/integrations/serviceChannel/client.js
|
||||||
|
|
||||||
|
import axios from 'axios';
|
||||||
|
import { Mutex } from 'async-mutex';
|
||||||
|
import qs from 'querystring';
|
||||||
|
import { loadSecrets } from '../../config/secrets.js';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
|
||||||
|
const secrets = loadSecrets();
|
||||||
|
|
||||||
|
const mutex = new Mutex();
|
||||||
|
|
||||||
|
let cachedToken = null;
|
||||||
|
let tokenExpiresAt = 0;
|
||||||
|
|
||||||
|
// Short-lived cache of the LAST token fetch failure. When SC credentials are
|
||||||
|
// invalid/expired, a single approval webhook can otherwise trigger 6+ token
|
||||||
|
// fetches (one per SC call in the flow), each hitting the OAuth endpoint and
|
||||||
|
// spamming the log. Cache the failure for a short window and fast-fail without
|
||||||
|
// hitting SC again.
|
||||||
|
let lastTokenError = null;
|
||||||
|
let lastTokenErrorAt = 0;
|
||||||
|
const TOKEN_FAIL_CACHE_MS = 30 * 1000; // 30 s
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Dedicated axios instance for ServiceChannel
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
export const scAxios = axios.create({
|
||||||
|
baseURL: secrets.serviceChannel.baseUrl,
|
||||||
|
timeout: 60000,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Automatic token injection + refresh on 401
|
||||||
|
scAxios.interceptors.request.use(async (cfg) => {
|
||||||
|
if (!cfg.headers.Authorization) {
|
||||||
|
const token = await getServiceChannelToken();
|
||||||
|
cfg.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return cfg;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort short summary of an axios error's response body for log lines.
|
||||||
|
* SC 4xx bodies are usually JSON like { "Message": "...", "ModelState": {...} }
|
||||||
|
* or plain text. We cap the length so a stack trace doesn't blow up the log.
|
||||||
|
*/
|
||||||
|
function summarizeAxiosErrorBody(error) {
|
||||||
|
const data = error?.response?.data;
|
||||||
|
if (data == null) return null;
|
||||||
|
let s;
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
s = data;
|
||||||
|
} else if (Buffer.isBuffer(data)) {
|
||||||
|
s = data.toString('utf8');
|
||||||
|
} else {
|
||||||
|
try { s = JSON.stringify(data); } catch { s = String(data); }
|
||||||
|
}
|
||||||
|
s = s.replace(/\s+/g, ' ').trim();
|
||||||
|
return s.length > 500 ? s.substring(0, 500) + `…(${s.length} chars)` : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
scAxios.interceptors.response.use(
|
||||||
|
response => response,
|
||||||
|
async error => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
logger('scAxios', '401 detected → forcing token refresh');
|
||||||
|
await getServiceChannelToken(true); // force refresh
|
||||||
|
// Retry once with new token
|
||||||
|
const originalRequest = error.config;
|
||||||
|
if (!originalRequest._retry) {
|
||||||
|
originalRequest._retry = true;
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${cachedToken}`;
|
||||||
|
return scAxios(originalRequest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For every non-401 4xx/5xx, enrich the error message with SC's response
|
||||||
|
// body preview + the HTTP method+path so downstream loggers get the *why*
|
||||||
|
// (SC returns detailed ModelState / Message JSON on 400s) instead of the
|
||||||
|
// generic axios "Request failed with status code 400".
|
||||||
|
const status = error?.response?.status;
|
||||||
|
if (status && status !== 401) {
|
||||||
|
const method = String(error?.config?.method || '').toUpperCase();
|
||||||
|
const url = error?.config?.url || '';
|
||||||
|
const body = summarizeAxiosErrorBody(error);
|
||||||
|
const suffix = ` [${method} ${url} → ${status}${body ? ` body=${body}` : ''}]`;
|
||||||
|
// Keep the original axios message but append ours so nothing breaks
|
||||||
|
// that greps for the classic "Request failed with status code XXX".
|
||||||
|
if (typeof error.message === 'string' && !error.message.includes(suffix)) {
|
||||||
|
error.message = error.message + suffix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Token management – cached + mutex-protected
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
export async function getServiceChannelToken(forceRefresh = false) {
|
||||||
|
if (!secrets.serviceChannel.clientId || !secrets.serviceChannel.clientSecret) {
|
||||||
|
throw new Error(
|
||||||
|
'ServiceChannel credentials are not configured. ' +
|
||||||
|
'Set SC_CLIENT_ID, SC_CLIENT_SECRET, SC_USERNAME, and SC_PASSWORD environment variables.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const release = await mutex.acquire();
|
||||||
|
|
||||||
|
// Fast-fail if the last fetch just failed. Prevents a cascade of retries
|
||||||
|
// (and OAuth calls) when credentials are broken. Handled OUTSIDE the
|
||||||
|
// try/catch so we don't double-log or double-wrap the error message.
|
||||||
|
{
|
||||||
|
const now = Date.now();
|
||||||
|
if (!forceRefresh && lastTokenError && (now - lastTokenErrorAt) < TOKEN_FAIL_CACHE_MS) {
|
||||||
|
release();
|
||||||
|
throw new Error(lastTokenError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (!forceRefresh && cachedToken && now < tokenExpiresAt) {
|
||||||
|
return cachedToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger('getServiceChannelToken', 'Fetching new token');
|
||||||
|
|
||||||
|
const basicAuth = Buffer.from(
|
||||||
|
`${secrets.serviceChannel.clientId}:${secrets.serviceChannel.clientSecret}`
|
||||||
|
).toString('base64');
|
||||||
|
|
||||||
|
const response = await axios.post(
|
||||||
|
secrets.serviceChannel.oauthUrl,
|
||||||
|
qs.stringify({
|
||||||
|
grant_type: 'password',
|
||||||
|
username: secrets.serviceChannel.username,
|
||||||
|
password: secrets.serviceChannel.password,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${basicAuth}`,
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
timeout: 10000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const { access_token, expires_in } = response.data;
|
||||||
|
|
||||||
|
cachedToken = access_token;
|
||||||
|
tokenExpiresAt = now + (expires_in * 1000) - 300_000; // refresh 5 min early
|
||||||
|
lastTokenError = null;
|
||||||
|
lastTokenErrorAt = 0;
|
||||||
|
|
||||||
|
logger('getServiceChannelToken', `New token acquired (expires in ~${expires_in / 60} min)`);
|
||||||
|
return access_token;
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err.response
|
||||||
|
? `${err.response.status} – ${JSON.stringify(err.response.data)}`
|
||||||
|
: err.message;
|
||||||
|
|
||||||
|
// Cache the failure so subsequent calls in the burst fail fast (no more
|
||||||
|
// OAuth hits) instead of each retrying and spamming the log.
|
||||||
|
const wrapped = `ServiceChannel token fetch failed: ${msg}`;
|
||||||
|
lastTokenError = wrapped;
|
||||||
|
lastTokenErrorAt = Date.now();
|
||||||
|
|
||||||
|
logger('getServiceChannelToken', `Failed: ${msg}`);
|
||||||
|
|
||||||
|
// ServiceChannel returns HTTP 400 with the literal string
|
||||||
|
// "Object reference not set to an instance of an object." when its OAuth
|
||||||
|
// endpoint receives credentials that don't map to a user (rotated password,
|
||||||
|
// disabled client, etc.). Surface a clear hint the first time this happens
|
||||||
|
// so it's obvious in the log what needs fixing (rotate SC_PASSWORD /
|
||||||
|
// SC_CLIENT_SECRET in .env).
|
||||||
|
if (
|
||||||
|
err.response?.status === 400 &&
|
||||||
|
typeof err.response.data === 'string' &&
|
||||||
|
err.response.data.toLowerCase().includes('object reference not set')
|
||||||
|
) {
|
||||||
|
logger(
|
||||||
|
'getServiceChannelToken',
|
||||||
|
'HINT: 400 "Object reference not set..." from SC OAuth almost always ' +
|
||||||
|
'means SC_CLIENT_ID/SC_CLIENT_SECRET/SC_USERNAME/SC_PASSWORD are stale. ' +
|
||||||
|
'Rotate credentials in ServiceChannel and update .env, then restart.',
|
||||||
|
'warn'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(wrapped);
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Simple health-check / token validation
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
export async function validateToken() {
|
||||||
|
try {
|
||||||
|
await scAxios.get('/workorders?$top=1');
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
logger('validateToken', `Token validation failed: ${err.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/integrations/serviceChannel/client.js
|
||||||
|
// ... your existing code ...
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get AV tickets that have been in "COMPLETED" + "CONFIRMED" status for at least X days
|
||||||
|
* Used for automatic Webex space cleanup
|
||||||
|
*/
|
||||||
|
export async function getTicketsReadyForSpaceCleanup(daysAfterCompletion = 7) {
|
||||||
|
console.log(`[SC-CLIENT] Checking for tickets ready for cleanup (${daysAfterCompletion} days after completion)`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cutoffDate = new Date();
|
||||||
|
cutoffDate.setDate(cutoffDate.getDate() - daysAfterCompletion);
|
||||||
|
const cutoffStr = cutoffDate.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||||
|
|
||||||
|
const response = await scAxios.get('/workorders', {
|
||||||
|
params: {
|
||||||
|
'trade': 'Audio', // Change to 'Audio & Video' if needed
|
||||||
|
'status': 'Completed' // Primary status
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const tickets = response.data?.value || response.data || [];
|
||||||
|
console.log(`Completed WorkOrders: ${tickets.length}`)
|
||||||
|
// Strict filter: only Completed + Confirmed (or Completed + Completed)
|
||||||
|
const readyTickets = tickets.filter(ticket => {
|
||||||
|
const primary = ticket.Status?.Primary?.toUpperCase();
|
||||||
|
const extended = ticket.Status?.Extended?.toUpperCase();
|
||||||
|
return primary === 'COMPLETED' &&
|
||||||
|
(extended === 'CONFIRMED' || extended === 'COMPLETED');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[SC-CLIENT] Found ${readyTickets.length} tickets ready for space cleanup`);
|
||||||
|
|
||||||
|
return readyTickets.map(ticket => ({
|
||||||
|
workOrderId: ticket.Id,
|
||||||
|
woNumber: ticket.WorkorderNumber || ticket.Id,
|
||||||
|
primaryStatus: ticket.Status?.Primary,
|
||||||
|
extendedStatus: ticket.Status?.Extended,
|
||||||
|
updatedDate: ticket.UpdatedDate,
|
||||||
|
description: ticket.Description?.substring(0, 100) || ''
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[SC-CLIENT] Error fetching tickets for cleanup:', err.message);
|
||||||
|
if (err.response) {
|
||||||
|
console.log('[SC-CLIENT] Response status:', err.response.status);
|
||||||
|
console.log('[SC-CLIENT] Response body:', JSON.stringify(err.response.data, null, 2));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current status of a specific work order
|
||||||
|
*/
|
||||||
|
export async function getWorkOrderStatus(woId) {
|
||||||
|
try {
|
||||||
|
const response = await scAxios.get(`/workorders/${woId}`, {
|
||||||
|
params: { $select: 'Id,WorkorderNumber,Status,UpdatedDate,Description' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const ticket = response.data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
workOrderId: ticket.Id,
|
||||||
|
woNumber: ticket.WorkorderNumber || ticket.Id,
|
||||||
|
primaryStatus: ticket.Status?.Primary,
|
||||||
|
extendedStatus: ticket.Status?.Extended,
|
||||||
|
updatedDate: ticket.UpdatedDate,
|
||||||
|
description: ticket.Description?.substring(0, 100) || ''
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[SC-CLIENT] Error getting status for WO ${woId}:`, err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export default scAxios;
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Resilient fetch helper (for bulk operations)
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET with retry for rate limits (429) and transient server errors (5xx).
|
||||||
|
*
|
||||||
|
* - Uses the shared scAxios instance (automatic token + 401 refresh).
|
||||||
|
* - Respects `Retry-After` header when the server provides one.
|
||||||
|
* - Exponential backoff + jitter to avoid thundering herd.
|
||||||
|
* - Safe default: 5 retries, starting ~750ms base delay.
|
||||||
|
*
|
||||||
|
* Use this (instead of raw scAxios.get) for any high-volume or bulk calls.
|
||||||
|
*/
|
||||||
|
export async function fetchWithRetry(
|
||||||
|
url,
|
||||||
|
config = {},
|
||||||
|
{ maxRetries = 5, baseDelayMs = 750 } = {}
|
||||||
|
) {
|
||||||
|
let attempt = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return await scAxios.get(url, config);
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.response?.status;
|
||||||
|
const isRetryable = status === 429 || (status >= 500 && status < 600);
|
||||||
|
|
||||||
|
if (isRetryable && attempt < maxRetries) {
|
||||||
|
const retryAfterHeader = err.response?.headers?.['retry-after'];
|
||||||
|
let delayMs = baseDelayMs * Math.pow(2, attempt);
|
||||||
|
|
||||||
|
if (retryAfterHeader) {
|
||||||
|
const parsed = parseInt(retryAfterHeader, 10);
|
||||||
|
if (!Number.isNaN(parsed) && parsed > 0) {
|
||||||
|
delayMs = parsed * 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jitter: 75%–125% of calculated delay (prevents synchronized retries)
|
||||||
|
delayMs *= 0.75 + Math.random() * 0.5;
|
||||||
|
delayMs = Math.min(Math.max(300, Math.round(delayMs)), 30000);
|
||||||
|
|
||||||
|
logger(
|
||||||
|
'sc:fetchRetry',
|
||||||
|
`${status} on ${url} (attempt ${attempt + 1}/${maxRetries}) → retry in ${delayMs}ms`
|
||||||
|
);
|
||||||
|
|
||||||
|
await sleep(delayMs);
|
||||||
|
attempt++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-retryable or exhausted retries — let caller handle
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { sleep };
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// New: Proposal & NTE approval support (for WAITING FOR APPROVAL webhooks + Adaptive Card flow)
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch proposals associated with a work order.
|
||||||
|
* Now uses the proper/recommended endpoint:
|
||||||
|
* GET /proposals/GetProposalsAssociatedWithWorkOrderAsync?trackingNumber={woId}
|
||||||
|
* (The old /workorders/{id}/proposals had reliability issues.)
|
||||||
|
*/
|
||||||
|
export async function getWorkOrderProposals(woId) {
|
||||||
|
return getProposalsAssociatedWithWorkOrder(woId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight fetch for NTE + basic status (no full notes).
|
||||||
|
*/
|
||||||
|
export async function getWorkOrderForNte(woId) {
|
||||||
|
if (!woId) return null;
|
||||||
|
try {
|
||||||
|
const res = await fetchWithRetry(`/workorders/${woId}`, {
|
||||||
|
params: { $select: 'Id,WorkorderNumber,Nte,Status,LocationName,LocationStoreId' },
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
} catch (err) {
|
||||||
|
logger('sc:getForNte', `Failed for WO ${woId}: ${err.message}`, 'warn');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the Nte (Not-To-Exceed) value on a work order.
|
||||||
|
* Uses PATCH on the root workorder resource.
|
||||||
|
*/
|
||||||
|
export async function updateWorkOrderNte(woId, nteValue) {
|
||||||
|
const nte = Number(nteValue);
|
||||||
|
if (!woId || !Number.isFinite(nte)) {
|
||||||
|
throw new Error('woId and numeric nteValue are required');
|
||||||
|
}
|
||||||
|
await scAxios.patch(`/workorders/${woId}`, { Nte: nte });
|
||||||
|
logger('sc:updateNte', `WO ${woId} Nte set to ${nte}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DEPRECATED. ServiceChannel v3 has no public POST /workorders/{id}/notes
|
||||||
|
* endpoint, so this call always returns 400. Notes must be attached to a
|
||||||
|
* status/approve action (via the `Note` / `Comments` / `ReasonString` fields
|
||||||
|
* on those requests). Left here only to preserve the export shape; will be
|
||||||
|
* removed once no external code references it.
|
||||||
|
*
|
||||||
|
* @deprecated pass audit text via `Comments`/`ReasonString` on approveProposal.
|
||||||
|
*/
|
||||||
|
export async function addApprovalNote(woId, text) {
|
||||||
|
logger(
|
||||||
|
'sc:addNote',
|
||||||
|
`addApprovalNote() is deprecated and does nothing (SC has no POST /workorders/${woId}/notes). ` +
|
||||||
|
`Attach note text via /approve or /status. Discarded text: "${String(text).substring(0, 80)}"`,
|
||||||
|
'warn'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a specific proposal by its ID/Number (parsed from work order notes like "Proposal #94434 has been created").
|
||||||
|
* Tries common SC v3 endpoints. Returns the proposal object which should include charges, Total/Amount,
|
||||||
|
* and itemized list (Items / LineItems / ProposalItems) with parts + labor breakout.
|
||||||
|
*/
|
||||||
|
export async function getProposal(proposalId) {
|
||||||
|
if (!proposalId) return null;
|
||||||
|
const pid = String(proposalId).trim();
|
||||||
|
|
||||||
|
// Common patterns for ServiceChannel proposal lookup
|
||||||
|
const urlAttempts = [
|
||||||
|
`/proposals/${pid}`,
|
||||||
|
`/proposals?proposalNumber=${pid}`,
|
||||||
|
`/proposals?Number=${pid}`,
|
||||||
|
`/proposals?Id=${pid}`,
|
||||||
|
// Fallback: search within workorders if we had woId, but here we use direct
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const url of urlAttempts) {
|
||||||
|
try {
|
||||||
|
const res = await fetchWithRetry(url, { timeout: 30000 });
|
||||||
|
let data = res.data;
|
||||||
|
if (data?.value && Array.isArray(data.value)) {
|
||||||
|
data = data.value[0] || null;
|
||||||
|
}
|
||||||
|
if (data && (data.Id || data.Number || data.ProposalNumber)) {
|
||||||
|
logger('sc:getProposal', `Successfully fetched proposal ${pid} via ${url}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// If the token itself is broken, all remaining URL attempts will fail
|
||||||
|
// the same way — abort the loop immediately instead of hammering.
|
||||||
|
if (err.message && err.message.startsWith('ServiceChannel token fetch failed')) {
|
||||||
|
logger('sc:getProposal', `Aborting proposal lookup for ${pid}: ${err.message}`, 'warn');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// try next
|
||||||
|
if (err.response && err.response.status !== 404) {
|
||||||
|
logger('sc:getProposal', `Attempt ${url} failed for ${pid}: ${err.message}`, 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger('sc:getProposal', `Could not locate proposal ${pid} after multiple attempts`, 'warn');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proper way to find proposals associated with a work order (per ServiceChannel API).
|
||||||
|
* Uses GET /proposals/GetProposalsAssociatedWithWorkOrderAsync?trackingNumber={woId}
|
||||||
|
* Returns array of lightweight proposal refs { ID, ProposalNumber, Status, ... }
|
||||||
|
*/
|
||||||
|
export async function getProposalsAssociatedWithWorkOrder(woId) {
|
||||||
|
if (!woId) return [];
|
||||||
|
try {
|
||||||
|
const res = await fetchWithRetry(
|
||||||
|
`/proposals/GetProposalsAssociatedWithWorkOrderAsync?trackingNumber=${woId}`,
|
||||||
|
{ timeout: 30000 }
|
||||||
|
);
|
||||||
|
return res.data || [];
|
||||||
|
} catch (err) {
|
||||||
|
logger('sc:getAssociatedProposals', `Failed for WO ${woId}: ${err.message}`, 'warn');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch full proposal details using the OData endpoint.
|
||||||
|
* GET /odata/proposals?$filter=Id eq {proposalId}
|
||||||
|
* This returns the rich object with AmountCategories (Materials, Installation Labor, etc.),
|
||||||
|
* Amount (total), Description, Status, Recommendation, etc.
|
||||||
|
*/
|
||||||
|
export async function getProposalByIdOdata(proposalId) {
|
||||||
|
if (!proposalId) return null;
|
||||||
|
try {
|
||||||
|
const res = await fetchWithRetry(
|
||||||
|
`/odata/proposals?$filter=Id eq ${proposalId}`,
|
||||||
|
{ timeout: 30000 }
|
||||||
|
);
|
||||||
|
const data = res.data;
|
||||||
|
if (data?.value && Array.isArray(data.value) && data.value.length > 0) {
|
||||||
|
logger('sc:getProposalOdata', `Fetched OData details for proposal ${proposalId}`);
|
||||||
|
return data.value[0];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
logger('sc:getProposalOdata', `Failed for ${proposalId}: ${err.message}`, 'warn');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approve a specific proposal using the official endpoint.
|
||||||
|
* PUT /proposals/{proposalId}/approve
|
||||||
|
* This properly approves the proposal (instead of just updating WO Nte + note).
|
||||||
|
* Body fields as per ServiceChannel API (Comments, ReasonString, AttachmentsToWO, etc.).
|
||||||
|
*/
|
||||||
|
export async function approveProposal(proposalId, body) {
|
||||||
|
if (!proposalId) {
|
||||||
|
throw new Error('proposalId is required to approve');
|
||||||
|
}
|
||||||
|
await scAxios.put(`/proposals/${proposalId}/approve`, body);
|
||||||
|
logger('sc:approveProposal', `Successfully approved proposal ${proposalId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch proposals that must be rejected before approving a revised proposal.
|
||||||
|
* GET /proposals/GetProposalsToReject?trackingNumber={woId}
|
||||||
|
*/
|
||||||
|
export async function getProposalsToReject(woId) {
|
||||||
|
if (!woId) return [];
|
||||||
|
try {
|
||||||
|
const res = await fetchWithRetry(
|
||||||
|
`/proposals/GetProposalsToReject?trackingNumber=${woId}`,
|
||||||
|
{ timeout: 30000 }
|
||||||
|
);
|
||||||
|
const data = res.data;
|
||||||
|
if (Array.isArray(data)) return data;
|
||||||
|
if (data?.value && Array.isArray(data.value)) return data.value;
|
||||||
|
return [];
|
||||||
|
} catch (err) {
|
||||||
|
// 502 "Proposals to Reject not found" is normal when no superseded proposals exist
|
||||||
|
if (err.response?.status === 400 || err.response?.status === 502) {
|
||||||
|
logger('sc:getProposalsToReject', `No proposals to reject for WO ${woId}`, 'info');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
logger('sc:getProposalsToReject', `Failed for WO ${woId}: ${err.message}`, 'warn');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _rejectionReasonsCache = null;
|
||||||
|
let _rejectionReasonsCachedAt = 0;
|
||||||
|
const REJECTION_REASONS_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||||
|
|
||||||
|
/** SC returns RejectionReasons as { "1": "desc", "2": "..." } — not an array. */
|
||||||
|
function _parseRejectionReasonsResponse(data) {
|
||||||
|
if (Array.isArray(data)) return data;
|
||||||
|
if (data?.value && Array.isArray(data.value)) return data.value;
|
||||||
|
if (data && typeof data === 'object') {
|
||||||
|
return Object.entries(data).map(([id, desc]) => ({
|
||||||
|
Id: Number(id),
|
||||||
|
Description: typeof desc === 'string' ? desc : String(desc ?? ''),
|
||||||
|
})).filter((r) => Number.isFinite(r.Id) && r.Id > 0);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch valid proposal rejection reason codes.
|
||||||
|
* GET /proposals/RejectionReasons
|
||||||
|
*/
|
||||||
|
export async function getProposalRejectionReasons() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (_rejectionReasonsCache && (now - _rejectionReasonsCachedAt) < REJECTION_REASONS_TTL_MS) {
|
||||||
|
return _rejectionReasonsCache;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetchWithRetry('/proposals/RejectionReasons', { timeout: 30000 });
|
||||||
|
const reasons = _parseRejectionReasonsResponse(res.data);
|
||||||
|
_rejectionReasonsCache = reasons;
|
||||||
|
_rejectionReasonsCachedAt = now;
|
||||||
|
return reasons;
|
||||||
|
} catch (err) {
|
||||||
|
logger('sc:getRejectionReasons', `Failed: ${err.message}`, 'warn');
|
||||||
|
return _rejectionReasonsCache || [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const REJECT_REASON_KEYWORDS = ['revised', 'superseded', 'replacement', 'replaced', 'updated', 'scope'];
|
||||||
|
|
||||||
|
/** Default when env/keyword lookup fails — "Change in scope of work" on most tenants. */
|
||||||
|
const DEFAULT_REJECT_REASON_ID = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve RejectReasonCodeId: env override, keyword match on SC reasons, or sensible default.
|
||||||
|
*/
|
||||||
|
export async function resolveRejectReasonCodeId() {
|
||||||
|
const envId = process.env.SC_PROPOSAL_REJECT_REASON_ID;
|
||||||
|
if (envId != null && envId !== '') {
|
||||||
|
const n = Number(envId);
|
||||||
|
if (Number.isFinite(n) && n > 0) return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reasons = await getProposalRejectionReasons();
|
||||||
|
if (!reasons.length) {
|
||||||
|
return DEFAULT_REJECT_REASON_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const kw of REJECT_REASON_KEYWORDS) {
|
||||||
|
const match = reasons.find((r) => {
|
||||||
|
const text = `${r.Description || ''} ${r.Name || ''} ${r.Reason || ''}`.toLowerCase();
|
||||||
|
return text.includes(kw);
|
||||||
|
});
|
||||||
|
if (match) {
|
||||||
|
const id = match.Id ?? match.RejectReasonCodeId ?? match.ReasonCodeId;
|
||||||
|
if (id != null) {
|
||||||
|
return Number(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = reasons[0];
|
||||||
|
const resolved = Number(first?.Id ?? first?.RejectReasonCodeId ?? first?.ReasonCodeId ?? DEFAULT_REJECT_REASON_ID);
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject a specific proposal.
|
||||||
|
* PUT /proposals/{proposalId}/reject
|
||||||
|
*/
|
||||||
|
export async function rejectProposal(proposalId, body) {
|
||||||
|
if (!proposalId) {
|
||||||
|
throw new Error('proposalId is required to reject');
|
||||||
|
}
|
||||||
|
await scAxios.put(`/proposals/${proposalId}/reject`, body);
|
||||||
|
logger('sc:rejectProposal', `Successfully rejected proposal ${proposalId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch proposal details by display number via OData.
|
||||||
|
* Used when associated-proposals list is empty but the webhook note parsed a proposal #.
|
||||||
|
*/
|
||||||
|
export async function getProposalByNumberOdata(proposalNumber) {
|
||||||
|
if (proposalNumber == null || proposalNumber === '') return null;
|
||||||
|
const num = String(proposalNumber).trim();
|
||||||
|
const filters = [`Number eq ${num}`, `Number eq '${num}'`];
|
||||||
|
|
||||||
|
for (const filter of filters) {
|
||||||
|
try {
|
||||||
|
const res = await fetchWithRetry(
|
||||||
|
`/odata/proposals?$filter=${encodeURIComponent(filter)}`,
|
||||||
|
{ timeout: 30000 }
|
||||||
|
);
|
||||||
|
const row = res.data?.value?.[0];
|
||||||
|
if (row) {
|
||||||
|
logger('sc:getProposalOdata', `Fetched OData details for proposal #${num}`);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger('sc:getProposalOdata', `Number filter "${filter}" failed for #${num}: ${err.message}`, 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OData fetch with optional $expand=AmountCategories (may surface nested detail on some tenants).
|
||||||
|
* Note: $expand=AmountCategories fails on many SC tenants — prefer getProposalByIdOdata.
|
||||||
|
*/
|
||||||
|
export async function getProposalByIdOdataExpanded(proposalId) {
|
||||||
|
if (!proposalId) return null;
|
||||||
|
try {
|
||||||
|
const res = await fetchWithRetry(
|
||||||
|
`/odata/proposals?$filter=Id eq ${proposalId}&$expand=AmountCategories`,
|
||||||
|
{ timeout: 30000 }
|
||||||
|
);
|
||||||
|
const data = res.data;
|
||||||
|
if (data?.value?.length > 0) {
|
||||||
|
logger('sc:getProposalOdata', `Fetched expanded OData for proposal ${proposalId}`);
|
||||||
|
return data.value[0];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
logger('sc:getProposalOdata', `Expanded fetch failed for ${proposalId}: ${err.message}`, 'warn');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/integrations/serviceChannel/index.js
Normal file
5
src/integrations/serviceChannel/index.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
// src/integrations/serviceChannel/index.js
|
||||||
|
// Clean re-exports for ServiceChannel integration.
|
||||||
|
|
||||||
|
export * from './client.js';
|
||||||
|
export * from './attachments.js';
|
||||||
0
src/integrations/serviceChannel/types.js
Normal file
0
src/integrations/serviceChannel/types.js
Normal file
74
src/integrations/webex/adminClient.js
Normal file
74
src/integrations/webex/adminClient.js
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
/**
|
||||||
|
* adminClient.js
|
||||||
|
* Webex client for privileged / admin / telephony operations.
|
||||||
|
*
|
||||||
|
* This is intentionally separate from botClient.js.
|
||||||
|
*
|
||||||
|
* Required for:
|
||||||
|
* - DECT network/handset/base station lookups
|
||||||
|
* - Device inventory for people (phones assigned to store users)
|
||||||
|
* - Any other Webex API calls that require broader admin or telephony scopes
|
||||||
|
*
|
||||||
|
* These operations typically need a different token (not the ServChan bot token)
|
||||||
|
* with the appropriate Webex admin/telephony permissions.
|
||||||
|
*
|
||||||
|
* During the 2026-05-28 cleanup, the old duplicated code from
|
||||||
|
* src/integrations/webex/client.js (previously contained DECT/telephony code)
|
||||||
|
* should be migrated here when the privileged token is available via env/config.
|
||||||
|
*
|
||||||
|
* For now this is a skeleton so the architecture is clear.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import axios from 'axios';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
|
||||||
|
export class WebexAdminClient {
|
||||||
|
constructor(token, baseURL = 'https://webexapis.com/v1') {
|
||||||
|
if (!token) {
|
||||||
|
logger('webex:adminClient', 'No token provided — privileged operations will fail', 'warn');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.axios = axios.create({
|
||||||
|
baseURL,
|
||||||
|
timeout: 15000,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Move the DECT / telephony functions here from the old client.js
|
||||||
|
// Examples that belong in this client:
|
||||||
|
// - getPersonIdByEmail
|
||||||
|
// - getDevicesForPerson
|
||||||
|
// - getDectNetworkId
|
||||||
|
// - getDectHandsets / getDectBasestations
|
||||||
|
// - getWebexPhonesForStore
|
||||||
|
// - etc.
|
||||||
|
|
||||||
|
async getPersonIdByEmail(email) {
|
||||||
|
// Placeholder — implement using this.axios
|
||||||
|
logger('webex:adminClient', 'getPersonIdByEmail not yet implemented in new adminClient');
|
||||||
|
throw new Error('Not implemented — migrate from legacy webex/client.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add other admin-only methods as they are migrated...
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedAdminClient = null;
|
||||||
|
|
||||||
|
export function getAdminClient() {
|
||||||
|
if (!cachedAdminClient) {
|
||||||
|
// Expect a separate privileged token (never the bot token)
|
||||||
|
const token = process.env.WEBEX_ADMIN_TOKEN || process.env.WEBEX_TELEPHONY_TOKEN;
|
||||||
|
if (!token) {
|
||||||
|
logger('webex:adminClient', 'WEBEX_ADMIN_TOKEN (or WEBEX_TELEPHONY_TOKEN) not set', 'error');
|
||||||
|
throw new Error('Missing privileged Webex token for admin operations');
|
||||||
|
}
|
||||||
|
cachedAdminClient = new WebexAdminClient(token);
|
||||||
|
}
|
||||||
|
return cachedAdminClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default getAdminClient;
|
||||||
231
src/integrations/webex/botClient.js
Normal file
231
src/integrations/webex/botClient.js
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
/**
|
||||||
|
* botClient.js
|
||||||
|
* Consolidated Webex client for standard bot operations.
|
||||||
|
*
|
||||||
|
* This is the primary client for normal ServChan bot activities:
|
||||||
|
* - Creating rooms
|
||||||
|
* - Posting messages
|
||||||
|
* - Adding members
|
||||||
|
* - Basic room helpers (getWOByRoom, getStoreByRoom)
|
||||||
|
*
|
||||||
|
* For privileged operations (DECT, telephony, etc.), use adminClient.js instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import axios from 'axios';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
import FormData from 'form-data';
|
||||||
|
|
||||||
|
class ServChanBotClient {
|
||||||
|
static #instance = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (ServChanBotClient.#instance) {
|
||||||
|
return ServChanBotClient.#instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = process.env.WEBEX_BOT_TOKEN;
|
||||||
|
const baseURL = process.env.WEBEX_BASE_URL || 'https://webexapis.com/v1';
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
logger('webex:botClient', 'WEBEX_BOT_TOKEN missing – bot will not work', 'error');
|
||||||
|
throw new Error('Missing WEBEX_BOT_TOKEN');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.axios = axios.create({
|
||||||
|
baseURL,
|
||||||
|
timeout: 15000,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.axios.interceptors.response.use(
|
||||||
|
(res) => res,
|
||||||
|
(err) => {
|
||||||
|
const msg = err.response
|
||||||
|
? `${err.response.status} - ${JSON.stringify(err.response.data)}`
|
||||||
|
: err.message;
|
||||||
|
logger('webex:botClient', `API error: ${msg}`, 'error');
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ServChanBotClient.#instance = this;
|
||||||
|
logger('webex:botClient', 'Initialized (bot token)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Core messaging & room operations
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async createRoom(title, teamId = null) {
|
||||||
|
const payload = { title, type: 'group' };
|
||||||
|
if (teamId) payload.teamId = teamId;
|
||||||
|
|
||||||
|
const res = await this.axios.post('/rooms', payload);
|
||||||
|
logger('webex:bot:createRoom', `Created ${res.data.id}`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMember(roomId, personEmail) {
|
||||||
|
const res = await this.axios.post('/memberships', { roomId, personEmail });
|
||||||
|
logger('webex:bot:addMember', `Added ${personEmail}`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMarkdown(roomId, markdown, textFallback = null) {
|
||||||
|
if (!roomId || !markdown) {
|
||||||
|
logger('webex:bot:sendMarkdown', 'Missing roomId or markdown', 'warn');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = { roomId, markdown };
|
||||||
|
if (textFallback) payload.text = textFallback;
|
||||||
|
|
||||||
|
const res = await this.axios.post('/messages', payload);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendWithAttachment(roomId, buffer, fileName, contentType = 'application/octet-stream', text = 'Attached file', fileUrl = null) {
|
||||||
|
if (!roomId || !fileName) {
|
||||||
|
logger('webex:bot:attachment', 'Missing required params', 'warn');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileUrl) {
|
||||||
|
const payload = { roomId, markdown: text, files: [fileUrl] };
|
||||||
|
const res = await this.axios.post('/messages', payload);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('roomId', roomId);
|
||||||
|
form.append('markdown', text);
|
||||||
|
form.append('files', buffer, { filename: fileName, contentType });
|
||||||
|
|
||||||
|
const res = await this.axios.post('/messages', form, {
|
||||||
|
headers: form.getHeaders(),
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Either buffer or fileUrl must be provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRoom(roomId) {
|
||||||
|
if (!roomId) throw new Error('roomId is required');
|
||||||
|
await this.axios.delete(`/rooms/${roomId}`);
|
||||||
|
logger('webex:bot:deleteRoom', `Deleted ${roomId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Helpers used by commands
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async getRoom(roomId) {
|
||||||
|
const res = await this.axios.get(`/rooms/${roomId}`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWOByRoom(roomId) {
|
||||||
|
try {
|
||||||
|
const room = await this.getRoom(roomId);
|
||||||
|
return this.#extractWoNumber(room?.title);
|
||||||
|
} catch (err) {
|
||||||
|
logger('webex:bot:getWOByRoom', `Failed for ${roomId}: ${err.message}`, 'warn');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStoreByRoom(roomId) {
|
||||||
|
try {
|
||||||
|
const room = await this.getRoom(roomId);
|
||||||
|
return this.#extractStoreNumber(room?.title);
|
||||||
|
} catch (err) {
|
||||||
|
logger('webex:bot:getStoreByRoom', `Failed for ${roomId}: ${err.message}`, 'warn');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#extractWoNumber(str) {
|
||||||
|
if (!str) return null;
|
||||||
|
const m = str.match(/WO[-–—]?\s*(\d+)/i);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
#extractStoreNumber(str) {
|
||||||
|
if (!str || typeof str !== 'string') return null;
|
||||||
|
|
||||||
|
const storeRegex = /Store\s*[#:=-]?\s*(\d{3,5})/i;
|
||||||
|
let m = str.match(storeRegex);
|
||||||
|
if (m) return m[1];
|
||||||
|
|
||||||
|
const fallback = /WO[-–—]?\s*\d+\s*[\|]\s*(\d{3,5})/i;
|
||||||
|
m = str.match(fallback);
|
||||||
|
if (m) return m[1];
|
||||||
|
|
||||||
|
const numbers = [...str.matchAll(/\b(\d{4,5})\b/g)].map(x => x[1]);
|
||||||
|
for (const n of numbers) {
|
||||||
|
if (n.length >= 6) continue;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Adaptive Card + person helpers (for approval flow)
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an Adaptive Card (v1.3) message.
|
||||||
|
* card = plain JS object matching Adaptive Card schema.
|
||||||
|
* The Webex /messages endpoint accepts attachments with contentType for cards.
|
||||||
|
*/
|
||||||
|
async sendAdaptiveCard(roomId, card, fallbackText = "This message contains an interactive card. Please view in a supported Webex client.") {
|
||||||
|
if (!roomId || !card) {
|
||||||
|
logger("webex:bot:sendAdaptiveCard", "Missing roomId or card", "warn");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
roomId,
|
||||||
|
text: fallbackText,
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
contentType: "application/vnd.microsoft.card.adaptive",
|
||||||
|
content: card,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const res = await this.axios.post("/messages", payload);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a message posted by this bot (e.g. remove an approval card after submit). */
|
||||||
|
async deleteMessage(messageId) {
|
||||||
|
if (!messageId) {
|
||||||
|
logger('webex:bot:deleteMessage', 'Missing messageId', 'warn');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await this.axios.delete(`/messages/${encodeURIComponent(messageId)}`);
|
||||||
|
logger('webex:bot:deleteMessage', `Deleted message ${messageId}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch person details (for approver attribution from attachmentAction.personId).
|
||||||
|
*/
|
||||||
|
async getPersonDetails(personId) {
|
||||||
|
if (!personId) return { displayName: "Unknown User", emails: [] };
|
||||||
|
try {
|
||||||
|
const res = await this.axios.get("/people/" + encodeURIComponent(personId));
|
||||||
|
return res.data;
|
||||||
|
} catch (err) {
|
||||||
|
logger("webex:bot:getPersonDetails", "Failed for " + personId + ": " + err.message, "warn");
|
||||||
|
return { displayName: personId, emails: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new ServChanBotClient();
|
||||||
7
src/integrations/webex/index.js
Normal file
7
src/integrations/webex/index.js
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
// src/integrations/webex/index.js
|
||||||
|
// Clean public API for the Webex integration layer.
|
||||||
|
|
||||||
|
export { default as botClient } from './botClient.js';
|
||||||
|
export { WebexAdminClient, getAdminClient } from './adminClient.js';
|
||||||
|
|
||||||
|
// No other legacy Webex clients remain in this folder.
|
||||||
150
src/integrations/xai/client.js
Normal file
150
src/integrations/xai/client.js
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
import { loadSecrets } from '../../config/secrets.js';
|
||||||
|
|
||||||
|
const secrets = loadSecrets();
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summarizes the initial ticket description when a WorkOrderCreated webhook arrives.
|
||||||
|
* This produces a short, clean, professional summary suitable for posting as the
|
||||||
|
* first message in a new ServChan Webex space.
|
||||||
|
*/
|
||||||
|
export async function summarizeTicketDescription(
|
||||||
|
rawDescription,
|
||||||
|
xaiApiKey,
|
||||||
|
options = {}
|
||||||
|
) {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const {
|
||||||
|
model = secrets.xai.model,
|
||||||
|
maxTokens = 300,
|
||||||
|
includeOriginal = false,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (!rawDescription?.trim()) {
|
||||||
|
return { summary: 'No description provided.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = xaiApiKey || secrets.xai.token;
|
||||||
|
if (!token) {
|
||||||
|
return {
|
||||||
|
summary: `(xAI not configured) Original: ${rawDescription.substring(0, 400)}${rawDescription.length > 400 ? '...' : ''}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemPrompt = `
|
||||||
|
You are an expert at turning messy, form-generated ticket descriptions into clear, concise, professional summaries
|
||||||
|
that someone can read in 10–15 seconds and immediately understand the issue.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Focus on: WHO is affected, WHAT is broken, WHERE it happens, WHEN it started / how often, IMPACT / severity
|
||||||
|
- Remove repetition, form boilerplate, labels like "Field1:", "Please select:", etc.
|
||||||
|
- Use natural, professional language — no emojis unless very clearly needed
|
||||||
|
- Keep technical terms if they are meaningful
|
||||||
|
- Aim for 2–6 sentences max
|
||||||
|
- If the text is already short & clear, just lightly clean it up
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const userPrompt = `
|
||||||
|
Summarize the following ticket description:
|
||||||
|
|
||||||
|
"""
|
||||||
|
${rawDescription.trim()}
|
||||||
|
"""
|
||||||
|
|
||||||
|
Provide only the clean summary — no extra commentary, no "Here's a summary:", just the readable text.
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
'https://api.x.ai/v1/chat/completions',
|
||||||
|
{
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: userPrompt },
|
||||||
|
],
|
||||||
|
temperature: 0.3,
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
top_p: 0.95,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
timeout: 15000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const summary = response.data.choices?.[0]?.message?.content?.trim() || '';
|
||||||
|
|
||||||
|
logger('xai:summarizeTicketDescription', `Finished in ${Date.now() - startTime}ms`);
|
||||||
|
|
||||||
|
if (includeOriginal) {
|
||||||
|
return { summary, original: rawDescription.trim() };
|
||||||
|
}
|
||||||
|
return { summary };
|
||||||
|
} catch (err) {
|
||||||
|
logger('xai:summarizeTicketDescription', `Error: ${err.message}`, 'error');
|
||||||
|
return {
|
||||||
|
summary: `(Summary unavailable) Original: ${rawDescription.substring(0, 400)}${rawDescription.length > 400 ? '...' : ''}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function summarizeTicketWithGrokFromContext(context, ticketId) {
|
||||||
|
if (!secrets.xai.token) {
|
||||||
|
return `(xAI not configured) Raw ticket info: ${context.substring(0, 200)}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemPrompt = `
|
||||||
|
You are an expert HVAC/facilities technician and ServiceChannel ticket analyst.
|
||||||
|
|
||||||
|
Summarize this ticket clearly and concisely.
|
||||||
|
Use the **ticket description** as the primary source for the **main problem / reason for the ticket**.
|
||||||
|
Use the notes to provide timeline, actions, status updates, and pending items.
|
||||||
|
|
||||||
|
Structure your summary with these sections:
|
||||||
|
- **Main Problem** (from description)
|
||||||
|
- **Key Events & Timeline** (chronological bullets from notes, most recent last)
|
||||||
|
- **Actions Taken**
|
||||||
|
- **Current Status / Blockers**
|
||||||
|
- **Pending / Next Steps**
|
||||||
|
|
||||||
|
Keep it professional, neutral, factual, under 250 words.
|
||||||
|
Use bullet points where helpful.
|
||||||
|
If notes are repetitive, deduplicate them.
|
||||||
|
If no notes, omit "Key Events & Timeline" or say "No notes recorded."
|
||||||
|
`;
|
||||||
|
|
||||||
|
const userPrompt = `Summarize this ServiceChannel ticket:\n${context}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
'https://api.x.ai/v1/chat/completions',
|
||||||
|
{
|
||||||
|
model: secrets.xai.model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: userPrompt }
|
||||||
|
],
|
||||||
|
temperature: 0.3,
|
||||||
|
max_tokens: 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${secrets.xai.token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data.choices[0].message.content.trim();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[GROK] Error:', err.response?.data || err.message);
|
||||||
|
return `(Summary failed) Raw ticket info: ${context.substring(0, 200)}...`;
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/server/adminAuth.js
Normal file
70
src/server/adminAuth.js
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
/**
|
||||||
|
* src/server/adminAuth.js
|
||||||
|
*
|
||||||
|
* Small, dependency-free HTTP auth middleware for admin/report endpoints
|
||||||
|
* (/cleanup-test, /stale-workorders, etc.).
|
||||||
|
*
|
||||||
|
* Accepts either:
|
||||||
|
* 1. `Authorization: Bearer <ADMIN_TOKEN>` header, or
|
||||||
|
* 2. `?token=<ADMIN_TOKEN>` query param (so it's usable from a browser bookmark)
|
||||||
|
*
|
||||||
|
* Configuration:
|
||||||
|
* ADMIN_TOKEN Required. If unset, every admin request is refused.
|
||||||
|
*
|
||||||
|
* Intentionally NOT full IAM — this is a stop-gap for internal endpoints that
|
||||||
|
* shouldn't be world-readable and shouldn't be one query flag away from doing
|
||||||
|
* destructive work.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
function timingSafeEq(a, b) {
|
||||||
|
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
||||||
|
const ab = Buffer.from(a);
|
||||||
|
const bb = Buffer.from(b);
|
||||||
|
if (ab.length !== bb.length) return false;
|
||||||
|
return crypto.timingSafeEqual(ab, bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {boolean} [options.allowInDev=true] If true and NODE_ENV !== 'production',
|
||||||
|
* requests are allowed through even without a token (dev convenience). Set to
|
||||||
|
* false to enforce auth in every environment.
|
||||||
|
*/
|
||||||
|
export function requireAdmin(options = {}) {
|
||||||
|
const { allowInDev = true } = options;
|
||||||
|
|
||||||
|
return function adminAuthMiddleware(req, res, next) {
|
||||||
|
const expected = process.env.ADMIN_TOKEN;
|
||||||
|
|
||||||
|
if (!expected) {
|
||||||
|
if (allowInDev && process.env.NODE_ENV !== 'production') {
|
||||||
|
// Dev-mode escape hatch. Log every time so it doesn't stay silent.
|
||||||
|
logger('adminAuth', `ADMIN_TOKEN unset — allowing ${req.method} ${req.originalUrl} (NODE_ENV=${process.env.NODE_ENV || 'undefined'})`, 'warn');
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
logger('adminAuth', `ADMIN_TOKEN not configured — denying ${req.method} ${req.originalUrl}`, 'error');
|
||||||
|
return res.status(503).send('Admin auth not configured (set ADMIN_TOKEN).');
|
||||||
|
}
|
||||||
|
|
||||||
|
let provided = null;
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (typeof authHeader === 'string' && authHeader.toLowerCase().startsWith('bearer ')) {
|
||||||
|
provided = authHeader.slice(7).trim();
|
||||||
|
}
|
||||||
|
if (!provided && typeof req.query.token === 'string') {
|
||||||
|
provided = req.query.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!provided || !timingSafeEq(provided, expected)) {
|
||||||
|
logger('adminAuth', `Denied ${req.method} ${req.originalUrl} from ${req.ip}`, 'warn');
|
||||||
|
return res.status(401).send('Unauthorized. Provide ADMIN_TOKEN via `Authorization: Bearer …` or `?token=…`.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default requireAdmin;
|
||||||
427
src/server/app.js
Normal file
427
src/server/app.js
Normal file
|
|
@ -0,0 +1,427 @@
|
||||||
|
/**
|
||||||
|
* src/server/app.js
|
||||||
|
*
|
||||||
|
* Express app factory.
|
||||||
|
* Extracted during thinning pass (2026-05-28) to make index.js a thin bootstrap.
|
||||||
|
*
|
||||||
|
* Dependencies are injected so the server module has no hidden coupling to the monolith.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
import bodyParser from 'body-parser';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import cron from 'node-cron';
|
||||||
|
|
||||||
|
import { logger, ensureLogDir, getLogDir } from '../utils/logger.js';
|
||||||
|
import { verifyWebhook } from './webhookAuth.js';
|
||||||
|
import { requireAdmin } from './adminAuth.js';
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// HTML escaping — used everywhere we interpolate SC-sourced data into HTML
|
||||||
|
// (technicians type note content and it lands here; without escaping this is
|
||||||
|
// a reflected XSS vector).
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
function esc(v) {
|
||||||
|
if (v === null || v === undefined) return '';
|
||||||
|
return String(v)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApp({
|
||||||
|
db,
|
||||||
|
DB_PATH,
|
||||||
|
webhookProcessor,
|
||||||
|
runSpaceCleanup,
|
||||||
|
Framework,
|
||||||
|
getStaleWorkOrdersReport,
|
||||||
|
}) {
|
||||||
|
// Note: `db` here should be the single database connection created by the main
|
||||||
|
// application using the production DB_PATH. This ensures we never accidentally
|
||||||
|
// talk to a different database file.
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(bodyParser.json({
|
||||||
|
// Capture raw body so webhook signature verification can HMAC it.
|
||||||
|
verify: (req, res, buf) => { req.rawBody = buf; }
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Enhanced Health Check with Docker/Container awareness
|
||||||
|
app.get('/health', async (req, res) => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const health = {
|
||||||
|
status: 'healthy',
|
||||||
|
service: 'servchan-bot',
|
||||||
|
version: process.env.npm_package_version || '1.0.0',
|
||||||
|
uptime: Math.round(process.uptime()),
|
||||||
|
uptime_human: `${Math.floor(process.uptime() / 3600)}h ${Math.floor((process.uptime() % 3600) / 60)}m`,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
environment: process.env.NODE_ENV || 'development',
|
||||||
|
|
||||||
|
container: {
|
||||||
|
node_version: process.version,
|
||||||
|
pid: process.pid,
|
||||||
|
platform: process.platform,
|
||||||
|
arch: process.arch,
|
||||||
|
memory: {
|
||||||
|
rss: `${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`,
|
||||||
|
heapTotal: `${(process.memoryUsage().heapTotal / 1024 / 1024).toFixed(2)} MB`,
|
||||||
|
heapUsed: `${(process.memoryUsage().heapUsed / 1024 / 1024).toFixed(2)} MB`,
|
||||||
|
external: `${(process.memoryUsage().external / 1024 / 1024).toFixed(2)} MB`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
database: {
|
||||||
|
status: 'unknown',
|
||||||
|
path: DB_PATH
|
||||||
|
},
|
||||||
|
|
||||||
|
webex: {
|
||||||
|
framework: Framework ? 'initialized' : 'not_ready',
|
||||||
|
spawn_mode: 'disabled'
|
||||||
|
},
|
||||||
|
|
||||||
|
response_time_ms: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
db.get('SELECT 1 as ok', (err, row) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(row);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
health.database.status = 'connected';
|
||||||
|
} catch (err) {
|
||||||
|
health.database.status = 'error';
|
||||||
|
health.database.error = err.message;
|
||||||
|
health.status = 'degraded';
|
||||||
|
}
|
||||||
|
|
||||||
|
health.response_time_ms = Date.now() - startTime;
|
||||||
|
|
||||||
|
const httpStatus = health.status === 'healthy' ? 200 : 503;
|
||||||
|
res.status(httpStatus).json(health);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lightweight liveness probe (used by Docker healthcheck)
|
||||||
|
app.get('/healthz', (req, res) => {
|
||||||
|
res.status(200).send('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Webhook route
|
||||||
|
//
|
||||||
|
// Auth is scaffolded via verifyWebhook middleware. Default mode is "off"
|
||||||
|
// (backward compatible). Once ServiceChannel is configured to sign / send a
|
||||||
|
// shared token, flip SC_WEBHOOK_AUTH_MODE=log to observe successes/failures
|
||||||
|
// in the log, then SC_WEBHOOK_AUTH_MODE=enforce to reject unauthenticated
|
||||||
|
// requests. See src/server/webhookAuth.js.
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
app.post('/webhook', verifyWebhook, (req, res) => {
|
||||||
|
logWebhookPayload(req.body);
|
||||||
|
|
||||||
|
// Basic shape check before we ack. If we can't even see an Object.Id,
|
||||||
|
// return 400 so the sender can log the malformed payload upstream.
|
||||||
|
if (!req.body || typeof req.body !== 'object' || !req.body.Object?.Id) {
|
||||||
|
logger('webhook:route', `Rejected malformed payload from ${req.ip}`, 'warn');
|
||||||
|
return res.status(400).json({ error: 'malformed webhook payload' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ack immediately and dispatch. If the processor throws synchronously
|
||||||
|
// (bad wiring) we log it but the response is already sent.
|
||||||
|
res.status(200).json({ ok: true });
|
||||||
|
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => webhookProcessor.processWebhook(req.body))
|
||||||
|
.catch(err => {
|
||||||
|
logger('webhook:route', `Unhandled error in processor for WO ${req.body?.Object?.Id}: ${err.message}`, 'error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// /cleanup-test — protected admin endpoint
|
||||||
|
//
|
||||||
|
// Dry-run by default. To actually delete/prune, you now need BOTH
|
||||||
|
// ?dryRun=false AND ?live=true. The old "one flag flips destructive mode"
|
||||||
|
// behavior was a foot-gun.
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
app.get('/cleanup-test', requireAdmin(), async (req, res) => {
|
||||||
|
const requestedLive = req.query.dryRun === 'false' && req.query.live === 'true';
|
||||||
|
const dryRun = !requestedLive;
|
||||||
|
|
||||||
|
logger('cleanup-test', `Called dryRun=${dryRun} live=${requestedLive} by ${req.ip}`);
|
||||||
|
|
||||||
|
// Pass the real db instance when available so we use the production database
|
||||||
|
const result = await runSpaceCleanup(dryRun, { db });
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return res.status(500).send(`<h2>Error</h2><pre>${esc(result.error)}</pre>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { summary, results, mode } = result;
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Space Cleanup - ${esc(mode)}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||||
|
table { border-collapse: collapse; width: 100%; margin-top: 20px; }
|
||||||
|
th, td { border: 1px solid #ccc; padding: 8px; text-align: left; }
|
||||||
|
th { background-color: #f0f0f0; }
|
||||||
|
.archive { background-color: #fff3cd; }
|
||||||
|
.delete { background-color: #f8d7da; }
|
||||||
|
.skipped { color: #666; }
|
||||||
|
h1 { color: #333; }
|
||||||
|
.banner { padding: 10px; border-radius: 4px; margin: 10px 0; }
|
||||||
|
.banner-live { background: #f8d7da; border: 1px solid #dc3545; }
|
||||||
|
.banner-dry { background: #d1ecf1; border: 1px solid #0c5460; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Space Cleanup Report - ${esc(mode)}</h1>
|
||||||
|
<div class="banner ${dryRun ? 'banner-dry' : 'banner-live'}">
|
||||||
|
<strong>${dryRun ? 'DRY RUN' : 'LIVE RUN'}</strong> —
|
||||||
|
${dryRun
|
||||||
|
? 'No changes were made. Add <code>?dryRun=false&live=true</code> to actually run.'
|
||||||
|
: 'Destructive actions were performed against Webex and the mappings DB.'}
|
||||||
|
</div>
|
||||||
|
<h2>Summary</h2>
|
||||||
|
<pre>${esc(JSON.stringify(summary, null, 2))}</pre>
|
||||||
|
|
||||||
|
<h2>Full Results (${results.length} mappings)</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Work Order ID</th>
|
||||||
|
<th>Room ID</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Days Old</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
`;
|
||||||
|
|
||||||
|
results.forEach(r => {
|
||||||
|
const rowClass = r.action.includes('archive') ? 'archive' :
|
||||||
|
r.action.includes('delete') ? 'delete' : 'skipped';
|
||||||
|
html += `
|
||||||
|
<tr class="${rowClass}">
|
||||||
|
<td>${esc(r.woId)}</td>
|
||||||
|
<td style="font-size: 0.85em; word-break: break-all;">${esc(r.roomId)}</td>
|
||||||
|
<td><strong>${esc(r.action)}</strong></td>
|
||||||
|
<td>${r.days !== null && r.days !== undefined ? esc(r.days) : '-'}</td>
|
||||||
|
<td>${esc(r.status)}</td>
|
||||||
|
<td>${esc(r.reason || '')}</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += `
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
res.send(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stale Work Order Report (Phase 0) — protected admin endpoint
|
||||||
|
if (getStaleWorkOrdersReport) {
|
||||||
|
app.get('/stale-workorders', requireAdmin(), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const report = await getStaleWorkOrdersReport(db);
|
||||||
|
|
||||||
|
if (report.error) {
|
||||||
|
return res.status(503).send(`
|
||||||
|
<h1>Stale Work Orders Report</h1>
|
||||||
|
<p style="color: #c00; font-weight: bold;">${esc(report.message)}</p>
|
||||||
|
<p>Please set the required ServiceChannel environment variables and restart the application.</p>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { summary, items } = report;
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Stale Work Orders Report</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||||
|
table { border-collapse: collapse; width: 100%; margin-top: 20px; font-size: 0.9em; }
|
||||||
|
th, td { border: 1px solid #ccc; padding: 8px; text-align: left; vertical-align: top; }
|
||||||
|
th { background-color: #f0f0f0; }
|
||||||
|
.stale { background-color: #fff3cd; }
|
||||||
|
.very-stale { background-color: #f8d7da; }
|
||||||
|
.note { font-size: 0.85em; color: #333; }
|
||||||
|
h1 { color: #333; }
|
||||||
|
.summary { background: #f8f9fa; padding: 15px; border-radius: 4px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Stale Work Orders Report</h1>
|
||||||
|
<div class="summary">
|
||||||
|
<strong>Generated:</strong> ${esc(new Date(summary.generatedAt).toLocaleString())}<br>
|
||||||
|
<strong>Total monitored:</strong> ${esc(summary.totalMonitored)}<br>
|
||||||
|
<strong>Stale (≥2 days no update):</strong> ${esc(summary.staleCount)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Stale Work Orders (${items.length})</h2>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
html += `<p>No stale work orders found. Great job!</p>`;
|
||||||
|
} else {
|
||||||
|
html += `
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>WO #</th>
|
||||||
|
<th>Store</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Days Since Activity</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
<th>Last Note</th>
|
||||||
|
<th>Links</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
`;
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
const rowClass = item.daysSinceActivity >= 7 ? 'very-stale' : 'stale';
|
||||||
|
|
||||||
|
let lastNoteHtml = '<em>No notes</em>';
|
||||||
|
if (item.lastNote) {
|
||||||
|
const author = esc(item.lastNote.CreatedBy || 'Unknown');
|
||||||
|
const noteBody = item.lastNote.NoteData || '';
|
||||||
|
const truncated = noteBody.length > 300 ? noteBody.substring(0, 300) + '…' : noteBody;
|
||||||
|
lastNoteHtml = `<div class="note"><strong>${author}</strong><br>${esc(truncated)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scLink = `https://www.servicechannel.com/sc/wo/Workorders/index?id=${encodeURIComponent(item.workOrderId)}`;
|
||||||
|
const store = item.workOrder?.Location?.StoreId
|
||||||
|
? `Store ${esc(item.workOrder.Location.StoreId)}`
|
||||||
|
: '';
|
||||||
|
const locName = esc(item.workOrder?.LocationName || '');
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<tr class="${rowClass}">
|
||||||
|
<td><strong>${esc(item.workOrder?.WorkorderNumber || item.workOrderId)}</strong></td>
|
||||||
|
<td>${store} ${locName}</td>
|
||||||
|
<td>${esc(item.workOrder?.Status?.Primary || '-')}<br><small>${esc(item.workOrder?.Status?.Extended || '')}</small></td>
|
||||||
|
<td><strong>${esc(item.daysSinceActivity)}</strong> days</td>
|
||||||
|
<td>${esc(item.reason || '-')}</td>
|
||||||
|
<td>${lastNoteHtml}</td>
|
||||||
|
<td>
|
||||||
|
<a href="${scLink}" target="_blank" rel="noopener noreferrer">View in ServiceChannel</a>
|
||||||
|
${item.roomId ? `<br><a href="webexteams://im?space=${encodeURIComponent(item.roomId)}" target="_blank" style="font-size: 0.85em;">Open Webex Space</a>` : ''}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += `
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<p style="margin-top: 30px; font-size: 0.85em; color: #666;">
|
||||||
|
Report generated on demand. Data sourced from local mappings + live ServiceChannel.
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
res.send(html);
|
||||||
|
} catch (err) {
|
||||||
|
logger('stale-workorders', `Error: ${err.message}`, 'error');
|
||||||
|
res.status(500).send(`<h2>Error</h2><pre>${esc(err.message)}</pre>`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Webhook payload sink + log rotation.
|
||||||
|
//
|
||||||
|
// Both write to the same directory the structured logger uses
|
||||||
|
// (src/utils/logPath.js → LOG_DIR env, default ./logs) so every log file lives
|
||||||
|
// in one place regardless of which module produced it.
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function logWebhookPayload(payload) {
|
||||||
|
try {
|
||||||
|
const dir = ensureLogDir();
|
||||||
|
const now = new Date();
|
||||||
|
const yyyymmdd = now.toISOString().slice(0, 10).replace(/-/g, '');
|
||||||
|
const filePath = path.join(dir, `${yyyymmdd}.webhook.log`);
|
||||||
|
const line = `${now.toISOString()} ${JSON.stringify(payload)}\n`;
|
||||||
|
fs.appendFileSync(filePath, line);
|
||||||
|
} catch (err) {
|
||||||
|
logger('webhook:archive', `Failed to persist raw payload: ${err.message}`, 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete `*.log` files older than N days in the shared log directory.
|
||||||
|
* - Only touches files ending in `.log` (avoids nuking non-log files).
|
||||||
|
* - Uses async fs and awaits everything so it doesn't race with writers.
|
||||||
|
*/
|
||||||
|
async function cleanupOldLogs({ maxAgeDays = 7 } = {}) {
|
||||||
|
const dir = getLogDir();
|
||||||
|
try {
|
||||||
|
ensureLogDir();
|
||||||
|
const now = Date.now();
|
||||||
|
const cutoff = maxAgeDays * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile()) continue;
|
||||||
|
if (!entry.name.endsWith('.log')) continue;
|
||||||
|
|
||||||
|
const filePath = path.join(dir, entry.name);
|
||||||
|
try {
|
||||||
|
const stats = await fsp.stat(filePath);
|
||||||
|
if ((now - stats.mtimeMs) > cutoff) {
|
||||||
|
await fsp.unlink(filePath);
|
||||||
|
logger('cleanupOldLogs', `Deleted ${entry.name}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger('cleanupOldLogs', `Failed to inspect/delete ${entry.name}: ${err.message}`, 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger('cleanupOldLogs', `Failed for ${dir}: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience helper to set up the daily log cleanup cron.
|
||||||
|
* Can be called from the thin bootstrap.
|
||||||
|
*/
|
||||||
|
export function setupCron() {
|
||||||
|
cron.schedule('0 15 0,8,16 * * *', () => {
|
||||||
|
cleanupOldLogs().catch(err => {
|
||||||
|
logger('cron:cleanupOldLogs', `Unhandled: ${err.message}`, 'error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
logger('cron', 'Log cleanup scheduled (0 15 0,8,16 * * *)');
|
||||||
|
}
|
||||||
|
|
||||||
|
export { cleanupOldLogs };
|
||||||
|
export default createApp;
|
||||||
306
src/server/webhookAuth.js
Normal file
306
src/server/webhookAuth.js
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
/**
|
||||||
|
* src/server/webhookAuth.js
|
||||||
|
*
|
||||||
|
* Webhook authentication middleware for ServiceChannel (and generic HMAC
|
||||||
|
* webhooks).
|
||||||
|
*
|
||||||
|
* Per ServiceChannel's docs
|
||||||
|
* https://developer.servicechannel.com/guides/wh/receive-events-and-respond/
|
||||||
|
* each webhook request includes:
|
||||||
|
* Sign-Type: HMACSHA256
|
||||||
|
* Sign-Data: <base64(HMAC-SHA256(rawBody, signingKey))>
|
||||||
|
*
|
||||||
|
* The Signing Key is fetched from
|
||||||
|
* GET /v3/NotificationSubscriptions/SigningKey
|
||||||
|
* (or copied out of the SC UI). Paste it into SC_WEBHOOK_SIGNING_SECRET and
|
||||||
|
* the defaults below match SC's format — you shouldn't need to override any
|
||||||
|
* other variable.
|
||||||
|
*
|
||||||
|
* Modes (via SC_WEBHOOK_AUTH_MODE):
|
||||||
|
* "off" — do nothing. Every request is accepted. (default)
|
||||||
|
* "log" — validate if credentials are set; log successes/failures with
|
||||||
|
* full diagnostics but still accept every request. Use this to
|
||||||
|
* confirm signatures line up before flipping to enforce.
|
||||||
|
* "enforce" — reject requests that fail validation with 401.
|
||||||
|
*
|
||||||
|
* Discovery aids (log mode only):
|
||||||
|
* - Auto-probes several known signature header names (SC's `sign-data`
|
||||||
|
* first) so a misconfigured header name doesn't silently fail.
|
||||||
|
* - Tries both base64 and hex encodings so you can't get bitten by that.
|
||||||
|
* - Reads the `Sign-Type` header when present to auto-pick the algorithm.
|
||||||
|
* - When nothing matches, logs the incoming x- and sign- headers
|
||||||
|
* (truncated) so you can see what SC actually sent.
|
||||||
|
*
|
||||||
|
* Configuration (all optional):
|
||||||
|
* SC_WEBHOOK_AUTH_MODE off | log | enforce (default: off)
|
||||||
|
* SC_WEBHOOK_SIGNING_SECRET Signing Key from ServiceChannel
|
||||||
|
* SC_WEBHOOK_SIGNATURE_HEADER HTTP header name (default: sign-data)
|
||||||
|
* SC_WEBHOOK_SIGNATURE_PREFIX e.g. "sha256=" if platform prefixes digest
|
||||||
|
* SC_WEBHOOK_SIGNATURE_ENCODING base64 | hex | auto (default: auto)
|
||||||
|
* SC_WEBHOOK_SIGNATURE_ALGO sha256 | sha1 | sha512 (default: sha256;
|
||||||
|
* overridden by
|
||||||
|
* Sign-Type header
|
||||||
|
* when present)
|
||||||
|
* SC_WEBHOOK_TOKEN Static shared secret (alternative to HMAC)
|
||||||
|
* SC_WEBHOOK_TOKEN_HEADER HTTP header name (default: x-webhook-token)
|
||||||
|
*
|
||||||
|
* IMPORTANT: for signature verification to work, the Express body parser MUST
|
||||||
|
* capture the raw body. In app.js we already use
|
||||||
|
* bodyParser.json({ verify: (req, res, buf) => { req.rawBody = buf; } })
|
||||||
|
* so `req.rawBody` is a Buffer of the raw JSON. If that ever changes, HMAC
|
||||||
|
* checks will silently start failing — the middleware logs a WARN in that case.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
// SC's own header comes first; the rest are common alternates so log-mode
|
||||||
|
// discovery still catches things even if the platform tweaks its scheme.
|
||||||
|
const KNOWN_SIG_HEADER_CANDIDATES = [
|
||||||
|
'sign-data',
|
||||||
|
'x-sc-signature',
|
||||||
|
'x-servicechannel-signature',
|
||||||
|
'x-webhook-signature',
|
||||||
|
'x-hub-signature-256',
|
||||||
|
'x-hub-signature',
|
||||||
|
'x-signature',
|
||||||
|
'signature',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map ServiceChannel's `Sign-Type: HMACSHA256` (and similar) to the algorithm
|
||||||
|
* name Node's crypto module wants.
|
||||||
|
*/
|
||||||
|
function algoFromSignType(signType) {
|
||||||
|
if (typeof signType !== 'string') return null;
|
||||||
|
const s = signType.trim().toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||||
|
if (s === 'HMACSHA256' || s === 'SHA256') return 'sha256';
|
||||||
|
if (s === 'HMACSHA1' || s === 'SHA1') return 'sha1';
|
||||||
|
if (s === 'HMACSHA512' || s === 'SHA512') return 'sha512';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMode() {
|
||||||
|
const raw = (process.env.SC_WEBHOOK_AUTH_MODE || 'off').toLowerCase().trim();
|
||||||
|
if (raw === 'log' || raw === 'enforce' || raw === 'off') return raw;
|
||||||
|
return 'off';
|
||||||
|
}
|
||||||
|
|
||||||
|
function timingSafeEqStr(a, b) {
|
||||||
|
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
||||||
|
const ab = Buffer.from(a);
|
||||||
|
const bb = Buffer.from(b);
|
||||||
|
if (ab.length !== bb.length) return false;
|
||||||
|
return crypto.timingSafeEqual(ab, bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripPrefix(sig, prefix) {
|
||||||
|
if (!prefix) return sig;
|
||||||
|
return sig.startsWith(prefix) ? sig.slice(prefix.length) : sig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute HMAC digests for the raw body under the configured algorithm.
|
||||||
|
* Returns { hex, base64 } so we can compare against either encoding.
|
||||||
|
*/
|
||||||
|
function computeDigests(secret, rawBody, algo) {
|
||||||
|
const hex = crypto.createHmac(algo, secret).update(rawBody).digest('hex');
|
||||||
|
const base64 = crypto.createHmac(algo, secret).update(rawBody).digest('base64');
|
||||||
|
return { hex, base64 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkSignature(req) {
|
||||||
|
const secret = process.env.SC_WEBHOOK_SIGNING_SECRET;
|
||||||
|
if (!secret) return { attempted: false };
|
||||||
|
|
||||||
|
if (!req.rawBody || !Buffer.isBuffer(req.rawBody)) {
|
||||||
|
return { attempted: true, ok: false, reason: 'rawBody not captured (bodyParser misconfigured)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the sender advertises Sign-Type (SC does: "HMACSHA256"), let that pick
|
||||||
|
// the algorithm — it's more authoritative than any env var we set. Fall
|
||||||
|
// back to SC_WEBHOOK_SIGNATURE_ALGO or sha256.
|
||||||
|
const declaredAlgo = algoFromSignType(req.headers['sign-type']);
|
||||||
|
const algo = declaredAlgo || (process.env.SC_WEBHOOK_SIGNATURE_ALGO || 'sha256').toLowerCase();
|
||||||
|
|
||||||
|
const prefix = process.env.SC_WEBHOOK_SIGNATURE_PREFIX || '';
|
||||||
|
const encPref = (process.env.SC_WEBHOOK_SIGNATURE_ENCODING || 'auto').toLowerCase();
|
||||||
|
const configuredHeader = (process.env.SC_WEBHOOK_SIGNATURE_HEADER || 'sign-data').toLowerCase();
|
||||||
|
|
||||||
|
// 1. Try the configured header first.
|
||||||
|
let providedHeaderName = configuredHeader;
|
||||||
|
let provided = req.headers[configuredHeader];
|
||||||
|
|
||||||
|
// 2. If the configured header is absent, opportunistically probe the well-
|
||||||
|
// known candidate list (this makes log-mode discovery easier — we can
|
||||||
|
// tell the user which header actually carries the signature).
|
||||||
|
if (typeof provided !== 'string') {
|
||||||
|
for (const cand of KNOWN_SIG_HEADER_CANDIDATES) {
|
||||||
|
if (cand === configuredHeader) continue;
|
||||||
|
const v = req.headers[cand];
|
||||||
|
if (typeof v === 'string' && v.length > 0) {
|
||||||
|
provided = v;
|
||||||
|
providedHeaderName = cand;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof provided !== 'string') {
|
||||||
|
return {
|
||||||
|
attempted: true,
|
||||||
|
ok: false,
|
||||||
|
reason: `no signature header found (looked for: ${configuredHeader}, ${KNOWN_SIG_HEADER_CANDIDATES.filter(h => h !== configuredHeader).join(', ')})`,
|
||||||
|
headerName: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripped = stripPrefix(provided.trim(), prefix);
|
||||||
|
|
||||||
|
let digests;
|
||||||
|
try {
|
||||||
|
digests = computeDigests(secret, req.rawBody, algo);
|
||||||
|
} catch (err) {
|
||||||
|
return { attempted: true, ok: false, reason: `hmac compute failed (${algo}): ${err.message}`, headerName: providedHeaderName };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try both encodings unless the caller pinned one.
|
||||||
|
const tryHex = encPref === 'auto' || encPref === 'hex';
|
||||||
|
const tryBase64 = encPref === 'auto' || encPref === 'base64';
|
||||||
|
|
||||||
|
let matchedEncoding = null;
|
||||||
|
if (tryHex && timingSafeEqStr(stripped, digests.hex)) matchedEncoding = 'hex';
|
||||||
|
if (!matchedEncoding && tryBase64 && timingSafeEqStr(stripped, digests.base64)) matchedEncoding = 'base64';
|
||||||
|
|
||||||
|
return {
|
||||||
|
attempted: true,
|
||||||
|
ok: !!matchedEncoding,
|
||||||
|
reason: matchedEncoding ? null : 'hmac mismatch',
|
||||||
|
headerName: providedHeaderName,
|
||||||
|
headerNameMatchesConfig: providedHeaderName === configuredHeader,
|
||||||
|
encoding: matchedEncoding,
|
||||||
|
algo,
|
||||||
|
// Only expose the leading chars in logs — never dump full signatures.
|
||||||
|
providedPreview: provided.substring(0, 16) + (provided.length > 16 ? '…' : ''),
|
||||||
|
expectedHexPreview: digests.hex.substring(0, 16) + '…',
|
||||||
|
expectedBase64Preview: digests.base64.substring(0, 16) + '…',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkStaticToken(req) {
|
||||||
|
const token = process.env.SC_WEBHOOK_TOKEN;
|
||||||
|
if (!token) return { attempted: false };
|
||||||
|
|
||||||
|
const headerName = (process.env.SC_WEBHOOK_TOKEN_HEADER || 'x-webhook-token').toLowerCase();
|
||||||
|
const provided = req.headers[headerName];
|
||||||
|
if (!provided || typeof provided !== 'string') {
|
||||||
|
return { attempted: true, ok: false, reason: `missing header ${headerName}` };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
attempted: true,
|
||||||
|
ok: timingSafeEqStr(provided, token),
|
||||||
|
reason: 'token mismatch',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeIncomingHeaders(req) {
|
||||||
|
// Only show headers that could plausibly carry auth info, and cap values
|
||||||
|
// so we don't spill full signatures / tokens into the log every request.
|
||||||
|
const interesting = {};
|
||||||
|
for (const [name, value] of Object.entries(req.headers)) {
|
||||||
|
const lc = name.toLowerCase();
|
||||||
|
const isInteresting =
|
||||||
|
/^x-/.test(lc) ||
|
||||||
|
lc === 'authorization' ||
|
||||||
|
lc === 'signature' ||
|
||||||
|
lc.startsWith('sign-'); // catches SC's Sign-Data / Sign-Type
|
||||||
|
if (!isInteresting) continue;
|
||||||
|
const v = Array.isArray(value) ? value.join(',') : String(value);
|
||||||
|
interesting[name] = v.length > 24 ? v.substring(0, 24) + `…(${v.length} chars)` : v;
|
||||||
|
}
|
||||||
|
return interesting;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Express middleware. Adds:
|
||||||
|
* req.webhookAuth = { mode, valid, checks: [...] }
|
||||||
|
* so downstream handlers can log/observe. Only rejects requests when mode === 'enforce'.
|
||||||
|
*/
|
||||||
|
export function verifyWebhook(req, res, next) {
|
||||||
|
const mode = getMode();
|
||||||
|
|
||||||
|
const sig = checkSignature(req);
|
||||||
|
const tok = checkStaticToken(req);
|
||||||
|
|
||||||
|
// Nothing configured to check — bail early.
|
||||||
|
if (!sig.attempted && !tok.attempted) {
|
||||||
|
req.webhookAuth = { mode, valid: null, checks: [] };
|
||||||
|
if (mode === 'enforce') {
|
||||||
|
logger('webhookAuth', 'enforce mode with no SC_WEBHOOK_SIGNING_SECRET or SC_WEBHOOK_TOKEN set — refusing request', 'error');
|
||||||
|
return res.status(500).json({ error: 'webhook auth misconfigured' });
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const anyOk = (sig.attempted && sig.ok) || (tok.attempted && tok.ok);
|
||||||
|
|
||||||
|
req.webhookAuth = {
|
||||||
|
mode,
|
||||||
|
valid: anyOk,
|
||||||
|
checks: [
|
||||||
|
sig.attempted ? { name: 'signature', ok: !!sig.ok, reason: sig.ok ? null : sig.reason, meta: {
|
||||||
|
headerName: sig.headerName,
|
||||||
|
headerNameMatchesConfig: sig.headerNameMatchesConfig,
|
||||||
|
encoding: sig.encoding,
|
||||||
|
algo: sig.algo,
|
||||||
|
}} : null,
|
||||||
|
tok.attempted ? { name: 'token', ok: !!tok.ok, reason: tok.ok ? null : tok.reason } : null,
|
||||||
|
].filter(Boolean),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (anyOk) {
|
||||||
|
if (mode !== 'off') {
|
||||||
|
const details = [];
|
||||||
|
if (sig.attempted && sig.ok) {
|
||||||
|
details.push(`sig[header=${sig.headerName}${sig.headerNameMatchesConfig ? '' : '/UNCONFIGURED'} algo=${sig.algo} enc=${sig.encoding}]`);
|
||||||
|
}
|
||||||
|
if (tok.attempted && tok.ok) details.push('token');
|
||||||
|
logger('webhookAuth', `OK ${details.join(' ')} mode=${mode}`);
|
||||||
|
|
||||||
|
// If the signature came in on a header we weren't configured for, nudge
|
||||||
|
// the operator to pin it explicitly so we stop probing every request.
|
||||||
|
if (sig.attempted && sig.ok && !sig.headerNameMatchesConfig) {
|
||||||
|
logger('webhookAuth',
|
||||||
|
`Detected signature on header "${sig.headerName}" but SC_WEBHOOK_SIGNATURE_HEADER is set to "${(process.env.SC_WEBHOOK_SIGNATURE_HEADER || 'sign-data').toLowerCase()}". Update .env to pin it.`,
|
||||||
|
'warn'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Something failed — write a detailed diagnostic in log mode.
|
||||||
|
const failedReasons = [];
|
||||||
|
if (sig.attempted && !sig.ok) failedReasons.push(`sig:${sig.reason}`);
|
||||||
|
if (tok.attempted && !tok.ok) failedReasons.push(`tok:${tok.reason}`);
|
||||||
|
|
||||||
|
if (mode === 'log') {
|
||||||
|
logger('webhookAuth', `WOULD-REJECT (log-only): ${failedReasons.join('; ')}`, 'warn');
|
||||||
|
if (sig.attempted && !sig.ok) {
|
||||||
|
logger('webhookAuth',
|
||||||
|
` provided[${sig.headerName || '?'}]=${sig.providedPreview || '(none)'} ` +
|
||||||
|
`expected.hex=${sig.expectedHexPreview || 'n/a'} ` +
|
||||||
|
`expected.b64=${sig.expectedBase64Preview || 'n/a'}`,
|
||||||
|
'warn'
|
||||||
|
);
|
||||||
|
const headers = summarizeIncomingHeaders(req);
|
||||||
|
logger('webhookAuth', ` incoming x-headers: ${JSON.stringify(headers)}`, 'warn');
|
||||||
|
}
|
||||||
|
} else if (mode === 'enforce') {
|
||||||
|
logger('webhookAuth', `REJECT: ${failedReasons.join('; ')}`, 'warn');
|
||||||
|
return res.status(401).json({ error: 'unauthorized webhook' });
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default verifyWebhook;
|
||||||
939
src/services/approvalService.js
Normal file
939
src/services/approvalService.js
Normal file
|
|
@ -0,0 +1,939 @@
|
||||||
|
/**
|
||||||
|
* src/services/approvalService.js
|
||||||
|
*
|
||||||
|
* Handles the "WAITING FOR APPROVAL" detection + Adaptive Card (v1.3) UX.
|
||||||
|
* - Fetches proposals + current NTE from ServiceChannel.
|
||||||
|
* - Posts markdown itemization summary + approval card.
|
||||||
|
* - Processes card submits: reject superseded proposals, approve, optional NTE override.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
import {
|
||||||
|
getWorkOrderForNte,
|
||||||
|
updateWorkOrderNte,
|
||||||
|
getProposalsAssociatedWithWorkOrder,
|
||||||
|
getProposalByIdOdata,
|
||||||
|
getProposalByNumberOdata,
|
||||||
|
getProposalsToReject,
|
||||||
|
resolveRejectReasonCodeId,
|
||||||
|
rejectProposal,
|
||||||
|
approveProposal,
|
||||||
|
} from '../integrations/serviceChannel/client.js';
|
||||||
|
import webexService from './webexService.js';
|
||||||
|
|
||||||
|
const CARD_DEDUP_TTL_MS = 5 * 60 * 1000;
|
||||||
|
const _recentApprovalCards = new Map();
|
||||||
|
|
||||||
|
function _pruneDedupCache(now = Date.now()) {
|
||||||
|
for (const [k, ts] of _recentApprovalCards.entries()) {
|
||||||
|
if (now - ts > CARD_DEDUP_TTL_MS) _recentApprovalCards.delete(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _extractProposalNumber(noteData) {
|
||||||
|
if (!noteData) return null;
|
||||||
|
const m = String(noteData).match(/Proposal\s*#?(\d+)/i);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _dedupKeys(woId, noteData) {
|
||||||
|
const keys = [`wo:${woId}:pending`];
|
||||||
|
const p = _extractProposalNumber(noteData);
|
||||||
|
if (p) keys.push(`wo:${woId}:p:${p}`);
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _proposalStatusPrimary(p) {
|
||||||
|
if (!p) return '';
|
||||||
|
if (typeof p.Status === 'string') return p.Status;
|
||||||
|
return p.Status?.Primary || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _isApproved(p) {
|
||||||
|
return _proposalStatusPrimary(p).toLowerCase() === 'approved';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _proposalRefNumber(p) {
|
||||||
|
return String(p?.Number ?? p?.ProposalNumber ?? p?.ID ?? p?.Id ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _proposalRefId(p) {
|
||||||
|
return p?.Id ?? p?.ID ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _proposalDisplayNumber(p) {
|
||||||
|
return p?.Number ?? p?.ProposalNumber ?? _proposalRefId(p) ?? '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _proposalDetailsUrl(proposal) {
|
||||||
|
const id = _proposalRefId(proposal);
|
||||||
|
if (!id) return null;
|
||||||
|
return `https://www.servicechannel.com/proposal/details/${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _formatMoney(n) {
|
||||||
|
const v = Number(n);
|
||||||
|
return Number.isFinite(v) ? `$${v.toFixed(2)}` : '$0.00';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _filterCategories(categories = []) {
|
||||||
|
return categories.filter((cat) => {
|
||||||
|
const cost = cat.TotalCost;
|
||||||
|
if (cost == null) return false;
|
||||||
|
const name = (cat.Name || '').toLowerCase();
|
||||||
|
if (name.includes('costs incurred to date')) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract line-item arrays from a proposal object (best-effort).
|
||||||
|
*/
|
||||||
|
export function extractLineItems(proposal) {
|
||||||
|
if (!proposal) return [];
|
||||||
|
const candidates = [
|
||||||
|
proposal.Items,
|
||||||
|
proposal.LineItems,
|
||||||
|
proposal.ProposalItems,
|
||||||
|
proposal.items,
|
||||||
|
proposal.Charges,
|
||||||
|
proposal.Materials,
|
||||||
|
];
|
||||||
|
for (const arr of candidates) {
|
||||||
|
if (Array.isArray(arr) && arr.length > 0) return arr;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function _lineItemPart(it) {
|
||||||
|
return it.PartNum || it.PartNumber || it.SKU || it.Code || it.Name || it.Description || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _lineItemQty(it) {
|
||||||
|
const q = it.Quantity ?? it.Qty ?? it.NumOfTech;
|
||||||
|
return q != null ? String(q) : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _lineItemUnitPrice(it) {
|
||||||
|
const p = it.UnitPrice ?? it.HourlyRate ?? it.Rate;
|
||||||
|
return p != null ? _formatMoney(p) : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _lineItemTotal(it) {
|
||||||
|
const t = it.Amount ?? it.Cost ?? it.Total ?? it.Value;
|
||||||
|
return t != null ? _formatMoney(t) : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SC-invoice-style markdown summary for a proposal.
|
||||||
|
*/
|
||||||
|
export function formatProposalMarkdown(wo, proposal, context = {}) {
|
||||||
|
if (!proposal) {
|
||||||
|
return `**Proposal Approval Required** — WO-${wo?.Number || wo?.Id || '???'}\n\nNo proposal details available.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const woNum = wo?.Number || wo?.Id || '???';
|
||||||
|
const woLink = `https://www.servicechannel.com/sc/wo/Workorders/index?id=${wo?.Id || woNum}`;
|
||||||
|
const store = wo?.LocationStoreId != null && wo?.LocationStoreId !== ''
|
||||||
|
? `Store ${wo.LocationStoreId}`
|
||||||
|
: (wo?.LocationName || '');
|
||||||
|
const provider = wo?.ProviderName || context.providerName || '';
|
||||||
|
const pNum = _proposalDisplayNumber(proposal);
|
||||||
|
const proposalDetailsUrl = _proposalDetailsUrl(proposal);
|
||||||
|
const pStatus = typeof proposal.Status === 'object'
|
||||||
|
? `${proposal.Status.Primary || ''}${proposal.Status.Extended ? ` | ${proposal.Status.Extended}` : ''}`
|
||||||
|
: (proposal.Status || 'Open');
|
||||||
|
const created = proposal.CreatedDate
|
||||||
|
? new Date(proposal.CreatedDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
|
: '';
|
||||||
|
const desc = (proposal.Description || proposal.Description2 || proposal.Comments || '').toString().trim();
|
||||||
|
const total = Number(proposal.Amount ?? proposal.Total ?? proposal.TotalAmount ?? 0);
|
||||||
|
|
||||||
|
let md = `## Proposal #${pNum}\n\n`;
|
||||||
|
if (proposalDetailsUrl) {
|
||||||
|
md += `[View full proposal details in ServiceChannel](${proposalDetailsUrl})\n\n`;
|
||||||
|
}
|
||||||
|
md += `[WO-${woNum}](${woLink})`;
|
||||||
|
if (store) md += ` | ${store}`;
|
||||||
|
if (provider) md += ` | ${provider}`;
|
||||||
|
md += '\n\n';
|
||||||
|
if (created) md += `**Created:** ${created} \n`;
|
||||||
|
md += `**Status:** ${pStatus} \n`;
|
||||||
|
if (desc) md += `**Description:** ${desc}\n`;
|
||||||
|
md += '\n';
|
||||||
|
|
||||||
|
const lineItems = extractLineItems(proposal);
|
||||||
|
if (lineItems.length > 0) {
|
||||||
|
md += '### Materials / Line Items\n\n';
|
||||||
|
md += '| Part | Qty | Unit Price | Total |\n';
|
||||||
|
md += '| --- | ---: | ---: | ---: |\n';
|
||||||
|
for (const it of lineItems) {
|
||||||
|
md += `| ${_lineItemPart(it)} | ${_lineItemQty(it)} | ${_lineItemUnitPrice(it)} | ${_lineItemTotal(it)} |\n`;
|
||||||
|
}
|
||||||
|
md += '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = _filterCategories(proposal.AmountCategories || []);
|
||||||
|
if (categories.length > 0 || total > 0) {
|
||||||
|
md += '### Summary\n\n';
|
||||||
|
md += '| Category | Amount |\n';
|
||||||
|
md += '| --- | ---: |\n';
|
||||||
|
for (const cat of categories) {
|
||||||
|
md += `| ${cat.Name || 'Category'} | ${_formatMoney(cat.TotalCost)} |\n`;
|
||||||
|
}
|
||||||
|
if (total > 0) {
|
||||||
|
md += `| **Total** | **${_formatMoney(total)}** |\n`;
|
||||||
|
}
|
||||||
|
md += '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
const toReject = context.proposalsToReject || [];
|
||||||
|
if (toReject.length > 0) {
|
||||||
|
const list = toReject.map((p) => `#${p.number || p.id} (${_formatMoney(p.amount)})`).join(', ');
|
||||||
|
md += `> **Note:** Approving this proposal will reject prior proposal(s): ${list}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return md.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildCategoryColumnSet(categories, total) {
|
||||||
|
const rows = [];
|
||||||
|
const header = {
|
||||||
|
type: 'ColumnSet',
|
||||||
|
columns: [
|
||||||
|
{ type: 'Column', width: 'stretch', items: [{ type: 'TextBlock', text: 'Category', weight: 'bolder', size: 'small' }] },
|
||||||
|
{ type: 'Column', width: 'auto', items: [{ type: 'TextBlock', text: 'Amount', weight: 'bolder', size: 'small', horizontalAlignment: 'right' }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
rows.push(header);
|
||||||
|
|
||||||
|
for (const cat of categories) {
|
||||||
|
rows.push({
|
||||||
|
type: 'ColumnSet',
|
||||||
|
spacing: 'none',
|
||||||
|
columns: [
|
||||||
|
{ type: 'Column', width: 'stretch', items: [{ type: 'TextBlock', text: cat.Name || 'Category', size: 'small', wrap: true }] },
|
||||||
|
{ type: 'Column', width: 'auto', items: [{ type: 'TextBlock', text: _formatMoney(cat.TotalCost), size: 'small', horizontalAlignment: 'right' }] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total > 0) {
|
||||||
|
rows.push({
|
||||||
|
type: 'ColumnSet',
|
||||||
|
separator: true,
|
||||||
|
spacing: 'small',
|
||||||
|
columns: [
|
||||||
|
{ type: 'Column', width: 'stretch', items: [{ type: 'TextBlock', text: 'Total', weight: 'bolder', size: 'small' }] },
|
||||||
|
{ type: 'Column', width: 'auto', items: [{ type: 'TextBlock', text: _formatMoney(total), weight: 'bolder', size: 'small', horizontalAlignment: 'right' }] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _normalizeProposalsToReject(raw = []) {
|
||||||
|
return raw.map((p) => ({
|
||||||
|
id: _proposalRefId(p),
|
||||||
|
number: p.Number || p.ProposalNumber || p.ID || p.Id,
|
||||||
|
amount: Number(p.Amount ?? p.Total ?? p.TotalAmount ?? 0),
|
||||||
|
})).filter((p) => p.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the pending proposal from associated list.
|
||||||
|
* Prefers note-parsed proposal #, else newest non-approved by CreatedDate.
|
||||||
|
*/
|
||||||
|
export function selectPendingProposal(associated = [], parsedProposalNumber = null) {
|
||||||
|
if (!associated.length) return null;
|
||||||
|
|
||||||
|
if (parsedProposalNumber) {
|
||||||
|
const match = associated.find((p) => _proposalRefNumber(p) === String(parsedProposalNumber));
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = associated.filter((p) => !_isApproved(p));
|
||||||
|
const pool = pending.length > 0 ? pending : associated;
|
||||||
|
|
||||||
|
const sorted = [...pool].sort((a, b) => {
|
||||||
|
const da = new Date(a.CreatedDate || a.CreatedDate_dto || 0).getTime();
|
||||||
|
const db = new Date(b.CreatedDate || b.CreatedDate_dto || 0).getTime();
|
||||||
|
return db - da;
|
||||||
|
});
|
||||||
|
|
||||||
|
return sorted[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a v1.3 Adaptive Card for proposal approval.
|
||||||
|
*/
|
||||||
|
export function buildApprovalAdaptiveCard(wo, proposals = [], currentNte = 0, options = {}) {
|
||||||
|
const { proposalsToReject = [] } = options;
|
||||||
|
const woNum = wo?.Number || wo?.Id || '???';
|
||||||
|
const woLink = `https://www.servicechannel.com/sc/wo/Workorders/index?id=${wo?.Id || woNum}`;
|
||||||
|
const store = wo?.LocationStoreId ? `Store ${wo.LocationStoreId}` : (wo?.LocationName || '');
|
||||||
|
const status = `${wo?.Status?.Primary || 'IN PROGRESS'} | ${wo?.Status?.Extended || 'WAITING FOR APPROVAL'}`;
|
||||||
|
|
||||||
|
const proposal = proposals?.length > 0 ? proposals[0] : null;
|
||||||
|
|
||||||
|
let proposalAmount = 0;
|
||||||
|
if (proposal) {
|
||||||
|
proposalAmount = Number(
|
||||||
|
proposal.Amount ?? proposal.Total ?? proposal.TotalAmount ?? proposal.Cost ?? proposal.Value ?? proposal.Nte ?? 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = extractLineItems(proposal);
|
||||||
|
if (!proposalAmount && items.length > 0) {
|
||||||
|
proposalAmount = items.reduce((sum, it) => sum + Number(it.Amount || it.Cost || it.Total || it.Value || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestedNte = (Number(currentNte) || 0) + proposalAmount;
|
||||||
|
|
||||||
|
const body = [
|
||||||
|
{
|
||||||
|
type: 'TextBlock',
|
||||||
|
text: 'Proposal Approval Required',
|
||||||
|
size: 'medium',
|
||||||
|
weight: 'bolder',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'TextBlock',
|
||||||
|
text: `[WO-${woNum}](${woLink}) • ${store}`,
|
||||||
|
isSubtle: true,
|
||||||
|
wrap: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'FactSet',
|
||||||
|
facts: [
|
||||||
|
{ title: 'Current Status', value: status },
|
||||||
|
{ title: 'Current NTE', value: currentNte ? _formatMoney(currentNte) : 'N/A' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (proposalsToReject.length > 0) {
|
||||||
|
const list = proposalsToReject.map((p) => `#${p.number || p.id} (${_formatMoney(p.amount)})`).join(', ');
|
||||||
|
body.push({
|
||||||
|
type: 'TextBlock',
|
||||||
|
text: `Approving will reject prior proposal(s): ${list}`,
|
||||||
|
color: 'warning',
|
||||||
|
weight: 'bolder',
|
||||||
|
wrap: true,
|
||||||
|
spacing: 'medium',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proposal) {
|
||||||
|
const pNum = _proposalDisplayNumber(proposal);
|
||||||
|
const proposalDetailsUrl = _proposalDetailsUrl(proposal);
|
||||||
|
body.push({
|
||||||
|
type: 'TextBlock',
|
||||||
|
text: `Proposal #${pNum}`,
|
||||||
|
weight: 'bolder',
|
||||||
|
spacing: 'medium',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (proposalDetailsUrl) {
|
||||||
|
body.push({
|
||||||
|
type: 'TextBlock',
|
||||||
|
text: `[View full proposal details in ServiceChannel](${proposalDetailsUrl})`,
|
||||||
|
size: 'small',
|
||||||
|
wrap: true,
|
||||||
|
spacing: 'small',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proposalAmount > 0) {
|
||||||
|
body.push({
|
||||||
|
type: 'TextBlock',
|
||||||
|
text: `Amount to add to NTE: **${_formatMoney(proposalAmount)}**`,
|
||||||
|
weight: 'bolder',
|
||||||
|
color: 'attention',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const pDesc = (proposal.Description || proposal.Description2 || proposal.Comments || '').toString().substring(0, 200);
|
||||||
|
if (pDesc) {
|
||||||
|
body.push({
|
||||||
|
type: 'TextBlock',
|
||||||
|
text: pDesc + (pDesc.length >= 200 ? '…' : ''),
|
||||||
|
size: 'small',
|
||||||
|
wrap: true,
|
||||||
|
isSubtle: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = _filterCategories(proposal.AmountCategories || []);
|
||||||
|
if (categories.length > 0) {
|
||||||
|
body.push({
|
||||||
|
type: 'TextBlock',
|
||||||
|
text: 'Proposal Charges',
|
||||||
|
weight: 'bolder',
|
||||||
|
spacing: 'medium',
|
||||||
|
});
|
||||||
|
body.push(..._buildCategoryColumnSet(categories, proposalAmount));
|
||||||
|
} else if (proposalAmount > 0) {
|
||||||
|
body.push({
|
||||||
|
type: 'TextBlock',
|
||||||
|
text: `Total Proposal Amount: ${_formatMoney(proposalAmount)} (detailed categories not available)`,
|
||||||
|
size: 'small',
|
||||||
|
isSubtle: true,
|
||||||
|
wrap: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
body.push({
|
||||||
|
type: 'TextBlock',
|
||||||
|
text: 'No specific proposal details found. You can still adjust the NTE manually below.',
|
||||||
|
wrap: true,
|
||||||
|
isSubtle: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
body.push(
|
||||||
|
{
|
||||||
|
type: 'TextBlock',
|
||||||
|
text: 'New NTE Amount (current NTE + proposal amount + any additional work)',
|
||||||
|
weight: 'bolder',
|
||||||
|
spacing: 'medium',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Input.Number',
|
||||||
|
id: 'newNte',
|
||||||
|
value: suggestedNte || currentNte || 0,
|
||||||
|
placeholder: 'Enter final approved NTE',
|
||||||
|
min: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'TextBlock',
|
||||||
|
text: 'Optional comment / additional instructions (will be recorded with the approval)',
|
||||||
|
spacing: 'small',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Input.Text',
|
||||||
|
id: 'comment',
|
||||||
|
placeholder: 'e.g. Approved as quoted + $75 for expedited parts',
|
||||||
|
isMultiline: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
||||||
|
type: 'AdaptiveCard',
|
||||||
|
version: '1.3',
|
||||||
|
body,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
type: 'Action.Submit',
|
||||||
|
title: 'Approve Proposal',
|
||||||
|
data: {
|
||||||
|
action: 'approveNte',
|
||||||
|
workOrderId: wo?.Id || woNum,
|
||||||
|
proposalId: proposal?.Id || proposal?.ID || null,
|
||||||
|
proposalNumber: proposal?.Number || proposal?.ProposalNumber || null,
|
||||||
|
suggestedNte,
|
||||||
|
currentNte: Number(currentNte) || 0,
|
||||||
|
proposalAmount,
|
||||||
|
proposalsToRejectJson: JSON.stringify(proposalsToReject),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Action.Submit',
|
||||||
|
title: 'Cancel',
|
||||||
|
associatedInputs: 'none',
|
||||||
|
data: {
|
||||||
|
action: 'dismissApprovalCard',
|
||||||
|
workOrderId: wo?.Id || woNum,
|
||||||
|
proposalId: proposal?.Id || proposal?.ID || null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared flow: fetch proposal data, post markdown summary + approval card.
|
||||||
|
*/
|
||||||
|
export async function postApprovalPackage(webexClient, roomId, woObj, noteData = null, { skipDedup = false, db = null } = {}) {
|
||||||
|
if (!roomId || !woObj) return;
|
||||||
|
|
||||||
|
const woId = woObj.Id;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (!skipDedup) {
|
||||||
|
_pruneDedupCache(now);
|
||||||
|
const dedupKeys = _dedupKeys(woId, noteData);
|
||||||
|
for (const k of dedupKeys) {
|
||||||
|
const lastPostedAt = _recentApprovalCards.get(k);
|
||||||
|
if (lastPostedAt && (now - lastPostedAt) < CARD_DEDUP_TTL_MS) {
|
||||||
|
logger('approval', `Skipping duplicate approval card for WO ${woId} (posted ${Math.round((now - lastPostedAt) / 1000)}s ago, key=${k})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const k of dedupKeys) _recentApprovalCards.set(k, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dedupKeys = _dedupKeys(woId, noteData);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedProposalNumber = _extractProposalNumber(noteData);
|
||||||
|
|
||||||
|
const [associated, proposalsToRejectRaw, woDetails] = await Promise.all([
|
||||||
|
getProposalsAssociatedWithWorkOrder(woId),
|
||||||
|
getProposalsToReject(woId),
|
||||||
|
getWorkOrderForNte(woId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger('approval', `Found ${associated.length} associated proposals for WO ${woId}; ${proposalsToRejectRaw.length} to reject`);
|
||||||
|
|
||||||
|
let selectedProposal = null;
|
||||||
|
let proposalIdForLog = null;
|
||||||
|
|
||||||
|
if (associated.length > 0) {
|
||||||
|
const target = selectPendingProposal(associated, parsedProposalNumber);
|
||||||
|
const pid = target ? (target.ID || target.Id) : null;
|
||||||
|
if (pid) {
|
||||||
|
selectedProposal = await getProposalByIdOdata(pid);
|
||||||
|
proposalIdForLog = pid;
|
||||||
|
}
|
||||||
|
} else if (parsedProposalNumber) {
|
||||||
|
logger('approval', `No associated proposals for WO ${woId}; trying OData by proposal #${parsedProposalNumber}`);
|
||||||
|
selectedProposal = await getProposalByNumberOdata(parsedProposalNumber);
|
||||||
|
if (selectedProposal) {
|
||||||
|
proposalIdForLog = selectedProposal.Id || selectedProposal.ID || parsedProposalNumber;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const proposalsToReject = _normalizeProposalsToReject(proposalsToRejectRaw)
|
||||||
|
.filter((p) => !proposalIdForLog || String(p.id) !== String(proposalIdForLog));
|
||||||
|
const currentNte = woDetails?.Nte ?? woObj?.Nte ?? 0;
|
||||||
|
const woForDisplay = { ...woObj, ...woDetails };
|
||||||
|
|
||||||
|
const sender = webexClient && typeof webexClient.sendAdaptiveCard === 'function'
|
||||||
|
? webexClient
|
||||||
|
: webexService;
|
||||||
|
|
||||||
|
const markdown = formatProposalMarkdown(woForDisplay, selectedProposal, { proposalsToReject });
|
||||||
|
await sender.sendMarkdown(roomId, markdown);
|
||||||
|
|
||||||
|
const card = buildApprovalAdaptiveCard(
|
||||||
|
woForDisplay,
|
||||||
|
selectedProposal ? [selectedProposal] : [],
|
||||||
|
currentNte,
|
||||||
|
{ proposalsToReject }
|
||||||
|
);
|
||||||
|
|
||||||
|
const proposalAmountForFallback = selectedProposal
|
||||||
|
? Number(selectedProposal.Amount || selectedProposal.Total || selectedProposal.TotalAmount || 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const fallback = `Approval card for WO-${woObj.Number || woId}. Proposal amount: ${_formatMoney(proposalAmountForFallback)}. Suggested NTE: ${_formatMoney((Number(currentNte) || 0) + proposalAmountForFallback)}.`;
|
||||||
|
|
||||||
|
if (db) {
|
||||||
|
const existing = await getPendingApprovalCard(db, woId);
|
||||||
|
if (existing?.messageId) {
|
||||||
|
await deleteApprovalCardMessage(existing.messageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardMsg = await sender.sendAdaptiveCard(roomId, card, fallback);
|
||||||
|
if (db && cardMsg?.id) {
|
||||||
|
await savePendingApprovalCard(db, {
|
||||||
|
workOrderId: woId,
|
||||||
|
roomId,
|
||||||
|
messageId: cardMsg.id,
|
||||||
|
proposalId: proposalIdForLog,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
logger('approval', `Posted approval package for WO-${woObj.Number || woId} in room ${roomId} (proposalId=${proposalIdForLog || 'n/a'}, reject=${proposalsToReject.length}, messageId=${cardMsg?.id || 'n/a'})`);
|
||||||
|
} catch (err) {
|
||||||
|
if (!skipDedup) {
|
||||||
|
for (const k of dedupKeys) _recentApprovalCards.delete(k);
|
||||||
|
}
|
||||||
|
logger('approval', `Failed to post approval package for WO ${woId}: ${err.message}`, 'error');
|
||||||
|
try {
|
||||||
|
const md = `**Action Required: Proposal Approval for WO-${woObj?.Number || woId}**\n\nStatus: ${woObj?.Status?.Primary} | ${woObj?.Status?.Extended}\n\nPlease review proposals in ServiceChannel and update NTE manually.`;
|
||||||
|
const sender = webexClient?.sendMarkdown ? webexClient : webexService;
|
||||||
|
await sender.sendMarkdown(roomId, md);
|
||||||
|
} catch (_) {}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAndPostApprovalCardIfNeeded(webexClient, roomId, woObj, noteData = null, { db = null } = {}) {
|
||||||
|
return postApprovalPackage(webexClient, roomId, woObj, noteData, { db });
|
||||||
|
}
|
||||||
|
|
||||||
|
function _parseProposalsToRejectFromSubmit(inputs) {
|
||||||
|
if (inputs.proposalsToRejectJson) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(inputs.proposalsToRejectJson);
|
||||||
|
if (Array.isArray(parsed)) return parsed;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function _isAlreadyRejectedError(msg) {
|
||||||
|
const m = String(msg).toLowerCase();
|
||||||
|
return m.includes('already rejected') || m.includes('was already rejected');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _isProposalResolved(noteData, woObj) {
|
||||||
|
const note = String(noteData || '');
|
||||||
|
const ext = (woObj?.Status?.Extended || '').toUpperCase();
|
||||||
|
if (/Proposal\s*#?\s*(\d+)\s+has been approved/i.test(note)) return true;
|
||||||
|
if (/Proposal\(s\)\s*#\s*(\d+)\s+has been rejected/i.test(note)) return true;
|
||||||
|
if (ext.includes('PROPOSAL APPROVED')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPendingApprovalCard(db, workOrderId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get(
|
||||||
|
'SELECT workOrderId, roomId, messageId, proposalId, postedAt FROM pending_approval_cards WHERE workOrderId = ?',
|
||||||
|
[workOrderId],
|
||||||
|
(err, row) => (err ? reject(err) : resolve(row || null))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePendingApprovalCard(db, { workOrderId, roomId, messageId, proposalId }) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(
|
||||||
|
`INSERT OR REPLACE INTO pending_approval_cards (workOrderId, roomId, messageId, proposalId, postedAt)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[workOrderId, roomId, messageId, proposalId ?? null, new Date().toISOString()],
|
||||||
|
(err) => (err ? reject(err) : resolve())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPendingApprovalCard(db, workOrderId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(
|
||||||
|
'DELETE FROM pending_approval_cards WHERE workOrderId = ?',
|
||||||
|
[workOrderId],
|
||||||
|
(err) => (err ? reject(err) : resolve())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteApprovalCardMessage(messageId) {
|
||||||
|
if (!messageId) return;
|
||||||
|
try {
|
||||||
|
const botClientMod = await import('../integrations/webex/botClient.js');
|
||||||
|
await botClientMod.default.deleteMessage(messageId);
|
||||||
|
logger('approval', `Deleted approval card message ${messageId}`);
|
||||||
|
} catch (err) {
|
||||||
|
logger('approval', `Could not delete approval card ${messageId}: ${err.message}`, 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove the adaptive card message after a successful approval or dismiss. */
|
||||||
|
async function _removeApprovalCard(bot, action) {
|
||||||
|
const messageId = action?.messageId;
|
||||||
|
if (!messageId) return;
|
||||||
|
|
||||||
|
if (typeof bot?.censor === 'function') {
|
||||||
|
try {
|
||||||
|
await bot.censor(messageId);
|
||||||
|
logger('approval:submit', `Removed approval card message ${messageId}`);
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
logger('approval:submit', `bot.censor failed for ${messageId}: ${err.message}`, 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const botClientMod = await import('../integrations/webex/botClient.js');
|
||||||
|
await botClientMod.default.deleteMessage(messageId);
|
||||||
|
logger('approval:submit', `Removed approval card message ${messageId} via API`);
|
||||||
|
} catch (err) {
|
||||||
|
logger('approval:submit', `Could not remove approval card ${messageId}: ${err.message}`, 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove stored approval card when proposal was approved/rejected in ServiceChannel.
|
||||||
|
*/
|
||||||
|
export async function removeApprovalCardIfResolved({ db, workOrderId, woObj, noteData }) {
|
||||||
|
if (!db || !workOrderId) return;
|
||||||
|
if (!_isProposalResolved(noteData, woObj)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = await getPendingApprovalCard(db, workOrderId);
|
||||||
|
if (!row?.messageId) return;
|
||||||
|
|
||||||
|
await deleteApprovalCardMessage(row.messageId);
|
||||||
|
await clearPendingApprovalCard(db, workOrderId);
|
||||||
|
logger('approval', `Removed stale approval card for WO ${workOrderId} (resolved in ServiceChannel)`);
|
||||||
|
} catch (err) {
|
||||||
|
logger('approval', `Failed to remove resolved approval card for WO ${workOrderId}: ${err.message}`, 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _rejectSupersededProposals(woId, proposalsToReject, approver, approverEmail, newProposalNumber) {
|
||||||
|
if (!proposalsToReject.length) return { rejected: [], failed: [] };
|
||||||
|
|
||||||
|
const reasonCodeId = await resolveRejectReasonCodeId();
|
||||||
|
const rejected = [];
|
||||||
|
const failed = [];
|
||||||
|
|
||||||
|
for (const p of proposalsToReject) {
|
||||||
|
const pid = p.id;
|
||||||
|
if (!pid) continue;
|
||||||
|
|
||||||
|
const rejectBody = {
|
||||||
|
Comments: `Superseded by proposal #${newProposalNumber || 'new'} — rejected via ServChan by ${approver}`,
|
||||||
|
ProviderEmail: '',
|
||||||
|
UserEmail: '',
|
||||||
|
RejectReasonCodeId: reasonCodeId,
|
||||||
|
ActionSource: 'Standard',
|
||||||
|
ReasonString: `Superseded by revised proposal — rejected via ServChan by ${approver}`,
|
||||||
|
PinNote: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await rejectProposal(pid, rejectBody);
|
||||||
|
rejected.push(p);
|
||||||
|
logger('approval:submit', `Rejected superseded proposal ${pid} (#${p.number}) for WO ${woId}`);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err.message || err.toString();
|
||||||
|
const scCode = err.response?.data?.ErrorCode;
|
||||||
|
if (_isAlreadyRejectedError(msg)) {
|
||||||
|
logger('approval:submit', `Proposal ${pid} already rejected — skipping`, 'warn');
|
||||||
|
rejected.push(p);
|
||||||
|
} else {
|
||||||
|
failed.push({ ...p, error: msg, scCode });
|
||||||
|
logger('approval:submit', `Failed to reject proposal ${pid}: ${msg}`, 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rejected, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleApprovalSubmit(bot, trigger, { db = null } = {}) {
|
||||||
|
const action = trigger?.attachmentAction;
|
||||||
|
if (!action || !action.inputs) return;
|
||||||
|
|
||||||
|
const inputs = action.inputs;
|
||||||
|
|
||||||
|
if (inputs.action === 'dismissApprovalCard') {
|
||||||
|
const woId = inputs.workOrderId;
|
||||||
|
await _removeApprovalCard(bot, action);
|
||||||
|
if (db && woId) {
|
||||||
|
try {
|
||||||
|
await clearPendingApprovalCard(db, woId);
|
||||||
|
} catch (err) {
|
||||||
|
logger('approval:submit', `Could not clear pending card row for WO ${woId}: ${err.message}`, 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await bot.say('Approval card dismissed. Approve in ServiceChannel when ready.');
|
||||||
|
} catch (_) {}
|
||||||
|
logger('approval:submit', `Dismissed approval card for WO ${woId || 'unknown'}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputs.action !== 'approveNte') return;
|
||||||
|
|
||||||
|
const woId = inputs.workOrderId;
|
||||||
|
const proposalId = inputs.proposalId;
|
||||||
|
const newProposalNumber = inputs.proposalNumber || proposalId;
|
||||||
|
const newNte = inputs.newNte;
|
||||||
|
const comment = inputs.comment || '';
|
||||||
|
|
||||||
|
if (!woId || newNte === undefined) {
|
||||||
|
try { await bot.say('Missing work order or NTE amount in approval submission.'); } catch (_) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericNte = Number(newNte);
|
||||||
|
if (!Number.isFinite(numericNte) || numericNte < 0) {
|
||||||
|
try { await bot.say('Please enter a valid non-negative NTE amount.'); } catch (_) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestedNte = Number(inputs.suggestedNte);
|
||||||
|
const nteWasOverridden = Number.isFinite(suggestedNte) && Math.abs(numericNte - suggestedNte) > 0.005;
|
||||||
|
|
||||||
|
let approver = 'Webex user';
|
||||||
|
let approverEmail = '';
|
||||||
|
try {
|
||||||
|
const botClientMod = await import('../integrations/webex/botClient.js');
|
||||||
|
const details = await botClientMod.default.getPersonDetails(action.personId);
|
||||||
|
approverEmail = details?.emails?.[0] || '';
|
||||||
|
approver = details?.displayName || approverEmail || action.personId || 'Webex user';
|
||||||
|
} catch (e) {
|
||||||
|
logger('approval:submit', `Could not resolve person ${action.personId}: ${e.message}`, 'warn');
|
||||||
|
}
|
||||||
|
|
||||||
|
let proposalsToReject = _parseProposalsToRejectFromSubmit(inputs);
|
||||||
|
let approveSucceeded = false;
|
||||||
|
let nteSucceeded = false;
|
||||||
|
const errorMessages = [];
|
||||||
|
let rejectedList = [];
|
||||||
|
|
||||||
|
// Step 0 — Reject superseded proposals (never reject the proposal we are about to approve)
|
||||||
|
if (proposalsToReject.length === 0) {
|
||||||
|
const fresh = await getProposalsToReject(woId);
|
||||||
|
proposalsToReject = _normalizeProposalsToReject(fresh);
|
||||||
|
}
|
||||||
|
if (proposalId) {
|
||||||
|
proposalsToReject = proposalsToReject.filter((p) => String(p.id) !== String(proposalId));
|
||||||
|
}
|
||||||
|
|
||||||
|
let rejectFailures = [];
|
||||||
|
if (proposalsToReject.length > 0) {
|
||||||
|
const { rejected, failed } = await _rejectSupersededProposals(
|
||||||
|
woId, proposalsToReject, approver, approverEmail, newProposalNumber
|
||||||
|
);
|
||||||
|
rejectedList = rejected;
|
||||||
|
rejectFailures = failed;
|
||||||
|
if (failed.length > 0) {
|
||||||
|
const failMsg = failed.map((f) => `#${f.number || f.id}: ${f.error}`).join('; ');
|
||||||
|
logger('approval:submit', `Pre-approve reject failed for WO ${woId} (will still attempt approve): ${failMsg}`, 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1 — Approve the proposal
|
||||||
|
try {
|
||||||
|
if (proposalId) {
|
||||||
|
const approveBody = {
|
||||||
|
Comments: `Approved by ${approver} via ServChan card${comment ? `: ${comment}` : ''}`,
|
||||||
|
ProviderEmail: '',
|
||||||
|
UserEmail: approverEmail,
|
||||||
|
RejectReasonCodeId: 0,
|
||||||
|
ActionSource: 'Standard',
|
||||||
|
ReasonString: `Approved by ${approver} via ServChan${comment ? `: ${comment}` : ''}`,
|
||||||
|
};
|
||||||
|
await approveProposal(proposalId, approveBody);
|
||||||
|
approveSucceeded = true;
|
||||||
|
logger('approval:submit', `Successfully approved proposal ${proposalId} for WO ${woId} by ${approver}`);
|
||||||
|
} else {
|
||||||
|
errorMessages.push('Missing proposalId — cannot approve via SC API');
|
||||||
|
logger('approval:submit', `Cannot approve WO ${woId}: no proposalId in submit payload`, 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
let msg = err.message || err.toString();
|
||||||
|
|
||||||
|
// Defensive retry: fetch fresh reject list and try once more
|
||||||
|
if (msg.toLowerCase().includes('reject') && proposalsToReject.length === 0) {
|
||||||
|
const fresh = _normalizeProposalsToReject(await getProposalsToReject(woId));
|
||||||
|
if (fresh.length > 0) {
|
||||||
|
const { rejected, failed } = await _rejectSupersededProposals(
|
||||||
|
woId, fresh, approver, approverEmail, newProposalNumber
|
||||||
|
);
|
||||||
|
rejectedList = rejected;
|
||||||
|
rejectFailures = failed;
|
||||||
|
try {
|
||||||
|
await approveProposal(proposalId, {
|
||||||
|
Comments: `Approved by ${approver} via ServChan card${comment ? `: ${comment}` : ''}`,
|
||||||
|
ProviderEmail: '',
|
||||||
|
UserEmail: approverEmail,
|
||||||
|
RejectReasonCodeId: 0,
|
||||||
|
ActionSource: 'Standard',
|
||||||
|
ReasonString: `Approved by ${approver} via ServChan${comment ? `: ${comment}` : ''}`,
|
||||||
|
});
|
||||||
|
approveSucceeded = true;
|
||||||
|
logger('approval:submit', `Approved proposal ${proposalId} after defensive reject retry for WO ${woId}`);
|
||||||
|
} catch (retryErr) {
|
||||||
|
msg = retryErr.message || retryErr.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!approveSucceeded) {
|
||||||
|
errorMessages.push(`Proposal approval: ${msg}`);
|
||||||
|
logger('approval:submit', `Proposal approve failed for ${proposalId}: ${msg}`, 'error');
|
||||||
|
if (msg.includes('804') || msg.includes('no permissions')) {
|
||||||
|
try {
|
||||||
|
const latest = await getProposalByIdOdata(proposalId);
|
||||||
|
const st = latest?.Status?.Primary || latest?.Status;
|
||||||
|
if (st && String(st).toLowerCase() === 'rejected') {
|
||||||
|
errorMessages.push(
|
||||||
|
'The target proposal is already Rejected in ServiceChannel — it may have been rejected in the pre-approve step. Check SC for the correct pending proposal.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2 — Optional NTE override
|
||||||
|
if (approveSucceeded && nteWasOverridden) {
|
||||||
|
try {
|
||||||
|
await updateWorkOrderNte(woId, numericNte);
|
||||||
|
nteSucceeded = true;
|
||||||
|
logger('approval:submit', `NTE overridden to $${numericNte.toFixed(2)} for WO ${woId}`);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err.message || err.toString();
|
||||||
|
logger('approval:submit', `Direct NTE override rejected by SC for WO ${woId}: ${msg}`, 'warn');
|
||||||
|
errorMessages.push(`Could not force NTE to $${numericNte.toFixed(2)} — SC applied proposal amount automatically.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3 — Confirmation
|
||||||
|
if (approveSucceeded) {
|
||||||
|
const nteLine = nteWasOverridden
|
||||||
|
? (nteSucceeded
|
||||||
|
? `NTE overridden to **${_formatMoney(numericNte)}**.`
|
||||||
|
: `NTE stays at SC's auto value (${_formatMoney(suggestedNte)}) — direct override was rejected. Adjust manually in SC if needed.`)
|
||||||
|
: `NTE will be raised to **${_formatMoney(suggestedNte)}** by ServiceChannel automatically.`;
|
||||||
|
|
||||||
|
let rejectLine = '';
|
||||||
|
if (rejectedList.length > 0) {
|
||||||
|
const list = rejectedList.map((p) => `#${p.number || p.id}`).join(', ');
|
||||||
|
rejectLine = `Prior proposal(s) rejected: **${list}**.\n\n`;
|
||||||
|
} else if (rejectFailures.length > 0) {
|
||||||
|
const list = rejectFailures.map((p) => `#${p.number || p.id}`).join(', ');
|
||||||
|
rejectLine = `Note: Could not auto-reject prior proposal(s) ${list} — verify status in ServiceChannel.\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirm =
|
||||||
|
`✅ **Proposal approved** by **${approver}** for WO **${woId}**.\n\n` +
|
||||||
|
rejectLine +
|
||||||
|
`${nteLine}` +
|
||||||
|
(comment ? `\n\nComment: ${comment}` : '') +
|
||||||
|
`\n\nAn audit note has been recorded on the work order in ServiceChannel.`;
|
||||||
|
|
||||||
|
await _removeApprovalCard(bot, action);
|
||||||
|
if (db && woId) {
|
||||||
|
try {
|
||||||
|
await clearPendingApprovalCard(db, woId);
|
||||||
|
} catch (err) {
|
||||||
|
logger('approval:submit', `Could not clear pending card row for WO ${woId}: ${err.message}`, 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await bot.say({ markdown: confirm });
|
||||||
|
logger('approval:submit',
|
||||||
|
`Success for WO ${woId} / proposal ${proposalId || 'n/a'}: rejected=${rejectedList.length}, ` +
|
||||||
|
`nteOverride=${nteWasOverridden ? (nteSucceeded ? 'applied' : 'rejected') : 'not-requested'} by ${approver}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const isTokenError = errorMessages.some((m) => m.includes('token fetch failed'));
|
||||||
|
const confirm = isTokenError
|
||||||
|
? `❌ **ServiceChannel login failed** — ServChan could not authenticate to the SC API.\n\n` +
|
||||||
|
`This usually means \`SC_USERNAME\` / \`SC_PASSWORD\` in \`.env\` do not match the SC user, or the bot was not restarted after updating credentials.\n\n` +
|
||||||
|
`Details: ${errorMessages.join('; ')}\n\n` +
|
||||||
|
`Fix credentials and restart ServChan, then retry approval in ServiceChannel or re-post the card.`
|
||||||
|
: `❌ Failed to approve proposal for WO ${woId}: ${errorMessages.join('; ')}. Please approve manually in ServiceChannel.`;
|
||||||
|
await bot.say({ markdown: confirm });
|
||||||
|
logger('approval:submit', `Complete failure for WO ${woId} / proposal ${proposalId || 'n/a'}: ${errorMessages.join('; ')}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
buildApprovalAdaptiveCard,
|
||||||
|
formatProposalMarkdown,
|
||||||
|
extractLineItems,
|
||||||
|
selectPendingProposal,
|
||||||
|
postApprovalPackage,
|
||||||
|
fetchAndPostApprovalCardIfNeeded,
|
||||||
|
removeApprovalCardIfResolved,
|
||||||
|
handleApprovalSubmit,
|
||||||
|
};
|
||||||
247
src/services/attachmentService.js
Normal file
247
src/services/attachmentService.js
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
/**
|
||||||
|
* src/services/attachmentService.js
|
||||||
|
*
|
||||||
|
* Auto-post ServiceChannel work order attachments to Webex spaces.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
import {
|
||||||
|
listWorkOrderAttachments,
|
||||||
|
downloadAttachment,
|
||||||
|
getContentTypeFromFilename,
|
||||||
|
} from '../integrations/serviceChannel/attachments.js';
|
||||||
|
import {
|
||||||
|
assertCollabSupportConfigured,
|
||||||
|
fetchCollabJson,
|
||||||
|
} from '../integrations/collabSupport/client.js';
|
||||||
|
import {
|
||||||
|
isWebexNativeUpload,
|
||||||
|
prepareWebexUpload,
|
||||||
|
} from '../utils/prepareWebexAttachment.js';
|
||||||
|
|
||||||
|
function isAutoPostEnabled() {
|
||||||
|
const raw = process.env.AUTO_POST_ATTACHMENTS;
|
||||||
|
if (raw == null || raw === '') return true;
|
||||||
|
return !/^(0|false|no|off)$/i.test(String(raw).trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInvoiceAttachment(att) {
|
||||||
|
if (!att) return false;
|
||||||
|
if (att.IsInvoiceDigitalCopy === true) return true;
|
||||||
|
const name = String(att.Name || att.fileName || '').toUpperCase();
|
||||||
|
return /INVOICE_/i.test(name) || name.endsWith('.PDF') && name.includes('INVOICE');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAttachment(att) {
|
||||||
|
return {
|
||||||
|
id: att.Id ?? att.id ?? att.attachmentId ?? null,
|
||||||
|
name: att.Name || att.fileName || `attachment_${att.Id ?? att.id ?? 'unknown'}`,
|
||||||
|
uri: att.Uri || att.downloadUri || null,
|
||||||
|
isInvoice: isInvoiceAttachment(att),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasNumericId(id) {
|
||||||
|
return id != null && /^\d+$/.test(String(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCaption(att, woNumber) {
|
||||||
|
const prefix = att.isInvoice ? 'Invoice' : 'Attachment';
|
||||||
|
const woLabel = woNumber ? `WO **${woNumber}**` : 'work order';
|
||||||
|
return `📎 ${prefix} from ${woLabel}: **${att.name}**`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLinkMarkdown(att, woNumber) {
|
||||||
|
const prefix = att.isInvoice ? 'Invoice' : 'Attachment';
|
||||||
|
const woLabel = woNumber ? `WO **${woNumber}**` : 'work order';
|
||||||
|
return `📎 ${prefix} from ${woLabel}: [${att.name}](${att.uri})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbGetPosted(db, workOrderId, attachmentId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get(
|
||||||
|
'SELECT 1 FROM posted_attachments WHERE workOrderId = ? AND attachmentId = ?',
|
||||||
|
[workOrderId, attachmentId],
|
||||||
|
(err, row) => (err ? reject(err) : resolve(!!row))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbMarkPosted(db, workOrderId, attachmentId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(
|
||||||
|
'INSERT OR IGNORE INTO posted_attachments (workOrderId, attachmentId, postedAt) VALUES (?, ?, ?)',
|
||||||
|
[workOrderId, attachmentId, new Date().toISOString()],
|
||||||
|
(err) => (err ? reject(err) : resolve())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postSingleAttachment(webex, roomId, att, woNumber) {
|
||||||
|
const fileName = att.name;
|
||||||
|
const contentType = getContentTypeFromFilename(fileName);
|
||||||
|
const caption = buildCaption(att, woNumber);
|
||||||
|
|
||||||
|
// Webex cannot ingest HEIC URLs; skip straight to download + convert.
|
||||||
|
if (att.uri && isWebexNativeUpload(fileName)) {
|
||||||
|
try {
|
||||||
|
await webex.sendWithAttachment(roomId, null, fileName, contentType, caption, att.uri);
|
||||||
|
logger('attachments', `Posted ${fileName} via URL for room ${roomId}`);
|
||||||
|
return { ok: true, linkOnly: false };
|
||||||
|
} catch (urlErr) {
|
||||||
|
logger('attachments', `URL post failed for ${fileName}: ${urlErr.message}`, 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (att.uri) {
|
||||||
|
try {
|
||||||
|
const { buffer, contentType: dlType } = await downloadAttachment(att.uri, fileName);
|
||||||
|
const prepared = await prepareWebexUpload(buffer, fileName, dlType || contentType);
|
||||||
|
await webex.sendWithAttachment(
|
||||||
|
roomId,
|
||||||
|
prepared.buffer,
|
||||||
|
prepared.fileName,
|
||||||
|
prepared.contentType,
|
||||||
|
caption
|
||||||
|
);
|
||||||
|
logger('attachments', `Posted ${prepared.fileName} via buffer for room ${roomId}`);
|
||||||
|
return { ok: true, linkOnly: false };
|
||||||
|
} catch (dlErr) {
|
||||||
|
logger('attachments', `Buffer post failed for ${fileName}: ${dlErr.message}`, 'warn');
|
||||||
|
if (/heic-convert|Cannot find package/i.test(dlErr.message)) {
|
||||||
|
logger(
|
||||||
|
'attachments',
|
||||||
|
'HEIC conversion requires heic-convert in node_modules — rebuild the container: docker compose down -v && docker compose up --build',
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (att.uri) {
|
||||||
|
await webex.sendMarkdown(roomId, buildLinkMarkdown(att, woNumber));
|
||||||
|
logger('attachments', `Posted link fallback for ${fileName} in room ${roomId}`);
|
||||||
|
return { ok: true, linkOnly: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
logger('attachments', `No URI for attachment ${att.id} (${fileName})`, 'warn');
|
||||||
|
return { ok: false, linkOnly: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCollabAttachments(woNumber) {
|
||||||
|
try {
|
||||||
|
assertCollabSupportConfigured();
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetchCollabJson(
|
||||||
|
'/woAttachments',
|
||||||
|
{ woId: woNumber },
|
||||||
|
{ logTag: 'attachments:collab', timeoutMs: 20_000 }
|
||||||
|
);
|
||||||
|
if (!result.ok || !result.data?.success) return [];
|
||||||
|
return (result.data.attachments || []).map((a) => normalizeAttachment({
|
||||||
|
id: a.attachmentId || a.id,
|
||||||
|
Name: a.fileName,
|
||||||
|
Uri: a.downloadUri,
|
||||||
|
fileName: a.fileName,
|
||||||
|
downloadUri: a.downloadUri,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
logger('attachments:collab', `CollabSupport fallback failed: ${err.message}`, 'warn');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post work order attachments to a Webex room.
|
||||||
|
*
|
||||||
|
* @param {object} options
|
||||||
|
* @param {object} options.db - sqlite3 db instance
|
||||||
|
* @param {object} options.webex - client with sendWithAttachment + sendMarkdown
|
||||||
|
* @param {string} options.roomId
|
||||||
|
* @param {number|string} options.workOrderId - SC internal WO id
|
||||||
|
* @param {number[]|string[]|null} [options.attachmentIds] - if set, only post these ids
|
||||||
|
* @param {string|number} [options.woNumber] - display WO number for captions
|
||||||
|
* @param {boolean} [options.skipDedup=false] - for manual /woAttachments resync
|
||||||
|
*/
|
||||||
|
export async function postWorkOrderAttachments({
|
||||||
|
db,
|
||||||
|
webex,
|
||||||
|
roomId,
|
||||||
|
workOrderId,
|
||||||
|
attachmentIds = null,
|
||||||
|
woNumber = null,
|
||||||
|
skipDedup = false,
|
||||||
|
}) {
|
||||||
|
if (!isAutoPostEnabled() && !skipDedup) {
|
||||||
|
logger('attachments', 'AUTO_POST_ATTACHMENTS is disabled — skipping');
|
||||||
|
return { posted: 0, skipped: 0, links: 0 };
|
||||||
|
}
|
||||||
|
if (!db || !webex || !roomId || !workOrderId) {
|
||||||
|
logger('attachments', 'Missing required params for postWorkOrderAttachments', 'warn');
|
||||||
|
return { posted: 0, skipped: 0, links: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw = await listWorkOrderAttachments(workOrderId);
|
||||||
|
if (!raw.length && woNumber) {
|
||||||
|
raw = await fetchCollabAttachments(woNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = raw.map(normalizeAttachment).filter((a) => a.uri);
|
||||||
|
|
||||||
|
const idFilter = attachmentIds?.length
|
||||||
|
? new Set(attachmentIds.map((id) => String(id)))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
let posted = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let links = 0;
|
||||||
|
|
||||||
|
for (const att of normalized) {
|
||||||
|
if (idFilter && att.id != null && !idFilter.has(String(att.id))) continue;
|
||||||
|
if (idFilter && att.id == null) continue;
|
||||||
|
|
||||||
|
if (!skipDedup && hasNumericId(att.id)) {
|
||||||
|
try {
|
||||||
|
const already = await dbGetPosted(db, workOrderId, att.id);
|
||||||
|
if (already) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger('attachments', `Dedup check failed for ${att.id}: ${err.message}`, 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await postSingleAttachment(webex, roomId, att, woNumber || workOrderId);
|
||||||
|
if (result.ok) {
|
||||||
|
if (result.linkOnly) links++;
|
||||||
|
if (!skipDedup && hasNumericId(att.id)) {
|
||||||
|
await dbMarkPosted(db, workOrderId, att.id);
|
||||||
|
}
|
||||||
|
posted++;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger('attachments', `Failed to post ${att.name} (${att.id}): ${err.message}`, 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (posted > 0 || skipped > 0 || links > 0) {
|
||||||
|
logger(
|
||||||
|
'attachments',
|
||||||
|
`WO ${workOrderId}: posted=${posted}, links=${links}, skipped=${skipped}` +
|
||||||
|
(idFilter ? ` (filter=${[...idFilter].join(',')})` : ' (all)')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { posted, skipped, links };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
postWorkOrderAttachments,
|
||||||
|
isAutoPostEnabled,
|
||||||
|
};
|
||||||
179
src/services/spaceCleanupService.js
Normal file
179
src/services/spaceCleanupService.js
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
// src/services/spaceCleanupService.js
|
||||||
|
import { getWorkOrderStatus } from '../integrations/serviceChannel/client.js';
|
||||||
|
import botClient from '../integrations/webex/botClient.js';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
import defaultDb from '../db/mappings.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the space cleanup job.
|
||||||
|
*
|
||||||
|
* @param {boolean} dryRun
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {object} [options.db] - Optional sqlite3 database instance.
|
||||||
|
* When provided, this allows the caller (e.g. the main app) to ensure we are
|
||||||
|
* always using the exact same database connection that the production bot is using.
|
||||||
|
*/
|
||||||
|
export async function runSpaceCleanup(dryRun = true, options = {}) {
|
||||||
|
const { db = defaultDb } = options;
|
||||||
|
|
||||||
|
const mode = dryRun ? '[DRY-RUN]' : '[LIVE]';
|
||||||
|
|
||||||
|
const REMOVE_OTHERS_AFTER_DAYS = 14; // "archive-like" step
|
||||||
|
const DELETE_AFTER_DAYS = 60;
|
||||||
|
|
||||||
|
console.log(`[SPACE-CLEANUP] ${mode} Starting job (Remove others ≥${REMOVE_OTHERS_AFTER_DAYS}d | Delete ≥${DELETE_AFTER_DAYS}d)...`);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
let qualifying = 0, removedOthers = 0, deleted = 0, skipped = 0, failed = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mappings = await new Promise((resolve, reject) => {
|
||||||
|
db.all('SELECT workOrderId, roomId FROM mappings', [], (err, rows) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(rows || []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const mapping of mappings) {
|
||||||
|
try {
|
||||||
|
const statusInfo = await getWorkOrderStatus(mapping.workOrderId);
|
||||||
|
if (!statusInfo) {
|
||||||
|
results.push({ woId: mapping.workOrderId, roomId: mapping.roomId, action: 'skipped', status: 'UNKNOWN', reason: 'status fetch failed' });
|
||||||
|
failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const primary = statusInfo.primaryStatus?.toUpperCase() || 'UNKNOWN';
|
||||||
|
const extended = statusInfo.extendedStatus?.toUpperCase() || '';
|
||||||
|
const statusDisplay = extended ? `${primary} (${extended})` : primary;
|
||||||
|
|
||||||
|
if (primary !== 'INVOICED' && primary !== 'COMPLETED') {
|
||||||
|
results.push({ woId: mapping.workOrderId, roomId: mapping.roomId, action: 'skipped', status: statusDisplay, reason: 'not qualifying' });
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
qualifying++;
|
||||||
|
|
||||||
|
const daysSinceUpdate = Math.floor((Date.now() - new Date(statusInfo.updatedDate)) / (1000 * 3600 * 24));
|
||||||
|
|
||||||
|
let action = 'skipped';
|
||||||
|
let reason = '';
|
||||||
|
|
||||||
|
if (daysSinceUpdate >= DELETE_AFTER_DAYS) {
|
||||||
|
action = 'delete';
|
||||||
|
reason = `${daysSinceUpdate} days → delete room`;
|
||||||
|
} else if (daysSinceUpdate >= REMOVE_OTHERS_AFTER_DAYS) {
|
||||||
|
action = 'remove_others';
|
||||||
|
reason = `${daysSinceUpdate} days → remove other members`;
|
||||||
|
} else {
|
||||||
|
reason = `only ${daysSinceUpdate} days old`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'skipped') {
|
||||||
|
skipped++;
|
||||||
|
results.push({ woId: mapping.workOrderId, roomId: mapping.roomId, action: 'skipped', days: daysSinceUpdate, status: statusDisplay, reason });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
results.push({ woId: mapping.workOrderId, roomId: mapping.roomId, action: `would_${action}`, days: daysSinceUpdate, status: statusDisplay, reason });
|
||||||
|
} else {
|
||||||
|
if (action === 'remove_others') {
|
||||||
|
await removeAllOtherMembers(mapping.roomId);
|
||||||
|
logger('SPACE-CLEANUP', `Removed all other members from room ${mapping.roomId} for WO ${mapping.workOrderId} [${statusDisplay}]`);
|
||||||
|
removedOthers++;
|
||||||
|
} else if (action === 'delete') {
|
||||||
|
await botClient.deleteRoom(mapping.roomId);
|
||||||
|
deleted++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up mapping
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
db.run('DELETE FROM mappings WHERE workOrderId = ?', [mapping.workOrderId], (err) => {
|
||||||
|
err ? reject(err) : resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
results.push({ woId: mapping.workOrderId, roomId: mapping.roomId, action, days: daysSinceUpdate, status: statusDisplay, reason });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
results.push({ woId: mapping.workOrderId, roomId: mapping.roomId, action: 'failed', status: 'ERROR', reason: err.message });
|
||||||
|
failed++;
|
||||||
|
logger('SPACE-CLEANUP', `Failed for WO ${mapping.workOrderId}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = { totalChecked: mappings.length, qualifying, removedOthers, deleted, skipped, failed, dryRun };
|
||||||
|
console.log(`[SPACE-CLEANUP] ${mode} Job completed:`, summary);
|
||||||
|
logger('SPACE-CLEANUP', `${mode} Job completed: ${JSON.stringify(summary)}`);
|
||||||
|
|
||||||
|
return { summary, results, mode };
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[SPACE-CLEANUP] Critical error:`, err.message);
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the bot's own identity so we never accidentally kick ourselves out of
|
||||||
|
// a room during "remove other members". We resolve it once from the Webex API,
|
||||||
|
// which is authoritative regardless of what env vars are set.
|
||||||
|
let _cachedBotPerson = null;
|
||||||
|
async function getBotIdentity(axiosInstance) {
|
||||||
|
if (_cachedBotPerson) return _cachedBotPerson;
|
||||||
|
|
||||||
|
// Prefer env-provided ID if set; still fetch email so the email-based skip
|
||||||
|
// works too even when the person ID is stale.
|
||||||
|
const envPersonId = process.env.WEBEX_BOT_PERSON_ID || null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await axiosInstance.get('/people/me');
|
||||||
|
_cachedBotPerson = {
|
||||||
|
id: data?.id || envPersonId,
|
||||||
|
emails: (data?.emails || []).map(e => e.toLowerCase()),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
logger('SPACE-CLEANUP', `Failed to resolve bot identity via /people/me: ${err.message}`, 'warn');
|
||||||
|
// Fall back to env-only. Emails list stays empty — the caller will still
|
||||||
|
// skip anything matching the well-known bot email suffix below.
|
||||||
|
_cachedBotPerson = { id: envPersonId, emails: [] };
|
||||||
|
}
|
||||||
|
return _cachedBotPerson;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Remove everyone except the bot itself (best-effort)
|
||||||
|
async function removeAllOtherMembers(roomId) {
|
||||||
|
try {
|
||||||
|
const axiosInstance = botClient.axios;
|
||||||
|
const bot = await getBotIdentity(axiosInstance);
|
||||||
|
|
||||||
|
if (!bot.id && bot.emails.length === 0) {
|
||||||
|
// We couldn't figure out who "we" are. Removing everyone in this state
|
||||||
|
// would evict the bot from its own room, orphaning it. Refuse.
|
||||||
|
logger('SPACE-CLEANUP', `Refusing to remove members from ${roomId}: bot identity unresolved (set WEBEX_BOT_PERSON_ID or verify /people/me works)`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await axiosInstance.get('/memberships', { params: { roomId, max: 200 } });
|
||||||
|
const memberships = data.items || [];
|
||||||
|
|
||||||
|
for (const m of memberships) {
|
||||||
|
const emailLc = (m.personEmail || '').toLowerCase();
|
||||||
|
|
||||||
|
// Skip the bot's own membership by any signal we can get.
|
||||||
|
if (bot.id && m.personId === bot.id) continue;
|
||||||
|
if (emailLc && bot.emails.includes(emailLc)) continue;
|
||||||
|
if (emailLc.endsWith('@webex.bot')) continue; // catches bot mail regardless of local part
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axiosInstance.delete(`/memberships/${m.id}`);
|
||||||
|
logger('SPACE-CLEANUP', `Removed ${m.personEmail || m.personId} from ${roomId}`);
|
||||||
|
} catch (e) {
|
||||||
|
logger('SPACE-CLEANUP', `Failed to remove member ${m.id}: ${e.message}`, 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger('SPACE-CLEANUP', `removeAllOtherMembers failed for ${roomId}: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
221
src/services/staleWorkOrderReportService.js
Normal file
221
src/services/staleWorkOrderReportService.js
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
/**
|
||||||
|
* src/services/staleWorkOrderReportService.js
|
||||||
|
*
|
||||||
|
* Generates the stale work order report for the /stale-workorders endpoint.
|
||||||
|
*
|
||||||
|
* Phase 0 logic:
|
||||||
|
* - Uses only work orders present in our local mappings table.
|
||||||
|
* - Determines staleness using:
|
||||||
|
* - Work order UpdatedDate
|
||||||
|
* - Most recent note's DateCreated
|
||||||
|
* - ScheduledDate (with special rules)
|
||||||
|
* - Flags work orders with no meaningful update in the last 2 days
|
||||||
|
* (or 7 days if they have a future scheduled date).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import pLimit from 'p-limit';
|
||||||
|
import { fetchWithRetry, sleep } from '../integrations/serviceChannel/client.js';
|
||||||
|
import { loadSecrets } from '../config/secrets.js';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
const secrets = loadSecrets();
|
||||||
|
|
||||||
|
// Very conservative concurrency + spacing for ServiceChannel bulk scans.
|
||||||
|
// Even 2 in parallel can trigger 429s without delays + retries.
|
||||||
|
const limit = pLimit(2);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch full details + notes for a single work order.
|
||||||
|
* Uses fetchWithRetry (handles 429/5xx with backoff + Retry-After).
|
||||||
|
* Calls are sequential (not parallel) to keep instantaneous load low.
|
||||||
|
*/
|
||||||
|
async function fetchWorkOrderDetails(workOrderId) {
|
||||||
|
// Work order with the fields we need for staleness + display
|
||||||
|
const woRes = await fetchWithRetry(
|
||||||
|
`/workorders/${workOrderId}`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
$select: 'Id,WorkorderNumber,Status,UpdatedDate,Description,Trade,ProviderName,LocationName,LocationStoreId,ScheduledDate'
|
||||||
|
},
|
||||||
|
timeout: 30000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Notes (most recent by DateCreated is used for last activity)
|
||||||
|
const notesRes = await fetchWithRetry(
|
||||||
|
`/workorders/${workOrderId}/notes`,
|
||||||
|
{ timeout: 30000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const workOrder = woRes.data;
|
||||||
|
const notes = notesRes.data?.Notes || [];
|
||||||
|
|
||||||
|
// Find most recent note by DateCreated
|
||||||
|
let lastNote = null;
|
||||||
|
if (notes.length > 0) {
|
||||||
|
lastNote = notes.reduce((latest, note) => {
|
||||||
|
const noteDate = new Date(note.DateCreated);
|
||||||
|
return !latest || noteDate > new Date(latest.DateCreated) ? note : latest;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
workOrder,
|
||||||
|
notes,
|
||||||
|
lastNote,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if a work order is stale and why.
|
||||||
|
*/
|
||||||
|
function evaluateStaleness(workOrder, lastNote) {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const updatedDate = workOrder.UpdatedDate ? new Date(workOrder.UpdatedDate) : null;
|
||||||
|
const scheduledDate = workOrder.ScheduledDate ? new Date(workOrder.ScheduledDate) : null;
|
||||||
|
const lastNoteDate = lastNote?.DateCreated ? new Date(lastNote.DateCreated) : null;
|
||||||
|
|
||||||
|
// Last meaningful activity = most recent of UpdatedDate and last note
|
||||||
|
const activityDates = [updatedDate, lastNoteDate].filter(Boolean);
|
||||||
|
const lastActivity = activityDates.length > 0
|
||||||
|
? new Date(Math.max(...activityDates.map(d => d.getTime())))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!lastActivity) {
|
||||||
|
// No activity data at all — treat as very stale
|
||||||
|
return {
|
||||||
|
isStale: true,
|
||||||
|
daysSinceActivity: 999,
|
||||||
|
reason: 'No activity data available',
|
||||||
|
category: 'no-data',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const daysSinceActivity = Math.floor((now - lastActivity) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
// Rule per user requirements:
|
||||||
|
// - If has ScheduledDate:
|
||||||
|
// - Flag if ScheduledDate is in the past, OR
|
||||||
|
// - Flag if no update in the last 7 days (even with future schedule)
|
||||||
|
// - Otherwise:
|
||||||
|
// - Flag if no update in last 2 days
|
||||||
|
|
||||||
|
if (scheduledDate) {
|
||||||
|
const scheduledInPast = scheduledDate <= now;
|
||||||
|
|
||||||
|
if (scheduledInPast) {
|
||||||
|
return {
|
||||||
|
isStale: true,
|
||||||
|
daysSinceActivity,
|
||||||
|
reason: `Scheduled for ${scheduledDate.toISOString().split('T')[0]} (date has passed)`,
|
||||||
|
category: 'scheduled-past',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (daysSinceActivity >= 7) {
|
||||||
|
return {
|
||||||
|
isStale: true,
|
||||||
|
daysSinceActivity,
|
||||||
|
reason: `No update in ${daysSinceActivity} days (scheduled for future: ${scheduledDate.toISOString().split('T')[0]})`,
|
||||||
|
category: 'long-inactive-with-schedule',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isStale: false, daysSinceActivity, reason: null, category: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// No scheduled date
|
||||||
|
if (daysSinceActivity >= 2) {
|
||||||
|
return {
|
||||||
|
isStale: true,
|
||||||
|
daysSinceActivity,
|
||||||
|
reason: `No update in ${daysSinceActivity} days`,
|
||||||
|
category: 'inactive',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isStale: false, daysSinceActivity, reason: null, category: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main function to generate the stale work orders report.
|
||||||
|
*/
|
||||||
|
export async function getStaleWorkOrdersReport(db) {
|
||||||
|
if (!secrets.serviceChannel.clientId) {
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
message: 'ServiceChannel credentials are not configured in the environment (SC_CLIENT_ID, SC_CLIENT_SECRET, SC_USERNAME, SC_PASSWORD).',
|
||||||
|
summary: { totalMonitored: 0, staleCount: 0, generatedAt: new Date().toISOString() },
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Get all work orders we care about from our local DB
|
||||||
|
// Note: We only select columns that are guaranteed to exist in the production schema.
|
||||||
|
// storeNumber was added later and may not exist in the live production database.
|
||||||
|
const mappings = await new Promise((resolve, reject) => {
|
||||||
|
db.all('SELECT workOrderId, roomId FROM mappings', [], (err, rows) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(rows || []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mappings.length === 0) {
|
||||||
|
return {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
totalMonitored: 0,
|
||||||
|
staleCount: 0,
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch details for all work orders (very low concurrency + spacing + retries)
|
||||||
|
const fetchPromises = mappings.map((mapping, index) =>
|
||||||
|
limit(async () => {
|
||||||
|
try {
|
||||||
|
// Spread start times even under the limiter to avoid thundering herd
|
||||||
|
if (index > 0) {
|
||||||
|
await sleep(320 + Math.random() * 220); // ~320-540ms stagger
|
||||||
|
}
|
||||||
|
|
||||||
|
const details = await fetchWorkOrderDetails(mapping.workOrderId);
|
||||||
|
const evaluation = evaluateStaleness(details.workOrder, details.lastNote);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...mapping,
|
||||||
|
...details,
|
||||||
|
...evaluation,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
// After all retries inside fetchWithRetry, this is a hard failure
|
||||||
|
logger('staleWorkOrderReport', `Failed to fetch WO ${mapping.workOrderId}: ${err.message}`, 'warn');
|
||||||
|
return {
|
||||||
|
...mapping,
|
||||||
|
error: err.message,
|
||||||
|
isStale: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await Promise.all(fetchPromises);
|
||||||
|
|
||||||
|
// 3. Filter to stale + actionable items and sort by days since activity (desc)
|
||||||
|
// - Exclude Completed items (Primary status === 'COMPLETED')
|
||||||
|
// - Errors here are items that failed even after retries + backoff
|
||||||
|
const staleItems = results
|
||||||
|
.filter(r => r.isStale && !r.error && (r.workOrder?.Status?.Primary?.toUpperCase() !== 'COMPLETED'))
|
||||||
|
.sort((a, b) => b.daysSinceActivity - a.daysSinceActivity);
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
totalMonitored: mappings.length,
|
||||||
|
staleCount: staleItems.length,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary,
|
||||||
|
items: staleItems,
|
||||||
|
};
|
||||||
|
}
|
||||||
39
src/services/ticketService.js
Normal file
39
src/services/ticketService.js
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
// src/services/ticketService.js
|
||||||
|
import { getServiceChannelToken } from '../integrations/serviceChannel/client.js';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { loadSecrets } from '../config/secrets.js';
|
||||||
|
import { summarizeTicketWithGrokFromContext } from '../integrations/xai/client.js';
|
||||||
|
|
||||||
|
const secrets = loadSecrets();
|
||||||
|
|
||||||
|
export async function getTicketSummary(woNumber) {
|
||||||
|
if (!secrets.serviceChannel.clientId) {
|
||||||
|
throw new Error('ServiceChannel credentials are not configured (SC_CLIENT_ID etc.).');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await getServiceChannelToken();
|
||||||
|
const ticketRes = await axios.get(
|
||||||
|
`${secrets.serviceChannel.baseUrl}/workorders/${woNumber}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } }
|
||||||
|
);
|
||||||
|
const ticket = ticketRes.data;
|
||||||
|
|
||||||
|
const notesRes = await axios.get(
|
||||||
|
`${secrets.serviceChannel.baseUrl}/workorders/${woNumber}/notes`,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } }
|
||||||
|
);
|
||||||
|
const notes = notesRes.data?.Notes || [];
|
||||||
|
|
||||||
|
let context = `Ticket ID: ${woNumber}\n`;
|
||||||
|
context += `Description: ${ticket.Description || 'N/A'}\n`;
|
||||||
|
context += `Trade: ${ticket.Trade || 'N/A'} | Status: ${ticket.Status?.Primary || 'N/A'}\n`;
|
||||||
|
context += `Notes:\n${notes.map(n => `${n.CreatedBy}: ${n.NoteData || ''}`).join('\n')}`;
|
||||||
|
|
||||||
|
const summary = await summarizeTicketWithGrokFromContext(context, woNumber);
|
||||||
|
|
||||||
|
return `**WO-${woNumber} Summary**\n\n${summary}`;
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Failed to fetch WO-${woNumber}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/services/webexService.js
Normal file
101
src/services/webexService.js
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
/**
|
||||||
|
* src/services/webexService.js
|
||||||
|
*
|
||||||
|
* Thin service layer over the Webex bot client.
|
||||||
|
*
|
||||||
|
* Purpose:
|
||||||
|
* - Provides a clean, ServChan-specific interface for Webex operations
|
||||||
|
* - Improves testability (easy to mock the entire service)
|
||||||
|
* - Central place for any future Webex business logic related to work orders
|
||||||
|
* - Better isolation between webhookProcessor and raw botClient calls
|
||||||
|
*
|
||||||
|
* Created as part of the 2026-05-28 refactoring (step 4).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import botClient from '../integrations/webex/botClient.js'; // default singleton for convenience
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
export class WebexService {
|
||||||
|
/**
|
||||||
|
* @param {object} [client] - Optional bot client instance.
|
||||||
|
* Defaults to the shared botClient singleton.
|
||||||
|
*/
|
||||||
|
constructor(client = botClient) {
|
||||||
|
this.client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Low-level passthroughs (kept for compatibility with current processor)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async createRoom(title, teamId = null) {
|
||||||
|
return this.client.createRoom(title, teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMember(roomId, personEmail) {
|
||||||
|
return this.client.addMember(roomId, personEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMarkdown(roomId, markdown, textFallback = null) {
|
||||||
|
return this.client.sendMarkdown(roomId, markdown, textFallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendWithAttachment(roomId, buffer, fileName, contentType, text, fileUrl = null) {
|
||||||
|
return this.client.sendWithAttachment(roomId, buffer, fileName, contentType, text, fileUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Higher-level ServChan-specific helpers (recommended for new code)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a work order room with the standard ServChan title format.
|
||||||
|
*/
|
||||||
|
async createWorkOrderRoom(workOrder, teamId) {
|
||||||
|
const title = `ServChan WO-${workOrder.Number} | Store ${workOrder.LocationStoreId} | ${workOrder.LocationName}`;
|
||||||
|
const room = await this.createRoom(title, teamId);
|
||||||
|
logger('webexService', `Created room for WO-${workOrder.Number}: ${room.id}`);
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the standard default members to a work order room.
|
||||||
|
*/
|
||||||
|
async addDefaultMembers(roomId, members = []) {
|
||||||
|
if (!members.length) return;
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
members.map(email => this.addMember(roomId, email))
|
||||||
|
);
|
||||||
|
|
||||||
|
results.forEach((result, i) => {
|
||||||
|
if (result.status === 'rejected') {
|
||||||
|
logger('webexService:addDefaultMembers', `Failed to add ${members[i]}: ${result.reason?.message || result.reason}`, 'warn');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts a message to a work order room.
|
||||||
|
*/
|
||||||
|
async postWorkOrderMessage(roomId, markdown) {
|
||||||
|
return this.sendMarkdown(roomId, markdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Adaptive Card support (for proposal approval cards)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async sendAdaptiveCard(roomId, card, fallbackText) {
|
||||||
|
return this.client.sendAdaptiveCard(roomId, card, fallbackText);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMessage(messageId) {
|
||||||
|
return this.client.deleteMessage(messageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience singleton (matches the pattern used elsewhere)
|
||||||
|
export const webexService = new WebexService();
|
||||||
|
|
||||||
|
export default webexService;
|
||||||
276
src/services/webhookProcessor.js
Normal file
276
src/services/webhookProcessor.js
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
/**
|
||||||
|
* webhookProcessor.js
|
||||||
|
*
|
||||||
|
* Core ServiceChannel → Webex webhook handling logic.
|
||||||
|
*
|
||||||
|
* Extracted during the 2026-05-28 cleanup refactor from the original monolith in index.js.
|
||||||
|
*
|
||||||
|
* This module owns:
|
||||||
|
* - Per-workOrderId mutex + queue to safely handle bursty ServiceChannel events
|
||||||
|
* - Room creation / lookup via mappings DB
|
||||||
|
* - Member seeding (first time only)
|
||||||
|
* - Message formatting per EventType (including xAI summarization for new WOs)
|
||||||
|
* - Posting to the correct Webex space
|
||||||
|
*
|
||||||
|
* IMPORTANT DB NOTE (production constraint):
|
||||||
|
* This processor does NOT create its own database connection.
|
||||||
|
* The caller must pass in the active sqlite3 `db` instance.
|
||||||
|
* The physical DB file used by production must never be moved or renamed.
|
||||||
|
*
|
||||||
|
* Usage (during transition):
|
||||||
|
* import { createWebhookProcessor } from './webhookProcessor.js';
|
||||||
|
* const processor = createWebhookProcessor({
|
||||||
|
* db,
|
||||||
|
* webex: botClient,
|
||||||
|
* summarizeDescription: mySummarizer,
|
||||||
|
* teamId: process.env.WEBEX_TEAM_ID,
|
||||||
|
* defaultMembers: [...],
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Then in the webhook route:
|
||||||
|
* await processor.processWebhook(payload);
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Mutex } from 'async-mutex';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
import approvalService from './approvalService.js';
|
||||||
|
import attachmentService from './attachmentService.js';
|
||||||
|
|
||||||
|
export function createWebhookProcessor(options = {}) {
|
||||||
|
const {
|
||||||
|
db,
|
||||||
|
webex, // expected to have: createRoom, addMember, sendMarkdown, getRoom?
|
||||||
|
summarizeDescription, // async (rawDescription, xaiToken, opts?) => { summary }
|
||||||
|
teamId,
|
||||||
|
defaultMembers = [],
|
||||||
|
xaiToken, // for the simple initial-description summarizer
|
||||||
|
// secrets are now loaded directly inside the modules that need them
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
throw new Error('webhookProcessor requires a sqlite3 db instance');
|
||||||
|
}
|
||||||
|
if (!webex) {
|
||||||
|
throw new Error('webhookProcessor requires a webex client (botClient)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-workOrderId concurrency control (private to this processor instance)
|
||||||
|
const mutexes = new Map(); // workOrderId → Mutex
|
||||||
|
const pendingQueues = new Map(); // workOrderId → Array<{payload, startTime}>
|
||||||
|
|
||||||
|
function scheduleAttachmentPost({ roomId, workOrderId, woNumber, attachmentIds = null }) {
|
||||||
|
attachmentService.postWorkOrderAttachments({
|
||||||
|
db,
|
||||||
|
webex,
|
||||||
|
roomId,
|
||||||
|
workOrderId,
|
||||||
|
woNumber,
|
||||||
|
attachmentIds,
|
||||||
|
}).catch((err) => {
|
||||||
|
logger('webhookProcessor:attachments', `Post error for WO ${workOrderId}: ${err.message}`, 'warn');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main entry point — call this for every incoming ServiceChannel webhook payload.
|
||||||
|
* Safe to call concurrently; internal mutex + queue handles ordering per WO.
|
||||||
|
*/
|
||||||
|
async function processWebhook(payload) {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const { Object: obj } = payload || {};
|
||||||
|
if (!obj || !obj.Id) {
|
||||||
|
logger('webhookProcessor', 'Invalid payload — missing Object.Id');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workOrderId = obj.Id;
|
||||||
|
|
||||||
|
// Ensure we have a mutex and queue for this work order
|
||||||
|
if (!mutexes.has(workOrderId)) {
|
||||||
|
mutexes.set(workOrderId, new Mutex());
|
||||||
|
}
|
||||||
|
const mutex = mutexes.get(workOrderId);
|
||||||
|
|
||||||
|
if (!pendingQueues.has(workOrderId)) {
|
||||||
|
pendingQueues.set(workOrderId, []);
|
||||||
|
}
|
||||||
|
const queue = pendingQueues.get(workOrderId);
|
||||||
|
|
||||||
|
queue.push({ payload, startTime });
|
||||||
|
|
||||||
|
let release;
|
||||||
|
try {
|
||||||
|
release = await mutex.acquire();
|
||||||
|
|
||||||
|
// 1. Find or create the Webex room for this work order
|
||||||
|
const row = await new Promise((resolve, reject) => {
|
||||||
|
db.get('SELECT roomId FROM mappings WHERE workOrderId = ?', [workOrderId], (err, r) => {
|
||||||
|
err ? reject(err) : resolve(r);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let roomId = row?.roomId;
|
||||||
|
let roomJustCreated = false;
|
||||||
|
|
||||||
|
if (!roomId) {
|
||||||
|
logger('webhookProcessor', `Creating new room for WO-${workOrderId}`);
|
||||||
|
|
||||||
|
const storeLabel = obj.LocationStoreId != null && obj.LocationStoreId !== ''
|
||||||
|
? String(obj.LocationStoreId)
|
||||||
|
: '?';
|
||||||
|
const title = `ServChan WO-${obj.Number} | Store ${storeLabel} | ${obj.LocationName || ''}`;
|
||||||
|
|
||||||
|
const created = await webex.createRoom(title, teamId);
|
||||||
|
roomId = created.id;
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
db.run(
|
||||||
|
'INSERT OR REPLACE INTO mappings (workOrderId, roomId) VALUES (?, ?)',
|
||||||
|
[workOrderId, roomId],
|
||||||
|
(err) => (err ? reject(err) : resolve())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
logger('webhookProcessor', `Created space ${roomId} for WO-${obj.Number}`);
|
||||||
|
roomJustCreated = true;
|
||||||
|
|
||||||
|
// Seed default members (best effort)
|
||||||
|
if (defaultMembers.length > 0) {
|
||||||
|
await Promise.allSettled(
|
||||||
|
defaultMembers.map(email => webex.addMember(roomId, email))
|
||||||
|
).then(results => {
|
||||||
|
results.forEach((r, i) => {
|
||||||
|
if (r.status === 'rejected') {
|
||||||
|
logger('webhookProcessor:addMember', `${defaultMembers[i]} failed: ${r.reason?.message || r.reason}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Drain the queue and post messages for this work order
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const { payload: p, startTime: itemStart } = queue.shift();
|
||||||
|
|
||||||
|
let text = '';
|
||||||
|
|
||||||
|
if (p.EventType === 'WorkOrderCreated') {
|
||||||
|
let summaryResult;
|
||||||
|
try {
|
||||||
|
summaryResult = summarizeDescription
|
||||||
|
? await summarizeDescription(p.Object.Description, xaiToken, { maxTokens: 300 })
|
||||||
|
: { summary: p.Object.Description?.substring(0, 300) || '' };
|
||||||
|
} catch (sumErr) {
|
||||||
|
logger('webhookProcessor', `Summarization failed for new WO ${p.Object.Id}: ${sumErr.message}`, 'warn');
|
||||||
|
summaryResult = { summary: p.Object.Description?.substring(0, 400) || 'No description' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocationStoreId can arrive as a numeric string, a number, or be
|
||||||
|
// missing. Old code did `LocationStoreId * 1` which produced NaN
|
||||||
|
// when the field was absent and dropped leading zeros for strings.
|
||||||
|
const storeLabel = p.Object.LocationStoreId != null && p.Object.LocationStoreId !== ''
|
||||||
|
? String(p.Object.LocationStoreId)
|
||||||
|
: '?';
|
||||||
|
|
||||||
|
text =
|
||||||
|
`## [New Work Order Created](https://www.servicechannel.com/sc/wo/Workorders/index?id=${p.Object.Id})\n\n` +
|
||||||
|
`### ServChan WO-${p.Object.Number} | Store ${storeLabel} | ${p.Object.LocationName || ''}\n` +
|
||||||
|
`**Trade:** ${p.Object.Trade || 'N/A'} | ${p.Object.ProviderName || 'N/A'}\n` +
|
||||||
|
`**Priority:** ${p.Object.Priority || 'N/A'} | ${p.Object.Category || 'N/A'} | ${p.Object.ProblemCode || 'N/A'}\n` +
|
||||||
|
`**Status:** ${p.Object.Status?.Primary || 'N/A'} | ${p.Object.Status?.Extended || 'N/A'}\n` +
|
||||||
|
`**Description:** ${summaryResult?.summary || p.Object.Description?.substring(0, 300) || 'No description'}\n\n`;
|
||||||
|
} else if (p.EventType === 'WorkOrderNoteAdded') {
|
||||||
|
const note = p.Object.Notes?.[0] || {};
|
||||||
|
text =
|
||||||
|
`### [Note Added](https://www.servicechannel.com/sc/wo/Workorders/index?id=${p.Object.Id})\n\n` +
|
||||||
|
`${note.NoteData || 'No note content'} — ${note.CreatedBy || 'Unknown'}\n\n` +
|
||||||
|
`**Status:** ${p.Object.Status?.Primary || 'N/A'} | ${p.Object.Status?.Extended || 'N/A'}`;
|
||||||
|
} else {
|
||||||
|
// Generic fallback for other events
|
||||||
|
text =
|
||||||
|
`**Work Order Update**\n\n` +
|
||||||
|
`**WO Number:** ${p.Object.Number || 'Unknown'}\n` +
|
||||||
|
`**Event:** ${p.EventType || 'Unknown'}\n` +
|
||||||
|
`**Current Status:** ${p.Object.Status?.Primary || 'N/A'}\n` +
|
||||||
|
`**Updated:** ${new Date().toISOString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text.trim()) {
|
||||||
|
text = `Webhook received for Work Order ${workOrderId} (${p.EventType || 'unknown event'}).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await webex.sendMarkdown(roomId, text);
|
||||||
|
logger('webhookProcessor', `Posted for ${workOrderId} (${Date.now() - itemStart}ms)`);
|
||||||
|
} catch (postErr) {
|
||||||
|
const details = postErr.response?.data ? JSON.stringify(postErr.response.data) : postErr.message;
|
||||||
|
logger('webhookProcessor', `Post failed for ${workOrderId}: ${details}`, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-post attachments referenced in this note
|
||||||
|
if (p.EventType === 'WorkOrderNoteAdded') {
|
||||||
|
const note = p.Object.Notes?.[0] || {};
|
||||||
|
if (note.AttachmentIds?.length) {
|
||||||
|
scheduleAttachmentPost({
|
||||||
|
roomId,
|
||||||
|
workOrderId,
|
||||||
|
woNumber: p.Object.Number,
|
||||||
|
attachmentIds: note.AttachmentIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-remove stale approval cards when resolved in ServiceChannel
|
||||||
|
const noteDataForApproval = (p.EventType === 'WorkOrderNoteAdded' && p.Object.Notes?.[0]?.NoteData) || null;
|
||||||
|
approvalService.removeApprovalCardIfResolved({
|
||||||
|
db,
|
||||||
|
workOrderId,
|
||||||
|
woObj: p.Object,
|
||||||
|
noteData: noteDataForApproval,
|
||||||
|
}).catch((e) => {
|
||||||
|
logger('webhookProcessor:approval', `Card remove error for ${workOrderId}: ${e.message}`, 'warn');
|
||||||
|
});
|
||||||
|
|
||||||
|
// NEW: Proposal approval card for WAITING FOR APPROVAL status (additive to text note)
|
||||||
|
const ext = (p.Object?.Status?.Extended || '').toUpperCase();
|
||||||
|
if (ext.includes('WAITING FOR APPROVAL')) {
|
||||||
|
// Capture the note that usually contains "Proposal #12345 has been created"
|
||||||
|
// so we can look up the specific proposal and its itemized parts + labor.
|
||||||
|
const noteData = noteDataForApproval;
|
||||||
|
|
||||||
|
// fire-and-forget; uses the injected webex (WebexService) or falls back internally
|
||||||
|
approvalService.fetchAndPostApprovalCardIfNeeded(webex, roomId, p.Object, noteData, { db }).catch(e => {
|
||||||
|
logger('webhookProcessor:approval', `Card post error for ${workOrderId}: ${e.message}`, 'warn');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roomJustCreated) {
|
||||||
|
scheduleAttachmentPost({
|
||||||
|
roomId,
|
||||||
|
workOrderId,
|
||||||
|
woNumber: obj.Number,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger('webhookProcessor', `Critical error for ${workOrderId}: ${err.message}\n${err.stack}`, 'error');
|
||||||
|
} finally {
|
||||||
|
if (release) release();
|
||||||
|
|
||||||
|
// Clean up empty queues/mutexes
|
||||||
|
if (queue.length === 0) {
|
||||||
|
pendingQueues.delete(workOrderId);
|
||||||
|
mutexes.delete(workOrderId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
processWebhook,
|
||||||
|
// Expose internal state for debugging / the /cleanup-test style endpoints if needed later
|
||||||
|
_getInternalState: () => ({ mutexes, pendingQueues }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createWebhookProcessor;
|
||||||
36
src/utils/logPath.js
Normal file
36
src/utils/logPath.js
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
// src/utils/logPath.js
|
||||||
|
//
|
||||||
|
// Single source of truth for where log files are written.
|
||||||
|
//
|
||||||
|
// All log-writing code (structured logger, webhook payload archive, log-
|
||||||
|
// cleanup cron) should resolve their target directory via getLogDir(). This
|
||||||
|
// way you can:
|
||||||
|
// - keep the default of ./logs (matches Docker/prod bind mounts) OR
|
||||||
|
// - override with LOG_DIR (e.g. /var/log/servchan) in any environment
|
||||||
|
// and everything stays consistent.
|
||||||
|
//
|
||||||
|
// The directory is resolved relative to process.cwd() when the env var is
|
||||||
|
// unset — that's what `./logs` means to Node when Dockerfile's WORKDIR /app
|
||||||
|
// is in effect, or when you `npm start` from the repo root locally.
|
||||||
|
|
||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
export const DEFAULT_LOG_DIR = './logs';
|
||||||
|
|
||||||
|
export function getLogDir() {
|
||||||
|
const raw = process.env.LOG_DIR || DEFAULT_LOG_DIR;
|
||||||
|
return path.resolve(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make sure the log directory exists. Safe to call every write.
|
||||||
|
* (`recursive: true` makes mkdirSync a no-op when the dir already exists.)
|
||||||
|
*/
|
||||||
|
export function ensureLogDir() {
|
||||||
|
const dir = getLogDir();
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default getLogDir;
|
||||||
59
src/utils/logger.js
Normal file
59
src/utils/logger.js
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
// src/utils/logger.js
|
||||||
|
//
|
||||||
|
// Structured logger: console + daily rotating file.
|
||||||
|
// Resolves the target directory via src/utils/logPath.js so every part of the
|
||||||
|
// codebase writes to the same place (configurable via LOG_DIR env var, default
|
||||||
|
// ./logs). See logPath.js for the reasoning.
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { getLogDir, ensureLogDir } from './logPath.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enhanced logger: console + daily file
|
||||||
|
* @param {string} context - usually function name or component (e.g. 'webhook', 'cron', 'deviceStatus')
|
||||||
|
* @param {string} message - the log content
|
||||||
|
* @param {string} [level='info'] - info, warn, error (for color/future filtering)
|
||||||
|
*/
|
||||||
|
export function logger(context, message, level = 'info') {
|
||||||
|
const now = new Date();
|
||||||
|
const dateStr = now.toLocaleString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const yearMonthDay = now.toISOString().slice(0, 10).replace(/-/g, ''); // YYYYMMDD
|
||||||
|
const logDir = ensureLogDir();
|
||||||
|
const logFile = path.join(logDir, `${yearMonthDay}.log`);
|
||||||
|
|
||||||
|
const prefixes = {
|
||||||
|
info: '[INFO]',
|
||||||
|
warn: '[WARN]',
|
||||||
|
error: '[ERROR]',
|
||||||
|
};
|
||||||
|
const prefix = prefixes[level] || '[INFO]';
|
||||||
|
console.log(`${dateStr} ${prefix} ${context.padEnd(15)} | ${message}`);
|
||||||
|
|
||||||
|
const fileLine = `${dateStr} ${prefix} ${context.padEnd(15)} | ${message}\n`;
|
||||||
|
try {
|
||||||
|
fs.appendFileSync(logFile, fileLine);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Logger failed to write to ${logFile}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience shortcuts
|
||||||
|
export const info = (context, msg) => logger(context, msg, 'info');
|
||||||
|
export const warn = (context, msg) => logger(context, msg, 'warn');
|
||||||
|
export const error = (context, msg) => logger(context, msg, 'error');
|
||||||
|
|
||||||
|
// Re-export so callers that need the directory (webhook archiver, log cleaner)
|
||||||
|
// don't have to import logPath separately.
|
||||||
|
export { getLogDir, ensureLogDir };
|
||||||
|
|
||||||
|
export default logger;
|
||||||
23
src/utils/normalize.js
Normal file
23
src/utils/normalize.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
// src/utils/normalize.js
|
||||||
|
export function normalizePlayerName(name) {
|
||||||
|
if (typeof name !== 'string' || !name.trim()) return '';
|
||||||
|
const upper = name.trim().toUpperCase();
|
||||||
|
|
||||||
|
// Your original logic (country.store.suffix patterns)
|
||||||
|
const match = upper.match(/^([A-Z]{2})\.?(?:(\d{1,6})\.?)?(?:OFFLINE|AE|AERIE)?\.?(\d{1,6})?\.?(OFFLINE|AE|AERIE)?$/i);
|
||||||
|
if (match) {
|
||||||
|
const country = match[1];
|
||||||
|
let store = match[2] || match[3] || '';
|
||||||
|
const suffixRaw = match[4] || '';
|
||||||
|
if (!store) return name.trim();
|
||||||
|
|
||||||
|
const paddedStore = store.padStart(6, '0');
|
||||||
|
let suffix = '';
|
||||||
|
if (suffixRaw === 'OFFLINE') suffix = 'MSCOFF';
|
||||||
|
else if (suffixRaw === 'AE' || suffixRaw === 'AERIE') suffix = `MSC${suffixRaw}`;
|
||||||
|
|
||||||
|
return `${country}${paddedStore}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return name.trim();
|
||||||
|
}
|
||||||
55
src/utils/prepareWebexAttachment.js
Normal file
55
src/utils/prepareWebexAttachment.js
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
/**
|
||||||
|
* Normalize attachment bytes/filenames for Webex Messages API uploads.
|
||||||
|
*
|
||||||
|
* Webex supports jpeg/png/gif/bmp and common office/pdf types — not HEIC/HEIF.
|
||||||
|
* @see https://developer.webex.com/docs/basics#message-attachments
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
import { getContentTypeFromFilename } from '../integrations/serviceChannel/attachments.js';
|
||||||
|
import convert from 'heic-convert';
|
||||||
|
|
||||||
|
const WEBEX_NATIVE_EXT = /\.(jpe?g|png|gif|bmp|pdf|docx?|xls|pptx?)$/i;
|
||||||
|
const CONVERT_TO_JPEG_EXT = /\.(heic|heif)$/i;
|
||||||
|
|
||||||
|
export function isWebexNativeUpload(fileName) {
|
||||||
|
return WEBEX_NATIVE_EXT.test(String(fileName || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function needsJpegConversion(fileName, contentType = '') {
|
||||||
|
const name = String(fileName || '');
|
||||||
|
if (CONVERT_TO_JPEG_EXT.test(name)) return true;
|
||||||
|
const ct = String(contentType || '').toLowerCase();
|
||||||
|
return ct === 'image/heic' || ct === 'image/heif' || ct === 'image/heif-sequence';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jpegFileName(originalName) {
|
||||||
|
const base = String(originalName || 'attachment').replace(/\.(heic|heif)$/i, '');
|
||||||
|
return `${base}.jpg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare a downloaded attachment for Webex upload.
|
||||||
|
* Converts HEIC/HEIF to JPEG when needed.
|
||||||
|
*/
|
||||||
|
export async function prepareWebexUpload(buffer, fileName, contentType) {
|
||||||
|
const resolvedType = contentType || getContentTypeFromFilename(fileName);
|
||||||
|
|
||||||
|
if (needsJpegConversion(fileName, resolvedType)) {
|
||||||
|
const jpegBuffer = Buffer.from(await convert({
|
||||||
|
buffer,
|
||||||
|
format: 'JPEG',
|
||||||
|
quality: 0.92,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const outName = jpegFileName(fileName);
|
||||||
|
logger('attachments:convert', `Converted ${fileName} → ${outName} (${jpegBuffer.length} bytes)`);
|
||||||
|
return { buffer: jpegBuffer, fileName: outName, contentType: 'image/jpeg' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedType === 'application/octet-stream') {
|
||||||
|
throw new Error(`Unsupported or unknown MIME type for Webex: ${fileName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { buffer, fileName, contentType: resolvedType };
|
||||||
|
}
|
||||||
68
src/utils/time.js
Normal file
68
src/utils/time.js
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
// utils/time.js
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable "X time ago" from ISO string
|
||||||
|
* @param {string} isoString - e.g. "2025-03-10T14:30:00Z"
|
||||||
|
* @returns {string} e.g. "2 hours ago"
|
||||||
|
*/
|
||||||
|
export function simpleTimeAgo(input) {
|
||||||
|
if (!input) return 'never';
|
||||||
|
|
||||||
|
let date;
|
||||||
|
if (input instanceof Date) {
|
||||||
|
date = input;
|
||||||
|
} else if (typeof input === 'number') {
|
||||||
|
date = new Date(input);
|
||||||
|
} else if (typeof input === 'string') {
|
||||||
|
let cleaned = input.trim();
|
||||||
|
// If no timezone (no Z or offset), assume UTC and append Z
|
||||||
|
if (!cleaned.endsWith('Z') && !cleaned.match(/[+-]\d{2}:\d{2}$/)) {
|
||||||
|
cleaned += 'Z';
|
||||||
|
console.log('[TIME] Appended Z to timestamp (assumed UTC):', cleaned);
|
||||||
|
}
|
||||||
|
date = new Date(cleaned);
|
||||||
|
} else {
|
||||||
|
console.log('[TIME] Invalid input type to simpleTimeAgo:', typeof input, input);
|
||||||
|
return 'invalid';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
console.log('[TIME] Invalid date parsed from:', input);
|
||||||
|
return 'invalid';
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const diffMs = now - date.getTime();
|
||||||
|
|
||||||
|
// Small clock skew tolerance (< 5 minutes future → treat as "just now")
|
||||||
|
if (diffMs < 0 && diffMs > -300000) {
|
||||||
|
return 'just now';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Future (real future)
|
||||||
|
if (diffMs < 0) {
|
||||||
|
const futureMs = -diffMs;
|
||||||
|
const futureSeconds = Math.floor(futureMs / 1000);
|
||||||
|
const futureMinutes = Math.floor(futureSeconds / 60);
|
||||||
|
const futureHours = Math.floor(futureMinutes / 60);
|
||||||
|
const futureDays = Math.floor(futureHours / 24);
|
||||||
|
|
||||||
|
if (futureSeconds < 60) return `in ${futureSeconds} seconds`;
|
||||||
|
if (futureMinutes < 60) return `in ${futureMinutes} minutes`;
|
||||||
|
if (futureHours < 24) return `in ${futureHours} hours`;
|
||||||
|
return `in ${futureDays} days`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Past
|
||||||
|
const seconds = Math.floor(diffMs / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
|
||||||
|
if (seconds < 60) return `${seconds} seconds ago`;
|
||||||
|
if (minutes < 60) return `${minutes} minutes ago`;
|
||||||
|
if (hours < 24) return `${hours} hours ago`;
|
||||||
|
if (days < 30) return `${days} days ago`;
|
||||||
|
|
||||||
|
return date.toLocaleDateString(); // fallback
|
||||||
|
}
|
||||||
37
tools/README.md
Normal file
37
tools/README.md
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# ServChan Tools
|
||||||
|
|
||||||
|
This directory contains standalone operational and data utility scripts used alongside (or historically with) the main ServChan Webex bot.
|
||||||
|
|
||||||
|
These scripts are **not** part of the runtime bot. They are one-off or scheduled tools for device management, data enrichment, and troubleshooting in the retail environment.
|
||||||
|
|
||||||
|
## Directory Layout
|
||||||
|
|
||||||
|
- **appleTV/** — Apple TV / AirPlay device discovery and Meraki enrichment scripts + sample data.
|
||||||
|
- **optisign/** — Optisign digital signage device status queries (GraphQL).
|
||||||
|
- **fixWiredTV/** — One-time remediation scripts for wired Apple TV / display issues (Meraki port flapping + MDM commands).
|
||||||
|
- **meraki/** — (Reserved) Meraki-specific enrichment or client data tools.
|
||||||
|
- `convert.js` — General data conversion helper.
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
- Most of these scripts read from `../config/config.json` (the same file used by the bot).
|
||||||
|
- Many were written iteratively and may have hardcoded values or be in varying states of maintenance.
|
||||||
|
- Several functions that these scripts previously performed locally (especially AV/device status for stores) have moved to the remote CollabSupport service that the main bot now calls for `/avStatus` and similar commands.
|
||||||
|
- Large CSV/JSON outputs are often generated here and may be gitignored or cleaned periodically.
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools/appleTV
|
||||||
|
node appleTV.js # or appleTV-enhanced.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Same pattern for the others. They are plain Node.js scripts (ESM or CJS — check the shebang/import style of each file).
|
||||||
|
|
||||||
|
## Future
|
||||||
|
|
||||||
|
As the main ServChan bot and its supporting remote services mature, some of these tools may be retired or folded into scheduled jobs / the remote CollabSupport API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last organized**: 2026-05-28 during ServChan base cleanup refactor.
|
||||||
708
tools/appleTV/appleTV-enhanced.js
Normal file
708
tools/appleTV/appleTV-enhanced.js
Normal file
|
|
@ -0,0 +1,708 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
import csv from 'csv-parser';
|
||||||
|
import path from 'path';
|
||||||
|
import axios from 'axios';
|
||||||
|
import pLimit from 'p-limit';
|
||||||
|
import { Parser } from 'json2csv';
|
||||||
|
|
||||||
|
var merakiNetworks = [];
|
||||||
|
|
||||||
|
|
||||||
|
var config = JSON.parse(fs.readFileSync('./config/config.json'));
|
||||||
|
|
||||||
|
let lastPortFetchTime = 0;
|
||||||
|
const MIN_DELAY_MS = 150; // ~6–7 calls/sec — safe under Meraki's 10/sec steady limit
|
||||||
|
const MAX_RETRIES = 5;
|
||||||
|
const BASE_BACKOFF_MS = 1000; // 1s, 2s, 4s, 8s, 16s...
|
||||||
|
|
||||||
|
// Global cache for device info (serial → {model, name, type, ...})
|
||||||
|
const deviceInfoCache = new Map();
|
||||||
|
|
||||||
|
|
||||||
|
// Assume you have these functions from your previous code
|
||||||
|
// e.g., findMerakiNetworkId(storeNum) → returns network ID
|
||||||
|
// getMerakiClients(networkId) → returns array of clients with description, vlan, switchport, status, etc.
|
||||||
|
|
||||||
|
async function getMerakiDeviceInfo(serial) {
|
||||||
|
if (!serial || serial === 'N/A') return null;
|
||||||
|
|
||||||
|
if (deviceInfoCache.has(serial)) {
|
||||||
|
return deviceInfoCache.get(serial);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://api.meraki.com/api/v1/devices/${serial}`;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'X-Cisco-Meraki-API-Key': config.auth.meraki.apiKey,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
let retryAfterSec = 2;
|
||||||
|
const retryAfter = response.headers.get('retry-after');
|
||||||
|
if (retryAfter) retryAfterSec = parseInt(retryAfter, 10) || 2;
|
||||||
|
console.warn(`429 on device info for ${serial} - wait ${retryAfterSec}s`);
|
||||||
|
await new Promise(r => setTimeout(r, retryAfterSec * 1000 + 300));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn(`Device info fetch failed for ${serial}: HTTP ${response.status}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = await response.json();
|
||||||
|
deviceInfoCache.set(serial, info);
|
||||||
|
return info;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Device info attempt ${attempt} error for ${serial}: ${err.message}`);
|
||||||
|
if (attempt === 3) return null;
|
||||||
|
await new Promise(r => setTimeout(r, 1000 * attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function throttledGetMerakiSwitchPortConfig(serial, portId) {
|
||||||
|
if (!serial || !portId) {
|
||||||
|
throw new Error('Missing serial or portId for port config fetch');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://api.meraki.com/api/v1/devices/${serial}/switch/ports/${portId}`;
|
||||||
|
|
||||||
|
// Step 1: Enforce minimum delay between calls
|
||||||
|
const now = Date.now();
|
||||||
|
const timeSinceLast = now - lastPortFetchTime;
|
||||||
|
if (timeSinceLast < MIN_DELAY_MS) {
|
||||||
|
const waitMs = MIN_DELAY_MS - timeSinceLast;
|
||||||
|
console.log(`Throttling port fetch for ${serial}/${portId} — waiting ${waitMs}ms`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, waitMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update timestamp right before the request (more accurate for burst control)
|
||||||
|
lastPortFetchTime = Date.now();
|
||||||
|
|
||||||
|
// Step 2: Attempt the request with retries on 429 and transient errors
|
||||||
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'X-Cisco-Meraki-API-Key': config.auth.meraki.apiKey,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle 429 specifically
|
||||||
|
if (response.status === 429) {
|
||||||
|
let retryAfterSec = 2; // default fallback
|
||||||
|
|
||||||
|
// Respect Retry-After header if present
|
||||||
|
const retryAfterHeader = response.headers.get('retry-after');
|
||||||
|
if (retryAfterHeader) {
|
||||||
|
const parsed = parseInt(retryAfterHeader, 10);
|
||||||
|
if (!isNaN(parsed) && parsed > 0) {
|
||||||
|
retryAfterSec = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
`429 Too Many Requests on ${serial}/${portId} ` +
|
||||||
|
`(attempt ${attempt}/${MAX_RETRIES}) — waiting ${retryAfterSec}s`
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, retryAfterSec * 1000 + 200)); // +200ms jitter
|
||||||
|
continue; // retry
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text().catch(() => 'Unknown error');
|
||||||
|
throw new Error(`Meraki API failed (${response.status}): ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log(`Successfully fetched port config for ${serial}/${portId}`);
|
||||||
|
return data;
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`Port fetch error for ${serial}/${portId} (attempt ${attempt}/${MAX_RETRIES}): ${err.message}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (attempt === MAX_RETRIES) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch port ${portId} on switch ${serial} after ${MAX_RETRIES} attempts: ${err.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exponential backoff for non-429 errors too (e.g., 500s, network blips)
|
||||||
|
const backoffMs = BASE_BACKOFF_MS * Math.pow(2, attempt - 1) + Math.random() * 200; // jitter
|
||||||
|
console.log(`Backing off for ${Math.round(backoffMs)}ms before retry ${attempt + 1}`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, backoffMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should never reach here due to throw on last attempt
|
||||||
|
throw new Error('Unexpected exit from retry loop');
|
||||||
|
}
|
||||||
|
// Helper: Fetch a single switch port configuration from Meraki API
|
||||||
|
async function getMerakiSwitchPortConfig(serial, portId) {
|
||||||
|
if (!serial || !portId) {
|
||||||
|
throw new Error('Missing serial or portId for port config fetch');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://api.meraki.com/api/v1/devices/${serial}/switch/ports/${portId}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'X-Cisco-Meraki-API-Key': config.auth.meraki.apiKey,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text().catch(() => 'Unknown error');
|
||||||
|
throw new Error(`Meraki API failed (${response.status}): ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Failed to fetch port ${portId} on switch ${serial}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Fetch all clients for a network (with basic pagination support)
|
||||||
|
async function getMerakiClients(networkId) {
|
||||||
|
let allClients = [];
|
||||||
|
let startingAfter = null;
|
||||||
|
const perPage = 1000;
|
||||||
|
|
||||||
|
do {
|
||||||
|
let url = `https://api.meraki.com/api/v1/networks/${networkId}/clients?perPage=${perPage}`;
|
||||||
|
if (startingAfter) {
|
||||||
|
url += `&startingAfter=${encodeURIComponent(startingAfter)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= 5; attempt++) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'X-Cisco-Meraki-API-Key': config.auth.meraki.apiKey,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
let retryAfterSec = 2;
|
||||||
|
const retryAfterHeader = response.headers.get('retry-after');
|
||||||
|
if (retryAfterHeader) {
|
||||||
|
const parsed = parseInt(retryAfterHeader, 10);
|
||||||
|
if (!isNaN(parsed) && parsed > 0) retryAfterSec = parsed;
|
||||||
|
}
|
||||||
|
console.warn(`429 on clients page for ${networkId} (attempt ${attempt}) - waiting ${retryAfterSec}s`);
|
||||||
|
await new Promise(r => setTimeout(r, retryAfterSec * 1000 + 300)); // jitter
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Clients fetch failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clients = await response.json();
|
||||||
|
allClients = allClients.concat(clients);
|
||||||
|
startingAfter = clients.length === perPage ? clients[clients.length - 1].id : null;
|
||||||
|
break; // success → next page
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
if (attempt === 5) throw err;
|
||||||
|
const backoff = 1000 * Math.pow(2, attempt - 1) + Math.random() * 500;
|
||||||
|
console.log(`Retry backoff ${Math.round(backoff)}ms for ${networkId}`);
|
||||||
|
await new Promise(r => setTimeout(r, backoff));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while (startingAfter);
|
||||||
|
|
||||||
|
return allClients;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllMerakiSwitchPorts(serial) {
|
||||||
|
if (!serial || serial === 'N/A') return [];
|
||||||
|
|
||||||
|
// Check if it's actually a switch
|
||||||
|
const device = await getMerakiDeviceInfo(serial);
|
||||||
|
if (!device || !device.model?.startsWith('MS')) {
|
||||||
|
const model = device?.model || 'unknown';
|
||||||
|
console.log(`Skipping ports fetch for non-switch device ${serial} (model: ${model})`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Fetching ports for confirmed switch ${serial} (model: ${device.model})`);
|
||||||
|
|
||||||
|
const url = `https://api.meraki.com/api/v1/devices/${serial}/switch/ports`;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'X-Cisco-Meraki-API-Key': config.auth.meraki.apiKey,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
let retryAfterSec = 2;
|
||||||
|
const retryAfter = response.headers.get('retry-after');
|
||||||
|
if (retryAfter) retryAfterSec = parseInt(retryAfter, 10) || 2;
|
||||||
|
console.warn(`429 on ports fetch for ${serial} - wait ${retryAfterSec}s`);
|
||||||
|
await new Promise(r => setTimeout(r, retryAfterSec * 1000 + 300));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text().catch(() => 'Unknown');
|
||||||
|
throw new Error(`HTTP ${response.status}: ${errText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ports = await response.json();
|
||||||
|
console.log(`Fetched ${ports.length} ports for switch ${serial}`);
|
||||||
|
return ports;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Ports fetch attempt ${attempt} failed for ${serial}: ${err.message}`);
|
||||||
|
if (attempt === 3) return [];
|
||||||
|
await new Promise(r => setTimeout(r, 1000 * attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main function – now outputs CSV directly
|
||||||
|
async function processCSVAndEnrichMeraki(csvFilePath, outputCsvPath = 'enriched-meraki-output.csv') {
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
// 1. Parse input CSV
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
fs.createReadStream(csvFilePath)
|
||||||
|
.pipe(csv())
|
||||||
|
.on('data', row => results.push(row))
|
||||||
|
.on('end', () => {
|
||||||
|
console.log(`Input CSV parsed - ${results.length} rows`);
|
||||||
|
resolve();
|
||||||
|
})
|
||||||
|
.on('error', err => reject(err));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Cache networks if not already done
|
||||||
|
if (merakiNetworks.length === 0) {
|
||||||
|
await cacheMerakiNetworks();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Build unique store → network map
|
||||||
|
const storeToNetwork = new Map();
|
||||||
|
const limit = pLimit(4);
|
||||||
|
|
||||||
|
const networkPromises = results.map(row => {
|
||||||
|
const friendlyName = row.device_friendly_name?.trim() || '';
|
||||||
|
if (!friendlyName) return Promise.resolve();
|
||||||
|
const storeNum = friendlyName.match(/(\d{6})/)?.[0];
|
||||||
|
if (!storeNum || storeToNetwork.has(storeNum)) return Promise.resolve();
|
||||||
|
|
||||||
|
return limit(() =>
|
||||||
|
findMerakiNetworkId(storeNum)
|
||||||
|
.then(netId => { if (netId) storeToNetwork.set(storeNum, netId); })
|
||||||
|
.catch(() => { })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(networkPromises);
|
||||||
|
console.log(`Found ${storeToNetwork.size} unique networks`);
|
||||||
|
|
||||||
|
// 4. Pre-fetch clients per network
|
||||||
|
const networkClientCache = new Map();
|
||||||
|
const clientPromises = [...storeToNetwork.entries()].map(([storeNum, netId]) =>
|
||||||
|
limit(async () => {
|
||||||
|
try {
|
||||||
|
const clients = await getMerakiClients(netId);
|
||||||
|
networkClientCache.set(netId, clients);
|
||||||
|
console.log(`Clients fetched: ${clients.length} for store ${storeNum}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Clients fetch failed for store ${storeNum}: ${err.message}`);
|
||||||
|
networkClientCache.set(netId, []);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(clientPromises);
|
||||||
|
|
||||||
|
// 5. Enrich rows – collect unique serials first
|
||||||
|
const enrichedRows = results.map(row => ({ ...row, meraki: null, error: null }));
|
||||||
|
const serialToPorts = new Map();
|
||||||
|
const uniqueSerials = new Set();
|
||||||
|
|
||||||
|
// Phase A: Match clients & collect serials
|
||||||
|
for (let i = 0; i < results.length; i++) {
|
||||||
|
const row = results[i];
|
||||||
|
const friendlyName = row.device_friendly_name?.trim() || '';
|
||||||
|
if (!friendlyName) {
|
||||||
|
enrichedRows[i].error = 'Missing friendly name';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.recentDeviceConnection !== 'Wired' || !match.switchport) {
|
||||||
|
enrichedRows[i].error = 'Wireless or non-switch connected client';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeNum = friendlyName.match(/(\d{6})/)?.[0];
|
||||||
|
if (!storeNum) continue;
|
||||||
|
|
||||||
|
const netId = storeToNetwork.get(storeNum);
|
||||||
|
if (!netId) continue;
|
||||||
|
|
||||||
|
const clients = networkClientCache.get(netId) || [];
|
||||||
|
const match = clients.find(c =>
|
||||||
|
c.description?.toUpperCase().trim() === friendlyName.toUpperCase().trim()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
enrichedRows[i].error = 'No matching Meraki client';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const merakiInfo = {
|
||||||
|
deviceName: match.recentDeviceName || 'N/A',
|
||||||
|
deviceSerial: match.recentDeviceSerial || 'N/A',
|
||||||
|
switchport: match.switchport || 'N/A',
|
||||||
|
vlan: match.vlan || 'N/A',
|
||||||
|
status: match.status || 'Unknown',
|
||||||
|
connection: match.recentDeviceConnection || 'N/A',
|
||||||
|
lastSeen: match.lastSeen || 'N/A',
|
||||||
|
ip: match.ip || 'N/A',
|
||||||
|
mac: match.mac || 'N/A'
|
||||||
|
};
|
||||||
|
|
||||||
|
enrichedRows[i].meraki = merakiInfo;
|
||||||
|
|
||||||
|
if (merakiInfo.deviceSerial !== 'N/A') {
|
||||||
|
uniqueSerials.add(merakiInfo.deviceSerial);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase B: Pre-fetch all ports for unique switches
|
||||||
|
if (uniqueSerials.size > 0) {
|
||||||
|
console.log(`Fetching ports for ${uniqueSerials.size} unique switches...`);
|
||||||
|
const portPromises = [...uniqueSerials].map(serial =>
|
||||||
|
limit(async () => {
|
||||||
|
const ports = await getAllMerakiSwitchPorts(serial);
|
||||||
|
serialToPorts.set(serial, ports);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.all(portPromises);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase C: Final enrichment from cache
|
||||||
|
for (const row of enrichedRows) {
|
||||||
|
const meraki = row.meraki;
|
||||||
|
if (!meraki || meraki.deviceSerial === 'N/A' || meraki.switchport === 'N/A') {
|
||||||
|
if (meraki) {
|
||||||
|
Object.assign(meraki, {
|
||||||
|
portType: 'N/A',
|
||||||
|
accessPolicyType: 'N/A',
|
||||||
|
stickyMacEnabled: false,
|
||||||
|
stickyMacList: [],
|
||||||
|
stickyMacAllowListLimit: 0,
|
||||||
|
stickyMacCount: 0,
|
||||||
|
portEnabled: 'N/A'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ports = serialToPorts.get(meraki.deviceSerial) || [];
|
||||||
|
const portConfig = ports.find(p =>
|
||||||
|
String(p.port || p.portId || p.number) === String(meraki.switchport)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (portConfig) {
|
||||||
|
meraki.portType = portConfig.type || 'N/A';
|
||||||
|
meraki.accessPolicyType = portConfig.accessPolicyType || 'N/A';
|
||||||
|
meraki.stickyMacEnabled = portConfig.accessPolicyType === 'Sticky MAC allow list';
|
||||||
|
meraki.stickyMacList = portConfig.stickyMacAllowList || [];
|
||||||
|
meraki.stickyMacAllowListLimit = portConfig.stickyMacAllowListLimit || 0;
|
||||||
|
meraki.stickyMacCount = meraki.stickyMacList.length;
|
||||||
|
meraki.portEnabled = portConfig.enabled ? 'Enabled' : 'Disabled';
|
||||||
|
} else {
|
||||||
|
meraki.portType = 'Not Found';
|
||||||
|
meraki.portEnabled = 'Not Found';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Convert enriched data → CSV
|
||||||
|
const flattened = enrichedRows.map(item => {
|
||||||
|
const m = item.meraki || {};
|
||||||
|
const stickyList = Array.isArray(m.stickyMacList) ? m.stickyMacList.join(', ') : '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
device_id: item.device_id || '',
|
||||||
|
device_friendly_name: item.device_friendly_name || '',
|
||||||
|
device_enrollment_user_name: item.device_enrollment_user_name || '',
|
||||||
|
_device_platform: item._device_platform || '',
|
||||||
|
device_os_version: item.device_os_version || '',
|
||||||
|
device_last_seen_utc: item.device_last_seen_utc || '',
|
||||||
|
device_enrollment_status: item.device_enrollment_status || '',
|
||||||
|
device_model_name: item.device_model_name || '',
|
||||||
|
_device_mac_address: item._device_mac_address || '',
|
||||||
|
device_mac_address: item.device_mac_address || '',
|
||||||
|
|
||||||
|
meraki_deviceName: m.deviceName || '',
|
||||||
|
meraki_deviceSerial: m.deviceSerial || '',
|
||||||
|
meraki_switchport: m.switchport || '',
|
||||||
|
meraki_vlan: m.vlan || '',
|
||||||
|
meraki_status: m.status || '',
|
||||||
|
meraki_connection: m.connection || '',
|
||||||
|
meraki_lastSeen: m.lastSeen || '',
|
||||||
|
meraki_ip: m.ip || '',
|
||||||
|
meraki_mac: m.mac || '',
|
||||||
|
|
||||||
|
meraki_portType: m.portType || 'N/A',
|
||||||
|
meraki_accessPolicyType: m.accessPolicyType || 'N/A',
|
||||||
|
meraki_portEnabled: m.portEnabled || 'N/A',
|
||||||
|
meraki_stickyMacEnabled: m.stickyMacEnabled === true ? 'true' : 'false',
|
||||||
|
meraki_stickyMacList: stickyList,
|
||||||
|
meraki_stickyMacAllowListLimit: m.stickyMacAllowListLimit ?? '',
|
||||||
|
meraki_stickyMacCount: m.stickyMacCount ?? '',
|
||||||
|
error: item.error || ''
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const fields = [
|
||||||
|
'device_id', 'device_friendly_name', 'device_enrollment_user_name', '_device_platform',
|
||||||
|
'device_os_version', 'device_last_seen_utc', 'device_enrollment_status', 'device_model_name',
|
||||||
|
'_device_mac_address', 'device_mac_address',
|
||||||
|
'meraki_deviceName', 'meraki_deviceSerial', 'meraki_switchport', 'meraki_vlan',
|
||||||
|
'meraki_status', 'meraki_connection', 'meraki_lastSeen', 'meraki_ip', 'meraki_mac',
|
||||||
|
'meraki_portType', 'meraki_accessPolicyType', 'meraki_portEnabled',
|
||||||
|
'meraki_stickyMacEnabled', 'meraki_stickyMacList', 'meraki_stickyMacAllowListLimit',
|
||||||
|
'meraki_stickyMacCount', 'error'
|
||||||
|
];
|
||||||
|
|
||||||
|
const parser = new Parser({ fields });
|
||||||
|
const csvContent = parser.parse(flattened);
|
||||||
|
fs.writeFileSync('enriched-meraki-intermediate.json', JSON.stringify(enrichedRows, null, 2));
|
||||||
|
fs.writeFileSync(outputCsvPath, csvContent);
|
||||||
|
console.log(`Enrichment complete. Output CSV saved to: ${outputCsvPath}`);
|
||||||
|
console.log(`Processed ${enrichedRows.length} rows`);
|
||||||
|
|
||||||
|
return enrichedRows; // optional – for further chaining if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findMerakiNetworkId(storeNum) {
|
||||||
|
var startTime = new Date().getTime();
|
||||||
|
var networkSearchTerm = Number(storeNum).toString().padStart(5, "0");
|
||||||
|
try {
|
||||||
|
// Filter networks by partial name match (case-insensitive)
|
||||||
|
const matchingNetworks = merakiNetworks.filter(net =>
|
||||||
|
(net.name || '').toLowerCase().includes(networkSearchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchingNetworks.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchingNetworks.length > 1) {
|
||||||
|
console.log(`Multiple networks match '${networkSearchTerm}':`);
|
||||||
|
matchingNetworks.forEach(net => console.log(`- ${net.name} (ID: ${net.id})`));
|
||||||
|
console.log('Using the first match...');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return matchingNetworks[0].id;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error finding Meraki Network: ${error}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cacheMerakiNetworks() {
|
||||||
|
|
||||||
|
try {
|
||||||
|
let allNetworks = [];
|
||||||
|
let nextUrl = `https://api.meraki.com/api/v1/organizations/${config.auth.meraki.orgId}/networks?perPage=1000`;
|
||||||
|
|
||||||
|
while (nextUrl) {
|
||||||
|
const response = await axios.get(nextUrl, {
|
||||||
|
headers: {
|
||||||
|
'X-Cisco-Meraki-API-Key': config.auth.meraki.apiKey,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageNetworks = response.data;
|
||||||
|
allNetworks = allNetworks.concat(pageNetworks);
|
||||||
|
|
||||||
|
//logger(`cacheMerakiNetworks()`, `Fetched ${pageNetworks.length} networks (total so far: ${allNetworks.length})`);
|
||||||
|
|
||||||
|
// Log raw header
|
||||||
|
//logger(`cacheMerakiNetworks()`, `Link header raw: ${response.headers.link || '(none)'}`);
|
||||||
|
|
||||||
|
// Robust next URL extraction
|
||||||
|
const linkHeader = response.headers.link;
|
||||||
|
let foundNext = null;
|
||||||
|
|
||||||
|
if (linkHeader) {
|
||||||
|
//logger(`cacheMerakiNetworks()`, `Full Link header (raw): ${linkHeader}`);
|
||||||
|
|
||||||
|
const parts = linkHeader.split(',');
|
||||||
|
//logger(`cacheMerakiNetworks()`, `Split into ${parts.length} parts`);
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const trimmed = part.trim();
|
||||||
|
//logger(`cacheMerakiNetworks()`, `Examining part: "${trimmed}"`);
|
||||||
|
|
||||||
|
// Forgiving checks: lower case, no quotes required, partial match
|
||||||
|
const lowerTrimmed = trimmed.toLowerCase();
|
||||||
|
if (lowerTrimmed.includes('rel=next') || lowerTrimmed.includes('rel="next"') || lowerTrimmed.includes("rel='next'")) {
|
||||||
|
//logger(`cacheMerakiNetworks()`, `→ Detected rel=next in: "${trimmed}"`);
|
||||||
|
|
||||||
|
const urlMatch = trimmed.match(/<([^>]+)>/);
|
||||||
|
if (urlMatch && urlMatch[1]) {
|
||||||
|
foundNext = urlMatch[1].trim(); // extra trim just in case
|
||||||
|
//logger(`cacheMerakiNetworks()`, `→ Extracted next URL: ${foundNext}`);
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
//logger(`cacheMerakiNetworks()`, `→ URL match failed on that part`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextUrl = foundNext;
|
||||||
|
|
||||||
|
if (!nextUrl) {
|
||||||
|
//logger(`cacheMerakiNetworks()`, `No next page detected – ending loop`);
|
||||||
|
} else {
|
||||||
|
//logger(`cacheMerakiNetworks()`, `Advancing to next URL: ${nextUrl}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merakiNetworks = allNetworks;
|
||||||
|
logger(`cacheMerakiNetworks()`, `Cached ${merakiNetworks.length} networks. (${new Date().getTime() - startTime}ms)`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Meraki Networks API error:', error.message);
|
||||||
|
if (error.response) {
|
||||||
|
console.error('Status:', error.response.status);
|
||||||
|
console.error('Data:', error.response.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rebootWorkspaceOneDevice(deviceId) {
|
||||||
|
const baseUrl = `https://${awHost}/api`; // e.g., as123.awmdm.com
|
||||||
|
const headers = {
|
||||||
|
'Authorization': `Basic ${Buffer.from('your_username:your_password').toString('base64')}`, // or use API key method
|
||||||
|
'aw-tenant-code': tenantCode,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${baseUrl}/mdm/devices/${deviceId}/commands`,
|
||||||
|
{ Command: 'RebootDevice' }, // or 'RestartDevice' for iOS/tvOS
|
||||||
|
{ headers }
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Reboot command sent successfully:', response.data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending reboot:');
|
||||||
|
console.error(error.response?.data || error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
async function updateSwitchPort(networkId, serial, portId, settings) {
|
||||||
|
const apiKey = 'YOUR_MERAKI_API_KEY_HERE';
|
||||||
|
const baseUrl = 'https://api.meraki.com/api/v1';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.put(
|
||||||
|
`${baseUrl}/networks/${networkId}/devices/${serial}/switch/ports/${portId}`,
|
||||||
|
settings,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'X-Cisco-Meraki-API-Key': apiKey,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Success:', response.data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating port:');
|
||||||
|
console.error(error.response?.data || error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage examples
|
||||||
|
updateSwitchPort('L_123456789012345678', 'Q3LU-ABCDE-12345', '8', false); // Disable port 8
|
||||||
|
// updateSwitchPort('L_123456789012345678', 'Q3LU-ABCDE-12345', '8', true); // Enable port 8*/
|
||||||
|
function logger(functionName, message) {
|
||||||
|
var d = new Date();
|
||||||
|
var year = d.getFullYear();
|
||||||
|
var month = (d.getMonth() + 1).toString().padStart(2, "0");
|
||||||
|
var day = d.getDate().toString().padStart(2, "0");
|
||||||
|
let logFile = path.join(`./logs/${year}${month}${day}.log`);
|
||||||
|
console.log(d.toLocaleString() + " " + functionName + ": " + message);
|
||||||
|
fs.appendFileSync(logFile, d.toLocaleString() + " " + functionName + ": " + message + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMdmQuery(deviceId, apiKey, tenantCode, awHost) {
|
||||||
|
const baseUrl = `https://${awHost}/api`; // e.g., as1991.awmdm.com
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Authorization': `Basic ${Buffer.from('your_username:your_password').toString('base64')}`, // or API key method
|
||||||
|
'aw-tenant-code': tenantCode,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${baseUrl}/mdm/devices/${deviceId}/commands`,
|
||||||
|
{ Command: 'QueryDevice' },
|
||||||
|
{ headers }
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Query command sent successfully:', response.data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending MDM Query:');
|
||||||
|
if (error.response) {
|
||||||
|
console.error('Status:', error.response.status);
|
||||||
|
console.error('Response:', error.response.data);
|
||||||
|
} else {
|
||||||
|
console.error(error.message);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage example
|
||||||
|
const csvFile = 'appleTV.csv';
|
||||||
|
const inputCsv = 'appleTV.csv';
|
||||||
|
await processCSVAndEnrichMeraki(inputCsv, 'enriched-meraki-output.csv')
|
||||||
|
.catch(err => console.error('Fatal error:', err));
|
||||||
345
tools/appleTV/appleTV.js
Normal file
345
tools/appleTV/appleTV.js
Normal file
|
|
@ -0,0 +1,345 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
import csv from 'csv-parser';
|
||||||
|
import path from 'path';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { match } from 'assert';
|
||||||
|
|
||||||
|
var merakiNetworks = [];
|
||||||
|
|
||||||
|
|
||||||
|
var config = JSON.parse(fs.readFileSync('./config/config.json'));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Assume you have these functions from your previous code
|
||||||
|
// e.g., findMerakiNetworkId(storeNum) → returns network ID
|
||||||
|
// getMerakiClients(networkId) → returns array of clients with description, vlan, switchport, status, etc.
|
||||||
|
|
||||||
|
async function processCSVAndEnrichMeraki(csvFilePath) {
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
// Step 1: Parse the CSV
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
fs.createReadStream(csvFilePath)
|
||||||
|
.pipe(csv())
|
||||||
|
.on('data', (row) => results.push(row))
|
||||||
|
.on('end', resolve)
|
||||||
|
.on('error', reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Process each row
|
||||||
|
const enrichedData = [];
|
||||||
|
for (const row of results) {
|
||||||
|
const friendlyName = row.device_friendly_name?.trim() || ''; // Column 2
|
||||||
|
|
||||||
|
if (!friendlyName) continue;
|
||||||
|
|
||||||
|
// Extract store number: e.g., "US003897MSCAERIE" → "003897"
|
||||||
|
const storeMatch = friendlyName.match(/(\d{6})/); // Pull 6 digits
|
||||||
|
const storeNum = storeMatch ? storeMatch[0] : null;
|
||||||
|
|
||||||
|
if (!storeNum) {
|
||||||
|
console.log(`Skipping row with invalid friendlyName: ${friendlyName}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 3: Find Meraki Network ID
|
||||||
|
const networkId = await findMerakiNetworkId(storeNum);
|
||||||
|
|
||||||
|
if (!networkId) {
|
||||||
|
console.log(`No Meraki network found for store: ${storeNum}`);
|
||||||
|
enrichedData.push({ ...row, meraki: null });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Get Meraki clients for the network
|
||||||
|
const clients = await getMerakiClients(networkId);
|
||||||
|
|
||||||
|
// Step 5: Find matching client by description === friendlyName (case-insensitive)
|
||||||
|
const matchingClient = clients.find(client =>
|
||||||
|
client.description?.toUpperCase().trim() === friendlyName.toUpperCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!matchingClient) {
|
||||||
|
console.log(`No matching Meraki client for device: ${friendlyName} in store ${storeNum}`);
|
||||||
|
enrichedData.push({ ...row, meraki: null });
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
console.log(matchingClient)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Extract relevant Meraki info (device, port, vlan, port status)
|
||||||
|
const merakiInfo = {
|
||||||
|
deviceName: matchingClient.recentDeviceName || 'N/A',
|
||||||
|
deviceSerial: matchingClient.recentDeviceSerial || 'N/A',
|
||||||
|
switchport: matchingClient.switchport || 'N/A',
|
||||||
|
vlan: matchingClient.vlan || 'N/A',
|
||||||
|
status: matchingClient.status || 'Unknown',
|
||||||
|
connection: matchingClient.recentDeviceConnection || 'N/A',
|
||||||
|
lastSeen: matchingClient.lastSeen || 'N/A',
|
||||||
|
ip: matchingClient.ip || 'N/A',
|
||||||
|
mac: matchingClient.mac || 'N/A'
|
||||||
|
// Add more fields as needed, e.g., portStatus if available from another API
|
||||||
|
};
|
||||||
|
|
||||||
|
// Note: If "port status" requires another Meraki call (e.g., get switch ports), add it here:
|
||||||
|
// const ports = await getMerakiSwitchPorts(matchingClient.recentDeviceSerial);
|
||||||
|
// Then find the port matching switchport and get its status (enabled, link, etc.)
|
||||||
|
|
||||||
|
enrichedData.push({ ...row, meraki: merakiInfo });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error processing store ${storeNum}, device ${friendlyName}:`, error.message);
|
||||||
|
enrichedData.push({ ...row, meraki: null, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7: Return or save the enriched data (e.g., as JSON)
|
||||||
|
return enrichedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMerakiClients(merakiNetwork) {
|
||||||
|
if (merakiNetwork) {
|
||||||
|
try {
|
||||||
|
let allClients = [];
|
||||||
|
let nextUrl = `https://api.meraki.com/api/v1/networks/${merakiNetwork}/clients?perPage=5000×pan=2592000`; // Start with high perPage + 24h window
|
||||||
|
|
||||||
|
while (nextUrl) {
|
||||||
|
const response = await axios.get(nextUrl, {
|
||||||
|
headers: {
|
||||||
|
'X-Cisco-Meraki-API-Key': config.auth.meraki.apiKey,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageClients = await response.data;
|
||||||
|
allClients = allClients.concat(pageClients);
|
||||||
|
|
||||||
|
//console.log(`Fetched ${pageClients.length} clients from this page (total so far: ${allClients.length})`);
|
||||||
|
|
||||||
|
// Check Link header for next page
|
||||||
|
const linkHeader = await response.headers.link;
|
||||||
|
if (!linkHeader) {
|
||||||
|
nextUrl = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the 'next' link from Link header (format: <url>; rel="next", ...)
|
||||||
|
const nextMatch = await linkHeader.match(/<([^>]+)>;\s*rel="next"/);
|
||||||
|
nextUrl = nextMatch ? nextMatch[1] : null;
|
||||||
|
}
|
||||||
|
return allClients;
|
||||||
|
} catch (error) {
|
||||||
|
logger(`getMerakiClients(${merakiNetwork})`, `Error: ${error}`)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findMerakiNetworkId(storeNum) {
|
||||||
|
var startTime = new Date().getTime();
|
||||||
|
var networkSearchTerm = Number(storeNum).toString().padStart(5, "0");
|
||||||
|
try {
|
||||||
|
// Filter networks by partial name match (case-insensitive)
|
||||||
|
const matchingNetworks = merakiNetworks.filter(net =>
|
||||||
|
(net.name || '').toLowerCase().includes(networkSearchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchingNetworks.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchingNetworks.length > 1) {
|
||||||
|
console.log(`Multiple networks match '${networkSearchTerm}':`);
|
||||||
|
matchingNetworks.forEach(net => console.log(`- ${net.name} (ID: ${net.id})`));
|
||||||
|
console.log('Using the first match...');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return matchingNetworks[0].id;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error finding Meraki Network: ${error}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cacheMerakiNetworks() {
|
||||||
|
|
||||||
|
try {
|
||||||
|
let allNetworks = [];
|
||||||
|
let nextUrl = `https://api.meraki.com/api/v1/organizations/${config.auth.meraki.orgId}/networks?perPage=1000`;
|
||||||
|
|
||||||
|
while (nextUrl) {
|
||||||
|
const response = await axios.get(nextUrl, {
|
||||||
|
headers: {
|
||||||
|
'X-Cisco-Meraki-API-Key': config.auth.meraki.apiKey,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageNetworks = response.data;
|
||||||
|
allNetworks = allNetworks.concat(pageNetworks);
|
||||||
|
|
||||||
|
//logger(`cacheMerakiNetworks()`, `Fetched ${pageNetworks.length} networks (total so far: ${allNetworks.length})`);
|
||||||
|
|
||||||
|
// Log raw header
|
||||||
|
//logger(`cacheMerakiNetworks()`, `Link header raw: ${response.headers.link || '(none)'}`);
|
||||||
|
|
||||||
|
// Robust next URL extraction
|
||||||
|
const linkHeader = response.headers.link;
|
||||||
|
let foundNext = null;
|
||||||
|
|
||||||
|
if (linkHeader) {
|
||||||
|
//logger(`cacheMerakiNetworks()`, `Full Link header (raw): ${linkHeader}`);
|
||||||
|
|
||||||
|
const parts = linkHeader.split(',');
|
||||||
|
//logger(`cacheMerakiNetworks()`, `Split into ${parts.length} parts`);
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const trimmed = part.trim();
|
||||||
|
//logger(`cacheMerakiNetworks()`, `Examining part: "${trimmed}"`);
|
||||||
|
|
||||||
|
// Forgiving checks: lower case, no quotes required, partial match
|
||||||
|
const lowerTrimmed = trimmed.toLowerCase();
|
||||||
|
if (lowerTrimmed.includes('rel=next') || lowerTrimmed.includes('rel="next"') || lowerTrimmed.includes("rel='next'")) {
|
||||||
|
//logger(`cacheMerakiNetworks()`, `→ Detected rel=next in: "${trimmed}"`);
|
||||||
|
|
||||||
|
const urlMatch = trimmed.match(/<([^>]+)>/);
|
||||||
|
if (urlMatch && urlMatch[1]) {
|
||||||
|
foundNext = urlMatch[1].trim(); // extra trim just in case
|
||||||
|
//logger(`cacheMerakiNetworks()`, `→ Extracted next URL: ${foundNext}`);
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
//logger(`cacheMerakiNetworks()`, `→ URL match failed on that part`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextUrl = foundNext;
|
||||||
|
|
||||||
|
if (!nextUrl) {
|
||||||
|
//logger(`cacheMerakiNetworks()`, `No next page detected – ending loop`);
|
||||||
|
} else {
|
||||||
|
//logger(`cacheMerakiNetworks()`, `Advancing to next URL: ${nextUrl}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merakiNetworks = allNetworks;
|
||||||
|
logger(`cacheMerakiNetworks()`, `Cached ${merakiNetworks.length} networks. (${new Date().getTime() - startTime}ms)`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Meraki Networks API error:', error.message);
|
||||||
|
if (error.response) {
|
||||||
|
console.error('Status:', error.response.status);
|
||||||
|
console.error('Data:', error.response.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rebootWorkspaceOneDevice(deviceId) {
|
||||||
|
const baseUrl = `https://${awHost}/api`; // e.g., as123.awmdm.com
|
||||||
|
const headers = {
|
||||||
|
'Authorization': `Basic ${Buffer.from('your_username:your_password').toString('base64')}`, // or use API key method
|
||||||
|
'aw-tenant-code': tenantCode,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${baseUrl}/mdm/devices/${deviceId}/commands`,
|
||||||
|
{ Command: 'RebootDevice' }, // or 'RestartDevice' for iOS/tvOS
|
||||||
|
{ headers }
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Reboot command sent successfully:', response.data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending reboot:');
|
||||||
|
console.error(error.response?.data || error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
async function updateSwitchPort(networkId, serial, portId, settings) {
|
||||||
|
const apiKey = 'YOUR_MERAKI_API_KEY_HERE';
|
||||||
|
const baseUrl = 'https://api.meraki.com/api/v1';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.put(
|
||||||
|
`${baseUrl}/networks/${networkId}/devices/${serial}/switch/ports/${portId}`,
|
||||||
|
settings,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'X-Cisco-Meraki-API-Key': apiKey,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Success:', response.data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating port:');
|
||||||
|
console.error(error.response?.data || error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage examples
|
||||||
|
updateSwitchPort('L_123456789012345678', 'Q3LU-ABCDE-12345', '8', false); // Disable port 8
|
||||||
|
// updateSwitchPort('L_123456789012345678', 'Q3LU-ABCDE-12345', '8', true); // Enable port 8*/
|
||||||
|
function logger(functionName, message) {
|
||||||
|
var d = new Date();
|
||||||
|
var year = d.getFullYear();
|
||||||
|
var month = (d.getMonth() + 1).toString().padStart(2, "0");
|
||||||
|
var day = d.getDate().toString().padStart(2, "0");
|
||||||
|
let logFile = path.join(`./logs/${year}${month}${day}.log`);
|
||||||
|
console.log(d.toLocaleString() + " " + functionName + ": " + message);
|
||||||
|
fs.appendFileSync(logFile, d.toLocaleString() + " " + functionName + ": " + message + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMdmQuery(deviceId, apiKey, tenantCode, awHost) {
|
||||||
|
const baseUrl = `https://${awHost}/api`; // e.g., as1991.awmdm.com
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Authorization': `Basic ${Buffer.from('your_username:your_password').toString('base64')}`, // or API key method
|
||||||
|
'aw-tenant-code': tenantCode,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${baseUrl}/mdm/devices/${deviceId}/commands`,
|
||||||
|
{ Command: 'QueryDevice' },
|
||||||
|
{ headers }
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Query command sent successfully:', response.data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending MDM Query:');
|
||||||
|
if (error.response) {
|
||||||
|
console.error('Status:', error.response.status);
|
||||||
|
console.error('Response:', error.response.data);
|
||||||
|
} else {
|
||||||
|
console.error(error.message);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage example
|
||||||
|
const csvFile = 'appleTV.csv';
|
||||||
|
await cacheMerakiNetworks();
|
||||||
|
processCSVAndEnrichMeraki(csvFile)
|
||||||
|
.then(enriched => {
|
||||||
|
//console.log('Enriched Data:', JSON.stringify(enriched, null, 2));
|
||||||
|
// Optional: save to file
|
||||||
|
fs.writeFileSync('enriched-meraki.csv.json', JSON.stringify(enriched, null, 2));
|
||||||
|
})
|
||||||
|
.catch(err => console.error('Fatal error:', err));
|
||||||
97
tools/convert.js
Normal file
97
tools/convert.js
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
import { Parser } from 'json2csv';
|
||||||
|
|
||||||
|
// Load the enriched JSON file
|
||||||
|
const jsonFile = 'enriched-meraki.csv.json'; // Your input JSON path
|
||||||
|
const data = JSON.parse(fs.readFileSync(jsonFile, 'utf8'));
|
||||||
|
|
||||||
|
// Flatten the nested objects for CSV-friendly structure
|
||||||
|
const flattenedData = data.map(item => {
|
||||||
|
const meraki = item.meraki || {}; // safe access
|
||||||
|
|
||||||
|
// Handle stickyMacList as comma-separated string (or empty string)
|
||||||
|
const stickyMacListStr = Array.isArray(meraki.stickyMacList) && meraki.stickyMacList.length > 0
|
||||||
|
? meraki.stickyMacList.join(', ')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
device_id: item.device_id || '',
|
||||||
|
device_friendly_name: item.device_friendly_name || '',
|
||||||
|
device_enrollment_user_name: item.device_enrollment_user_name || '',
|
||||||
|
_device_platform: item._device_platform || '',
|
||||||
|
device_os_version: item.device_os_version || '',
|
||||||
|
device_last_seen_utc: item.device_last_seen_utc || '',
|
||||||
|
device_enrollment_status: item.device_enrollment_status || '',
|
||||||
|
device_model_name: item.device_model_name || '',
|
||||||
|
_device_mac_address: item._device_mac_address || '',
|
||||||
|
device_mac_address: item.device_mac_address || '',
|
||||||
|
|
||||||
|
// Existing Meraki fields
|
||||||
|
meraki_deviceName: meraki.deviceName || '',
|
||||||
|
meraki_switchport: meraki.switchport || '',
|
||||||
|
meraki_vlan: meraki.vlan || '',
|
||||||
|
meraki_status: meraki.status || '',
|
||||||
|
meraki_connection: meraki.connection || '',
|
||||||
|
meraki_lastSeen: meraki.lastSeen || '',
|
||||||
|
meraki_ip: meraki.ip || '',
|
||||||
|
meraki_mac: meraki.mac || '',
|
||||||
|
|
||||||
|
// New Meraki port / Sticky MAC fields
|
||||||
|
meraki_portType: meraki.portType || 'N/A',
|
||||||
|
meraki_portEnabled: meraki.portEnabled != null
|
||||||
|
? (typeof meraki.portEnabled === 'boolean'
|
||||||
|
? (meraki.portEnabled ? 'Enabled' : 'Disabled')
|
||||||
|
: meraki.portEnabled) // handles 'Error' / 'N/A'
|
||||||
|
: 'N/A',
|
||||||
|
meraki_accessPolicyType: meraki.accessPolicyType || 'N/A',
|
||||||
|
meraki_stickyMacEnabled: meraki.stickyMacEnabled === true ? 'true' : 'false', // explicit string for CSV clarity
|
||||||
|
meraki_stickyMacList: stickyMacListStr,
|
||||||
|
meraki_stickyMacAllowListLimit: meraki.stickyMacAllowListLimit != null ? meraki.stickyMacAllowListLimit : '',
|
||||||
|
meraki_stickyMacCount: meraki.stickyMacCount != null ? meraki.stickyMacCount : '',
|
||||||
|
meraki_portError: meraki.portError || '' // only present if fetch failed
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Define all CSV column headers (order matters for output)
|
||||||
|
const fields = [
|
||||||
|
'device_id',
|
||||||
|
'device_friendly_name',
|
||||||
|
'device_enrollment_user_name',
|
||||||
|
'_device_platform',
|
||||||
|
'device_os_version',
|
||||||
|
'device_last_seen_utc',
|
||||||
|
'device_enrollment_status',
|
||||||
|
'device_model_name',
|
||||||
|
'_device_mac_address',
|
||||||
|
'device_mac_address',
|
||||||
|
|
||||||
|
'meraki_deviceName',
|
||||||
|
'meraki_switchport',
|
||||||
|
'meraki_vlan',
|
||||||
|
'meraki_status',
|
||||||
|
'meraki_connection',
|
||||||
|
'meraki_lastSeen',
|
||||||
|
'meraki_ip',
|
||||||
|
'meraki_mac',
|
||||||
|
|
||||||
|
// New fields
|
||||||
|
'meraki_portType',
|
||||||
|
'meraki_accessPolicyType',
|
||||||
|
'meraki_portEnabled', // ← NEW
|
||||||
|
'meraki_stickyMacEnabled',
|
||||||
|
'meraki_stickyMacList',
|
||||||
|
'meraki_stickyMacAllowListLimit',
|
||||||
|
'meraki_stickyMacCount',
|
||||||
|
'meraki_portError'
|
||||||
|
];
|
||||||
|
|
||||||
|
const opts = { fields };
|
||||||
|
const parser = new Parser(opts);
|
||||||
|
const csv = parser.parse(flattenedData);
|
||||||
|
|
||||||
|
// Save to output CSV
|
||||||
|
const csvFile = 'enriched-meraki-output.csv';
|
||||||
|
fs.writeFileSync(csvFile, csv);
|
||||||
|
|
||||||
|
console.log(`CSV file created: ${csvFile}`);
|
||||||
|
console.log(`Total rows processed: ${flattenedData.length}`);
|
||||||
223
tools/fixWiredTV/fixWiredTV.js
Normal file
223
tools/fixWiredTV/fixWiredTV.js
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
var merakiNetworks = [];
|
||||||
|
|
||||||
|
var config = JSON.parse(fs.readFileSync('./config/config.json'));
|
||||||
|
var devices = JSON.parse(fs.readFileSync('./enriched-meraki.csv.json'));
|
||||||
|
|
||||||
|
async function showTime() {
|
||||||
|
for (var device of devices) {
|
||||||
|
if (device.meraki && device.meraki.connection == "Wired" && (device.meraki.vlan == "103" || device.meraki.vlan == "143" || device.meraki.vlan == "145" || device.meraki.vlan == "310" || device.meraki.vlan == "360")) {
|
||||||
|
console.log(`Working on ${device.device_friendly_name}.`)
|
||||||
|
await updateSwitchPort(device.meraki.deviceSerial, device.meraki.switchport, {
|
||||||
|
enabled: false, vlan: 340, accessPolicyType: "Sticky MAC allow list",
|
||||||
|
stickyMacAllowList: [],
|
||||||
|
stickyMacAllowListLimit: 1
|
||||||
|
})
|
||||||
|
await wait(10)
|
||||||
|
await updateSwitchPort(device.meraki.deviceSerial, device.meraki.switchport, { enabled: true })
|
||||||
|
//await sendMDMCommand(device.device_id, {
|
||||||
|
// "CommandXml": "<dict><key>RequestType</key><string>RestartDevice</string></dict>"
|
||||||
|
//})
|
||||||
|
//await wait(30)
|
||||||
|
//await updateSwitchPort(device.meraki.deviceSerial, device.meraki.switchport, { enabled: true, vlan: 340 })
|
||||||
|
//await wait(30)
|
||||||
|
//await sendMDMCommand(device.device_id, {
|
||||||
|
// "CommandXml": "<dict><key>RequestType</key><string>DeviceInformation</string><key>Queries</key><array><string>Model</string><string>SerialNumber</string><string>OSVersion</string></array></dict>"
|
||||||
|
//}
|
||||||
|
//)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMDMCommand(deviceId, command) {
|
||||||
|
|
||||||
|
const tokenResponse = await axios.post(
|
||||||
|
'https://na.uemauth.workspaceone.com/connect/token',
|
||||||
|
new URLSearchParams({
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
client_id: '4e43d8a448fd423984776c0b376d9047',
|
||||||
|
client_secret: '0F858A7DC18563D057176BE1BF11A7B3',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const accessToken = tokenResponse.data.access_token;
|
||||||
|
|
||||||
|
const baseUrl = `https://as1991.awmdm.com/api`; // e.g., as1991.awmdm.com
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'aw-tenant-code': 'cMrIjIsQkKSj0N7giRG36o9OgvNHhuG4+dQGkZXm7G8=',
|
||||||
|
Accept: 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${baseUrl}/mdm/devices/${deviceId}/commands?command=CustomMdmCommand`,
|
||||||
|
command,
|
||||||
|
{ headers }
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Command sent successfully: ${response.data}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending MDM Query:');
|
||||||
|
if (error.response) {
|
||||||
|
console.error('Status:', error.response.status);
|
||||||
|
console.error('Response:', error.response.data);
|
||||||
|
} else {
|
||||||
|
console.error(error.message);
|
||||||
|
}
|
||||||
|
//throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateSwitchPort(serial, portId, settings) {
|
||||||
|
const baseUrl = 'https://api.meraki.com/api/v1';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.put(
|
||||||
|
`${baseUrl}/devices/${serial}/switch/ports/${portId}`,
|
||||||
|
settings,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'X-Cisco-Meraki-API-Key': config.auth.meraki.apiKey,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
//console.log('Success:', response.data);
|
||||||
|
return "Port updated.";
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating port:');
|
||||||
|
console.error(error.response?.data || error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findMerakiNetworkId(storeNum) {
|
||||||
|
var startTime = new Date().getTime();
|
||||||
|
var networkSearchTerm = Number(storeNum).toString().padStart(5, "0");
|
||||||
|
try {
|
||||||
|
// Filter networks by partial name match (case-insensitive)
|
||||||
|
const matchingNetworks = merakiNetworks.filter(net =>
|
||||||
|
(net.name || '').toLowerCase().includes(networkSearchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(matchingNetworks)
|
||||||
|
|
||||||
|
if (matchingNetworks.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchingNetworks.length > 1) {
|
||||||
|
console.log(`Multiple networks match '${networkSearchTerm}':`);
|
||||||
|
matchingNetworks.forEach(net => console.log(`- ${net.name} (ID: ${net.id})`));
|
||||||
|
console.log('Using the first match...');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return matchingNetworks[0].id;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error finding Meraki Network: ${error}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cacheMerakiNetworks() {
|
||||||
|
var startTime = new Date().getTime();
|
||||||
|
|
||||||
|
try {
|
||||||
|
let allNetworks = [];
|
||||||
|
let nextUrl = `https://api.meraki.com/api/v1/organizations/${config.auth.meraki.orgId}/networks?perPage=1000`;
|
||||||
|
|
||||||
|
while (nextUrl) {
|
||||||
|
const response = await axios.get(nextUrl, {
|
||||||
|
headers: {
|
||||||
|
'X-Cisco-Meraki-API-Key': config.auth.meraki.apiKey,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageNetworks = response.data;
|
||||||
|
allNetworks = allNetworks.concat(pageNetworks);
|
||||||
|
|
||||||
|
//logger(`cacheMerakiNetworks()`, `Fetched ${pageNetworks.length} networks (total so far: ${allNetworks.length})`);
|
||||||
|
|
||||||
|
// Log raw header
|
||||||
|
//logger(`cacheMerakiNetworks()`, `Link header raw: ${response.headers.link || '(none)'}`);
|
||||||
|
|
||||||
|
// Robust next URL extraction
|
||||||
|
const linkHeader = response.headers.link;
|
||||||
|
let foundNext = null;
|
||||||
|
|
||||||
|
if (linkHeader) {
|
||||||
|
//logger(`cacheMerakiNetworks()`, `Full Link header (raw): ${linkHeader}`);
|
||||||
|
|
||||||
|
const parts = linkHeader.split(',');
|
||||||
|
//logger(`cacheMerakiNetworks()`, `Split into ${parts.length} parts`);
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const trimmed = part.trim();
|
||||||
|
//logger(`cacheMerakiNetworks()`, `Examining part: "${trimmed}"`);
|
||||||
|
|
||||||
|
// Forgiving checks: lower case, no quotes required, partial match
|
||||||
|
const lowerTrimmed = trimmed.toLowerCase();
|
||||||
|
if (lowerTrimmed.includes('rel=next') || lowerTrimmed.includes('rel="next"') || lowerTrimmed.includes("rel='next'")) {
|
||||||
|
//logger(`cacheMerakiNetworks()`, `→ Detected rel=next in: "${trimmed}"`);
|
||||||
|
|
||||||
|
const urlMatch = trimmed.match(/<([^>]+)>/);
|
||||||
|
if (urlMatch && urlMatch[1]) {
|
||||||
|
foundNext = urlMatch[1].trim(); // extra trim just in case
|
||||||
|
//logger(`cacheMerakiNetworks()`, `→ Extracted next URL: ${foundNext}`);
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
//logger(`cacheMerakiNetworks()`, `→ URL match failed on that part`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextUrl = foundNext;
|
||||||
|
|
||||||
|
if (!nextUrl) {
|
||||||
|
//logger(`cacheMerakiNetworks()`, `No next page detected – ending loop`);
|
||||||
|
} else {
|
||||||
|
//logger(`cacheMerakiNetworks()`, `Advancing to next URL: ${nextUrl}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merakiNetworks = allNetworks;
|
||||||
|
logger(`cacheMerakiNetworks()`, `Cached ${merakiNetworks.length} networks. (${new Date().getTime() - startTime}ms)`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Meraki Networks API error:', error.message);
|
||||||
|
if (error.response) {
|
||||||
|
console.error('Status:', error.response.status);
|
||||||
|
console.error('Data:', error.response.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logger(functionName, message) {
|
||||||
|
var d = new Date();
|
||||||
|
var year = d.getFullYear();
|
||||||
|
var month = (d.getMonth() + 1).toString().padStart(2, "0");
|
||||||
|
var day = d.getDate().toString().padStart(2, "0");
|
||||||
|
let logFile = path.join(`./logs/${year}${month}${day}.log`);
|
||||||
|
console.log(d.toLocaleString() + " " + functionName + ": " + message);
|
||||||
|
fs.appendFileSync(logFile, d.toLocaleString() + " " + functionName + ": " + message + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function wait(seconds) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, seconds * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
showTime();
|
||||||
|
|
||||||
111
tools/optisign/optisign.js
Normal file
111
tools/optisign/optisign.js
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
//const { GraphQLClient, gql } = require('graphql-request');
|
||||||
|
import { GraphQLClient, gql } from 'graphql-request';
|
||||||
|
|
||||||
|
async function getStoreDevicesStatus(storeNumber, apiKey) {
|
||||||
|
const endpoint = 'https://graphql-gateway.optisigns.com/graphql';
|
||||||
|
|
||||||
|
const client = new GraphQLClient(endpoint, {
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
const paddedStore = storeNumber.toString().padStart(6, '0');
|
||||||
|
|
||||||
|
const query = gql`
|
||||||
|
query GetDevices($first: Int, $after: String) {
|
||||||
|
devices(query: {}, first: $first, after: $after) {
|
||||||
|
page {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
_id
|
||||||
|
deviceName
|
||||||
|
UUID
|
||||||
|
pairingCode
|
||||||
|
currentType
|
||||||
|
currentAssetId
|
||||||
|
currentPlaylistId
|
||||||
|
localAppVersion
|
||||||
|
lastHeartBeat # Confirmed field!
|
||||||
|
# status # still null in sample, but can keep if it populates sometimes
|
||||||
|
# updatedAt # if you see it later
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pageInfo { hasNextPage endCursor }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
let allDevices = [];
|
||||||
|
let after = null;
|
||||||
|
const pageSize = 50;
|
||||||
|
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
const data = await client.request(query, { first: pageSize, after });
|
||||||
|
allDevices = allDevices.concat(data.devices.page.edges.map(e => e.node));
|
||||||
|
after = data.devices.page.pageInfo.hasNextPage ? data.devices.page.pageInfo.endCursor : null;
|
||||||
|
} while (after);
|
||||||
|
|
||||||
|
const storeDevices = allDevices.filter(d => d.deviceName?.includes(paddedStore));
|
||||||
|
|
||||||
|
if (storeDevices.length === 0) {
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return storeDevices;
|
||||||
|
/* storeDevices.forEach(device => {
|
||||||
|
let statusLine = '• **Playback Status:** Idle / Not playing';
|
||||||
|
let details = '';
|
||||||
|
|
||||||
|
if (device.currentPlaylistId || device.currentAssetId) {
|
||||||
|
const isPlaylist = device.currentType === 'PLAYLIST' || !!device.currentPlaylistId;
|
||||||
|
statusLine = `• **Playback Status:** Active - Playing content (${isPlaylist ? 'Playlist' : 'Asset/Single'})`;
|
||||||
|
details = `\n - Assigned ID: ${device.currentPlaylistId || device.currentAssetId || 'N/A'}\n - Type: ${device.currentType || 'unknown'}\n - App Version: ${device.localAppVersion || 'N/A'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heartbeat / Connectivity Status
|
||||||
|
let heartbeatInfo = '';
|
||||||
|
if (device.lastHeartBeat) {
|
||||||
|
const hbDate = new Date(device.lastHeartBeat);
|
||||||
|
const now = new Date(); // Current time in UTC, then we'll adjust display
|
||||||
|
const minutesAgo = Math.round((now - hbDate) / 60000);
|
||||||
|
|
||||||
|
const hbLocal = hbDate.toLocaleString('en-US', { timeZone: 'America/New_York' });
|
||||||
|
heartbeatInfo = `\n - Last Heartbeat: ${hbLocal} (${minutesAgo} min ago)`;
|
||||||
|
|
||||||
|
if (minutesAgo <= 10) {
|
||||||
|
heartbeatInfo += ' 🟢 **Online & checking in** (likely synced)';
|
||||||
|
} else if (minutesAgo <= 30) {
|
||||||
|
heartbeatInfo += ' 🟡 **Possibly delayed**';
|
||||||
|
} else {
|
||||||
|
heartbeatInfo += ' 🔴 **Offline / not checking in**';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
heartbeatInfo = '\n - No heartbeat data available';
|
||||||
|
}
|
||||||
|
|
||||||
|
md += `**${device.deviceName}** (${device.UUID.substring(0, 8)}...)\n`;
|
||||||
|
md += `${statusLine}\n`;
|
||||||
|
md += `${details}${heartbeatInfo}\n\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
md += `**Last checked:** ${new Date().toLocaleString('en-US', { timeZone: 'America/New_York' })} EST\n`;
|
||||||
|
md += `(via OptiSigns API)`;
|
||||||
|
|
||||||
|
console.log(md);
|
||||||
|
return md;
|
||||||
|
*/
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const errMd = `**Error:** ${error.message}`;
|
||||||
|
console.error(errMd);
|
||||||
|
return errMd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Example usage
|
||||||
|
const apiKey = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOiJnaEF3SEhKdTdkS3RmcEdkTSIsImNpZCI6Ilk2cXlaZXAzSE1NeHBuYXRMIiwiYWlkIjoidHh1NGhUcnZEMkdaM25tOU0iLCJpYXQiOjE3NDc0MTAxNjgsImV4cCI6MTc0NzQxMzc2OCwiaXNzIjoidHh1NGhUcnZEMkdaM25tOU0ifQ.TZRzzV6-QejdW4H3vppeypzExv4ppHdIKHSHpZXbKmpDhktlWrOQLQf4sNeX-UPa0bKKQcp9b8qHGpeu163bhmQAnnkWROqMD2z8tZ2kKgW34iXH5B_Ur1TbylvUS_ZsfFDQiUnT1Lf9waMAhZJeYKP4j70ak7BclMFf7XMGNGy75vSknzi1x7nvB1AOS5kEcPIV_oZNXlBVLLnEM1t3Edk6K-jXDmR_XHqN0lI3t0NX0yr2VTgt2KSqDRPaGcNBmuXHUgeB8xsEW4Dlk8rIin7qW4WkrvycnjyL2ZpT9EFZWYjaQNyJoTmJjxeywl9JrzrO1sIoYJ0QdHAOmgsnIDiYnw_eR73Mi0l3QUC9fZfgD91gidwKMWt3LIm4KCkynRNPOlBz7UGTp_wb4qCSFCNiYB6fGhWT1u7rcoR1g7sCHwCX6pkSMhKs1ZSOprTZSUFeFloqE3ufCNACQMa5I8QYi4Lz7KdEOn9yK2UM13Te1Shs3fD2HS23pyiLjznBMQg9z5TWTzbPWteR7FyKIRZjtdF1Foe5BJp-EmN_FR74Ndz1lo-0b10stszQE18ob9HSWsmWu_Tqg_KG4wBxOzKM-V8Eksx4eFyHeh8lzGXESzH2pGYfs7M3Y5Dvy1a3Hy9Xh7O0n82EW9MUHTXiny3yycD9vuUyG4FNQ7K2NuY'; // Replace with your actual key
|
||||||
|
const storeNumber = "002248";
|
||||||
|
const deviceName = 'US000629VW02'; // Replace with your device name (e.g., incorporating the 6-digit store number)
|
||||||
|
getStoreDevicesStatus(storeNumber, apiKey);
|
||||||
Loading…
Reference in a new issue