collabcentral/html/jobDetail.js
Joseph B. McQueen e604c7e9c9 Initial commit: multi-bot CollabCentral
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>
2026-07-01 17:53:07 -04:00

179 lines
6.3 KiB
JavaScript

var pathArray = window.location.pathname.split('/');
var appName = pathArray[2];
var urlParams = new URLSearchParams(window.location.search);
var jobId = urlParams.get('jobId');
if (!getCookie("id")) {
var randomNumber = Math.random().toString().substring(2);
fetch('/CollabCentral/' + appName + '/authUrl')
.then(res => res.text())
.then(res => { window.location.href = res + randomNumber; });
} else {
bootstrap();
}
function bootstrap() {
fetch('/CollabCentral/' + appName + '/info')
.then(res => res.json())
.then(info => {
document.title = info.label + " — Job #" + (jobId || "?");
document.getElementById("appLabel").innerHTML = info.label;
document.getElementById("name").innerHTML = getCookie("displayName") || "";
document.getElementById("appName").href = info.faviconUrl;
document.getElementById("jobLink").href = "/CollabCentral/" + appName + "/monitorJobs.html";
var appIcon = document.getElementById("appIcon");
appIcon.src = info.iconUrl;
appIcon.style.maxHeight = "150px";
appIcon.style.maxWidth = "150px";
if (!info.authorized) {
document.getElementById("unauthorizedPanel").classList.remove("hidden");
return;
}
loadJobDetail();
})
.catch(err => console.error("Failed to load /info:", err));
}
function loadJobDetail() {
if (!jobId) {
document.getElementById("notFoundPanel").classList.remove("hidden");
return;
}
fetch('/CollabCentral/' + appName + '/jobs/detail/' + encodeURIComponent(jobId))
.then(res => {
if (res.status === 404) {
document.getElementById("notFoundPanel").classList.remove("hidden");
return null;
}
if (!res.ok) throw new Error("HTTP " + res.status);
return res.json();
})
.then(job => {
if (!job) return;
renderJob(job);
document.getElementById("detailPanel").classList.remove("hidden");
})
.catch(err => {
console.error("Failed to load job detail:", err);
document.getElementById("notFoundPanel").classList.remove("hidden");
});
}
function renderJob(job) {
document.getElementById("jobTitle").textContent = "Job #" + (job.jobId || "?");
var subtitle = "Sent by " + (job.senderDisplayName || "unknown");
if (job.startTime) subtitle += " · started " + formatDate(job.startTime);
document.getElementById("subtitle").textContent = subtitle;
var dl = document.getElementById("summaryGrid");
dl.innerHTML = "";
var stats = job.stats || {};
var rows = [
["Job ID", job.jobId],
["Submitted by", job.senderDisplayName],
["Scheduled for", job.scheduledFor ? formatDate(job.scheduledFor) : "Sent immediately"],
["Started", formatDate(job.startTime)],
["Completed", job.endTime ? formatDate(job.endTime) : "In progress"],
["Total duration", stats.totalTime],
["Webex time", stats.webexTime],
["Average per message", stats.averageTime],
["Recipients", (job.memberList || []).length],
["Delivered", formatSuccess(stats)]
];
for (var i = 0; i < rows.length; i++) {
var key = rows[i][0], val = rows[i][1];
if (val === undefined || val === null || val === "") continue;
var dt = document.createElement("dt"); dt.textContent = key;
var dd = document.createElement("dd"); dd.textContent = val;
dl.appendChild(dt); dl.appendChild(dd);
}
var preview = document.getElementById("messagePreview");
var msg = (job.message && job.message.english) || job.message || {};
if (msg.html) {
preview.innerHTML = msg.html;
} else if (msg.markdown) {
preview.textContent = msg.markdown;
} else if (msg.text) {
preview.textContent = msg.text;
} else if (typeof msg.raw === "string") {
preview.textContent = msg.raw;
} else {
preview.innerHTML = "<em>No message content available.</em>";
}
var members = job.memberList || [];
document.getElementById("recipientCount").textContent = members.length;
$('#recipients').dataTable({
data: members.map(formatRecipientRow),
columns: [
{ data: 'displayName' },
{ data: 'email' },
{ data: 'status' },
{ data: 'time' },
{ data: 'error' }
],
pageLength: 50,
lengthMenu: [25, 50, 100, 250],
order: [[2, 'asc']],
searching: true,
info: true,
paging: true
});
}
function formatRecipientRow(m) {
var result = m.results || null;
var status;
var email = "—";
var err = "";
if (!result) {
status = '<span class="pill pending">Pending</span>';
} else if (result.id && !result.message && !result.errors) {
status = '<span class="pill ok">Delivered</span>';
email = result.toPersonEmail || email;
} else {
status = '<span class="pill err">Failed</span>';
email = result.toPersonEmail || email;
if (result.message) err = result.message;
else if (result.errors && result.errors.length) {
err = result.errors.map(function (e) { return e.description || e.code || JSON.stringify(e); }).join("; ");
}
}
return {
displayName: m.displayName || "—",
email: email,
status: status,
time: m.msgTime ? (m.msgTime / 1000).toFixed(2) + " s" : "—",
error: err
};
}
function formatSuccess(stats) {
if (!stats || stats.succeededMsgs === undefined) return null;
var pct = (typeof stats.succeededMsgPct === "number") ? stats.succeededMsgPct.toFixed(1) : stats.succeededMsgPct;
return stats.succeededMsgs + " / " + stats.totalMsgs + " (" + pct + "%)";
}
function formatDate(d) {
if (!d) return null;
try { return new Date(d).toLocaleString(); } catch (e) { return d; }
}
function getCookie(cname) {
let name = cname + "=";
let decodedCookie = decodeURIComponent(document.cookie);
let ca = decodedCookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) == ' ') c = c.substring(1);
if (c.indexOf(name) == 0) return c.substring(name.length, c.length);
}
return "";
}