index.js had grown to 1,642 lines / ~50 functions. This peels the
three biggest self-contained concerns out into their own modules
and wires them back in through a dependency-bag factory so each
module stays free of module-level mutable state.
lib/webex.js — every webexapis.com round-trip (fetchWithRateLimit,
whoAmI, findWebexGroup, getGroupMembers, getPersonInfo,
findPersonByEmail, sendDirectMessage, sendMessageWithRetry,
sendDirectCard, deleteMessage, refreshToken). Factory closes over
token getters and the logger.
lib/translation.js — Google Translate fan-out (buildTranslations,
translateMessage). Reuses the webex 429 helper so the app has one
retry policy.
lib/jobs.js — cron-driven pipeline (checkScheduledJobs,
processRunningQueue, sendQueueJobMessages, buildJobCompletedCard)
plus the two recipient-resolution helpers (collectGroupMembers,
buildPeopleList). Factory takes jobs/queue/userPrefs/webex/etc so
mutable state stays owned by index.js.
index.js: instantiates webex/translator/jobsPipeline once, rewires
every call site to go through them, and drops ~715 lines of moved
code plus a dead msToTime wrapper. Down from 1,642 → 969 lines,
26 top-level functions instead of 50+.
Tests: 53/53 helpers still green. Smoke tested /info, /admin/users,
/user/groups/list, /user/groups/find, /jobs/list/all, and the
unauth 401 path — all match pre-R2 behavior.
Co-authored-by: Cursor <cursoragent@cursor.com>
Every add/remove of a favorite group or authorized user was rewriting
config.json — the same file that carries structural bot metadata and
was committed to git. This split ends the git-noise and lets ops
deploy fresh installs without a pre-populated user list.
Split
- config.json (committed) stays structural: server, per-bot labels,
integration + service-account ids, languages.
- config/authorized.json (gitignored) is the new mutable source of
truth: { admins: [personId...], bot: { <appName>: { <personId>:
{ id, displayName, email, avatar, groups: [...] } } } }.
- Seeded authorized.json with the current admins list and all
authorized users (3 on novi, 4 on techupdates) so this commit is
a pure move — no data lost, no downtime.
Helpers (lib/helpers.js)
- New getAuthorizedEntry(authorized, app, id) as the single lookup
point every consumer goes through, so nullability is uniform.
- isAuthorized() gains an authorized-doc arg (pure signature stays
testable): fails closed when the doc is missing / partially
loaded, so a broken deploy grants no access.
- isAdmin() now reads authorized.admins instead of config.admins.
Runtime (index.js)
- loadAuthorized() with an ENOENT fallback to { admins: [], bot: {} }
so a fresh deploy can bootstrap via the admin page instead of
requiring a hand-crafted authorized.json.
- All 8 previous config.webex.bot[app].authorized sites (favorites
read/add/remove, admin list/add/delete, isAuthorized) now go
through the authorized doc.
- Every mutation writes to config/authorized.json instead of
config/config.json.
Latent-bug fixup (uncovered while smoke-testing this refactor)
- The /user/:scope/:action fallthroughs used res.status(4xx)
without .send(...), so unknown scopes / unauthorized callers got
a hung request instead of a response. Added ".send(...)" bodies
so the response actually completes.
Docs + tests
- README updated: new "Authorized users" step in "Adding a new bot",
updated file-layout section, docker mount list adds
authorized.json.
- Test suite expanded from 48 → 53 with a new getAuthorizedEntry
group and the existing isAuthorized/isAdmin cases reshaped for
the new signatures.
Smoke tested the auth matrix end-to-end (admin + non-admin + signed-
out across /info, /admin/users, /user/groups/list): every path
returns the expected code and body.
Co-authored-by: Cursor <cursoragent@cursor.com>
Three users added to techupdates.authorized via the admin page:
Samantha Matthews, Rachel Grattan, and Chloe Piccola. Committed so
the on-disk config stays in sync with origin after admin actions.
Co-authored-by: Cursor <cursoragent@cursor.com>
Two features stitched together because they touch the same building-
job data path.
--- 1. Retract preview DM on cancel -----------------------------------
The /jobs/edit flow DMs a preview of the composed message to the
sender's own Webex space. Until now that DM lingered even if the
sender then hit Cancel or Discard.
- /jobs/edit now stores the returned message id as
jobs.building[key].previewMessageId.
- /jobs/cancel captures that id before deleting the building entry,
saves the cancel first, then fires a best-effort
DELETE /v1/messages/<id> against Webex.
- New deleteWebexMessage(messageId, appName) helper wraps the DELETE.
Uses the bot token (bots own their messages) and never throws — a
Webex hiccup logs but doesn't fail the cancel that already
succeeded on our side. Called fire-and-forget so the HTTP response
isn't blocked on a slow Webex round trip.
--- 2. Admin: manage authorized users -------------------------------
Admin authority lives in a new top-level config.admins array of
personIds (seeded with Joe's id). Admin actions are cross-bot in
concept but the routes are :app-scoped because the resource being
edited is per-bot and it lets admin reuse the existing OAuth session
without a separate auth surface.
Helpers
- lib/helpers.js: new pure isAdmin(config, personId) that fails
closed when admins is missing, not-an-array, or config is null.
- test/helpers.test.js: 5 new assertions covering the happy path,
the "not in list" case, missing personId, non-array admins, and
missing config. Total suite is now 48 assertions across 11 groups.
Server-side (index.js)
- New findPersonByEmail(email) helper hits Webex /v1/people?email=
using the service account token, returns
{ id, displayName, email, avatar } or null.
- /info now returns isAdmin so the client can decide whether to
render the admin dropdown.
- New requireAdmin(req, res) gate returns 401 for signed-out and
403 for signed-in-but-not-admin (distinct codes so the frontend
can render distinct panels).
- GET /CollabCentral/:app/admin/users → list users
- POST /CollabCentral/:app/admin/users → lookup + add
- DELETE /CollabCentral/:app/admin/users/:id → remove
- Shared adminUserRow / adminUsersList shape so every response is an
authoritative snapshot the client can render without merging.
- DELETE of an unknown id is idempotent — returns 200 removed:false
without rewriting config.json.
Frontend
- New html/admin.html + html/admin.js on the shared layout. Panels
swap between not-signed-in / not-admin / admin. Add-user form
takes an email; user list renders as rows with Webex avatar
(fallback initials), name, email, favorite-group count, and a
Remove button that confirm()s before firing DELETE.
- html/js/app.js: renderUserChip() replaces the plain-text top-right
user label with a proper button + dropdown menu when the caller
is an admin. Menu is keyboard-friendly (Escape to close), closes
on outside-click, and currently exposes one item ("Admin" →
admin.html). Non-admins get the plain-text label unchanged, so
the existing pages are visually identical for them.
- html/css/app.css: new .appHeader__userBtn / .appHeader__userMenu
dropdown, .inlineFieldRow for the email-plus-button pattern, and
.userRow* rules for the admin user list.
Config
- Add config.admins array seeded with Joe McQueen's personId.
- config.json also picks up an in-app state change from the running
instance (an "AV Team" favorite removed from Joe's techupdates
authorized entry via the favorites UI). Rolling that into this
commit so the file stops drifting from origin.
Co-authored-by: Cursor <cursoragent@cursor.com>
Users can now abandon a draft they don't want to send. Two entry
points, one shared server action.
Backend
- New action on the existing POST /CollabCentral/:app/jobs/:action
route: /jobs/cancel. Deletes jobs.building[<personId>::<appName>]
if present, saves jobs.json, returns 200 with { cancelled: true }.
- Idempotent: if there was no building entry (client fires this on
every cancel click regardless of state), the endpoint no-ops with
{ cancelled: false } and never touches jobs.json. That keeps disk
writes tied to real state changes instead of every click.
- Wrapped saveConfig in try/catch so a disk-full or permission
failure returns a clean 500 instead of crashing the request.
Frontend
- New "Cancel" button in the compose form's action row, sits next
to "Review & send" as a secondary action.
- cancelMessage() checks whether the form has anything in it (text,
image, CSV, groups, schedule) and only prompts window.confirm() if
there's actually a draft to lose. Blank-form clicks skip the
prompt so the button behaves as expected on first load.
- New "Discard" action on the review modal for "wait, actually no"
decisions after clicking Review. Positioned on the far left of the
modal footer with margin-right: auto so it's visually separated
from the affirmative "Keep editing" / "Send" pair — a stray click
on the way to Send lands on Keep editing, not Discard.
- Extended clearForm() to also clear both VirtualSelect group
pickers via setValue([]) (previously only text/files/schedule got
reset, leaving stale group selections after a cancel or send).
- New .btn-danger style: muted red text on a transparent background,
filled-red hover state. Used for the modal Discard button and
available for future destructive UI.
Not covered (follow-up if wanted): the preview DM the bot sends to
the sender during /jobs/edit isn't tracked in the building job, so
Cancel doesn't retract that Webex message. Adding server-side
tracking of the preview message id would let cancel call
DELETE /v1/messages/<id> to clean it up.
Co-authored-by: Cursor <cursoragent@cursor.com>
Selecting a group in "Additional groups" now auto-saves it to the
caller's favorites, and each favorite gets a × affordance for one-click
removal. Favorites still live where they always have —
config.webex.bot[app].authorized[personId].groups — so nothing changes
for existing installations.
Backend
- New POST /CollabCentral/:app/user/groups/add. Body { id }. Validates
the caller is authorized for :app, validates :id resolves to a real
Webex group by looking it up in the cached org-wide group list
(blocks arbitrary strings from being stuffed into config.json),
dedups against the existing favorites array, writes config.json via
saveConfig, and returns the updated array.
- New POST /CollabCentral/:app/user/groups/remove. Body { id }.
Filters that id out of the caller's favorites, writes only when
something actually changed (a remove of an unknown id no-ops instead
of rewriting config.json), and returns the updated array.
- Both endpoints are safe against concurrent writes: index.js is
single-process and node is single-threaded, so read/mutate/write
runs atomically per request.
- Both log a compact audit line (last-8 of personId + group name) so
operators can see who is curating what.
Frontend (sendMessage.html + .js + app.css)
- New "Manage favorites" chip strip renders directly under the
Favorite Groups picker. Each favorite becomes a pill (uses .alias
when set, otherwise .name; long labels truncate with ellipsis).
Clicking × on a pill removes that favorite server-side and updates
the strip locally.
- Additional Groups picker wires a 'change' listener that diffs the
current selection against the previous one and only fires
/user/groups/add for newly-picked ids (never re-fires on the
reselect side of a deselect+reselect, never spams the API with the
full selection on every keystroke).
- Client keeps favoriteGroups mirrored to every API response so the
"already a favorite?" dedup check is a pure in-memory lookup — no
wasted round trips when the user re-picks a group they already
favorited.
- Transient status message ("Added to favorites." / "Removed from
favorites." / error variants) fades under the field label; sticks
around ~4s.
- New shared styles: .fieldLabelRow (label + inline status),
.fieldStatus (with --error variant), .chipStrip, .chip,
.chip__label, .chip__close (with hover/focus states).
Co-authored-by: Cursor <cursoragent@cursor.com>
The /user/groups/find endpoint used to hit Webex on every request,
paginating through ~10k groups 500 at a time (roughly 20 sequential
round trips per compose-page load). Now the whole list lives in a
process-local cache that refreshes on startup and once a day at 03:00
CRON_TIMEZONE.
- New groupsCache = { data, lastRefreshed, refreshing }. refreshing is
a shared in-flight Promise so a startup refresh and the daily cron
can't stampede if their timing overlaps.
- refreshGroupsCache() wraps findWebexGroup() with load timing +
error logging.
- getGroupsFromCache() returns the cached list immediately; only the
very first request after startup waits (and only if the startup
refresh hasn't completed yet).
- Warm the cache in the background right after loadBotProfiles() in
the app.listen callback.
- Third cron ('0 0 3 * * *') refreshes the cache daily, offset from
the 01:10 jobs cleanup and per-minute token/scheduled-jobs tick so
they don't fight for the event loop.
Bug fixes rolled in while I was in findWebexGroup:
- Return reject(...) on a non-ok Webex response instead of also
continuing the while loop, which used to race resolve/reject.
- Return reject(error) from the try/catch so a Webex hiccup no longer
hangs the paginator forever; previously it caught+logged and let
the loop spin.
- Drop the stray `memberSize = 0` assignment (memberSize was never
declared in scope).
- Drop the per-page console.log noise; failures now go through the
timestamped logger under the findWebexGroup tag.
Route cleanup:
- /user/groups/find now serves getGroupsFromCache() and returns 502
with a logged reason if the cache is empty AND the refresh failed.
- Dropped the stray saveConfig(response, "./testData.json") that
used to persist the entire fetched group list to a scratch file on
every request.
Also: fix "identify" -> "identity" in the whoAmI startup log line.
Co-authored-by: Cursor <cursoragent@cursor.com>
The bug: SESSION_COOKIE_OPTIONS had httpOnly: true, so js/app.js could
not see the `id` cookie set by /oauth. Every page load thought the user
was signed out, redirected to Webex, minted a fresh token, set another
invisible cookie, redirected back, and looped -- until Webex's Common
Token Store hit its per-user limit and returned
error=tokenlimit_reached on the next callback.
Fix:
- httpOnly is now explicitly false with a comment explaining why: the
app's design has always relied on the client reading the id and
displayName cookies via document.cookie.
- sameSite tightened change from 'strict' to 'lax' so the cookie
reliably survives the webex.com -> /oauth -> /sendMessage.html
redirect chain across all browsers (some treat continuations of a
cross-site navigation as cross-site for strict cookies).
- Stop setting access_token, refresh_token, avatar, email, orgId on
the response. `req.cookies.*` grep confirms the server never reads
any of them, and the client uses id/displayName only. Removing the
token cookies also eliminates a would-be XSS foothold.
Existing broken sessions: setting a new cookie with the same name and
path replaces the old one regardless of httpOnly flag, so a single
completed OAuth after this deploy repairs the browser state.
Co-authored-by: Cursor <cursoragent@cursor.com>
The nav link race:
- Every page's header used `<a id="jobLink" href=''>` and the real
destination was only assigned later, after /info returned. An empty
href resolves to the current document URL, so clicking "Monitor Jobs"
on sendMessage before /info completed silently reloaded sendMessage.
- Fixed structurally: nav links now use static relative hrefs baked
into the HTML ("./sendMessage.html", "./monitorJobs.html"), so the
destination is correct the moment the DOM parses.
Shared UI:
- New html/css/app.css: design tokens (palette, radius, shadow, font),
sticky compact top header (bot avatar + label on the left, nav pills
in the middle, current user on the right, active-page highlight via
aria-current), card containers, form styling with focus rings,
DataTables theme overrides, status pills, modal, and responsive
breakpoints.
- New html/js/app.js: shared browser bootstrap. Parses appName from
the URL, redirects to OAuth if the id cookie is missing, fetches
/info once, populates the header, applies aria-current to the
active nav link, and invokes a per-page onReady callback with
{ info, appName }. Also exports getCookie, escapeHtml, and formatDate
helpers so each page stops shipping its own copy.
Per-page rewrites:
- sendMessage.html/.js: form now lives in a card, image preview only
shows when a file is attached, EasyMDE + VirtualSelect styled to
match the theme, submit is a primary button, confirmation modal
redesigned. All bootstrap code deleted (delegated to app.js).
- monitorJobs.html/.js: three cards (Running / Scheduled / Completed)
with themed DataTables. Completed table sorts by start time desc,
paginates, and searches; message column truncates HTML previews to
~120 chars. Empty-state text per table. `jobId` and running-row
"view" links go to jobDetail via safe relative URLs.
- jobDetail.html/.js: same shared header + card layout; summary grid,
message preview, and recipient table styled to match the new
palette.
Sanity checks:
- All 43 helper tests still pass.
- Server boots cleanly on port 3001.
- Curl of sendMessage/monitorJobs/jobDetail all return 200 with the
shared header markup.
- /CollabCentral/:app/css/app.css and /CollabCentral/:app/js/app.js
both serve 200 (shared static mount is per-bot as expected).
- No local href in any page is empty; every nav target resolves at
parse time.
- /info and requireBot 404 gate unchanged.
Co-authored-by: Cursor <cursoragent@cursor.com>
- Move buildingKey, jobsForApp, getBotToken, isBotEnabled, getBotConfig,
isAuthorized, getOAuthRedirectUri, buildAuthUrl, cleanCompletedJobs,
and msToTime into lib/helpers.js as state-free functions that accept
config, botTokens, or env as parameters. COMPLETED_RETENTION_DAYS also
lives there so callers and tests share the constant.
- Replace the bodies in index.js with thin wrappers that pass the module-
level state into the pure helpers. Call sites and behavior are
unchanged; index.js shrinks by ~60 lines.
- Move the cleanCompletedJobs logging into the cron caller so the pure
helper returns a result object (jobs, removed, cutoff) that tests can
assert on without capturing stdout.
- Add test/helpers.test.js with 43 assertions across 10 suites covering
the enable/disable gating, per-bot draft isolation, authorization,
OAuth URL construction, retention filter (including endTime -> startTime
-> created fallback and the safety default for jobs missing a
timestamp), and the duration formatter.
- Wire `npm test` to `node --test test/*.test.js` (no new deps, uses the
built-in node:test runner) and document it in the README.
Smoke test confirms unchanged HTTP behavior for /info (known + unknown
bots), the requireBot 404 gate, and the 401 path on jobs/list/completed.
Co-authored-by: Cursor <cursoragent@cursor.com>
- Add README with local setup, add-a-bot walkthrough, Docker run
recipe, and a rundown of committed vs. runtime state.
- Wrap the cron schedules with an explicit timezone (default
America/New_York, override via CRON_TIMEZONE) so cadence is
independent of the host/container clock.
- Route saveServiceAccountToken through a try/catch so a disk hiccup
no longer bubbles up as an unhandled exception during token refresh,
and keep the in-memory copy usable even on write failure.
- Silence per-message queue.on('active') / on('completed') logs that
also registered a new listener on every job invocation (accumulating
on the shared queue over time).
- Fix logIt reference (undefined; would have thrown if
checkScheduledJobs ever rejected) and a broken JSON.stringify.result
debug log that always evaluated to undefined.
- Dockerfile: switch to node:20-slim, install from the lockfile via
npm ci --omit=dev, set NODE_ENV=production, and document that env
and volumes are supplied at runtime.
Co-authored-by: Cursor <cursoragent@cursor.com>
Extends the single-Novi codebase into a multi-bot mass-messenger where
each bot has its own token, avatar, label, and per-user authorization.
- Secrets moved out of config.json: per-bot tokens in gitignored
config/botTokens.json (with enabled flag), service-account OAuth in
gitignored config/token.json (rewritten by refresh cron), integration
and Google keys in .env.
- Single Webex integration handles OAuth for all bots via a per-app
redirect URI derived from OAUTH_CALLBACK_URL_TEMPLATE.
- New requireBot middleware and getBotConfig helper reject requests for
unknown or disabled bots at the /CollabCentral/:app boundary.
- New /info endpoint plus dynamic frontend loading (sendMessage,
monitorJobs) so pages self-describe per bot, including bot avatar
fetched from Webex /people/me at startup.
- Job draft state keyed by cookieId + appName so each bot has its own
building queue; job list/detail endpoints filter by appName so users
only see jobs from bots they are authorized on.
- New jobDetail page for a readable per-job view; completed jobs are
retained for 30 days by the cleanup cron.
- Completion adaptive cards use per-bot avatar and label.
- Miscellaneous fixes: off-by-two in the send loop, removed three dead
send/process variants, added defensive init for jobs.* on load,
dropped the deprecated crypto npm shim, and cleaned up stray logger
labels and typos.
Co-authored-by: Cursor <cursoragent@cursor.com>