collabcentral/routes/user.js
Joseph B. McQueen 90eb889523 Phase R3: split Express routes into routes/*.js
Every app.get/post/delete handler used to live directly in index.js,
which turned the file into the routing layer, the state store, and
the wiring root all at once. This peels the handlers out into five
per-concern modules and leaves index.js as a small composition root.

routes/info.js
- GET /status
- GET /CollabCentral/:app/info

routes/auth.js
- GET /CollabCentral/:app/authUrl
- GET /CollabCentral/:app/oauth (owns SESSION_COOKIE_OPTIONS now)

routes/user.js
- GET /CollabCentral/:app/user/:scope/:action (groups list / find)
- POST /CollabCentral/:app/user/groups/{add,remove}

routes/admin.js
- GET/POST /CollabCentral/:app/admin/users
- DELETE /CollabCentral/:app/admin/users/:id
- Owns requireAdmin + adminUserRow + adminUsersList (private to
  the module now that no other caller needs them).

routes/jobs.js
- POST /CollabCentral/:app/jobs/:action (edit / runNow / schedule /
  cancel)
- GET  /CollabCentral/:app/jobs/list/:scope
- GET  /CollabCentral/:app/jobs/detail/:jobId
- Owns buildJob (moved from index.js) since nothing outside the
  jobs routes ever called it.

Wiring pattern: each module exports registerXxxRoutes(app, deps)
and receives its dependencies through a dep-bag (isAuthorized,
isAdmin, webex, translator, jobsPipeline, saveConfig, helpers,
authorized, jobs, upload, sharp, uuid, fs, logger, ...). No route
module reaches into module-level state — that stays owned by
index.js.

index.js: 969 -> 457 lines (72% smaller than the original 1,642).
Now contains only imports, state loading (config, jobs, authorized,
tokens, botProfiles, groupsCache), the lib factory instantiations,
Express startup + cron schedules, the four register* calls, and a
handful of small helpers (saveConfig, cleanCompletedJobs,
isAuthorized, isAdmin, logger).

Tests still 53/53 green. Smoke-tested every route (auth matrix +
unknown bot + jobs detail 404 + signed-out 401 vs non-admin 403)
against a live process; every case matches pre-R3 behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 08:43:16 -04:00

125 lines
6 KiB
JavaScript

// Per-user data endpoints:
// GET /CollabCentral/:app/user/groups/list — this user's favorites
// GET /CollabCentral/:app/user/groups/find — org-wide groups
// (cached)
// POST /CollabCentral/:app/user/groups/add — save a favorite
// POST /CollabCentral/:app/user/groups/remove — drop a favorite
//
// All four require the caller to be authorized on :app. Favorites live
// in the mutable `authorized` doc (config/authorized.json) so they
// survive restarts and are picklable in the compose page's Favorite
// Groups selector.
//
// registerUserRoutes(app, { isAuthorized, authorized, helpers,
// getGroupsFromCache, saveConfig, logger })
export function registerUserRoutes(app, deps) {
var isAuthorized = deps.isAuthorized;
var authorized = deps.authorized;
var helpers = deps.helpers;
var getGroupsFromCache = deps.getGroupsFromCache;
var saveConfig = deps.saveConfig;
var logger = deps.logger || function () {};
app.get('/CollabCentral/:app/user/:scope/:action', function (req, res) {
if (!isAuthorized(req.params.app, req.cookies.id)) {
return res.status(401).send('Unauthorized.');
}
if (req.params.scope !== 'groups') {
return res.status(404).send('Not found.');
}
if (req.params.action === 'list') {
logger('apiEndpoint(' + req.params.app + ')', 'GET /user/groups/list');
var listEntry = helpers.getAuthorizedEntry(authorized, req.params.app, req.cookies.id);
return res.status(200).send((listEntry && listEntry.groups) || []);
}
if (req.params.action === 'find') {
logger('apiEndpoint(' + req.params.app + ')', 'GET /user/groups/find');
return getGroupsFromCache()
.then(function (groups) { res.status(200).send(groups); })
.catch(function (err) {
logger('apiEndpoint(' + req.params.app + ')',
'groups/find failed: ' + (err && err.message || err));
res.status(502).send('Failed to load groups from Webex.');
});
}
return res.status(404).send('Not found.');
});
// Add / remove a favorite group for the calling user. Validation:
// - the caller is authorized for :app
// - :id looks like a Webex SCIM group id we know about (present in
// the cached org-wide list) — stops bad actors from stuffing
// arbitrary strings into the store.
// Because index.js is single-process and node is single-threaded,
// concurrent requests here can't corrupt authorized.json — every
// read/mutate/write executes atomically within one handler.
app.post('/CollabCentral/:app/user/groups/add', async function (req, res) {
if (!isAuthorized(req.params.app, req.cookies.id)) return res.status(401).send('Unauthorized.');
var appName = req.params.app;
var personId = req.cookies.id;
var groupId = req.body && req.body.id;
if (!groupId) return res.status(400).send('Missing group id.');
var group;
try {
var allGroups = await getGroupsFromCache();
group = allGroups.find(function (g) { return g.id === groupId; });
} catch (err) {
logger('apiEndpoint(' + appName + ')',
'groups/add cache lookup failed: ' + (err && err.message || err));
return res.status(502).send('Group list unavailable.');
}
if (!group) return res.status(404).send('Unknown group id.');
var entry = helpers.getAuthorizedEntry(authorized, appName, personId);
// isAuthorized guarantees the entry exists but be defensive; a
// race between an admin removal and this request could otherwise
// crash.
if (!entry) return res.status(401).send('Unauthorized.');
var favorites = Array.isArray(entry.groups) ? entry.groups : [];
if (favorites.find(function (g) { return g.id === groupId; })) {
return res.status(200).send(favorites);
}
favorites.push({ name: group.displayName, id: group.id });
entry.groups = favorites;
try {
saveConfig(authorized, './config/authorized.json');
} catch (err) {
logger('apiEndpoint(' + appName + ')',
'groups/add save failed: ' + (err && err.message || err));
return res.status(500).send('Failed to save favorite.');
}
logger('apiEndpoint(' + appName + ')',
'groups/add ' + personId.slice(-8) + ' + "' + group.displayName + '"');
res.status(200).send(favorites);
});
app.post('/CollabCentral/:app/user/groups/remove', function (req, res) {
if (!isAuthorized(req.params.app, req.cookies.id)) return res.status(401).send('Unauthorized.');
var appName = req.params.app;
var personId = req.cookies.id;
var groupId = req.body && req.body.id;
if (!groupId) return res.status(400).send('Missing group id.');
var entry = helpers.getAuthorizedEntry(authorized, appName, personId);
if (!entry) return res.status(401).send('Unauthorized.');
var favorites = Array.isArray(entry.groups) ? entry.groups : [];
var before = favorites.length;
var removed = favorites.find(function (g) { return g.id === groupId; });
favorites = favorites.filter(function (g) { return g.id !== groupId; });
if (favorites.length === before) return res.status(200).send(favorites);
entry.groups = favorites;
try {
saveConfig(authorized, './config/authorized.json');
} catch (err) {
logger('apiEndpoint(' + appName + ')',
'groups/remove save failed: ' + (err && err.message || err));
return res.status(500).send('Failed to remove favorite.');
}
logger('apiEndpoint(' + appName + ')',
'groups/remove ' + personId.slice(-8) + ' - "' + (removed && removed.name || groupId) + '"');
res.status(200).send(favorites);
});
}