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>
This commit is contained in:
Joseph B. McQueen 2026-07-02 08:43:16 -04:00
parent 8c3a32f47d
commit 90eb889523
6 changed files with 720 additions and 556 deletions

600
index.js
View file

@ -16,6 +16,11 @@ import * as helpers from './lib/helpers.js';
import { createWebexClient } from './lib/webex.js';
import { createTranslator } from './lib/translation.js';
import { createJobsPipeline } from './lib/jobs.js';
import { registerInfoRoutes } from './routes/info.js';
import { registerAuthRoutes } from './routes/auth.js';
import { registerUserRoutes } from './routes/user.js';
import { registerAdminRoutes } from './routes/admin.js';
import { registerJobsRoutes } from './routes/jobs.js';
const queue = new PQueue({ concurrency: 10 });
@ -351,571 +356,54 @@ cron.schedule('0 0 3 * * *', function () {
app.use('/CollabCentral/:app', requireBot)
app.use('/CollabCentral/:app', express.static('html'))
app.get('/status', function (req, res) {
res.status(200).send({ "status": "I'm alive!'" });
// Route registrations. Each module receives a dep-bag so the routes
// themselves stay decoupled from the concrete state/utilities defined
// here. Order matters only for /oauth (must come after requireBot).
registerInfoRoutes(app, {
getBotProfile: getBotProfile,
isAuthorized: isAuthorized,
isAdmin: isAdmin,
});
app.get('/CollabCentral/:app/authUrl', function (req, res) {
var url = buildAuthUrl(req.params.app);
if (!url) {
return res.status(500).send('OAuth is not configured. Check WEBEX_INTEGRATION_CLIENT_ID and OAUTH_CALLBACK_URL_TEMPLATE.');
}
res.send(url);
registerAuthRoutes(app, {
buildAuthUrl: buildAuthUrl,
getOAuthRedirectUri: getOAuthRedirectUri,
webex: webex,
env: process.env,
logger: logger,
});
// Returns the bot's display metadata + whether the current cookie is authorized.
// The front-end uses this to drive page labels, icons, and the
// "you don't have access" view, so adding a new bot doesn't require any code
// changes in the HTML/JS.
app.get('/CollabCentral/:app/info', function (req, res) {
var appName = req.params.app;
var botCfg = req.botConfig; // populated by requireBot
var profile = getBotProfile(appName) || {};
var iconBase = botCfg.iconBase || appName;
var personId = req.cookies && req.cookies.id;
res.status(200).send({
appName: appName,
label: botCfg.label || appName,
displayName: profile.displayName || botCfg.label || appName,
iconUrl: '/CollabCentral/' + appName + '/' + iconBase + '.png',
faviconUrl: '/CollabCentral/' + appName + '/' + iconBase + '.ico',
avatarUrl: profile.avatar || null,
authorized: isAuthorized(appName, personId),
isAdmin: isAdmin(personId)
});
registerUserRoutes(app, {
isAuthorized: isAuthorized,
authorized: authorized,
helpers: helpers,
getGroupsFromCache: getGroupsFromCache,
saveConfig: saveConfig,
logger: logger,
});
app.post('/CollabCentral/:app/jobs/:action', (req, res) => {
if (isAuthorized(req.params.app, req.cookies.id)) {
req.setTimeout(3000000);
if (req.params.action == "edit") {
logger("apiEndpoint(" + req.params.app + ")", "POST /jobs/edit");
buildJob(req, res)
.then(job => {
webex.sendDirectMessage(job.senderId, job.message.raw, job.imageName, req.params.app)
.then(async function (result) {
jobs.building[buildingKey(req)].message.english = {
"text": result.text,
"markdown": result.markdown,
"html": result.html
}
// Remember the preview DM's Webex message id so
// /jobs/cancel can retract it and /jobs/runNow can
// treat it as "already delivered" (future).
if (result && result.id) {
jobs.building[buildingKey(req)].previewMessageId = result.id;
}
await translator.buildTranslations(jobs.building[buildingKey(req)].message.english)
.then(result => {
jobs.building[buildingKey(req)].message = result;
})
.catch(error => console.log("Error translating: " + error));
res.status(200).send(job)
saveConfig(jobs, "./config/jobs.json")
})
.catch(error => console.log("Error sending message: " + error));
})
.catch(error => console.log("Error buildJob: " + error));
} else if (req.params.action == "runNow") {
logger("apiEndpoint(" + req.params.app + ")", "POST /jobs/runNow");
jobs.running.push(jobs.building[buildingKey(req)]);
delete jobs.building[buildingKey(req)];
jobsPipeline.processRunningQueue()
.catch(error => console.log("sendRunningMessages error: " + error))
res.status(204).redirect("/CollabCentral/" + req.params.app + "/monitorJobs.html");
} else if (req.params.action == "schedule") {
logger("apiEndpoint(" + req.params.app + ")", "POST /jobs/schedule");
jobs.scheduled.push(jobs.building[buildingKey(req)]);
delete jobs.building[buildingKey(req)];
saveConfig(jobs, './config/jobs.json')
res.status(204).redirect("/CollabCentral/" + req.params.app + "/monitorJobs.html");
} else if (req.params.action == "cancel") {
// Abandon the caller's in-progress "building" job for this bot.
// A building entry exists as soon as the user clicks "Review &
// send" (POST /jobs/edit) and lingers on the server until the
// user promotes it to running/scheduled — or until now, where
// this route just drops it on the floor. Idempotent: 200 whether
// or not a building entry actually existed for (personId, app),
// so the client can always fire this on "cancel" without needing
// to check state first.
//
// If the building job has a previewMessageId (captured during
// /jobs/edit), also retract that DM from the sender's own space
// so the review message doesn't hang around after they discarded
// the draft. Best-effort — a failed Webex delete is logged but
// does not fail the cancel.
logger("apiEndpoint(" + req.params.app + ")", "POST /jobs/cancel");
var key = buildingKey(req);
var job = jobs.building[key];
var hadJob = !!job;
if (hadJob) {
var previewId = job.previewMessageId;
delete jobs.building[key];
try {
saveConfig(jobs, './config/jobs.json');
} catch (err) {
logger('apiEndpoint(' + req.params.app + ')',
'jobs/cancel save failed: ' + (err && err.message || err));
return res.status(500).send('Failed to cancel job.');
}
if (previewId) {
// Fire-and-forget; we've already committed the cancel
// to disk and we don't want a slow Webex round trip to
// hold the response.
webex.deleteMessage(previewId, req.params.app);
}
}
return res.status(200).send({ cancelled: hadJob });
} else { res.status(404) }
} else { res.status(401).send("You are not authorized.") }
})
app.get('/CollabCentral/:app/jobs/list/:scope', (req, res) => {
if (!req.cookies || !isAuthorized(req.params.app, req.cookies.id)) {
return res.status(401).send("You are not authorized.");
}
var appName = req.params.app;
if (req.params.scope == "all") {
logger("apiEndpoint(" + appName + ")", "GET /jobs/list/all");
return res.status(200).send({
building: jobs.building[buildingKey(req)],
running: jobsForApp(jobs.running, appName),
scheduled: jobsForApp(jobs.scheduled, appName),
completed: jobsForApp(jobs.completed, appName)
});
}
if (req.params.scope == "building") {
logger("apiEndpoint(" + appName + ")", "GET /jobs/list/building");
return res.status(200).send(jobs.building[buildingKey(req)]);
}
if (req.params.scope == "running") {
logger("apiEndpoint(" + appName + ")", "GET /jobs/list/running");
var runningJobs = [];
for (var job of jobsForApp(jobs.running, appName)) {
var completedSends = 0;
for (var member of (job.memberList || [])) {
if (member.results) completedSends++;
}
runningJobs.push({
jobId: job.jobId,
senderDisplayName: job.senderDisplayName,
appName: job.appName,
startTime: job.startTime,
totalRecipients: (job.memberList || []).length,
completedRecipients: completedSends
});
}
return res.status(200).send(runningJobs);
}
if (req.params.scope == "scheduled") {
logger("apiEndpoint(" + appName + ")", "GET /jobs/list/scheduled");
var scheduledJobs = [];
for (var job of jobsForApp(jobs.scheduled, appName)) {
scheduledJobs.push({
senderDisplayName: job.senderDisplayName,
appName: job.appName,
message: job.message && job.message.english && job.message.english.html,
totalRecipients: (job.memberList || []).length,
scheduledFor: job.scheduledFor
});
}
return res.status(200).send(scheduledJobs);
}
if (req.params.scope == "completed") {
logger("apiEndpoint(" + appName + ")", "GET /jobs/list/completed");
return res.status(200).send(jobsForApp(jobs.completed, appName));
}
return res.status(404).send();
registerAdminRoutes(app, {
isAdmin: isAdmin,
authorized: authorized,
webex: webex,
saveConfig: saveConfig,
logger: logger,
});
// Returns the full job object for a given jobId, but only if the job belongs
// to the requested bot. Looks across completed/running/scheduled so the same
// URL keeps working as a job moves through its lifecycle.
app.get('/CollabCentral/:app/jobs/detail/:jobId', (req, res) => {
if (!req.cookies || !isAuthorized(req.params.app, req.cookies.id)) {
return res.status(401).send("You are not authorized.");
}
var appName = req.params.app;
var jobId = req.params.jobId;
logger("apiEndpoint(" + appName + ")", "GET /jobs/detail/" + jobId);
var all = (jobs.completed || []).concat(jobs.running || [], jobs.scheduled || []);
var match = all.find(function (j) {
return j && j.appName === appName && String(j.jobId) === String(jobId);
registerJobsRoutes(app, {
jobs: jobs,
isAuthorized: isAuthorized,
helpers: helpers,
webex: webex,
translator: translator,
jobsPipeline: jobsPipeline,
saveConfig: saveConfig,
upload: upload,
sharp: sharp,
uuid: uuid,
fs: fs,
logger: logger,
});
if (!match) return res.status(404).send("Job not found.");
res.status(200).send(match);
});
app.get('/CollabCentral/:app/user/:scope/:action', (req, res) => {
console.log("Got /user/:scope/:action request.");
//console.log(req.cookies);
if (isAuthorized(req.params.app, req.cookies.id)) {
//console.log("Person is authorized for the request.")
if (req.params.scope == "groups") {
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);
res.status(200).send((listEntry && listEntry.groups) || []);
} else if (req.params.action == "find") {
logger("apiEndpoint(" + req.params.app + ")", "GET /user/groups/find");
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.');
});
}
} else { res.status(404).send('Not found.'); }
} else { res.status(401).send('Unauthorized.'); }
})
// Add / remove a favorite group for the calling user. Favorites live inside
// authorized.json under `bot[app][personId].groups` so they survive restarts
// and are picklable in the compose page's Favorite Groups selector. Both
// endpoints validate that:
// - the caller is authorized for :app
// - :id looks like a Webex SCIM group id we know about (present in the
// cached org-wide group list) — this stops a bad actor from stuffing
// arbitrary strings into the config.
// 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);
});
// ---- Admin: manage the authorized-user list for a bot ---------------------
//
// Admin authority lives in authorized.admins (personIds). Every admin route
// rejects with 403 for non-admin callers — 403 rather than 401 so the client
// can tell an admin-only route apart from a signed-out state (which would
// 401). Admin actions are cross-bot in concept but the routes are still
// :app-scoped because the resource being edited is per-bot (authorized users
// on THAT bot) and it keeps the OAuth session model unchanged (admin uses
// the same session cookie as any other page).
//
// requireAdmin returns null on success or an Express-response-sending
// function on failure, so each handler stays a straight-line function.
function requireAdmin(req, res) {
if (!req.cookies || !req.cookies.id) return res.status(401).send('Unauthorized.');
if (!isAdmin(req.cookies.id)) return res.status(403).send('Admin only.');
return null;
}
// Shape of a single row returned by the admin users endpoints. Kept in one
// helper so add / delete / list all produce identical output — the client
// can treat every response as an authoritative "here's the current state".
function adminUserRow(entry) {
var groups = Array.isArray(entry && entry.groups) ? entry.groups : [];
return {
id: entry.id,
displayName: entry.displayName || '',
email: entry.email || '',
avatar: entry.avatar || null,
groupCount: groups.length
};
}
function adminUsersList(appName) {
var bucket = (authorized.bot && authorized.bot[appName]) || {};
return Object.keys(bucket).map(function (id) { return adminUserRow(bucket[id]); });
}
app.get('/CollabCentral/:app/admin/users', function (req, res) {
if (requireAdmin(req, res)) return;
logger('apiEndpoint(' + req.params.app + ')', 'GET /admin/users');
res.status(200).send(adminUsersList(req.params.app));
});
app.post('/CollabCentral/:app/admin/users', async function (req, res) {
if (requireAdmin(req, res)) return;
var appName = req.params.app;
var email = req.body && req.body.email && String(req.body.email).trim();
if (!email) return res.status(400).send('Missing email.');
var person;
try {
person = await webex.findPersonByEmail(email);
} catch (err) {
logger('apiEndpoint(' + appName + ')',
'admin/users lookup failed for "' + email + '": ' + (err && err.message || err));
return res.status(502).send('Failed to reach Webex directory.');
}
if (!person) return res.status(404).send('No Webex user found for that email.');
if (!authorized.bot[appName]) authorized.bot[appName] = {};
var bucket = authorized.bot[appName];
if (!bucket[person.id]) {
bucket[person.id] = {
id: person.id,
displayName: person.displayName,
email: person.email,
avatar: person.avatar,
groups: []
};
try {
saveConfig(authorized, './config/authorized.json');
} catch (err) {
logger('apiEndpoint(' + appName + ')',
'admin/users save failed: ' + (err && err.message || err));
return res.status(500).send('Failed to save.');
}
logger('apiEndpoint(' + appName + ')',
'admin/users + "' + person.displayName + '" <' + person.email + '>');
}
res.status(200).send({
user: adminUserRow(bucket[person.id]),
users: adminUsersList(appName)
});
});
app.delete('/CollabCentral/:app/admin/users/:id', function (req, res) {
if (requireAdmin(req, res)) return;
var appName = req.params.app;
var targetId = req.params.id;
var bucket = (authorized.bot && authorized.bot[appName]) || {};
if (!bucket[targetId]) {
return res.status(200).send({ removed: false, users: adminUsersList(appName) });
}
var name = bucket[targetId].displayName || targetId;
delete bucket[targetId];
try {
saveConfig(authorized, './config/authorized.json');
} catch (err) {
logger('apiEndpoint(' + appName + ')',
'admin/users delete save failed: ' + (err && err.message || err));
return res.status(500).send('Failed to save.');
}
logger('apiEndpoint(' + appName + ')', 'admin/users - "' + name + '"');
res.status(200).send({ removed: true, users: adminUsersList(appName) });
});
// Options for the session cookies set after a successful OAuth round-trip.
//
// httpOnly is deliberately false: js/app.js reads `id` (to decide whether to
// redirect to OAuth) and `displayName` (to render the user chip in the header)
// via document.cookie. Enabling httpOnly here hides the cookies from JS, which
// causes an infinite auth loop where every page load thinks the user is not
// signed in and kicks off a fresh OAuth exchange — burning Webex tokens until
// the CTS token limit is reached.
//
// sameSite is 'lax' rather than 'strict' so the cookie survives the OAuth
// redirect chain webexapis.com -> /oauth -> /sendMessage.html on all browsers.
const SESSION_COOKIE_OPTIONS = {
httpOnly: false,
secure: true,
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000
};
app.get(`/CollabCentral/:app/oauth`, async function (req, res) {
var appName = req.params.app;
var authCode = req.query.code;
if (!process.env.WEBEX_INTEGRATION_CLIENT_ID || !process.env.WEBEX_INTEGRATION_CLIENT_SECRET || !process.env.OAUTH_CALLBACK_URL_TEMPLATE) {
logger("oauth", "Missing Webex integration env vars; cannot complete OAuth.");
return res.status(500).send("OAuth is not configured on the server.");
}
if (!authCode) {
return res.status(400).send("Missing OAuth `code` query parameter.");
}
var url = new URL("https://webexapis.com/v1/access_token");
var params = new URLSearchParams();
params.append('grant_type', 'authorization_code');
params.append('client_id', process.env.WEBEX_INTEGRATION_CLIENT_ID);
params.append('client_secret', process.env.WEBEX_INTEGRATION_CLIENT_SECRET);
params.append('code', authCode);
params.append('redirect_uri', getOAuthRedirectUri(appName));
var response;
try {
response = await webex.fetchWithRateLimit(url, { method: 'POST', body: params });
} catch (error) {
logger("oauth", "Token exchange fetch failed: " + (error && error.message || error));
return res.status(502).send("Failed to reach Webex to complete OAuth.");
}
if (!response.ok) {
var errText = await response.text().catch(() => '');
logger("oauth", "Token exchange returned " + response.status + " " + response.statusText + " " + errText);
return res.status(401).send(response.statusText || "OAuth token exchange failed.");
}
var jsonData = await response.json();
var whoami;
try {
whoami = await webex.whoAmI(jsonData.access_token);
} catch (err) {
logger("oauth", "whoAmI failed: " + (err && err.message || err));
return res.status(502).send("Failed to read your Webex profile after OAuth.");
}
logger("oauth", whoami.displayName + " successfully authed for " + appName + ".");
// Only the two cookies that the server (req.cookies.id, .displayName) and
// the client (getCookie('id'), getCookie('displayName')) actually read.
// Access/refresh tokens deliberately stay out of the browser: they never
// need to leave the server, and putting them in cookies would expose them
// to any XSS that might slip in later.
res
.cookie('id', whoami.id, SESSION_COOKIE_OPTIONS)
.cookie('displayName', whoami.displayName, SESSION_COOKIE_OPTIONS)
.redirect(301, '/CollabCentral/' + appName + '/sendMessage.html');
});
function buildJob(req, res) {
return new Promise(async function (resolve, reject) {
res.setTimeout(3000000)
var memberList = [];
var memberListPromises = [];
upload(req, res, async function (err) {
//console.log(req.files);
//console.log(req.body);
jobs.building[buildingKey(req)] = {
"appName": req.body.appName,
"senderId": req.cookies.id,
"senderDisplayName": req.cookies.displayName,
"groups": req.body.groups,
"message": {
"raw": req.body.message,
"english": {}
},
"memberList": []
}
// check for error thrown by multer- file size etc
if (err || req.files === undefined) {
//no file
} else {
if (req.files["uploadImage"]) {
for (var file of req.files["uploadImage"]) {
let fileName = uuid() + ".jpeg"
var image = await sharp(file.buffer).jpeg({ quality: 40, }).toFile('./uploads/' + fileName).catch(err => { console.log('error: ', err) })
jobs.building[buildingKey(req)].imageName = fileName;
logger("buildJob", "uploadImage " + file.originalname + " to " + fileName)
}
}
if (req.files["uploadCSV"]) {
for (var file of req.files["uploadCSV"]) {
let fileName = uuid() + ".csv";
fs.writeFileSync('./uploads/' + fileName, file.buffer)
jobs.building[buildingKey(req)].csv = fileName;
logger("buildJob", "uploadCSV " + file.originalname + " to " + fileName)
memberListPromises.push(jobsPipeline.buildPeopleList(fileName));
}
}
}
if (req.body.groups) {
logger("buildJob", "Locating groups: " + req.body.groups);
memberListPromises.push(jobsPipeline.collectGroupMembers(req.body.groups));
}
if (req.body.scheduledFor) {
logger("buildJob", "Scheduling for " + new Date(req.body.scheduledFor).toLocaleString())
jobs.building[buildingKey(req)].scheduledFor = req.body.scheduledFor;
saveConfig(jobs, "./config/jobs.json")
}
Promise.allSettled(memberListPromises)
.then(peoplePromises => {
console.log(JSON.stringify(peoplePromises))
for (var peopleList of peoplePromises) {
if (peopleList.status == "fulfilled") {
for (var person of peopleList.value) {
memberList.push(person);
}
}
}
//Make user members unique
var uniqueMembers = [...new Map(memberList.map((m) => [m.id, m])).values()];
jobs.building[buildingKey(req)].memberList = uniqueMembers;
var numSending = uniqueMembers.length.toString();
saveConfig(jobs, "./config/jobs.json")
resolve(jobs.building[buildingKey(req)]);
})
})
})
}
function saveConfig(jsonObject, configFile) {
const jsonData = JSON.stringify(jsonObject, null, 4);

124
routes/admin.js Normal file
View file

@ -0,0 +1,124 @@
// Admin endpoints for managing a bot's authorized-user list:
// GET /CollabCentral/:app/admin/users — list
// POST /CollabCentral/:app/admin/users — add (by email)
// DELETE /CollabCentral/:app/admin/users/:id — remove
//
// Admin authority lives in authorized.admins (personIds). Every admin
// route rejects with 403 for non-admin callers — 403 rather than 401 so
// the client can tell an admin-only route apart from a signed-out state
// (which would 401). Admin actions are cross-bot in concept but the
// routes are still :app-scoped because the resource being edited is
// per-bot (authorized users on THAT bot) and it keeps the OAuth session
// model unchanged (admin uses the same session cookie as any other page).
//
// registerAdminRoutes(app, { isAdmin, authorized, webex, saveConfig,
// logger })
// requireAdmin returns null on success or an Express-response-sending
// function on failure, so each handler stays a straight-line function.
function makeRequireAdmin(isAdmin) {
return function requireAdmin(req, res) {
if (!req.cookies || !req.cookies.id) return res.status(401).send('Unauthorized.');
if (!isAdmin(req.cookies.id)) return res.status(403).send('Admin only.');
return null;
};
}
// Shape of a single row returned by the admin users endpoints. Kept in
// one helper so add / delete / list all produce identical output — the
// client can treat every response as an authoritative "here's the
// current state".
function adminUserRow(entry) {
var groups = Array.isArray(entry && entry.groups) ? entry.groups : [];
return {
id: entry.id,
displayName: entry.displayName || '',
email: entry.email || '',
avatar: entry.avatar || null,
groupCount: groups.length
};
}
export function registerAdminRoutes(app, deps) {
var isAdmin = deps.isAdmin;
var authorized = deps.authorized;
var webex = deps.webex;
var saveConfig = deps.saveConfig;
var logger = deps.logger || function () {};
var requireAdmin = makeRequireAdmin(isAdmin);
function adminUsersList(appName) {
var bucket = (authorized.bot && authorized.bot[appName]) || {};
return Object.keys(bucket).map(function (id) { return adminUserRow(bucket[id]); });
}
app.get('/CollabCentral/:app/admin/users', function (req, res) {
if (requireAdmin(req, res)) return;
logger('apiEndpoint(' + req.params.app + ')', 'GET /admin/users');
res.status(200).send(adminUsersList(req.params.app));
});
app.post('/CollabCentral/:app/admin/users', async function (req, res) {
if (requireAdmin(req, res)) return;
var appName = req.params.app;
var email = req.body && req.body.email && String(req.body.email).trim();
if (!email) return res.status(400).send('Missing email.');
var person;
try {
person = await webex.findPersonByEmail(email);
} catch (err) {
logger('apiEndpoint(' + appName + ')',
'admin/users lookup failed for "' + email + '": ' + (err && err.message || err));
return res.status(502).send('Failed to reach Webex directory.');
}
if (!person) return res.status(404).send('No Webex user found for that email.');
if (!authorized.bot[appName]) authorized.bot[appName] = {};
var bucket = authorized.bot[appName];
if (!bucket[person.id]) {
bucket[person.id] = {
id: person.id,
displayName: person.displayName,
email: person.email,
avatar: person.avatar,
groups: []
};
try {
saveConfig(authorized, './config/authorized.json');
} catch (err) {
logger('apiEndpoint(' + appName + ')',
'admin/users save failed: ' + (err && err.message || err));
return res.status(500).send('Failed to save.');
}
logger('apiEndpoint(' + appName + ')',
'admin/users + "' + person.displayName + '" <' + person.email + '>');
}
res.status(200).send({
user: adminUserRow(bucket[person.id]),
users: adminUsersList(appName)
});
});
app.delete('/CollabCentral/:app/admin/users/:id', function (req, res) {
if (requireAdmin(req, res)) return;
var appName = req.params.app;
var targetId = req.params.id;
var bucket = (authorized.bot && authorized.bot[appName]) || {};
if (!bucket[targetId]) {
return res.status(200).send({ removed: false, users: adminUsersList(appName) });
}
var name = bucket[targetId].displayName || targetId;
delete bucket[targetId];
try {
saveConfig(authorized, './config/authorized.json');
} catch (err) {
logger('apiEndpoint(' + appName + ')',
'admin/users delete save failed: ' + (err && err.message || err));
return res.status(500).send('Failed to save.');
}
logger('apiEndpoint(' + appName + ')', 'admin/users - "' + name + '"');
res.status(200).send({ removed: true, users: adminUsersList(appName) });
});
}

102
routes/auth.js Normal file
View file

@ -0,0 +1,102 @@
// OAuth endpoints.
// GET /CollabCentral/:app/authUrl — returns the Webex authorize URL
// the browser should redirect to.
// GET /CollabCentral/:app/oauth — Webex redirects the user back to
// this URL with a `code` query
// param; we exchange it for tokens
// and set the `id`+`displayName`
// session cookies.
//
// registerAuthRoutes(app, { buildAuthUrl, getOAuthRedirectUri, webex,
// env, logger })
// Options for the session cookies set after a successful OAuth round-trip.
//
// httpOnly is deliberately false: js/app.js reads `id` (to decide whether
// to redirect to OAuth) and `displayName` (to render the user chip in the
// header) via document.cookie. Enabling httpOnly here hides the cookies
// from JS, which causes an infinite auth loop where every page load thinks
// the user is not signed in and kicks off a fresh OAuth exchange — burning
// Webex tokens until the CTS token limit is reached.
//
// sameSite is 'lax' rather than 'strict' so the cookie survives the OAuth
// redirect chain webexapis.com -> /oauth -> /sendMessage.html on all
// browsers.
const SESSION_COOKIE_OPTIONS = {
httpOnly: false,
secure: true,
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000
};
export function registerAuthRoutes(app, deps) {
var buildAuthUrl = deps.buildAuthUrl;
var getOAuthRedirectUri = deps.getOAuthRedirectUri;
var webex = deps.webex;
var env = deps.env || {};
var logger = deps.logger || function () {};
app.get('/CollabCentral/:app/authUrl', function (req, res) {
var url = buildAuthUrl(req.params.app);
if (!url) {
return res.status(500).send('OAuth is not configured. Check WEBEX_INTEGRATION_CLIENT_ID and OAUTH_CALLBACK_URL_TEMPLATE.');
}
res.send(url);
});
app.get('/CollabCentral/:app/oauth', async function (req, res) {
var appName = req.params.app;
var authCode = req.query.code;
if (!env.WEBEX_INTEGRATION_CLIENT_ID || !env.WEBEX_INTEGRATION_CLIENT_SECRET || !env.OAUTH_CALLBACK_URL_TEMPLATE) {
logger('oauth', 'Missing Webex integration env vars; cannot complete OAuth.');
return res.status(500).send('OAuth is not configured on the server.');
}
if (!authCode) {
return res.status(400).send('Missing OAuth `code` query parameter.');
}
var url = new URL('https://webexapis.com/v1/access_token');
var params = new URLSearchParams();
params.append('grant_type', 'authorization_code');
params.append('client_id', env.WEBEX_INTEGRATION_CLIENT_ID);
params.append('client_secret', env.WEBEX_INTEGRATION_CLIENT_SECRET);
params.append('code', authCode);
params.append('redirect_uri', getOAuthRedirectUri(appName));
var response;
try {
response = await webex.fetchWithRateLimit(url, { method: 'POST', body: params });
} catch (error) {
logger('oauth', 'Token exchange fetch failed: ' + (error && error.message || error));
return res.status(502).send('Failed to reach Webex to complete OAuth.');
}
if (!response.ok) {
var errText = await response.text().catch(() => '');
logger('oauth', 'Token exchange returned ' + response.status + ' ' + response.statusText + ' ' + errText);
return res.status(401).send(response.statusText || 'OAuth token exchange failed.');
}
var jsonData = await response.json();
var whoami;
try {
whoami = await webex.whoAmI(jsonData.access_token);
} catch (err) {
logger('oauth', 'whoAmI failed: ' + (err && err.message || err));
return res.status(502).send('Failed to read your Webex profile after OAuth.');
}
logger('oauth', whoami.displayName + ' successfully authed for ' + appName + '.');
// Only the two cookies that the server (req.cookies.id,
// .displayName) and the client (getCookie('id'),
// getCookie('displayName')) actually read. Access/refresh tokens
// deliberately stay out of the browser: they never need to leave
// the server, and putting them in cookies would expose them to
// any XSS that might slip in later.
res
.cookie('id', whoami.id, SESSION_COOKIE_OPTIONS)
.cookie('displayName', whoami.displayName, SESSION_COOKIE_OPTIONS)
.redirect(301, '/CollabCentral/' + appName + '/sendMessage.html');
});
}

39
routes/info.js Normal file
View file

@ -0,0 +1,39 @@
// Two "no state to speak of" endpoints:
// GET /status — bare liveness probe (no auth).
// GET /CollabCentral/:app/info — bot metadata + session flags used
// by the frontend bootstrap to decide
// which page shell to render.
//
// registerInfoRoutes(app, { getBotProfile, isAuthorized, isAdmin })
export function registerInfoRoutes(app, deps) {
var getBotProfile = deps.getBotProfile;
var isAuthorized = deps.isAuthorized;
var isAdmin = deps.isAdmin;
app.get('/status', function (req, res) {
res.status(200).send({ status: "I'm alive!'" });
});
// Returns the bot's display metadata + whether the current cookie is
// authorized. The front-end uses this to drive page labels, icons,
// and the "you don't have access" view, so adding a new bot doesn't
// require any code changes in the HTML/JS.
app.get('/CollabCentral/:app/info', function (req, res) {
var appName = req.params.app;
var botCfg = req.botConfig; // populated by requireBot
var profile = getBotProfile(appName) || {};
var iconBase = botCfg.iconBase || appName;
var personId = req.cookies && req.cookies.id;
res.status(200).send({
appName: appName,
label: botCfg.label || appName,
displayName: profile.displayName || botCfg.label || appName,
iconUrl: '/CollabCentral/' + appName + '/' + iconBase + '.png',
faviconUrl: '/CollabCentral/' + appName + '/' + iconBase + '.ico',
avatarUrl: profile.avatar || null,
authorized: isAuthorized(appName, personId),
isAdmin: isAdmin(personId)
});
});
}

286
routes/jobs.js Normal file
View file

@ -0,0 +1,286 @@
// Jobs endpoints — the core of the app:
// POST /CollabCentral/:app/jobs/:action — edit / runNow / schedule /
// cancel
// GET /CollabCentral/:app/jobs/list/:scope
// GET /CollabCentral/:app/jobs/detail/:jobId
//
// registerJobsRoutes(app, { jobs, isAuthorized, helpers, webex,
// translator, jobsPipeline, saveConfig, upload,
// sharp, uuid, fs, logger })
//
// Building (draft) jobs are keyed by cookieId + appName so a user
// authorized on multiple bots can have one independent draft per bot.
export function registerJobsRoutes(app, deps) {
var jobs = deps.jobs;
var isAuthorized = deps.isAuthorized;
var helpers = deps.helpers;
var webex = deps.webex;
var translator = deps.translator;
var jobsPipeline = deps.jobsPipeline;
var saveConfig = deps.saveConfig;
var upload = deps.upload;
var sharp = deps.sharp;
var uuid = deps.uuid;
var fs = deps.fs;
var logger = deps.logger || function () {};
function buildingKey(req) {
return helpers.buildingKey(req.cookies && req.cookies.id, req.params.app);
}
// Takes the multipart/form-data submitted by the compose page and
// fans it into a `building` job entry: any uploaded image gets JPEG-
// compressed to ./uploads/, any uploaded CSV gets parsed into a
// recipient list, and any group ids get expanded through Webex.
// Resolves with the fully-formed building job entry once every side
// effect settles.
function buildJob(req, res) {
return new Promise(function (resolve) {
res.setTimeout(3000000);
var memberList = [];
var memberListPromises = [];
upload(req, res, async function (err) {
jobs.building[buildingKey(req)] = {
appName: req.body.appName,
senderId: req.cookies.id,
senderDisplayName: req.cookies.displayName,
groups: req.body.groups,
message: { raw: req.body.message, english: {} },
memberList: []
};
if (!err && req.files) {
if (req.files['uploadImage']) {
for (var file of req.files['uploadImage']) {
let fileName = uuid() + '.jpeg';
await sharp(file.buffer).jpeg({ quality: 40 }).toFile('./uploads/' + fileName)
.catch(err => logger('buildJob', 'sharp error: ' + err));
jobs.building[buildingKey(req)].imageName = fileName;
logger('buildJob', 'uploadImage ' + file.originalname + ' to ' + fileName);
}
}
if (req.files['uploadCSV']) {
for (var file of req.files['uploadCSV']) {
let fileName = uuid() + '.csv';
fs.writeFileSync('./uploads/' + fileName, file.buffer);
jobs.building[buildingKey(req)].csv = fileName;
logger('buildJob', 'uploadCSV ' + file.originalname + ' to ' + fileName);
memberListPromises.push(jobsPipeline.buildPeopleList(fileName));
}
}
}
if (req.body.groups) {
logger('buildJob', 'Locating groups: ' + req.body.groups);
memberListPromises.push(jobsPipeline.collectGroupMembers(req.body.groups));
}
if (req.body.scheduledFor) {
logger('buildJob',
'Scheduling for ' + new Date(req.body.scheduledFor).toLocaleString());
jobs.building[buildingKey(req)].scheduledFor = req.body.scheduledFor;
saveConfig(jobs, './config/jobs.json');
}
Promise.allSettled(memberListPromises).then(function (peoplePromises) {
for (var peopleList of peoplePromises) {
if (peopleList.status === 'fulfilled') {
for (var person of peopleList.value) {
memberList.push(person);
}
}
}
var uniqueMembers = [...new Map(memberList.map(m => [m.id, m])).values()];
jobs.building[buildingKey(req)].memberList = uniqueMembers;
saveConfig(jobs, './config/jobs.json');
resolve(jobs.building[buildingKey(req)]);
});
});
});
}
app.post('/CollabCentral/:app/jobs/:action', function (req, res) {
if (!isAuthorized(req.params.app, req.cookies.id)) {
return res.status(401).send('You are not authorized.');
}
req.setTimeout(3000000);
if (req.params.action === 'edit') {
logger('apiEndpoint(' + req.params.app + ')', 'POST /jobs/edit');
return buildJob(req, res)
.then(job => {
webex.sendDirectMessage(job.senderId, job.message.raw, job.imageName, req.params.app)
.then(async function (result) {
jobs.building[buildingKey(req)].message.english = {
text: result.text,
markdown: result.markdown,
html: result.html
};
// Remember the preview DM's Webex message id
// so /jobs/cancel can retract it and
// /jobs/runNow can treat it as "already
// delivered" (future).
if (result && result.id) {
jobs.building[buildingKey(req)].previewMessageId = result.id;
}
await translator.buildTranslations(jobs.building[buildingKey(req)].message.english)
.then(t => { jobs.building[buildingKey(req)].message = t; })
.catch(error => logger('jobs/edit', 'Error translating: ' + error));
res.status(200).send(job);
saveConfig(jobs, './config/jobs.json');
})
.catch(error => logger('jobs/edit', 'Error sending message: ' + error));
})
.catch(error => logger('jobs/edit', 'Error buildJob: ' + error));
}
if (req.params.action === 'runNow') {
logger('apiEndpoint(' + req.params.app + ')', 'POST /jobs/runNow');
jobs.running.push(jobs.building[buildingKey(req)]);
delete jobs.building[buildingKey(req)];
jobsPipeline.processRunningQueue()
.catch(error => logger('jobs/runNow', 'sendRunningMessages error: ' + error));
return res.status(204).redirect('/CollabCentral/' + req.params.app + '/monitorJobs.html');
}
if (req.params.action === 'schedule') {
logger('apiEndpoint(' + req.params.app + ')', 'POST /jobs/schedule');
jobs.scheduled.push(jobs.building[buildingKey(req)]);
delete jobs.building[buildingKey(req)];
saveConfig(jobs, './config/jobs.json');
return res.status(204).redirect('/CollabCentral/' + req.params.app + '/monitorJobs.html');
}
if (req.params.action === 'cancel') {
// Abandon the caller's in-progress "building" job for this
// bot. A building entry exists as soon as the user clicks
// "Review & send" (POST /jobs/edit) and lingers on the
// server until the user promotes it to running/scheduled —
// or until now, where this route just drops it on the
// floor. Idempotent: 200 whether or not a building entry
// actually existed for (personId, app), so the client can
// always fire this on "cancel" without needing to check
// state first.
//
// If the building job has a previewMessageId (captured
// during /jobs/edit), also retract that DM from the
// sender's own space so the review message doesn't hang
// around after they discarded the draft. Best-effort — a
// failed Webex delete is logged but does not fail the
// cancel.
logger('apiEndpoint(' + req.params.app + ')', 'POST /jobs/cancel');
var key = buildingKey(req);
var job = jobs.building[key];
var hadJob = !!job;
if (hadJob) {
var previewId = job.previewMessageId;
delete jobs.building[key];
try {
saveConfig(jobs, './config/jobs.json');
} catch (err) {
logger('apiEndpoint(' + req.params.app + ')',
'jobs/cancel save failed: ' + (err && err.message || err));
return res.status(500).send('Failed to cancel job.');
}
if (previewId) {
// Fire-and-forget; we've already committed the
// cancel to disk and we don't want a slow Webex
// round trip to hold the response.
webex.deleteMessage(previewId, req.params.app);
}
}
return res.status(200).send({ cancelled: hadJob });
}
return res.status(404).send('Unknown jobs action.');
});
app.get('/CollabCentral/:app/jobs/list/:scope', function (req, res) {
if (!req.cookies || !isAuthorized(req.params.app, req.cookies.id)) {
return res.status(401).send('You are not authorized.');
}
var appName = req.params.app;
if (req.params.scope === 'all') {
logger('apiEndpoint(' + appName + ')', 'GET /jobs/list/all');
return res.status(200).send({
building: jobs.building[buildingKey(req)],
running: helpers.jobsForApp(jobs.running, appName),
scheduled: helpers.jobsForApp(jobs.scheduled, appName),
completed: helpers.jobsForApp(jobs.completed, appName)
});
}
if (req.params.scope === 'building') {
logger('apiEndpoint(' + appName + ')', 'GET /jobs/list/building');
return res.status(200).send(jobs.building[buildingKey(req)]);
}
if (req.params.scope === 'running') {
logger('apiEndpoint(' + appName + ')', 'GET /jobs/list/running');
var runningJobs = [];
for (var job of helpers.jobsForApp(jobs.running, appName)) {
var completedSends = 0;
for (var member of (job.memberList || [])) {
if (member.results) completedSends++;
}
runningJobs.push({
jobId: job.jobId,
senderDisplayName: job.senderDisplayName,
appName: job.appName,
startTime: job.startTime,
totalRecipients: (job.memberList || []).length,
completedRecipients: completedSends
});
}
return res.status(200).send(runningJobs);
}
if (req.params.scope === 'scheduled') {
logger('apiEndpoint(' + appName + ')', 'GET /jobs/list/scheduled');
var scheduledJobs = [];
for (var job of helpers.jobsForApp(jobs.scheduled, appName)) {
scheduledJobs.push({
senderDisplayName: job.senderDisplayName,
appName: job.appName,
message: job.message && job.message.english && job.message.english.html,
totalRecipients: (job.memberList || []).length,
scheduledFor: job.scheduledFor
});
}
return res.status(200).send(scheduledJobs);
}
if (req.params.scope === 'completed') {
logger('apiEndpoint(' + appName + ')', 'GET /jobs/list/completed');
return res.status(200).send(helpers.jobsForApp(jobs.completed, appName));
}
return res.status(404).send();
});
// Returns the full job object for a given jobId, but only if the
// job belongs to the requested bot. Looks across
// completed/running/scheduled so the same URL keeps working as a
// job moves through its lifecycle.
app.get('/CollabCentral/:app/jobs/detail/:jobId', function (req, res) {
if (!req.cookies || !isAuthorized(req.params.app, req.cookies.id)) {
return res.status(401).send('You are not authorized.');
}
var appName = req.params.app;
var jobId = req.params.jobId;
logger('apiEndpoint(' + appName + ')', 'GET /jobs/detail/' + jobId);
var all = (jobs.completed || []).concat(jobs.running || [], jobs.scheduled || []);
var match = all.find(function (j) {
return j && j.appName === appName && String(j.jobId) === String(jobId);
});
if (!match) return res.status(404).send('Job not found.');
res.status(200).send(match);
});
}

125
routes/user.js Normal file
View file

@ -0,0 +1,125 @@
// 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);
});
}