Compare commits

...

12 Commits

Author SHA1 Message Date
817fa47edd Add data volume and document per-user settings
All checks were successful
Build and push image / build (push) Successful in 13s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:04:22 -04:00
53c519ed62 Add settings dialog and first-visit setup prompt
A gear button and a setup prompt edit the saved settings, with name
suggestions, and apply them without a reload. Panels and status columns
can be hidden; the closed watcher and ding keep running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:04:22 -04:00
9479b122fa Save per-user settings on the server
Each user's sites, groups, technicians, timing, weather location and
hidden panels/columns are saved by Zoho email in DATA_DIR, merged over
the .env defaults and validated on PUT /api/settings. /api/names
suggests names from recent tickets, and weather is cached per zip from a
fixed list of campuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:04:22 -04:00
1a9de77dc6 Note that the bare-metal test run uses port 3005
All checks were successful
Build and push image / build (push) Successful in 53s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:23:47 -04:00
b29c0a022d Record live test results and drop outdated screenshots
The per-user sign-in was tested live on bare metal behind NPM: Zoho
sign-in, SDP data, Sign out and the IP access list all work. Token
renewal and the first Docker deploy are still to check.

- README: section on testing on the server without Docker (Gitea holds
  port 3000, NPM runs in Docker so forward to its network gateway, keep
  PORT out of the production .env), plus Cloudflare proxy and access
  list notes. The screenshots are removed until they're retaken.
- NOTES: deployment gotchas from the test run.
- ROADMAP and SESSION: what's verified, what's left, new resume prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:22:44 -04:00
f620cd96d4 Document hosted per-user setup and plan per-browser settings
README covers the Server-based Zoho client, the new .env settings,
deployment (image, compose, Nginx Proxy Manager), signing in and the
security model. NOTES records the Zoho OAuth gotchas and the decisions
behind the rebuild. ROADMAP describes the current architecture and the
planned per-browser settings panel. CLAUDE.md and SESSION.md are
brought up to date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:38:08 -04:00
c2cf725819 Add Docker image, example compose file and Gitea build workflow
- Dockerfile: node:24-alpine, production deps only, runs as the node
  user, healthcheck on /healthz. No volume, because the server keeps
  no state.
- docker-compose.yml: example for the VPS. It publishes no ports,
  since Docker's port publishing bypasses ufw and would skip NPM's IP
  access list. It joins NPM's network instead.
- .gitea/workflows/docker.yml: on v*-prod* tags, builds on the
  self-hosted runner and pushes v0idf1sh/sdp-dashboard:<tag> and
  :latest using the DHUB_UNAME and DHUB_PASS secrets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:38:08 -04:00
b6ef6a195c Replace shared device-code token with per-user Zoho sign-in
Each user now signs in with their own Zoho account through the web
OAuth flow (Server-based Application client), and every SDP query runs
with that user's token. Nobody who reaches the page can see data
without signing in.

- auth.js: authorization URL, code exchange, token renewal, revoke and
  user info, replacing the device-code flow.
- session.js: tokens kept in an AES-256-GCM encrypted, HttpOnly,
  SameSite=Lax cookie keyed by SESSION_SECRET. The access token renews
  when due, concurrent calls share one renewal, and sessions end after
  SESSION_DAYS.
- routes.js: /auth/login, /auth/callback (state checked), /auth/logout
  (revokes at Zoho); /api/session, /api/closed, /api/workload and
  /api/weather all need a sign-in.
- The server is stateless. boardService.js, tokenStore.js, ADMIN_KEY
  and data/token.json are gone. The page runs the checks itself and
  keeps the closed feed, backfilling 12 hours on first load.
- tickets.js: the closed feed query pages, and SDP 401s are rethrown
  so the user is signed out.
- server.js: PUBLIC_URL, trust proxy, security headers, /healthz.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:38:08 -04:00
3e79a24efd . 2026-10-15 19:07:07 -04:00
1e3f8dc1ad . 2026-10-15 16:53:51 -04:00
c75b5889cf Update credits and licensing 2026-10-15 15:38:59 -04:00
8c7b5fbd26 Slopify This Thang 2026-10-15 15:23:05 -04:00
30 changed files with 2725 additions and 420 deletions

13
.dockerignore Normal file
View File

@ -0,0 +1,13 @@
.git
.gitea
.vscode
node_modules
npm-debug.log*
.env
.env.*
docs
data
images
*.md
Dockerfile
docker-compose*.yml

View File

@ -0,0 +1,43 @@
# Builds the image and pushes it to Docker Hub when a production tag (e.g. v1.1.0-prod1) is pushed.
# Restarting the container on the VPS is done by hand for now.
name: Build and push image
on:
push:
tags:
- 'v*-prod*'
env:
IMAGE: v0idf1sh/sdp-dashboard
jobs:
build:
runs-on: self-hosted
steps:
- name: Check out
uses: actions/checkout@v4
- name: Log in to Docker Hub
env:
DHUB_UNAME: ${{ secrets.DHUB_UNAME }}
DHUB_PASS: ${{ secrets.DHUB_PASS }}
run: echo "$DHUB_PASS" | docker login --username "$DHUB_UNAME" --password-stdin
- name: Build
run: |
docker build \
--pull \
--label "org.opencontainers.image.version=${{ gitea.ref_name }}" \
--label "org.opencontainers.image.revision=${{ gitea.sha }}" \
-t "$IMAGE:${{ gitea.ref_name }}" \
-t "$IMAGE:latest" \
.
- name: Push
run: |
docker push "$IMAGE:${{ gitea.ref_name }}"
docker push "$IMAGE:latest"
- name: Log out
if: always()
run: docker logout

3
.gitignore vendored
View File

@ -1,3 +1,6 @@
# saved settings and name suggestions from local runs
data/
# Logs
logs
*.log

47
CLAUDE.md Normal file
View File

@ -0,0 +1,47 @@
# CLAUDE.md
Context for Claude Code sessions in this repo. Keep this short. Longer notes live in `docs/`.
## Project
A help desk dashboard for ManageEngine ServiceDesk Plus Cloud (`ithelp.mainecc.edu`). It's a Node/Express server, shipped as the Docker image `v0idf1sh/sdp-dashboard`, running behind Nginx Proxy Manager with an MCCS IP allow list. Each user signs in with their own Zoho account; the session is an encrypted cookie. `.env` sets the defaults (central region: KVCC, EMCC, CMCC, MCCS); each user can override region, timing, weather and hidden panels in Settings, saved on the server by Zoho email in `DATA_DIR` (a Docker volume).
- **Goal and next steps:** per-user settings (built, needs live checks) and panel modules. See `docs/ROADMAP.md`.
- **Decisions, API gotchas and known issues:** `docs/NOTES.md`. Read it before touching any SDP query.
- **Latest session summary and handoff:** `SESSION.md`.
## Run
```text
npm install
npm run app # node src/server.js from the repo root; .env lives in the repo root
```
The app is served at <http://localhost:3000>. `.env` needs `CLIENT_ID`/`CLIENT_SECRET` from a Zoho **Server-based Application** client with `http://localhost:3000/auth/callback` as a redirect URI, plus a `SESSION_SECRET` of 32 or more characters.
Releases: push a `v*-prod*` tag. `.gitea/workflows/docker.yml` builds and pushes the image; the VPS is updated by hand.
## Layout
- `src/server.js`: reads `.env`, holds the region `settings` and `timing`, and starts Express.
- `src/auth.js`: Zoho web OAuth (authorization URL, code exchange, renewal, revoke, user info).
- `src/session.js`: the encrypted session cookie and the `requireSession` middleware, which renews the access token when it's due.
- `src/routes.js`: `/auth/*` (login, callback, logout) and `/api/*` (session, settings, names, closed, workload, weather; all need a sign-in).
- `src/settings.js`: per-user settings: validation, `LIMITS`, merging over the `.env` defaults. `src/store.js` is the JSON file store under it.
- `src/names.js`: shared cache of site/group/technician names for the settings suggestions.
- `src/places.js`: the weather locations people can pick.
- `src/tickets.js`: all SDP queries (closed feed, status table, closed today, unassigned). A 401 from SDP is rethrown so the route signs the user out.
- `src/weather.js`: OpenWeatherMap, cached for 10 minutes per zip and shared by all users.
- `src/public/index.html`: the whole front end in one file (inline CSS and JS, no build step). It runs the polling timers, keeps the closed feed, and has the settings dialog.
- `Dockerfile`, `docker-compose.yml` (example for the VPS), `.gitea/workflows/docker.yml`.
## Working agreements
- Never print or commit `.env` values (Zoho client secret, `SESSION_SECRET`, OpenWeatherMap key). Redact them when inspecting.
- Never send Zoho tokens to the page or put them in logs. They stay inside the encrypted cookie and server memory for the length of a request.
- There are no automated tests yet. Verify server logic by stubbing `axios.get` and `axios.post`. Resolve axios from the project with `require.resolve('axios', { paths: [dir] })` so the stub hits the same instance. Verify the page by evaluating its `<script>` with stubbed `document`, `fetch`, `location` and `history`, or by taking a screenshot with headless Edge.
- The sandbox can't bind TCP ports, but it can listen on a unix socket with a short relative path (`cd` to the scratchpad and listen on `x.sock`). To run `server.js` end to end, patch `express.application.listen`. Docker isn't available in the sandbox.
- Match the existing style: 4-space indent and CommonJS on the server, 2-space indent in `index.html`, lowercase `//` comments explaining why rather than what. Some files have CRLF line endings, so use the Edit tool rather than sed for multi-line changes.
- Keep the core closed-ticket watcher (feed and ding) working and on by default.
- Don't commit unless asked. Commit messages end with the Co-Authored-By line for Claude.
- Record new decisions and gotchas in `docs/NOTES.md`, and move roadmap items as they're done.

22
Dockerfile Normal file
View File

@ -0,0 +1,22 @@
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
# install dependencies first so code-only changes reuse this layer
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY src ./src
# sessions live in each user's browser; the only state is saved settings and name
# suggestions in /app/data, which compose mounts as a volume. Made here so node owns it.
RUN mkdir -p /app/data && chown node:node /app/data
ENV DATA_DIR=/app/data
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/healthz || exit 1
CMD ["node", "src/server.js"]

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 sgrant
Copyright (c) 2026 Skylar Grant
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

182
README.md
View File

@ -1,48 +1,182 @@
# sdp-request-watcher
A help desk dashboard for ManageEngine ServiceDesk Plus (SDP) Cloud. It shows each technician's open tickets by status, plays a sound when a ticket is closed, tracks the unassigned queue, and shows local weather.
## Env and Server Setup
env.example contains the setup for the env requires a Non-Browser Application key from https://api-console.zoho.com/add
Place the .env file inside the src directory
Everyone signs in with their own Zoho account, and every query runs as that person. It's built as a Docker image (`v0idf1sh/sdp-dashboard`) to run behind Nginx Proxy Manager, which limits access to MCCS network addresses.
Technicians and Statuses are checked in the .env file
## Setup
I resolved the issue with it only finding closed tickets so it should now find resolved and closed I havent tested with others but will
### 1. Zoho API client
The technician field can have spaces after the comma however the statuses can not. There can be no spaces before the status or it wont work properly.
Create a **Server-based Application** at <https://api-console.zoho.com/add>:
- **Homepage URL:** the dashboard's address, e.g. `https://dashboard.example.com`.
- **Authorized Redirect URI:** that address plus `/auth/callback`, e.g. `https://dashboard.example.com/auth/callback`. It must match `PUBLIC_URL` exactly. To also run it locally, add `http://localhost:3000/auth/callback`, or use a second client for local work.
That gives you the client ID and secret. The app asks each user for two scopes: `SDPOnDemand.requests.READ`, so it can read requests but never change them, and `AaaServer.profile.READ`, only to show who is signed in.
## Site Setup
Open the site after the server is running, then connect the Zoho account from the board.
The old **Non-Browser Application** client (device-code sign-in) is no longer used.
1. Start the server from the `src` folder with node ./server.js
2. Open the local dashboard in your browser. Should be localhost:3000
3. Click **Connect** to begin the Zoho authorization flow.
4. Approve the device code on the Zoho page, then return to the dashboard.
### 2. `.env` file
What you should see:
Copy `src/env.example` to a file named `.env` and fill it in. The region, timing and weather settings are the defaults everyone starts with; each person can change their own in **Settings** (see [Settings](#settings)). dotenv reads `.env` from the folder you start the server in, so keep `.env` in the project root and start the server from there (see step 3).
![Site setup step 1](images/1.png)
| Setting | Required | What it does |
| --- | --- | --- |
| `CLIENT_ID`, `CLIENT_SECRET` | Yes | The Zoho Server-based Application credentials. |
| `SESSION_SECRET` | Yes | At least 32 characters. Encrypts the sign-in cookie. Generate one with `openssl rand -hex 32`. Changing it signs everyone out. |
| `PUBLIC_URL` | Yes when hosted | The address people open, with no trailing slash, e.g. `https://dashboard.example.com`. Zoho sends people back to `PUBLIC_URL/auth/callback`. Defaults to `http://localhost:3000`. Cookies are marked secure when it starts with `https://`. |
| `SESSION_DAYS` | No | How many days a sign-in lasts before Zoho is asked again. Defaults to 7. |
| `TECHNICIAN_NAMES` | No | Comma-separated technician names, spelled as they appear in SDP. One table row per name. Leave it empty to include every technician. |
| `STATUS_NAMES` | No | Statuses that count as "closed" for the ding and the Closed today count. Everything else counts as open. Defaults to `Closed,Resolved,Canceled`. |
| `SITE_NAMES` | No | Only include requests from these sites (the region). Leave it empty for all sites. |
| `GROUP_NAMES` | No | Only include requests in these support groups, e.g. `Customer Support`. Leave it empty for all groups. |
| `UNASSIGNED_EXCLUDED_STATUSES` | No | Statuses the Unassigned requests counter leaves out. Defaults to `STATUS_NAMES`. |
| `WORKLOAD_MINUTES` | No | How often the status table and unassigned count refresh, in minutes. Defaults to 5. |
| `OWM_API_KEY`, `OWM_ZIP_CODE` | No | OpenWeatherMap key and the default zip code for the weather card. Without a key the card is hidden. People pick other locations from the list in `src/places.js`. A free key from <https://openweathermap.org/api> is enough. New keys can take a couple of hours to start working. |
| `DATA_DIR` | No | Where saved settings (`settings.json`) and name suggestions (`names.json`) are kept. Defaults to `data/` in the project root; the Docker image uses `/app/data`. |
| `PORT` | No | Port the server listens on. Defaults to 3000. Leave it alone in Docker. |
| `TRUST_PROXY` | No | Which proxies to believe for the client's IP address in the logs. Defaults to private and loopback addresses, which covers NPM on the same Docker host. |
![Site setup step 2](images/2.png)
Spaces around commas are fine in every list.
![Site setup step 3](images/3.png)
The central region settings currently in use:
![Site setup step 4](images/4.png)
```text
SITE_NAMES=Base Site,ASSIGN,CMCC,CMCC-Damariscotta,EMCC,KVCC,KVCC-Alfond,MCCS
GROUP_NAMES=Customer Support
UNASSIGNED_EXCLUDED_STATUSES=Canceled,Closed,Need Information,On Hold,Resolved
```
Once connected, the board will begin checking for tickets automatically and refreshing on its own based on the time set in the server.js file.
These match the "Central Region Unassigned" custom view in ManageEngine. The Unassigned requests count was checked against that view and matched.
### 3. Run it locally
```text
npm install
npm run app
```
`npm run app` runs `node src/server.js` from the project root. Then open <http://localhost:3000>. Leave `PUBLIC_URL` unset (or set it to `http://localhost:3000`) and register `http://localhost:3000/auth/callback` with the Zoho client.
### Testing on the server without Docker
To try a branch on the VPS before building an image, run it directly with Node behind a separate test subdomain:
- Use Node 24 to match the image, run `npm ci`, and keep a **test** `.env` in the repo root. Give it its own `SESSION_SECRET`, and set `PUBLIC_URL` to the test subdomain. Register that subdomain's `/auth/callback` with the Zoho client.
- Port 3000 is taken by Gitea on the VPS, so set `PORT` (the test run uses `PORT=3005`) in the test `.env` only. **Leave `PORT` out of the production `.env`**: the container must listen on 3000, which its health check and the NPM host expect.
- NPM runs in Docker, so `localhost` in a proxy host means NPM's own container. Forward the test host to the gateway of NPM's network instead. Find it with `docker network inspect npm_default --format '{{(index .IPAM.Config 0).Gateway}}'`. If ufw is on, allow that network's subnet to the test port (`sudo ufw allow from <subnet> to any port 3005 proto tcp`).
- Attach the same access list as production.
## Deployment
### Image
Pushing a tag that matches `v*-prod*` (for example `v1.1.0-prod1`) runs `.gitea/workflows/docker.yml` on the `self-hosted` Gitea runner. It builds the image and pushes it to Docker Hub as `v0idf1sh/sdp-dashboard:<tag>` and `:latest`. The runner needs Docker, and the repository needs `DHUB_UNAME` and `DHUB_PASS` secrets (a Docker Hub access token works for the password). Pulling the new image and restarting the container is done by hand.
```text
git tag v1.1.0-prod1
git push origin v1.1.0-prod1
```
### Server
On the VPS, put `docker-compose.yml` and a filled-in `.env` in the same folder, then:
```text
docker compose pull
docker compose up -d
```
Sign-ins live in each browser's cookie, so restarting or upgrading the container doesn't sign anyone out. The only state is people's saved settings and the name suggestions, in the `sdp-data` volume mounted at `/app/data`. Keep that volume when upgrading; losing it only resets everyone to the `.env` defaults.
The compose file publishes no ports. Docker's port publishing bypasses the host firewall, so `3000:3000` would let anyone reach the app directly and skip NPM's access list. Instead the container joins NPM's Docker network (`npm_default` by default; set `NPM_NETWORK` if yours is named differently, and check with `docker network ls`).
### Nginx Proxy Manager
- **Proxy host:** the dashboard's subdomain, forwarding to `http://sdp-dashboard:3000`.
- **SSL:** request a certificate and turn on **Force SSL**. Sign-in cookies are secure-only when `PUBLIC_URL` is `https://`.
- **Access list:** allow MCCS' public IP blocks and deny everything else. Attach it to the proxy host. To check it, load the site from a phone on mobile data; it should return 403 Forbidden.
- **Cloudflare:** if the DNS record is proxied (orange cloud), NPM may see Cloudflare's addresses instead of the visitor's. Then the access list blocks everyone, MCCS included. If allowed addresses get 403s, either switch the record to DNS only, or add `real_ip_header CF-Connecting-IP;` in the proxy host's **Advanced** tab. With the proxy on, use Cloudflare SSL mode **Full (strict)**; Flexible combined with Force SSL causes a redirect loop.
## Signing in
Opening the dashboard shows a **Sign in with Zoho** button. It goes to Zoho's consent page, and Zoho sends you back signed in. Use the Zoho account you use for ServiceDesk Plus. The dashboard shows what that account can see, limited to the region in that person's settings.
The sign-in is kept in an encrypted cookie in that browser for `SESSION_DAYS` (7 by default), and the hourly Zoho access token is renewed automatically. **Sign out** clears the cookie and revokes the token at Zoho. If Zoho or SDP stops accepting the sign-in, the page goes back to the sign-in button.
### Security
- **Network:** NPM only lets in MCCS' public IP addresses (campuses and VPN). From anywhere else the subdomain doesn't answer.
- **Per-user sign-in:** every request needs the user's own Zoho sign-in. There is no shared token, so a reachable page with no sign-in shows nothing.
- **Tokens:** the Zoho tokens live only in the user's cookie, encrypted and authenticated with `SESSION_SECRET` (AES-256-GCM). The cookie is `HttpOnly`, so page scripts can't read it, and `SameSite=Lax`. The server doesn't store tokens or sessions.
- **Read-only:** the only SDP scope is `SDPOnDemand.requests.READ`.
- **Settings:** saved on the server, keyed by the email Zoho returns at sign-in. They hold names and numbers only, never tokens. The server checks every value (plain names, list lengths, refresh intervals, weather locations from a fixed list). Changing the region can't show anyone more than SDP already lets them see, because every query uses their own token.
## The dashboard
- **Stat tiles**: unassigned requests, total open requests, closed today, closed in the last hour, and the technician with the most open tickets.
- **Tickets by technician**: each technician's open tickets by status. **Total open** and **Closed today** come first, so they stay visible on narrow screens while the status columns scroll sideways. Darker cells mean more tickets. A row flashes when that technician closes a ticket.
- **Weather**: current conditions and a 5-day forecast for `OWM_ZIP_CODE`, refreshed every 10 minutes.
- **Recently closed**: the live closed-ticket feed, checked every minute. A ding plays for each new close. The sound is on by default, and the **Sound on close** toggle mutes it in that browser.
- **Refresh now** reruns every check immediately.
## Settings
**⚙ Settings** in the header, and a setup prompt on someone's first visit, let each person choose:
- **Sites, groups and technicians** to watch. Empty means everyone. Suggestions come from names seen on recent tickets anywhere in SDP (refreshed at most hourly and shared by everyone). Any name can be typed, but it must match SDP's spelling; a name that differs only in case is saved with SDP's spelling.
- **Timing:** how often the status table recounts (2 to 60 minutes) and how long closed tickets stay in the feed (1 to 24 hours). The closed check itself always runs every minute.
- **Weather location**, from the list of campuses in `src/places.js`.
- **What's shown:** the stat tiles, weather card and closed feed can be hidden (the ding still plays with the feed hidden), and so can individual status columns.
Settings are saved to the person's Zoho account on the server, so they follow them to any browser. Only the values that differ from `.env` are stored, so someone who never changed the region picks up later changes to `.env`. **Reset to defaults** goes back to `.env`. The closed and open status rules aren't per person; they stay in `.env`.
The browser runs the checks, using the signed-in user's token, so every open dashboard makes its own SDP calls. That's one small query a minute and a recount at the person's chosen interval (`WORKLOAD_MINUTES` by default). On first load it fetches the feed's full length (12 hours by default) of closes.
If a query fails, a red error box shows what SDP returned, and the other sections keep working. If the table ever shows **partial**, a query matched more tickets than it can page through (2,000). The server log (`docker compose logs`) then has the details, including SDP's total count. A very large total usually means SDP ignored the search criteria.
### Unassigned verification list
`src/public/index.html` still has a hidden table listing every unassigned request by ID and subject. It was used to confirm the count matches the ManageEngine view. To show it again, set `SHOW_UNASSIGNED_LIST = true`.
## How it works
| File | Role |
| --- | --- |
| `src/server.js` | Reads `.env`, then starts Express. |
| `src/auth.js` | Zoho web sign-in: the authorization URL, code exchange, token renewal and revoke. |
| `src/session.js` | The encrypted session cookie, and renewing the access token when it's due. |
| `src/routes.js` | `/auth/login`, `/auth/callback` and `/auth/logout`, plus `/api/session`, `/api/settings`, `/api/names`, `/api/closed`, `/api/workload` and `/api/weather`, which need a sign-in. |
| `src/settings.js` | Each user's saved settings: validation, limits, and merging over the `.env` defaults. |
| `src/names.js` | The shared cache of site, group and technician names for the settings suggestions. |
| `src/store.js` | The small JSON file store both of those use. |
| `src/places.js` | The weather locations people can pick from. |
| `src/tickets.js` | SDP API queries for the closed feed, the status table, closed today and unassigned requests. |
| `src/weather.js` | OpenWeatherMap current weather and forecast, cached for 10 minutes per location. |
| `src/public/index.html` | The dashboard page. It runs the checks and keeps the closed feed. |
| `Dockerfile`, `docker-compose.yml` | The image, and an example of how to run it. |
| `.gitea/workflows/docker.yml` | Builds and pushes the image on `v*-prod*` tags. |
Every SDP query is limited to the configured sites, groups and technicians. Results are also re-checked locally, so a ticket from outside the region is never counted even if the API returns one.
## Requirements
axios
dotenv
Node.js 18 or newer to run it directly; the image uses Node 24. Dependencies are `axios`, `dotenv` and `express`, all installed by `npm install`.
## Reference Materials/ Provided by Skylar
## Credits
- **Skylar Grant**: project owner and author.
- **Isaac Godin**: original page design.
- **Claude** (Anthropic, via Claude Code): dashboard, region scoping, unassigned counter, weather card, SDP query fixes, per-user sign-in and Docker packaging, built with Skylar.
## Reference materials (provided by Skylar)
- [Non-Browser Apps - Overview](https://www.zoho.com/accounts/protocol/oauth/limited-input-devices.html)
- [Non-Browser Apps - Initiation Request](https://www.zoho.com/accounts/protocol/oauth/devices/initiation-request.html)
- [Non-Browser Apps - Polling Request](https://www.zoho.com/accounts/protocol/oauth/devices/polling-request.html)
- [Get List Request](https://www.manageengine.com/products/service-desk/sdpod-v3-api/requests/request.html#get-list-request)
- [Input Data](https://www.manageengine.com/products/service-desk/sdpod-v3-api/getting-started/input-data.html)
- [Search Criteria](https://www.manageengine.com/products/service-desk/sdpod-v3-api/getting-started/search-criteria.html)
- [OpenWeatherMap Current Weather](https://openweathermap.org/current) and [5 Day Forecast](https://openweathermap.org/forecast5)
## License
MIT. See [LICENSE](LICENSE).

71
SESSION.md Normal file
View File

@ -0,0 +1,71 @@
# Session summary: 2026-10-15
A Claude Code session that turned the closed-ticket board into a dashboard, then rebuilt sign-in so it can be hosted. Background is in `CLAUDE.md`, `docs/ROADMAP.md` and `docs/NOTES.md`.
## Where things stand
The dashboard is now a stateless server meant to run as the Docker image `v0idf1sh/sdp-dashboard` on the RackNerd VPS, behind Nginx Proxy Manager. NPM's access list limits the site to MCCS' public IP blocks. Every user signs in with their own Zoho account. Filtering is still the central region from `.env`.
It's committed and pushed on branch `v2.0.0`. **Tested live on 2026-10-15** as a bare-metal run on the VPS (Node, `PORT=3005`) behind `dash-test` in NPM: Zoho sign-in, live SDP data, Sign out, and the IP access list (mobile data gets 403 Forbidden) all work. Still to check: hourly token renewal, and the first Docker image build and deploy.
## Auth rebuild and hosting (this session's last piece of work)
- **Sign-in:** Zoho web OAuth (`/auth/login` → Zoho consent → `/auth/callback`). It needs a new **Server-based Application** client in the Zoho API console, with redirect URI `PUBLIC_URL/auth/callback`; the old Non-Browser client is no longer used. Scopes: `SDPOnDemand.requests.READ` and `AaaServer.profile.READ` (for the name in the header).
- **Sessions:** the Zoho tokens live in an AES-256-GCM encrypted, `HttpOnly`, `SameSite=Lax` cookie (`Secure` over https), keyed by `SESSION_SECRET`. Each sign-in lasts `SESSION_DAYS` (default 7). The access token renews automatically; concurrent calls share one renewal. Sign out revokes the refresh token at Zoho. A rejected token, from Zoho or a 401 from SDP, sends the page back to the sign-in button.
- **Stateless server:** removed `boardService.js`, `tokenStore.js`, the device-code flow, `ADMIN_KEY` and `data/token.json` (phase 1 was superseded). The page runs the checks itself (`/api/closed` every minute, with 12 hours on first load; `/api/workload` every `WORKLOAD_MINUTES`) and keeps the closed feed. All `/api` routes, weather included, need a sign-in. `/healthz` is public for the Docker healthcheck.
- **Docker:** `Dockerfile` (node:24-alpine, runs as the `node` user, with a healthcheck) and `.dockerignore`. The example `docker-compose.yml` publishes **no ports**, because Docker's port publishing bypasses ufw and would skip NPM's access list. It joins NPM's network instead (`NPM_NETWORK`, default `npm_default`).
- **CI:** `.gitea/workflows/docker.yml` runs on `v*-prod*` tag pushes on the `self-hosted` runner. It logs in with the `DHUB_UNAME` and `DHUB_PASS` secrets, then builds and pushes `:<tag>` and `:latest`. Restarting on the VPS is manual.
- **Docs:** README (setup, deployment, NPM, signing in, security), `env.example`, `CLAUDE.md`, `docs/NOTES.md` (Zoho OAuth gotchas, decisions) and `docs/ROADMAP.md` (new architecture, plus the planned design for per-browser settings).
### Verified
- End to end against the real `server.js` over a unix socket, with axios stubbed: login redirect and state cookie; callback with a forged or missing state, a refused sign-in and a bad code; cookie flags and encryption; tokens never reach the page; a tampered cookie; three concurrent calls with an expired token renewing once; a revoked refresh token; a session past `SESSION_DAYS`; an SDP 401 signing the user out; sign-out revoking the token.
- The page script with stubbed DOM and fetch: the signed-out panel, `?signin=` messages, first-load backfill with no ding, a ding for a new close, and signing out mid-session.
- **Live, bare metal behind NPM (2026-10-15):** Zoho sign-in, SDP data, Sign out, and the access list blocking mobile data.
- **Not verified yet:** token renewal after an hour, the Docker build, the Gitea workflow, the production container behind `sdp-dashboard:3000`.
## Earlier the same day (dashboard work)
1. **Dashboard redesign** (`index.html`, `tickets.js`, `boardService.js`): stat tiles; a technician × status table with Total open and Closed today columns; the live closed feed in a side column; a "Sound on close" toggle, on by default; Refresh now. The table refreshes every `WORKLOAD_MINUTES` (default 5). The closed watcher still checks every minute.
2. **Central region scoping:** `SITE_NAMES` and `GROUP_NAMES` in `.env` are applied to every SDP query, and each ticket is also re-checked locally.
3. **Unassigned requests counter:** `UNASSIGNED_EXCLUDED_STATUSES` mirrors the ManageEngine "Central Region Unassigned" view. **Verified to match the view exactly.** The debug list is kept but hidden (`SHOW_UNASSIGNED_LIST = false` in `index.html`).
4. **Weather card** (`src/weather.js`, `/api/weather`): OpenWeatherMap current weather and 5-day forecast with icons, using `OWM_API_KEY` and `OWM_ZIP_CODE`, cached for 10 minutes. Working.
5. **Sign-in changes:** the Zoho page opens in a new tab, the button only appears once the device code exists, and the page checks quickly after Connect is clicked.
6. **Table layout:** totals moved to the left so they don't get cut off; the auth panel text is wider.
7. **SDP query fixes:**
- `greater_than` → `greater than`. The wrong spelling made SDP ignore all the criteria and return about 124k tickets.
- Paging now dedupes by ID and stops early when the total can't be reached.
- The "partial" note now names which query was cut off.
- **Verified fixed:** the "partial" note is gone.
8. **Small fixes:** `[hidden]` CSS bug (Refresh now showed when disconnected); a misplaced comment in `server.js`.
9. **README rewritten.** The screenshots it had then were removed after the auth rebuild; new ones are due.
10. **Credits:** page footer, README, `package.json` contributors, `server.js` header. Isaac Godin keeps the design credit.
11. **License:** MIT (LICENSE copyright Skylar Grant; package.json now says MIT, not ISC).
12. **Repo structure for working with Claude:** `CLAUDE.md`, `docs/ROADMAP.md`, `docs/NOTES.md`.
## Settings in `.env`
New: `PUBLIC_URL`, `SESSION_SECRET`, `SESSION_DAYS` (optional), `TRUST_PROXY` (optional). `CLIENT_ID` and `CLIENT_SECRET` must now come from the Server-based client. Gone: `ADMIN_KEY`, `TOKEN_FILE`. Unchanged: `SITE_NAMES`, `GROUP_NAMES`, `TECHNICIAN_NAMES`, `STATUS_NAMES`, `UNASSIGNED_EXCLUDED_STATUSES`, `WORKLOAD_MINUTES`, `OWM_API_KEY`, `OWM_ZIP_CODE`. All are documented in `src/env.example` and the README.
## Known issues
- On a typical screen the status table scrolls sideways ("Need Info" gets cut off), because the page is capped at 1,320px.
- No screenshots in the README for now (`images/` removed); Skylar is retaking them.
- The README's reference links for Zoho's device flow were removed; links for the server-based (web) flow haven't been added.
- Each open dashboard polls SDP with its own token. That's fine for a few viewers; watch SDP rate limits if wallboards multiply.
## Next steps
Already done: Zoho Server-based client, NPM proxy hosts (`dash` and `dash-test`) with the MCCS access list, Cloudflare DNS, a wildcard certificate, and the bare-metal test.
1. **Token renewal:** keep a tab open for more than an hour and confirm it stays signed in.
2. **First image:** check the `DHUB_UNAME` and `DHUB_PASS` secrets and the runner's `self-hosted` label, then push a `v*-prod*` tag and watch the workflow.
3. **Production container:** use a production `.env` with **no `PORT` line** and `PUBLIC_URL=https://` the production subdomain, check NPM's network name, then `docker compose up -d`. Confirm `docker ps` shows it healthy and the production subdomain signs in.
4. **Screenshots** for the README.
5. **Then:** per-browser settings, as designed in `docs/ROADMAP.md` (not started).
## To pick up
Start a Claude Code session in this repo and say something like:
*"Read SESSION.md and docs/ROADMAP.md. The per-user sign-in works live on bare metal; next is the first Docker deploy. Help me check the production container, then we'll plan the per-browser settings panel."*

31
docker-compose.yml Normal file
View File

@ -0,0 +1,31 @@
# Example compose file for the VPS. Copy src/env.example to .env next to this file and fill it in.
# Nginx Proxy Manager forwards the subdomain to sdp-dashboard:3000 and enforces the IP allow list.
services:
sdp-dashboard:
image: v0idf1sh/sdp-dashboard:latest
container_name: sdp-dashboard
restart: unless-stopped
env_file: .env
environment:
# must match the redirect URI registered in the Zoho API console, minus /auth/callback
PUBLIC_URL: https://dashboard.example.com
# No published ports on purpose. Docker's port publishing bypasses ufw, so "3000:3000" would
# let anyone reach the app directly and skip NPM's access list. NPM reaches it over the
# shared network instead, using the container name as the forward hostname.
expose:
- "3000"
# each user's saved settings and the shared name suggestions
volumes:
- sdp-data:/app/data
networks:
- proxy
volumes:
sdp-data:
networks:
proxy:
# the network Nginx Proxy Manager's container is on; check with `docker network ls`
external: true
name: ${NPM_NETWORK:-npm_default}

51
docs/NOTES.md Normal file
View File

@ -0,0 +1,51 @@
# Notes
## SDP v3 API gotchas
Docs: <https://www.manageengine.com/products/service-desk/sdpod-v3-api/getting-started/search-criteria.html>
- **Condition names have spaces:** `greater than`, `is not`, `lesser than`. `greater_than` isn't valid, and SDP silently ignored the **entire** search criteria because of it, returning every ticket (124,050). If a query's `total_count` is huge, suspect the criteria first.
- **Paging:** `row_count` is at most 100. Use `start_index`, and stop when `list_info.has_more_rows` is false. Ask for `get_total_count: true` on the first page. `getAllRequests` dedupes by ID and stops at 20 pages or when the total is unreachable.
- **Multiple values:** use a `values: [...]` array, and join criteria with `logical_operator: 'AND'` (see `allOf()` in `tickets.js`).
- **"Technician is empty":** written as `{ field: 'technician', condition: 'is' }` with no value. This isn't documented, since the docs only show the "is not" form, but it's verified to work.
- **Field names that work in filters:** `site.name`, `group.name`, `technician.name`, `status.name`, `last_updated_time`.
- **Group names:** the ManageEngine UI shows groups as "Customer Support « KVCC", but the API name is plain "Customer Support".
- **Sign-in scope:** `SDPOnDemand.requests.READ` is enough for every SDP query so far. `AaaServer.profile.READ` is added only for the signed-in user's name (`GET accounts.zoho.com/oauth/user/info`). If that call fails, sign-in still works. Changing the scopes means everyone signs in again.
## Zoho OAuth gotchas
- **Client type:** web sign-in needs a **Server-based Application** client. The old Non-Browser (device-code) client can't do redirects. The redirect URI must match `PUBLIC_URL/auth/callback` exactly.
- **Refresh tokens:** you only get one with `access_type=offline`, and `prompt=consent` makes Zoho issue one on every sign-in. Access tokens last about an hour; renew with `POST accounts.zoho.com/oauth/v2/token`, `grant_type=refresh_token`.
- **20 refresh tokens per user per client:** Zoho silently deletes the oldest when a 21st is issued. Each browser sign-in creates one, which is why Sign out revokes (`POST /oauth/v2/token/revoke?token=...`). A user signed in on more than 20 browsers would find the oldest one signed out.
- **Errors come back as HTTP 200:** a bad code or refresh token returns `{ "error": "invalid_code" }` with status 200. `auth.js` checks for `access_token` instead of relying on axios throwing.
- **Data centre:** everything points at `accounts.zoho.com` (US). The callback also gets `location` and `accounts-server` parameters, which would matter only for users in another Zoho DC.
## Deployment gotchas
- **NPM runs in Docker**, so `localhost` in a proxy host is NPM's own container. For a bare-metal test run, forward to the gateway of NPM's Docker network and allow that subnet through ufw. The production container is reached by name (`sdp-dashboard:3000`) because it joins NPM's network.
- **Port 3000 is Gitea's on the VPS.** Bare-metal test runs use `PORT=3005`. Never put `PORT` in the production `.env`; the container listens on 3000, and its health check and the NPM host expect that.
- **Cloudflare proxy and the NPM access list:** a proxied record can make NPM see Cloudflare's IPs, so the allow list blocks everyone. The fix is DNS only, or `real_ip_header CF-Connecting-IP;` in the proxy host's Advanced config. The live test on 2026-10-15 worked with the access list (an MCCS-listed IP got in, mobile data got a 403).
## Decisions
- **2026-10-15:** **MIT license.** Copyright Skylar Grant.
- **2026-10-15:** **OpenWeatherMap** for weather. The free tier covers current weather and the 5-day forecast, and results are cached for 10 minutes. The National Weather Service API was the alternative (free, no key), but it needs three calls per lookup.
- **2026-10-15:** **Unassigned counter** uses its own status exclusions (`UNASSIGNED_EXCLUDED_STATUSES`) to match the ManageEngine view. The debug list is kept but hidden (`SHOW_UNASSIGNED_LIST` in `index.html`).
- **2026-10-15:** **Per-user Zoho sign-in replaces the shared service token.** This reverses the earlier "one service token plus region config" decision, and drops the `ADMIN_KEY` and `data/token.json` from phase 1. Leadership's concern was that anyone who reached the page could see data while a shared token was live. Now every request needs the viewer's own sign-in, and NPM limits the site to MCCS addresses. The cost is that each open dashboard polls SDP itself; acceptable for a small team.
- **2026-10-15:** **Sessions in an encrypted cookie, not a server store.** AES-256-GCM with a key from `SESSION_SECRET`, `HttpOnly`, `SameSite=Lax` (Lax so the cookie survives the redirect back from Zoho), and `Secure` when `PUBLIC_URL` is https. The container needs no volume or database, and restarts don't sign anyone out. Renewing the access token rewrites the cookie but keeps `startedAt`, so `SESSION_DAYS` is a hard limit. Concurrent calls with an expired token share one renewal (`renewals` map in `session.js`).
- **2026-10-15:** **The browser runs the checks** and keeps the closed feed (first load asks for 12 hours, then 10-minute windows every minute). The server has no per-user timers or state. This also suits per-browser settings later: the browser will send its settings with each call.
- **2026-10-15:** **Per-user settings are stored on the server, keyed by Zoho email** (lowercased), in `DATA_DIR/settings.json`. Chosen over the planned `localStorage` so settings follow people between browsers and no export/import is needed. This gives the container state, so compose mounts the `sdp-data` volume at `/app/data`. Only values that differ from `.env` are stored, so `.env` changes still reach people who never changed that field. A plain JSON file, rewritten via temp file and rename, is enough for a small team with one process; move to SQLite if that stops being true.
- **2026-10-15:** **The server reads settings itself** rather than the browser sending them with each call, as first planned. The browser can only change them through `PUT /api/settings`, which validates everything (`LIMITS` in `settings.js`). That's for protecting SDP and the server, not access control: queries use the user's own token, and everyone at MCCS can see every site in SDP anyway.
- **2026-10-15:** **Name suggestions without a new scope.** `/api/names` runs one unscoped query (last 30 days, up to 500 tickets, names only) with the caller's token, at most hourly for everyone, and merges into `DATA_DIR/names.json` so the list grows over time. Typed names are still accepted; a name matching a suggestion except for case is saved with SDP's spelling, because the technician filters compare exactly. Proper pick lists from SDP's setup/users APIs would need another scope and a re-sign-in for everyone.
- **2026-10-15:** **Weather locations are a fixed list** (`src/places.js`), not free zip codes, so a browser can't make the server look up arbitrary places and the per-zip cache stays small. The `.env` zip is added to the list if it's missing.
- **2026-10-15:** **Status rules stay in `.env`** (`STATUS_NAMES`, `UNASSIGNED_EXCLUDED_STATUSES`). They're the help desk's definition of open and closed, not a personal preference. Hiding a status column is per user, but hidden columns still count toward Total open.
- **2026-10-15:** **Sound stays per browser** (`localStorage`), not in the saved settings, so a muted desk browser doesn't mute the wallboard signed in as the same person.
- **2026-10-15:** **No published ports in compose.** Docker's port publishing bypasses ufw, which would skip NPM's IP access list. The container joins NPM's network instead.
- **2026-10-15:** **CI uses plain `docker` commands** on the self-hosted runner, not the buildx or login actions, so there are fewer moving parts on the runner. Pushes both the tag and `latest`.
## Known issues
- The status table scrolls sideways once there are more statuses than fit, because the page is capped at 1,320px. Consider widening the page or narrowing the sidebar.
- All statuses not in `STATUS_NAMES` count as open, including "Need Information" and "On Hold". This is intentional; people can hide those columns in Settings.
- If Zoho doesn't return an email at sign-in (the profile call failed), that session can't save settings and uses the defaults; signing in again fixes it.
- No screenshots: `images/` was removed on 2026-10-15 and new ones are due.

37
docs/ROADMAP.md Normal file
View File

@ -0,0 +1,37 @@
# Roadmap
## Goal
A hosted dashboard for MCCS help desk staff. Everyone signs in with their own Zoho account and sees the queue for their region. Each browser can be customised, and new panels are easy to add.
## Current architecture
- **Hosting:** Docker image on a VPS behind Nginx Proxy Manager, which only lets in MCCS' public IP blocks. The image is built by Gitea Actions; the container is restarted by hand.
- **Identity and data:** each user signs in with their own Zoho account (web OAuth, Server-based Application client), and every SDP query runs with that user's token. What a user sees is limited by their own SDP permissions and then by the region filter.
- **Sessions:** the Zoho tokens live in an encrypted, `HttpOnly` cookie. The server stores no sessions, so restarts and upgrades don't sign anyone out. Its only state is saved settings and name suggestions.
- **Polling:** the browser runs the checks (closed feed every minute, status table at the user's interval) and keeps the closed feed. The server answers each call with the caller's token and their saved settings. Weather (per location) and name suggestions are shared.
- **Region and settings:** `.env` sets the defaults (`SITE_NAMES`, `GROUP_NAMES`, `TECHNICIAN_NAMES`, `WORKLOAD_MINUTES`, `OWM_ZIP_CODE`). Each user can override sites, groups, technicians, timing, weather location and hidden panels/columns in Settings; these are saved on the server by Zoho email in `DATA_DIR` (a Docker volume). The status lists stay in `.env` for everyone.
## Next
- [ ] **Finish checking the rebuild live.** Done on 2026-10-15 with a bare-metal test run behind `dash-test`: Zoho sign-in, live SDP data, Sign out, and the NPM access list (403 from mobile data). Still to check: the token renews after an hour without a sign-in prompt, and the first `v*-prod*` image builds, starts healthy and serves the production subdomain.
- [ ] **Per-user settings: finish live checks.** Built on 2026-10-15 (branch `v2.1.0`), tested with stubs only. Check against real SDP: the name suggestions query, the setup prompt on first sign-in, and the `sdp-data` volume surviving `docker compose pull && up -d`. Fill in campus satellites in `src/places.js`.
- [ ] **Panel modules:** each panel has a server route to fetch its data and page code to draw it. The weather card is the model.
- [ ] **Status table width:** it scrolls sideways once there are more statuses than fit (see `NOTES.md`).
## Open questions
- Does SDP restrict technicians to certain sites? With per-user tokens, that decides whether someone in another region sees their region's tickets or nothing.
- How many dashboards will be open at once? Each one polls SDP with its own token, so check the SDP API rate limits if wallboards multiply.
## Done
- Dashboard: stat tiles, technician × status table, live closed feed with the ding.
- Central region scoping by site and group.
- Unassigned counter. Verified to match the ManageEngine "Central Region Unassigned" view on 2026-10-15.
- Weather card (OpenWeatherMap).
- Fixed SDP paging and the date filter (see `NOTES.md`).
- Server-based auth rebuild (2026-10-15): per-user Zoho web sign-in, sessions in an encrypted browser cookie, a stateless server, and the browser running the checks. This replaced the shared device-code token, the `ADMIN_KEY` and `data/token.json` from the short-lived phase 1.
- Docker image `v0idf1sh/sdp-dashboard`, an example `docker-compose.yml`, and a Gitea Actions build on `v*-prod*` tags.
- Live test on 2026-10-15: per-user sign-in, data, Sign out and the IP access list all work behind NPM.
- Per-user settings (2026-10-15, `v2.1.0`): ⚙ Settings and a first-visit setup prompt; sites, groups, technicians with suggestions from recent SDP tickets; table refresh and feed length; weather location from a campus list; hidden panels and status columns. Saved server side by Zoho email, so they follow the user between browsers.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

24
package-lock.json generated
View File

@ -1,14 +1,19 @@
{
"name": "Dashboard",
"name": "sdp-request-watcher",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdp-request-watcher",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"axios": "^1.13.2",
"dotenv": "^17.4.2",
"express": "^5.2.1"
}
"axios": "1.x",
"dotenv": "18.x",
"express": "5.x"
},
"devDependencies": {}
},
"node_modules/accepts": {
"version": "2.0.0",
@ -228,10 +233,13 @@
}
},
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"version": "18.0.8",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.8.tgz",
"integrity": "sha512-2KDeoFoK/zRtcnbunCDlLG5VWVMcyqeQc6iK65o231nfNAcuGrKoqUQfJFUtBI+QlShk5HKIHxwebBtUl3rEfA==",
"license": "BSD-2-Clause",
"bin": {
"dotenv": "dist/index.cjs"
},
"engines": {
"node": ">=12"
},

View File

@ -1,22 +1,27 @@
{
"name": "sdp-request-watcher",
"version": "1.0.0",
"description": "A local NodeJS app and web server to watch Zoho ManageEngine SDP via API.",
"description": "A hosted help desk dashboard for ManageEngine ServiceDesk Plus Cloud, with per-user Zoho sign-in.",
"repository": {
"type": "git",
"url": "https://git.vfsh.dev/sgrant/sdp-request-watcher.git"
},
"license": "ISC",
"license": "MIT",
"author": "Skylar Grant",
"contributors": [
"Isaac Godin",
"Claude (Anthropic) <noreply@anthropic.com>"
],
"type": "commonjs",
"main": "src/server.js",
"scripts": {
"app": "node src/server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"axios": "^1.13.2",
"dotenv": "^17.4.2",
"express": "^5.2.1"
"axios": "1.x",
"dotenv": "18.x",
"express": "5.x"
},
"devDependencies": {}
}

View File

@ -1,80 +1,90 @@
const axios = require('axios');
async function getDeviceCode(clientId) {
// MCCS is on Zoho's US data centre; users on another DC would need accounts.zoho.<tld>
const ACCOUNTS_URL = 'https://accounts.zoho.com';
// profile.READ is only for showing who's signed in; the dashboard itself needs requests.READ
const SCOPES = 'SDPOnDemand.requests.READ,AaaServer.profile.READ';
// zoho reports most failures as a 200 with an error field, not an http error
function zohoError(data, fallback) {
const err = new Error('Zoho: ' + (data?.error || fallback));
err.zoho = true;
return err;
}
function createZohoAuth({ clientId, clientSecret, redirectUri }) {
// the page Zoho shows to approve the dashboard; state ties the callback to this browser
function authorizeUrl(state) {
const params = new URLSearchParams({
client_id: clientId,
scope: 'SDPOnDemand.requests.READ',
grant_type: 'device_request',
access_type: 'offline'
response_type: 'code',
scope: SCOPES,
redirect_uri: redirectUri,
state,
// offline is what gets us a refresh token; consent makes zoho issue one every time
access_type: 'offline',
prompt: 'consent'
});
return `${ACCOUNTS_URL}/oauth/v2/auth?${params}`;
}
const res = await axios.post('https://accounts.zoho.com/oauth/v3/device/code', params);
async function tokenRequest(fields) {
const params = new URLSearchParams({ client_id: clientId, client_secret: clientSecret, ...fields });
const res = await axios.post(`${ACCOUNTS_URL}/oauth/v2/token`, params);
if (!res.data?.access_token) throw zohoError(res.data, 'no access token');
return res.data;
}
function pollForToken(clientId, clientSecret, deviceCode, interval, expiresIn) {
const pollParams = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
code: deviceCode,
grant_type: 'device_token'
});
return new Promise((resolve, reject) => {
const deadline = Date.now() + (expiresIn || 300) * 1000;
const timer = setInterval(async () => {
if (Date.now() > deadline) {
clearInterval(timer);
reject(new Error('Expired try again.'));
return;
}
async function exchangeCode(code) {
const data = await tokenRequest({ grant_type: 'authorization_code', redirect_uri: redirectUri, code });
if (!data.refresh_token) throw zohoError(data, 'no refresh token');
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt: Date.now() + (data.expires_in || 3600) * 1000
};
}
// access tokens last about an hour; the refresh token lasts until revoked
async function refreshAccessToken(refreshToken) {
const data = await tokenRequest({ grant_type: 'refresh_token', refresh_token: refreshToken });
return {
accessToken: data.access_token,
expiresAt: Date.now() + (data.expires_in || 3600) * 1000
};
}
// zoho keeps at most 20 refresh tokens per user per client and silently drops the oldest,
// so revoke on sign-out rather than leaving them to pile up
async function revokeToken(refreshToken) {
await axios.post(`${ACCOUNTS_URL}/oauth/v2/token/revoke`, new URLSearchParams({ token: refreshToken }));
}
async function getUser(accessToken) {
try {
const res = await axios.post('https://accounts.zoho.com/oauth/v3/device/token', pollParams);
if (res.data.access_token) {
clearInterval(timer);
resolve(res.data.access_token);
}
} catch (err) {
}
}, interval + 500);
const res = await axios.get(`${ACCOUNTS_URL}/oauth/user/info`, {
headers: { Authorization: `Zoho-oauthtoken ${accessToken}` }
});
}
async function startAuthentication({ clientId, clientSecret, state }) {
state.auth = 'pending';
state.lastError = null;
const deviceData = await getDeviceCode(clientId);
state.verificationUrl = deviceData.verification_uri_complete;
state.userCode = deviceData.user_code;
console.log('Open this URL and authorize:', deviceData.verification_uri_complete);
console.log('Code: ', deviceData.user_code);
try {
const accessToken = await pollForToken(
clientId,
clientSecret,
deviceData.device_code,
deviceData.interval,
deviceData.expires_in
);
state.auth = 'connected';
state.verificationUrl = null;
state.userCode = null;
return { accessToken };
return {
name: res.data.Display_Name || [res.data.First_Name, res.data.Last_Name].filter(Boolean).join(' ') || null,
email: res.data.Email || null
};
} catch (err) {
state.auth = 'error';
state.lastError = err.message;
state.verificationUrl = null;
state.userCode = null;
return null;
// only cosmetic; the page just says "Signed in" without a name
console.warn('Could not read Zoho profile:', err.response?.data || err.message);
return { name: null, email: null };
}
}
return {
authorizeUrl,
exchangeCode,
refreshAccessToken,
revokeToken,
getUser
};
}
module.exports = {
startAuthentication
createZohoAuth
};

View File

@ -1,89 +0,0 @@
const { startAuthentication } = require('./auth');
const { fetchClosedTickets } = require('./tickets');
function createBoardService({ clientId, clientSecret, technicians, statusSet, windowMinutes, checkMinutes }) {
const state = {
auth: 'disconnected',
verificationUrl: null,
userCode: null,
lastCheck: null,
lastError: null
};
const tickets = new Map();
let accessToken = null;
let pollingTimer = null;
async function refreshTickets() {
if (!accessToken) return;
await fetchClosedTickets({
accessToken,
state,
tickets,
technicians,
statusSet,
windowMinutes
});
}
function startPolling() {
if (pollingTimer) return;
pollingTimer = setInterval(() => {
refreshTickets().catch(err => {
state.lastError = err.message;
state.lastCheck = Date.now();
});
}, checkMinutes * 60 * 1000);
}
async function connect() {
const authResult = await startAuthentication({
clientId,
clientSecret,
state
});
if (!authResult?.accessToken) return;
accessToken = authResult.accessToken;
console.log('Authorization successful');
await refreshTickets();
startPolling();
}
function failConnection(err) {
state.auth = 'error';
state.lastError = err.message;
state.verificationUrl = null;
state.userCode = null;
}
function getState() {
return {
...state,
windowMinutes,
checkMinutes,
watching: technicians,
serverTime: Date.now(),
tickets: [...tickets.values()].sort((a, b) => b.closedAt - a.closedAt)
};
}
async function checkNow() {
await refreshTickets();
}
return {
connect,
checkNow,
failConnection,
getState
};
}
module.exports = {
createBoardService
};

View File

@ -1,4 +1,27 @@
# Zoho API console "Server-based Application" client. Its redirect URI must be PUBLIC_URL/auth/callback
CLIENT_ID=
CLIENT_SECRET=
# the address people open the dashboard at (no trailing slash); defaults to http://localhost:3000
PUBLIC_URL=https://dashboard.example.com
# encrypts the sign-in cookie; at least 32 characters, e.g. from `openssl rand -hex 32`. Changing it signs everyone out
SESSION_SECRET=
# optional: days before a sign-in expires and Zoho is asked again (default 7)
SESSION_DAYS=7
# The region below is the default each user starts with; anyone can change their own in Settings.
# technicians in the status table (leave empty for everyone, plus an Unassigned row)
TECHNICIAN_NAMES=First Last, First Last
STATUS_NAMES=Closed,Resolved
# only requests from these sites are shown (central region: KVCC, EMCC, CMCC, MCCS and their satellite sites)
SITE_NAMES=Base Site,ASSIGN,CMCC,CMCC-Damariscotta,EMCC,KVCC,KVCC-Alfond,MCCS
# optional: also limit to these support groups, e.g. Customer Support (leave empty for all groups)
GROUP_NAMES=
# statuses the Unassigned Requests counter leaves out (defaults to STATUS_NAMES)
UNASSIGNED_EXCLUDED_STATUSES=Canceled,Closed,Need Information,On Hold,Resolved
# optional: weather widget, free key from https://openweathermap.org/api (current weather + 5 day forecast)
OWM_API_KEY=
# default weather location; people can pick another from the list in src/places.js
OWM_ZIP_CODE=04937
# optional: how often the technician/status table is recounted, in minutes (default 5)
WORKLOAD_MINUTES=5
# optional: where saved settings and name suggestions are kept (default ./data; /app/data in Docker)
# DATA_DIR=

63
src/names.js Normal file
View File

@ -0,0 +1,63 @@
const path = require('path');
const { createJsonFile } = require('./store');
const { fetchRecentNames } = require('./tickets');
const REFRESH_HOURS = 1; // SDP is asked for fresh names at most this often, whoever asks
const LOOKBACK_DAYS = 30;
const PAGES = 5; // 500 tickets is plenty to find the active technicians and sites
const MAX_NAMES = 500; // per list, so the file can't grow without end
const FIELDS = ['sites', 'groups', 'technicians'];
// Names seen in SDP, shared by all users and kept across restarts, so suggestions build up
// over time. Typing a name by hand always works; this only saves people from typos.
function createNameCache({ dataDir, seed }) {
const file = createJsonFile(path.join(dataDir, 'names.json'), { sites: [], groups: [], technicians: [], fetchedAt: 0 });
let pending = null;
function merge(found) {
const value = file.get();
for (const field of FIELDS) {
const byLower = new Map(value[field].map(n => [n.toLowerCase(), n]));
for (const name of found[field] || []) {
if (!byLower.has(name.toLowerCase())) byLower.set(name.toLowerCase(), name);
}
value[field] = [...byLower.values()].sort((a, b) => a.localeCompare(b)).slice(0, MAX_NAMES);
}
}
// the user's token is used to refresh; if SDP says no, the cached names are still returned
async function list(accessToken) {
if (Date.now() - file.get().fetchedAt > REFRESH_HOURS * 60 * 60 * 1000) {
pending = pending || fetchRecentNames({ accessToken, days: LOOKBACK_DAYS, pages: PAGES })
.then(found => {
merge(found);
file.get().fetchedAt = Date.now();
return file.save();
})
.catch(err => {
if (err.response?.status === 401) throw err;
console.error('Could not refresh name suggestions:', err.response?.data || err.message);
})
.finally(() => { pending = null; });
await pending;
}
const value = file.get();
merge(seed());
return Object.fromEntries(FIELDS.map(f => [f, value[f]]));
}
// a typed name that differs from SDP only in case is stored with SDP's spelling, since the
// technician filters compare exactly
function canonical(field, names) {
const byLower = new Map(file.get()[field].map(n => [n.toLowerCase(), n]));
return names.map(n => byLower.get(n.toLowerCase()) || n);
}
return { list, canonical };
}
module.exports = {
createNameCache
};

16
src/places.js Normal file
View File

@ -0,0 +1,16 @@
// Weather locations people can pick from in settings. Keeping it to a fixed list means the
// browser can't make the server look up arbitrary places, and the weather cache stays small.
// Add satellites here as needed; zip is a US zip code.
module.exports = [
{ zip: '04330', label: 'MCCS System Office (Augusta)' },
{ zip: '04210', label: 'CMCC (Auburn)' },
{ zip: '04543', label: 'CMCC Damariscotta' },
{ zip: '04401', label: 'EMCC (Bangor)' },
{ zip: '04937', label: 'KVCC (Fairfield)' },
{ zip: '04944', label: 'KVCC Alfond Campus (Hinckley)' },
{ zip: '04769', label: 'NMCC (Presque Isle)' },
{ zip: '04106', label: 'SMCC (South Portland)' },
{ zip: '04011', label: 'SMCC Midcoast (Brunswick)' },
{ zip: '04619', label: 'WCCC (Calais)' },
{ zip: '04090', label: 'YCCC (Wells)' }
];

File diff suppressed because it is too large Load Diff

View File

@ -1,37 +1,186 @@
const express = require('express');
const { fetchClosedTickets, fetchWorkload, fetchUnassigned } = require('./tickets');
const { LIMITS, PANELS, SettingsError } = require('./settings');
function createRoutes(boardService) {
// Sign-in: Zoho's web OAuth flow. Every user approves with their own Zoho account.
function createAuthRoutes({ zoho, sessions }) {
const router = express.Router();
router.get('/state', (req, res) => {
res.json(boardService.getState());
router.get('/login', (req, res) => {
res.redirect(zoho.authorizeUrl(sessions.startState(res)));
});
router.post('/connect', async (req, res) => {
const currentState = boardService.getState();
if (currentState.auth === 'pending' || currentState.auth === 'connected') {
return res.json({ ok: true });
router.get('/callback', async (req, res) => {
if (!sessions.checkState(req, res, req.query.state)) {
return res.redirect('/?signin=expired');
}
if (req.query.error || !req.query.code) {
console.warn('Sign-in refused by Zoho:', req.query.error || 'no code');
return res.redirect('/?signin=denied');
}
try {
boardService.connect().catch(err => {
boardService.failConnection(err);
});
res.json({ ok: true });
const tokens = await zoho.exchangeCode(String(req.query.code));
const user = await zoho.getUser(tokens.accessToken);
sessions.start(res, tokens, user);
console.log(`Signed in: ${user.email || user.name || 'unknown user'} from ${req.ip}`);
res.redirect('/');
} catch (err) {
boardService.failConnection(err);
res.status(500).json({ ok: false, error: err.message });
console.error('Sign-in failed:', err.response?.data || err.message);
res.redirect('/?signin=failed');
}
});
router.post('/check', async (req, res) => {
await boardService.checkNow();
router.post('/logout', async (req, res) => {
const session = sessions.read(req);
sessions.clear(res);
if (session) {
console.log(`Signed out: ${session.user?.email || session.user?.name || 'unknown user'}`);
await zoho.revokeToken(session.refreshToken).catch(err => {
console.warn('Could not revoke Zoho token:', err.message);
});
}
res.json({ ok: true });
});
return router;
}
// Data: each call runs against SDP with the caller's own token and their saved settings.
// Nothing is cached per user, so the browser decides how often to ask (see checkMinutes and
// the user's workloadMinutes).
function createApiRoutes({ sessions, weatherService, settings, timing, userSettings, nameCache, places }) {
const router = express.Router();
router.use((req, res, next) => {
res.set('Cache-Control', 'no-store');
next();
});
router.use(sessions.requireSession);
// turns an SDP 401 thrown by tickets.js into a sign-out
const signedOutOnSdp401 = handler => async (req, res) => {
try {
await handler(req, res);
} catch (err) {
if (err.response?.status !== 401) throw err;
sessions.clear(res);
res.status(401).json({ error: 'ServiceDesk Plus rejected your sign-in' });
}
};
// everything the settings panel needs, sent with the session and after each save
const settingsReply = user => ({
settings: userSettings.forUser(user),
settingsSaved: userSettings.hasSaved(user),
canSaveSettings: userSettings.canSave(user)
});
router.get('/session', (req, res) => {
res.json({
user: req.session.user,
sessionEndsAt: req.session.startedAt + timing.sessionDays * 24 * 60 * 60 * 1000,
...settingsReply(req.session.user),
settingsLimits: { ...LIMITS, panels: PANELS },
places: weatherService.enabled ? places : [],
checkMinutes: timing.checkMinutes,
windowMinutes: timing.windowMinutes
});
});
// only JSON bodies; a cross-site form can't send one without a CORS preflight we never answer
const readJson = express.json({ limit: '32kb' });
const jsonBody = (req, res, next) => readJson(req, res, err => {
if (err) return res.status(400).json({ error: 'Settings were not valid JSON' });
next();
});
router.put('/settings', jsonBody, async (req, res) => {
if (!req.is('application/json')) return res.status(415).json({ error: 'Send settings as JSON' });
const input = { ...req.body };
for (const field of ['sites', 'groups', 'technicians']) {
if (Array.isArray(input[field])) input[field] = nameCache.canonical(field, input[field]);
}
try {
await userSettings.save(req.session.user, input);
} catch (err) {
if (err instanceof SettingsError) return res.status(400).json({ error: err.message });
console.error('Could not save settings:', err.message);
return res.status(500).json({ error: 'The server could not save your settings' });
}
res.json(settingsReply(req.session.user));
});
router.delete('/settings', async (req, res) => {
try {
await userSettings.reset(req.session.user);
} catch (err) {
console.error('Could not reset settings:', err.message);
return res.status(500).json({ error: 'The server could not reset your settings' });
}
res.json(settingsReply(req.session.user));
});
// suggestions for the settings panel; typing any name still works
router.get('/names', signedOutOnSdp401(async (req, res) => {
res.json(await nameCache.list(req.session.accessToken));
}));
router.get('/closed', signedOutOnSdp401(async (req, res) => {
const prefs = userSettings.forUser(req.session.user);
// the page asks for the full feed on first load, then a short overlapping window each check
const minutes = Math.min(Math.max(Number(req.query.minutes) || timing.windowMinutes, 1), prefs.feedHours * 60);
const state = { lastCheck: null, lastError: null };
const tickets = new Map();
await fetchClosedTickets({
accessToken: req.session.accessToken,
state,
tickets,
technicians: prefs.technicians,
statusSet: settings.statusSet,
scope: { sites: prefs.sites, groups: prefs.groups },
windowMinutes: minutes
});
res.json({
checkedAt: state.lastCheck,
error: state.lastError,
tickets: [...tickets.values()].sort((a, b) => b.closedAt - a.closedAt)
});
}));
router.get('/workload', signedOutOnSdp401(async (req, res) => {
const prefs = userSettings.forUser(req.session.user);
const scope = { sites: prefs.sites, groups: prefs.groups };
const workload = { updatedAt: null, error: null, truncated: false, statuses: [], technicians: [] };
const unassigned = { updatedAt: null, error: null, truncated: false, count: 0, discarded: 0, tickets: [] };
await fetchWorkload({
accessToken: req.session.accessToken,
workload,
technicians: prefs.technicians,
closedStatuses: settings.closedStatuses,
scope
});
await fetchUnassigned({
accessToken: req.session.accessToken,
unassigned,
excludedStatuses: settings.unassignedExcludedStatuses,
scope
});
res.json({ workload, unassigned });
}));
router.get('/weather', async (req, res) => {
res.json(await weatherService.getWeather(userSettings.forUser(req.session.user).weatherZip));
});
return router;
}
module.exports = {
createRoutes
createAuthRoutes,
createApiRoutes
};

View File

@ -1,43 +1,127 @@
// sdp-request-watcher: help desk dashboard for ManageEngine SDP Cloud
// Skylar Grant, with Claude (Anthropic). Page design by Isaac Godin.
require('dotenv').config({ quiet: true });
const path = require('path');
const express = require('express');
const { createBoardService } = require('./boardService');
const { createRoutes } = require('./routes');
const { createZohoAuth } = require('./auth');
const { createSessions } = require('./session');
const { createAuthRoutes, createApiRoutes } = require('./routes');
const { createWeatherService } = require('./weather');
const { createUserSettings } = require('./settings');
const { createNameCache } = require('./names');
const places = require('./places');
const { CLIENT_ID, CLIENT_SECRET, TECHNICIAN_NAMES, STATUS_NAMES } = process.env;
const { CLIENT_ID, CLIENT_SECRET, SESSION_SECRET, TECHNICIAN_NAMES, STATUS_NAMES, SITE_NAMES, GROUP_NAMES, OWM_API_KEY, OWM_ZIP_CODE } = process.env;
const PORT = process.env.PORT || 3000; // default 3000
// the address people open; zoho sends them back to PUBLIC_URL/auth/callback after sign-in
const PUBLIC_URL = (process.env.PUBLIC_URL || `http://localhost:${PORT}`).replace(/\/+$/, '');
if (!CLIENT_ID || !CLIENT_SECRET) {
console.error('Client id and secret missing from .env');
const problems = [
!CLIENT_ID || !CLIENT_SECRET ? 'CLIENT_ID and CLIENT_SECRET are required' : null,
!SESSION_SECRET || SESSION_SECRET.length < 32 ? 'SESSION_SECRET must be at least 32 characters (try: openssl rand -hex 32)' : null
].filter(Boolean);
if (problems.length) {
problems.forEach(p => console.error(p));
process.exit(1);
}
const technicians = (TECHNICIAN_NAMES ? TECHNICIAN_NAMES.split(',') : []).map(t => t.trim()).filter(Boolean);
const statuses = (STATUS_NAMES ? STATUS_NAMES.split(',') : []).map(s => s.trim()).filter(Boolean);
const statusSet = new Set(statuses.map(s => s.toLowerCase()));
const toList = value => (value ? value.split(',') : []).map(v => v.trim()).filter(Boolean);
const WINDOW_MINUTES = 10; // how far back the server looks in minutes
const CHECK_MINUTES = 1; // how often the server checks for new tickets in minutes
const technicians = toList(TECHNICIAN_NAMES);
const statuses = toList(STATUS_NAMES);
// statuses that count as done; everything else shows up in the workload table
const closedStatuses = statuses.length ? statuses : ['Closed', 'Resolved', 'Canceled'];
const app = express();
// the status rules every user shares; these aren't in the settings panel
const settings = {
statusSet: new Set(statuses.map(s => s.toLowerCase())),
closedStatuses,
// statuses the unassigned counter ignores, to match the ManageEngine view
unassignedExcludedStatuses: process.env.UNASSIGNED_EXCLUDED_STATUSES !== undefined
? toList(process.env.UNASSIGNED_EXCLUDED_STATUSES)
: closedStatuses
};
//creates the board service with the config specified
const boardService = createBoardService({
const timing = {
windowMinutes: 10, // how far back each closed check looks, in minutes
checkMinutes: 1, // how often the page checks for newly closed tickets, in minutes
sessionDays: Number(process.env.SESSION_DAYS) || 7 // how long a sign-in lasts before Zoho is asked again
};
const zoho = createZohoAuth({
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
technicians,
statusSet,
windowMinutes: WINDOW_MINUTES,
checkMinutes: CHECK_MINUTES
redirectUri: `${PUBLIC_URL}/auth/callback`
});
app.use(express.static(path.join(__dirname, 'public')));
const sessions = createSessions({
secret: SESSION_SECRET,
// the proxy terminates https, so go by the public address rather than the request
secure: PUBLIC_URL.startsWith('https://'),
sessionDays: timing.sessionDays,
zoho
});
app.use('/api', createRoutes(boardService));
// weather widget is optional; it stays hidden without an API key
const weatherService = createWeatherService({ apiKey: OWM_API_KEY });
// the .env zip is the default location; make sure it's on the list people pick from
if (OWM_ZIP_CODE && !places.some(p => p.zip === OWM_ZIP_CODE)) {
places.unshift({ zip: OWM_ZIP_CODE, label: `Default (${OWM_ZIP_CODE})` });
}
// what each user starts with; the settings panel overrides these per user
const defaults = {
sites: toList(SITE_NAMES), // empty means every site
groups: toList(GROUP_NAMES), // empty means every group
technicians, // empty means everyone, plus an Unassigned row
workloadMinutes: Number(process.env.WORKLOAD_MINUTES) || 5, // how often the page recounts the status table
feedHours: 12, // how long a closed ticket stays in the feed
weatherZip: OWM_ZIP_CODE || null,
hiddenStatuses: [], // status columns left out of the table
hiddenPanels: [] // see PANELS in settings.js
};
// settings and name suggestions are the only things the server keeps; mount a volume here in docker
const dataDir = path.resolve(process.env.DATA_DIR || path.join(__dirname, '..', 'data'));
const userSettings = createUserSettings({ dataDir, defaults, places });
const nameCache = createNameCache({
dataDir,
seed: () => {
const saved = userSettings.allNames();
return {
sites: [...defaults.sites, ...saved.sites],
groups: [...defaults.groups, ...saved.groups],
technicians: [...defaults.technicians, ...saved.technicians]
};
}
});
const app = express();
app.disable('x-powered-by');
// behind nginx proxy manager: believe X-Forwarded-For from private addresses (the docker network)
// so logs show the real client ip
app.set('trust proxy', process.env.TRUST_PROXY || 'loopback, linklocal, uniquelocal');
app.use((req, res, next) => {
res.set({
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'same-origin'
});
next();
});
// for the docker healthcheck; says nothing about sign-in or data
app.get('/healthz', (req, res) => res.send('ok'));
app.use(express.static(path.join(__dirname, 'public')));
app.use('/auth', createAuthRoutes({ zoho, sessions }));
app.use('/api', createApiRoutes({ sessions, weatherService, settings, timing, userSettings, nameCache, places }));
//starts listening on port specified
app.listen(PORT, () => {
console.log(`Open http://localhost:${PORT}`);
console.log(`Listening on port ${PORT}, public address ${PUBLIC_URL}, data in ${dataDir}`);
});

146
src/session.js Normal file
View File

@ -0,0 +1,146 @@
const crypto = require('crypto');
const COOKIE = 'sdp_session';
const STATE_COOKIE = 'sdp_oauth_state';
// renew this long before expiry so a slow SDP query never runs on a dead token
const RENEW_EARLY_MS = 5 * 60 * 1000;
function parseCookies(header) {
const cookies = {};
for (const part of (header || '').split(';')) {
const eq = part.indexOf('=');
if (eq < 1) continue;
try {
cookies[part.slice(0, eq).trim()] = decodeURIComponent(part.slice(eq + 1).trim());
} catch (err) {
// a malformed cookie from somewhere else on the domain; ignore it
}
}
return cookies;
}
// The whole session lives in the browser: the user's Zoho tokens, encrypted and authenticated
// with SESSION_SECRET, in an httpOnly cookie page scripts can't read. The server keeps nothing,
// so restarts and new image versions don't sign anyone out.
function createSessions({ secret, secure, sessionDays, zoho }) {
const key = crypto.createHash('sha256').update(secret).digest();
const maxAgeMs = sessionDays * 24 * 60 * 60 * 1000;
const cookieBase = { httpOnly: true, secure, sameSite: 'lax', path: '/' };
function seal(payload) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const body = Buffer.concat([cipher.update(JSON.stringify(payload), 'utf8'), cipher.final()]);
return Buffer.concat([iv, cipher.getAuthTag(), body]).toString('base64url');
}
function open(value) {
try {
const raw = Buffer.from(value, 'base64url');
const decipher = crypto.createDecipheriv('aes-256-gcm', key, raw.subarray(0, 12));
decipher.setAuthTag(raw.subarray(12, 28));
const body = Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]);
return JSON.parse(body.toString('utf8'));
} catch (err) {
// tampered, truncated, or sealed with an old SESSION_SECRET
return null;
}
}
function write(res, session) {
// the cookie dies with the session; renewing the access token doesn't extend it
const maxAge = Math.max(0, session.startedAt + maxAgeMs - Date.now());
res.cookie(COOKIE, seal(session), { ...cookieBase, maxAge });
}
function clear(res) {
res.clearCookie(COOKIE, cookieBase);
}
function read(req) {
const session = open(parseCookies(req.headers.cookie)[COOKIE] || '');
if (!session || !session.refreshToken) return null;
if (Date.now() > session.startedAt + maxAgeMs) return null;
return session;
}
function start(res, tokens, user) {
const session = { ...tokens, user, startedAt: Date.now() };
write(res, session);
return session;
}
// A page load fires several calls at once, all carrying the same expired cookie. Renew once
// per refresh token and hand the result to all of them, rather than minting a token each.
const renewals = new Map();
function renew(refreshToken) {
const id = crypto.createHash('sha256').update(refreshToken).digest('hex');
const cached = renewals.get(id);
if (cached) return cached;
const pending = zoho.refreshAccessToken(refreshToken);
renewals.set(id, pending);
pending.then(
// keep the answer until shortly before it too needs renewing, then forget it
tokens => setTimeout(() => renewals.delete(id), Math.max(0, tokens.expiresAt - RENEW_EARLY_MS - Date.now())).unref(),
() => renewals.delete(id)
);
return pending;
}
// middleware: puts the signed-in session on req.session, renewing the access token if due
async function requireSession(req, res, next) {
const session = read(req);
if (!session) {
clear(res);
return res.status(401).json({ error: 'Not signed in' });
}
if (Date.now() >= session.expiresAt - RENEW_EARLY_MS) {
try {
Object.assign(session, await renew(session.refreshToken));
write(res, session);
} catch (err) {
if (err.zoho || err.response?.status === 400) {
// the refresh token was revoked or has been dropped by zoho; sign in again
clear(res);
return res.status(401).json({ error: 'Your Zoho sign-in has expired' });
}
return res.status(502).json({ error: 'Could not reach Zoho: ' + err.message });
}
}
req.session = session;
next();
}
// one-time value that ties Zoho's callback to the browser that started the sign-in
function startState(res) {
const state = crypto.randomBytes(16).toString('hex');
res.cookie(STATE_COOKIE, state, { ...cookieBase, maxAge: 10 * 60 * 1000 });
return state;
}
function checkState(req, res, given) {
const expected = parseCookies(req.headers.cookie)[STATE_COOKIE];
res.clearCookie(STATE_COOKIE, cookieBase);
if (!expected || typeof given !== 'string') return false;
// hash first so the comparison is fixed-length whatever the query string holds
const hash = value => crypto.createHash('sha256').update(value).digest();
return crypto.timingSafeEqual(hash(expected), hash(given));
}
return {
read,
start,
clear,
requireSession,
startState,
checkState
};
}
module.exports = {
createSessions
};

143
src/settings.js Normal file
View File

@ -0,0 +1,143 @@
const path = require('path');
const { createJsonFile } = require('./store');
// What the server accepts from the settings panel. These protect SDP and the server, not
// access: every query runs with the user's own token, so no setting can show more than SDP
// already lets them see.
const LIMITS = {
sites: 50,
groups: 50,
technicians: 100,
hiddenStatuses: 50,
nameLength: 100,
workloadMinutes: { min: 2, max: 60 },
feedHours: { min: 1, max: 24 }
};
// sections of the page that can be switched off; the closed watcher and its ding keep
// running when the feed is hidden
const PANELS = ['stats', 'weather', 'feed'];
const LIST_FIELDS = ['sites', 'groups', 'technicians', 'hiddenStatuses'];
class SettingsError extends Error {}
// trims, collapses spaces and drops case-insensitive repeats; rejects anything that isn't a plain name
function cleanList(value, field) {
if (!Array.isArray(value)) throw new SettingsError(`${field} must be a list`);
const seen = new Set();
const out = [];
for (const item of value) {
if (typeof item !== 'string') throw new SettingsError(`${field} must be a list of names`);
const name = item.replace(/\s+/g, ' ').trim();
if (!name) continue;
if (name.length > LIMITS.nameLength) throw new SettingsError(`"${name.slice(0, 40)}…" is too long`);
if (/[\u0000-\u001f\u007f]/.test(name)) throw new SettingsError(`${field} has an invalid name`);
if (seen.has(name.toLowerCase())) continue;
seen.add(name.toLowerCase());
out.push(name);
}
if (out.length > LIMITS[field]) throw new SettingsError(`At most ${LIMITS[field]} ${field}`);
return out;
}
function cleanNumber(value, field) {
const n = Number(value);
const { min, max } = LIMITS[field];
if (!Number.isInteger(n) || n < min || n > max) throw new SettingsError(`${field} must be a whole number from ${min} to ${max}`);
return n;
}
const sameValue = (a, b) => JSON.stringify(a) === JSON.stringify(b);
// Per-user settings, kept on the server and keyed by the email Zoho gave at sign-in, so they
// follow a person between browsers. Only fields that differ from the .env defaults are stored,
// so a user who never touched the region picks up later changes to .env.
function createUserSettings({ dataDir, defaults, places }) {
const file = createJsonFile(path.join(dataDir, 'settings.json'), { users: {} });
const zips = new Set(places.map(p => p.zip));
const keyFor = user => (user?.email ? user.email.toLowerCase() : null);
function validate(input) {
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new SettingsError('Settings must be an object');
const clean = {};
for (const field of LIST_FIELDS) {
if (input[field] !== undefined) clean[field] = cleanList(input[field], field);
}
for (const field of ['workloadMinutes', 'feedHours']) {
if (input[field] !== undefined) clean[field] = cleanNumber(input[field], field);
}
if (input.weatherZip !== undefined) {
if (!zips.has(input.weatherZip)) throw new SettingsError('Pick a weather location from the list');
clean.weatherZip = input.weatherZip;
}
if (input.hiddenPanels !== undefined) {
if (!Array.isArray(input.hiddenPanels) || input.hiddenPanels.some(p => !PANELS.includes(p))) {
throw new SettingsError(`hiddenPanels can only contain ${PANELS.join(', ')}`);
}
clean.hiddenPanels = PANELS.filter(p => input.hiddenPanels.includes(p));
}
return clean;
}
function stored(user) {
const key = keyFor(user);
return key ? file.get().users[key] : undefined;
}
// the defaults with this user's changes on top
function forUser(user) {
const record = stored(user);
const prefs = { ...defaults, ...(record?.settings || {}) };
// a location dropped from places.js falls back to the default rather than breaking weather
if (!zips.has(prefs.weatherZip)) prefs.weatherZip = defaults.weatherZip;
return prefs;
}
async function save(user, input) {
const key = keyFor(user);
if (!key) throw new SettingsError('Zoho didn\'t share your email at sign-in, so settings can\'t be saved. Sign out and in again.');
const clean = validate(input);
const overrides = {};
for (const [field, value] of Object.entries(clean)) {
if (!sameValue(value, defaults[field])) overrides[field] = value;
}
file.get().users[key] = { settings: overrides, updatedAt: Date.now() };
await file.save();
return forUser(user);
}
async function reset(user) {
const key = keyFor(user);
if (!key || !file.get().users[key]) return forUser(user);
// keep an empty record so the first-run prompt doesn't come back
file.get().users[key] = { settings: {}, updatedAt: Date.now() };
await file.save();
return forUser(user);
}
return {
forUser,
// false until the user has saved once; the page shows the setup prompt until then
hasSaved: user => !!stored(user),
canSave: user => !!keyFor(user),
save,
reset,
// every name anyone has saved, to help the suggestions along
allNames: () => {
const names = { sites: [], groups: [], technicians: [] };
for (const { settings } of Object.values(file.get().users)) {
for (const field of Object.keys(names)) names[field].push(...(settings[field] || []));
}
return names;
}
};
}
module.exports = {
LIMITS,
PANELS,
SettingsError,
createUserSettings
};

38
src/store.js Normal file
View File

@ -0,0 +1,38 @@
const fs = require('fs');
const path = require('path');
// A small JSON file the server keeps in memory and writes back on change. Enough for a few
// dozen users' settings; one process owns the file, so there's no locking beyond queuing writes.
function createJsonFile(file, initial) {
let value = initial;
try {
value = { ...initial, ...JSON.parse(fs.readFileSync(file, 'utf8')) };
} catch (err) {
if (err.code !== 'ENOENT') console.error(`Could not read ${file}, starting empty:`, err.message);
}
let queue = Promise.resolve();
// write to a temp file and rename, so a crash mid-write never leaves half a file
function save() {
const json = JSON.stringify(value, null, 2);
const tmp = `${file}.tmp`;
const write = queue.then(async () => {
await fs.promises.mkdir(path.dirname(file), { recursive: true });
await fs.promises.writeFile(tmp, json);
await fs.promises.rename(tmp, file);
});
// a failed write shouldn't block the ones after it
queue = write.catch(() => {});
return write;
}
return {
get: () => value,
save
};
}
module.exports = {
createJsonFile
};

View File

@ -1,5 +1,9 @@
const axios = require('axios');
const API_URL = 'https://ithelp.mainecc.edu/api/v3/requests';
const PAGE_SIZE = 100; // SDP caps row_count at 100 per page
const MAX_PAGES = 20; // safety net so a bad filter can't page forever
function toMillis(completedTime) {
if (!completedTime) return null;
const raw = typeof completedTime === 'object' ? completedTime.value : completedTime;
@ -14,15 +18,98 @@ function getRelevantTicketTime(ticket) {
|| null;
}
async function fetchClosedTickets({ accessToken, state, tickets, technicians, statusSet, windowMinutes }) {
// the user's token was rejected; let the route sign them out instead of showing it as a data error
function rethrowIfSignedOut(err) {
if (err.response?.status === 401) throw err;
}
function describeError(err) {
const detail = err.response?.data;
console.error('Error: ', detail || err.message);
return detail?.response_status?.messages?.[0]?.message
|| (typeof detail === 'string' ? detail : null)
|| err.message;
}
// joins search criteria with AND, skipping any that are null
function allOf(...criteria) {
return criteria.flat().filter(Boolean).map((c, i) => (i ? { ...c, logical_operator: 'AND' } : c));
}
// limits a query to the configured sites/groups (the region)
function scopeCriteria(scope) {
return [
scope.sites.length ? { field: 'site.name', condition: 'is', values: scope.sites } : null,
scope.groups.length ? { field: 'group.name', condition: 'is', values: scope.groups } : null
];
}
// same check done locally, in case the API ever returns something outside the region
function inScope(ticket, scope) {
const matches = (list, name) => !list.length
|| (!!name && list.some(item => item.toLowerCase() === name.toLowerCase()));
return matches(scope.sites, ticket.site?.name) && matches(scope.groups, ticket.group?.name);
}
async function getRequests(accessToken, listInfo) {
const response = await axios.get(API_URL, {
params: { input_data: JSON.stringify({ list_info: listInfo }) },
headers: {
Authorization: `Zoho-oauthtoken ${accessToken}`,
Accept: 'application/vnd.manageengine.sdp.v3+json'
}
});
return response.data || {};
}
async function getAllRequests(accessToken, listInfo, label) {
const byId = new Map();
let totalCount = null;
let pages = 0;
let duplicates = 0;
let truncated = true;
for (let page = 0; page < MAX_PAGES; page++) {
const data = await getRequests(accessToken, {
...listInfo,
row_count: PAGE_SIZE,
start_index: page * PAGE_SIZE + 1,
get_total_count: page === 0
});
pages++;
if (page === 0) totalCount = data.list_info?.total_count ?? null;
// dedupe by id so a page that repeats (or shifts as tickets change) can't double count
let added = 0;
for (const ticket of data.requests || []) {
const id = String(ticket.id ?? ticket.display_id);
if (byId.has(id)) duplicates++;
else { byId.set(id, ticket); added++; }
}
if (!data.list_info?.has_more_rows) { truncated = false; break; }
// more matches than we could ever page through almost always means the API ignored our
// search criteria; don't spend 19 more calls downloading the wrong data
if (totalCount > PAGE_SIZE * MAX_PAGES) break;
// a full page of repeats means start_index isn't moving us forward; stop rather than loop
if (!added) break;
}
const requests = [...byId.values()];
if (truncated || duplicates) {
console.warn(`[${label}] pages=${pages} unique=${requests.length} duplicates=${duplicates} total_count=${totalCount} truncated=${truncated}`);
}
return { requests, truncated, totalCount };
}
async function fetchClosedTickets({ accessToken, state, tickets, technicians, statusSet, scope, windowMinutes }) {
if (!accessToken) return;
try {
const windowStart = Date.now() - windowMinutes * 60 * 1000;
const listInfo = {
list_info: {
row_count: 200,
// paged, because the page asks for the last 12 hours on first load, not just a few minutes
const data = await getAllRequests(accessToken, {
sort_field: 'last_updated_time',
sort_order: 'desc',
fields_required: [
@ -32,29 +119,25 @@ async function fetchClosedTickets({ accessToken, state, tickets, technicians, st
'completed_time',
'resolved_time',
'last_updated_time',
'technician'
'technician',
'site',
'group'
],
search_criteria: [
search_criteria: allOf(
{
field: 'last_updated_time',
condition: 'greater_than',
condition: 'greater than',
value: windowStart.toString()
}
]
}
};
},
scopeCriteria(scope)
)
}, 'closed feed');
const response = await axios.get('https://ithelp.mainecc.edu/api/v3/requests', {
params: { input_data: JSON.stringify(listInfo) },
headers: {
Authorization: `Zoho-oauthtoken ${accessToken}`,
Accept: 'application/vnd.manageengine.sdp.v3+json'
}
});
const found = response.data?.requests || [];
const found = data.requests;
for (const ticket of found) {
if (!inScope(ticket, scope)) continue;
const statusName = ticket.status?.name || '';
const statusInternalName = ticket.status?.internal_name || '';
const statusMatches = !statusSet.size
@ -82,23 +165,188 @@ async function fetchClosedTickets({ accessToken, state, tickets, technicians, st
});
}
const cutoff = Date.now() - 12 * 60 * 60 * 1000;
for (const [key, ticket] of tickets) {
if (ticket.closedAt < cutoff) tickets.delete(key);
}
state.lastCheck = Date.now();
state.lastError = null;
} catch (err) {
const detail = err.response?.data;
state.lastError = detail?.response_status?.messages?.[0]?.message
|| (typeof detail === 'string' ? detail : null)
|| err.message;
rethrowIfSignedOut(err);
state.lastError = describeError(err);
state.lastCheck = Date.now();
console.error('Error: ', detail || err.message);
}
}
// Counts every open ticket by technician and status, plus how many each
// technician has closed since midnight.
async function fetchWorkload({ accessToken, workload, technicians, closedStatuses, scope }) {
if (!accessToken) return;
const startOfDay = new Date();
startOfDay.setHours(0, 0, 0, 0);
const closedLower = new Set(closedStatuses.map(s => s.toLowerCase()));
const byLowerName = new Map(technicians.map(t => [t.toLowerCase(), t]));
const techCriterion = technicians.length
? { field: 'technician.name', condition: 'is', values: technicians }
: null;
// maps a ticket to the row it's counted under, or null to skip it
function rowFor(ticket) {
if (!inScope(ticket, scope)) return null;
const name = ticket.technician?.name;
if (!technicians.length) return name || 'Unassigned';
return name ? byLowerName.get(name.toLowerCase()) || null : null;
}
try {
const open = await getAllRequests(accessToken, {
sort_field: 'created_time',
sort_order: 'desc',
fields_required: ['display_id', 'status', 'technician', 'site', 'group'],
search_criteria: allOf(
{ field: 'status.name', condition: 'is not', values: closedStatuses },
techCriterion,
scopeCriteria(scope)
)
}, 'open tickets');
const closed = await getAllRequests(accessToken, {
sort_field: 'last_updated_time',
sort_order: 'desc',
fields_required: ['display_id', 'status', 'technician', 'site', 'group', 'completed_time', 'resolved_time', 'last_updated_time'],
search_criteria: allOf(
{ field: 'last_updated_time', condition: 'greater than', value: startOfDay.getTime().toString() },
{ field: 'status.name', condition: 'is', values: closedStatuses },
techCriterion,
scopeCriteria(scope)
)
}, 'closed today');
const rows = new Map(technicians.map(t => [t, { name: t, counts: {}, open: 0, closedToday: 0 }]));
const row = name => {
if (!rows.has(name)) rows.set(name, { name, counts: {}, open: 0, closedToday: 0 });
return rows.get(name);
};
const statuses = new Map();
for (const ticket of open.requests) {
const status = ticket.status?.name;
if (!status || closedLower.has(status.toLowerCase())) continue;
const tech = rowFor(ticket);
if (!tech) continue;
const r = row(tech);
r.counts[status] = (r.counts[status] || 0) + 1;
r.open++;
if (!statuses.has(status)) {
const color = /^#[0-9a-f]{3,8}$/i.test(ticket.status.color || '') ? ticket.status.color : null;
statuses.set(status, { name: status, color, total: 0 });
}
statuses.get(status).total++;
}
for (const ticket of closed.requests) {
const closedAt = getRelevantTicketTime(ticket);
if (!closedAt || closedAt < startOfDay.getTime()) continue;
const tech = rowFor(ticket);
if (tech) row(tech).closedToday++;
}
// Open first, then busiest statuses
workload.statuses = [...statuses.values()].sort((a, b) =>
(b.name === 'Open') - (a.name === 'Open') || b.total - a.total || a.name.localeCompare(b.name));
workload.technicians = [...rows.values()];
if (!technicians.length) workload.technicians.sort((a, b) => a.name.localeCompare(b.name));
// which query hit the page cap, and how many tickets SDP says matched, so the page can explain it
const cutOff = (label, result) => result.truncated
&& `${label} (${result.requests.length} of ${result.totalCount ?? '?'})`;
workload.truncated = [cutOff('open tickets', open), cutOff('closed today', closed)]
.filter(Boolean).join(', ') || false;
workload.updatedAt = Date.now();
workload.error = null;
} catch (err) {
rethrowIfSignedOut(err);
workload.error = describeError(err);
workload.updatedAt = Date.now();
}
}
// Mirrors the "Central Region Unassigned" view: in-scope, not excluded, no technician.
async function fetchUnassigned({ accessToken, unassigned, excludedStatuses, scope }) {
if (!accessToken) return;
const excludedLower = new Set(excludedStatuses.map(s => s.toLowerCase()));
try {
const result = await getAllRequests(accessToken, {
sort_field: 'created_time',
sort_order: 'desc',
fields_required: ['display_id', 'subject', 'status', 'technician', 'site', 'group', 'created_time'],
search_criteria: allOf(
scopeCriteria(scope),
excludedStatuses.length ? { field: 'status.name', condition: 'is not', values: excludedStatuses } : null,
// "is empty": the API documents "technician is not empty" as condition 'is not' with no value
{ field: 'technician', condition: 'is' }
)
}, 'unassigned');
unassigned.tickets = result.requests
.filter(t => !t.technician && inScope(t, scope) && !excludedLower.has((t.status?.name || '').toLowerCase()))
.map(t => ({
id: t.display_id,
subject: t.subject,
status: t.status?.name || null,
site: t.site?.name || null,
group: t.group?.name || null,
created: t.created_time?.display_value || null
}));
unassigned.count = unassigned.tickets.length;
// how many the API returned that we threw out locally; non-zero means the criteria are off
unassigned.discarded = result.requests.length - unassigned.count;
unassigned.truncated = result.truncated;
unassigned.updatedAt = Date.now();
unassigned.error = null;
} catch (err) {
rethrowIfSignedOut(err);
unassigned.error = describeError(err);
unassigned.updatedAt = Date.now();
}
}
// Site, group and technician names from recently updated tickets anywhere (no region filter),
// for the settings panel's suggestions. Only names come back, never ticket details.
async function fetchRecentNames({ accessToken, days, pages }) {
const since = Date.now() - days * 24 * 60 * 60 * 1000;
const names = { sites: new Set(), groups: new Set(), technicians: new Set() };
// its own loop rather than getAllRequests: there will always be more rows than we want,
// which getAllRequests would read as SDP ignoring the criteria
for (let page = 0; page < pages; page++) {
const data = await getRequests(accessToken, {
sort_field: 'last_updated_time',
sort_order: 'desc',
row_count: PAGE_SIZE,
start_index: page * PAGE_SIZE + 1,
fields_required: ['display_id', 'technician', 'site', 'group'],
search_criteria: { field: 'last_updated_time', condition: 'greater than', value: since.toString() }
});
for (const t of data.requests || []) {
if (t.site?.name) names.sites.add(t.site.name);
if (t.group?.name) names.groups.add(t.group.name);
if (t.technician?.name) names.technicians.add(t.technician.name);
}
if (!data.list_info?.has_more_rows) break;
}
return {
sites: [...names.sites],
groups: [...names.groups],
technicians: [...names.technicians]
};
}
module.exports = {
fetchClosedTickets
fetchClosedTickets,
fetchWorkload,
fetchUnassigned,
fetchRecentNames
};

107
src/weather.js Normal file
View File

@ -0,0 +1,107 @@
const axios = require('axios');
const API_URL = 'https://api.openweathermap.org/data/2.5';
const CACHE_MINUTES = 10; // OpenWeatherMap only updates about every 10 minutes anyway
const FORECAST_DAYS = 5;
// only pass through icon codes like "10d" so the page can build the image URL safely
function iconCode(icon) {
return /^\d\d[dn]$/.test(icon || '') ? icon : null;
}
// the 5 day forecast comes in 3 hour slots; roll them up into one entry per local day
function dailyForecast(forecast) {
const offset = (forecast.city?.timezone || 0) * 1000;
const days = new Map();
for (const slot of forecast.list || []) {
const local = new Date(slot.dt * 1000 + offset);
const key = local.toISOString().slice(0, 10);
if (!days.has(key)) days.set(key, { date: key, low: Infinity, high: -Infinity, pop: 0, noon: null });
const day = days.get(key);
day.low = Math.min(day.low, slot.main.temp_min);
day.high = Math.max(day.high, slot.main.temp_max);
day.pop = Math.max(day.pop, slot.pop || 0);
// the slot nearest midday best represents the day's conditions
const distance = Math.abs(local.getUTCHours() - 12);
if (!day.noon || distance < day.noon.distance) day.noon = { distance, weather: slot.weather?.[0] };
}
return [...days.values()].slice(0, FORECAST_DAYS).map(day => ({
date: day.date,
low: Math.round(day.low),
high: Math.round(day.high),
pop: Math.round(day.pop * 100),
condition: day.noon?.weather?.main || null,
icon: iconCode(day.noon?.weather?.icon)
}));
}
// one cache entry per zip; zips come from the fixed list in places.js, so this stays small
function createWeatherService({ apiKey }) {
const enabled = !!apiKey;
const cache = new Map();
async function fetchWeather(zipCode) {
// US zip by default; "12345,ca" style values pass through untouched
const zip = zipCode.includes(',') ? zipCode : `${zipCode},us`;
const params = { zip, units: 'imperial', appid: apiKey };
const [current, forecast] = await Promise.all([
axios.get(`${API_URL}/weather`, { params }),
axios.get(`${API_URL}/forecast`, { params })
]);
const c = current.data;
return {
place: c.name,
updatedAt: c.dt * 1000,
temp: Math.round(c.main.temp),
feelsLike: Math.round(c.main.feels_like),
high: Math.round(c.main.temp_max),
low: Math.round(c.main.temp_min),
humidity: c.main.humidity,
wind: Math.round(c.wind?.speed || 0),
condition: c.weather?.[0]?.main || null,
description: c.weather?.[0]?.description || null,
icon: iconCode(c.weather?.[0]?.icon),
sunrise: c.sys?.sunrise ? c.sys.sunrise * 1000 : null,
sunset: c.sys?.sunset ? c.sys.sunset * 1000 : null,
forecast: dailyForecast(forecast.data)
};
}
async function getWeather(zipCode) {
if (!enabled || !zipCode) return { enabled: false };
if (!cache.has(zipCode)) cache.set(zipCode, { data: null, fetchedAt: 0, pending: null, lastError: null });
const entry = cache.get(zipCode);
if (Date.now() - entry.fetchedAt > CACHE_MINUTES * 60 * 1000) {
// share one request between callers that arrive while it's in flight
entry.pending = entry.pending || fetchWeather(zipCode)
.then(data => {
entry.data = data;
entry.lastError = null;
})
.catch(err => {
entry.lastError = err.response?.data?.message || err.message;
console.error(`Weather error (${zipCode}): `, entry.lastError);
})
.finally(() => {
entry.fetchedAt = Date.now();
entry.pending = null;
});
await entry.pending;
}
return { enabled: true, error: entry.lastError, ...entry.data };
}
return { enabled, getWeather };
}
module.exports = {
createWeatherService
};