webex-booking-webhook/src/server.js
jmcqueen a93a1194e6 Add Webex booking webhook with occupancy tracking and utilization reports.
Track in-meeting occupancy via xAPI polling and workspaceMetrics, store Webex attendee counts for linked meetings, and surface under-utilized room bookings on the dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 16:25:41 -04:00

2414 lines
No EOL
86 KiB
JavaScript

import express from 'express';
import Database from 'better-sqlite3';
import axios from 'axios';
import fs from 'fs';
import path from 'path';
import dotenv from 'dotenv';
dotenv.config();
// Create logs directory if it doesn't exist
const LOG_DIR = path.join(process.cwd(), 'logs');
if (!fs.existsSync(LOG_DIR)) {
fs.mkdirSync(LOG_DIR, { recursive: true });
}
const WEBHOOK_LOG_PATH = path.join(LOG_DIR, 'webhook.log');
// Simple logger function
function logWebhook(payload) {
const timestamp = new Date().toISOString();
const logEntry = `[${timestamp}] ${JSON.stringify(payload)}\n\n`;
// Append to file
fs.appendFile(WEBHOOK_LOG_PATH, logEntry, (err) => {
if (err) console.error('Failed to write to webhook.log:', err.message);
});
// Still show a summary in console
console.log(`📥 Webhook logged → logs/webhook.log | Type: ${payload.type || 'unknown'} | Events: ${payload.events?.length || 0}`);
}
const app = express();
app.use(express.json());
const PORT = process.env.PORT || 3000;
const DB_PATH = path.join(process.cwd(), 'data', 'active_bookings.db');
const TOKEN_PATH = path.join(process.cwd(), 'tokens', 'wbxOpsToken.json');
const WEBEX_BOT_TOKEN = process.env.WEBEX_BOT_TOKEN;
const CLIENT_ID = process.env.WEBEX_CLIENT_ID;
const CLIENT_SECRET = process.env.WEBEX_CLIENT_SECRET;
const WEBHOOK_AUTH_TOKEN = process.env.WEBHOOK_AUTH_TOKEN;
const OCCUPANCY_POLL_INTERVAL_MS = Number(process.env.OCCUPANCY_POLL_INTERVAL_MS) || 180000;
const UNDER_UTILIZED_THRESHOLD_PCT = Number(process.env.UNDER_UTILIZED_THRESHOLD_PCT) || 25;
const WORKSPACE_METRICS_END_DELAY_MS = Number(process.env.WORKSPACE_METRICS_END_DELAY_MS) || 45000;
if (!WEBEX_BOT_TOKEN) console.warn('⚠️ WEBEX_BOT_TOKEN not set');
if (!CLIENT_ID || !CLIENT_SECRET) console.warn('⚠️ Missing WEBEX_CLIENT_ID or CLIENT_SECRET');
if (!WEBHOOK_AUTH_TOKEN) {
console.error('❌ WEBHOOK_AUTH_TOKEN is required in .env for security');
process.exit(1);
}
// ====================== SQLite Setup ======================
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS bookings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
Title TEXT,
MeetingId TEXT NOT NULL,
OrganizerName TEXT,
OrganizerEmail TEXT,
OrganizerId TEXT,
StartTime TEXT,
Duration TEXT,
WorkspaceId TEXT NOT NULL,
DeviceId TEXT,
DeviceName TEXT,
CalendarEmail TEXT,
googleEventId TEXT,
IsRecurring INTEGER DEFAULT 0,
Guests INTEGER DEFAULT 0,
calendarRoomName TEXT,
Cause TEXT,
MinutesFreed INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(MeetingId, WorkspaceId)
);
`);
console.log('✅ SQLite database ready');
// Safe column addition for Start/End tracking
const extraColumns = [
{ name: 'ActualStartTime', type: 'TEXT' },
{ name: 'ActualEndTime', type: 'TEXT' }
];
for (const col of extraColumns) {
try {
const exists = db.prepare(`SELECT 1 FROM pragma_table_info('bookings') WHERE name = ?`).get(col.name);
if (!exists) {
db.exec(`ALTER TABLE bookings ADD COLUMN ${col.name} ${col.type}`);
console.log(`✅ Added column: ${col.name}`);
}
} catch (e) { }
}
// xAPI / Room Analytics columns
const xapiColumns = [
{ name: 'RoomPeopleCount', type: 'INTEGER' },
{ name: 'MicActivity', type: 'INTEGER' }, // 0-100
{ name: 'CallActive', type: 'INTEGER' }, // 0 or 1
{ name: 'EndedBy', type: 'TEXT' }, // Who ended it (if known)
{ name: 'xAPI_LastChecked', type: 'TEXT' }
];
for (const col of xapiColumns) {
try {
const exists = db.prepare(`SELECT 1 FROM pragma_table_info('bookings') WHERE name = ?`).get(col.name);
if (!exists) {
db.exec(`ALTER TABLE bookings ADD COLUMN ${col.name} ${col.type}`);
console.log(`✅ Added xAPI column: ${col.name}`);
}
} catch (e) { }
}
// Safe way to add columns (SQLite doesn't support IF NOT EXISTS on ALTER TABLE)
const columnsToAdd = [
{ name: 'googleEventId', type: 'TEXT' },
{ name: 'calendarRoomName', type: 'TEXT' },
{ name: 'enrichmentStatus', type: 'TEXT' }
];
// Webex Meeting Info columns from Google Calendar
const webexColumns = [
{ name: 'webexMeetingId', type: 'TEXT' },
{ name: 'webexSipAddress', type: 'TEXT' },
{ name: 'WebexAttendeeCount', type: 'INTEGER' }
];
for (const col of webexColumns) {
try {
const exists = db.prepare(`SELECT 1 FROM pragma_table_info('bookings') WHERE name = ?`).get(col.name);
if (!exists) {
db.exec(`ALTER TABLE bookings ADD COLUMN ${col.name} ${col.type}`);
console.log(`✅ Added column: ${col.name}`);
}
} catch (e) {
console.log(`Column ${col.name} already exists`);
}
}
// Webex Password column
try {
const exists = db.prepare(`SELECT 1 FROM pragma_table_info('bookings') WHERE name = ?`).get('webexPassword');
if (!exists) {
db.exec(`ALTER TABLE bookings ADD COLUMN webexPassword TEXT`);
console.log(`✅ Added column: webexPassword`);
}
} catch (e) {
console.log(`Column webexPassword already exists`);
}
// Location / Workspace columns
const locationColumns = [
{ name: 'LocationName', type: 'TEXT' },
{ name: 'Floor', type: 'TEXT' },
{ name: 'RoomType', type: 'TEXT' },
{ name: 'Capacity', type: 'INTEGER' }
];
for (const col of locationColumns) {
try {
const exists = db.prepare(`SELECT 1 FROM pragma_table_info('bookings') WHERE name = ?`).get(col.name);
if (!exists) {
db.exec(`ALTER TABLE bookings ADD COLUMN ${col.name} ${col.type}`);
console.log(`✅ Added column: ${col.name}`);
}
} catch (e) { }
}
// Occupancy aggregate columns
const occupancyColumns = [
{ name: 'RoomPeopleCountMax', type: 'INTEGER' },
{ name: 'RoomPeopleCountAvg', type: 'REAL' },
{ name: 'RoomPeopleCountSamples', type: 'INTEGER' },
{ name: 'OccupancyPctMax', type: 'REAL' },
{ name: 'OccupancySource', type: 'TEXT' }
];
for (const col of occupancyColumns) {
try {
const exists = db.prepare(`SELECT 1 FROM pragma_table_info('bookings') WHERE name = ?`).get(col.name);
if (!exists) {
db.exec(`ALTER TABLE bookings ADD COLUMN ${col.name} ${col.type}`);
console.log(`✅ Added occupancy column: ${col.name}`);
}
} catch (e) { }
}
db.exec(`
CREATE TABLE IF NOT EXISTS occupancy_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
MeetingId TEXT NOT NULL,
WorkspaceId TEXT NOT NULL,
sampled_at TEXT NOT NULL,
people_count INTEGER NOT NULL,
source TEXT NOT NULL
);
`);
console.log('✅ Occupancy samples table ready');
// Workspace lookup cache (to resolve base64 IDs to friendly names)
db.exec(`
CREATE TABLE IF NOT EXISTS workspace_lookup (
workspaceId TEXT PRIMARY KEY,
displayName TEXT,
locationName TEXT,
floorId TEXT,
roomType TEXT,
capacity INTEGER,
sipAddress TEXT,
lastUpdated DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
console.log('✅ Workspace lookup table ready');
/*
// ====================== ONE-TIME FULL LOCATION BACKFILL ======================
// Run this once to populate both the lookup table and existing bookings
console.log('🔄 Running FULL location backfill...');
const uniqueWorkspaces = db.prepare(`
SELECT DISTINCT WorkspaceId FROM bookings
`).all();
let cached = 0;
let updated = 0;
for (const row of uniqueWorkspaces) {
try {
const workspace = await getWorkspaceInfo(row.WorkspaceId);
if (workspace && workspace.displayName) {
// Cache the workspace
db.prepare(`
INSERT OR REPLACE INTO workspace_lookup
(workspaceId, displayName, locationName, floorId, roomType, capacity, sipAddress)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
row.WorkspaceId,
workspace.displayName,
workspace.locationName,
workspace.floorId,
workspace.roomType,
workspace.capacity,
workspace.sipAddress
);
cached++;
// Update all bookings for this workspace
const result = db.prepare(`
UPDATE bookings
SET LocationName = ?,
Floor = ?,
RoomType = ?,
Capacity = ?
WHERE WorkspaceId = ?
`).run(
workspace.displayName,
workspace.floorId,
workspace.roomType,
workspace.capacity,
row.WorkspaceId
);
updated += result.changes;
}
} catch (err) {
console.error(` Failed to backfill workspace ${row.WorkspaceId}:`, err.message);
}
}
console.log(`✅ Full backfill completed:`);
console.log(` - Cached ${cached} workspaces`);
console.log(` - Updated ${updated} booking records with location data`);
*/
/*
// === RE-BACKFILL to apply cached names to all bookings ===
console.log('🔄 Applying cached workspace names to all bookings...');
const result = db.prepare(`
UPDATE bookings
SET LocationName = (
SELECT displayName
FROM workspace_lookup
WHERE workspace_lookup.workspaceId = bookings.WorkspaceId
),
Floor = (
SELECT floorId
FROM workspace_lookup
WHERE workspace_lookup.workspaceId = bookings.WorkspaceId
),
RoomType = (
SELECT roomType
FROM workspace_lookup
WHERE workspace_lookup.workspaceId = bookings.WorkspaceId
),
Capacity = (
SELECT capacity
FROM workspace_lookup
WHERE workspace_lookup.workspaceId = bookings.WorkspaceId
)
WHERE LocationName IS NULL OR LocationName LIKE 'Y2lzY29zcGFyazov%' -- only update raw IDs
`).run();
console.log(`✅ Applied friendly names to ${result.changes} bookings`);
*/
/*
// ====================== TEMPORARY FULL LOCATION BACKFILL ======================
// Run this once to populate workspace_lookup and update all existing bookings
// You can remove this block after it runs successfully
console.log('🔄 Running FULL location backfill...');
const uniqueWorkspaces = db.prepare(`
SELECT DISTINCT WorkspaceId FROM bookings
`).all();
let cached = 0;
let updated = 0;
for (const row of uniqueWorkspaces) {
try {
const workspace = await getWorkspaceInfo(row.WorkspaceId);
if (workspace && workspace.displayName) {
// 1. Cache the workspace info
db.prepare(`
INSERT OR REPLACE INTO workspace_lookup
(workspaceId, displayName, locationName, floorId, roomType, capacity, sipAddress)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
row.WorkspaceId,
workspace.displayName,
workspace.locationName,
workspace.floorId,
workspace.roomType,
workspace.capacity,
workspace.sipAddress
);
cached++;
// 2. Update all bookings for this workspace
const result = db.prepare(`
UPDATE bookings
SET LocationName = ?,
Floor = ?,
RoomType = ?,
Capacity = ?
WHERE WorkspaceId = ?
`).run(
workspace.displayName,
workspace.floorId,
workspace.roomType,
workspace.capacity,
row.WorkspaceId
);
updated += result.changes;
}
} catch (err) {
console.error(` Failed to backfill workspace ${row.WorkspaceId}:`, err.message);
}
}
console.log(`✅ Full backfill completed:`);
console.log(` - Cached ${cached} workspaces`);
console.log(` - Updated ${updated} booking records with location data`);
*/
/*
const result = db.prepare(`
UPDATE bookings
SET
LocationName = COALESCE(
(SELECT displayName FROM workspace_lookup WHERE workspace_lookup.workspaceId = bookings.WorkspaceId),
bookings.LocationName
),
Floor = COALESCE(
(SELECT floorId FROM workspace_lookup WHERE workspace_lookup.workspaceId = bookings.WorkspaceId),
bookings.Floor
),
RoomType = COALESCE(
(SELECT roomType FROM workspace_lookup WHERE workspace_lookup.workspaceId = bookings.WorkspaceId),
bookings.RoomType
),
Capacity = COALESCE(
(SELECT capacity FROM workspace_lookup WHERE workspace_lookup.workspaceId = bookings.WorkspaceId),
bookings.Capacity
)
WHERE LocationName LIKE 'Y2lzY%'
OR Floor LIKE 'Y2lzY%'
OR LocationName IS NULL
OR Floor IS NULL;
`).run();
*/
// Floor lookup cache
db.exec(`
CREATE TABLE IF NOT EXISTS floor_lookup (
floorId TEXT PRIMARY KEY,
locationId TEXT,
floorNumber INTEGER,
displayName TEXT,
lastUpdated DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
console.log('✅ Floor lookup table ready');
// ====================== TOKEN MANAGEMENT ======================
async function readTokenFile() {
try {
const raw = fs.readFileSync(TOKEN_PATH, 'utf8');
return JSON.parse(raw);
} catch (err) {
console.error('❌ Could not read wbxOpsToken.json');
throw err;
}
}
async function writeTokenFile(tokenData) {
fs.writeFileSync(TOKEN_PATH, JSON.stringify(tokenData, null, 2));
console.log('✅ Token file updated');
}
async function refreshAccessToken() {
const current = await readTokenFile();
if (!current.refresh_token) throw new Error('No refresh_token found');
console.log('🔄 Refreshing Webex access token...');
const response = await axios.post('https://webexapis.com/v1/access_token',
new URLSearchParams({
grant_type: 'refresh_token',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
refresh_token: current.refresh_token,
}), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
}
);
const newData = {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token || current.refresh_token,
expires_in: response.data.expires_in,
token_type: response.data.token_type,
expires_at: Date.now() + (response.data.expires_in * 1000) - 60000 // 1 min safety buffer
};
await writeTokenFile(newData);
return newData.access_token;
}
async function getValidAccessToken() {
let tokenData = await readTokenFile();
if (!tokenData.expires_at || Date.now() > tokenData.expires_at) {
return await refreshAccessToken();
}
return tokenData.access_token;
}
// ====================== HELPERS ======================
// Small delay helper
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
// Resolve floorId to friendly name using Webex API
async function getFloorInfo(locationId, floorId) {
if (!locationId || !floorId) return null;
// Check cache first
const cached = db.prepare(`
SELECT displayName, floorNumber
FROM floor_lookup
WHERE floorId = ?
`).get(floorId);
if (cached) return cached.displayName || `Floor ${cached.floorNumber || ''}`;
// Not cached → call Webex API
try {
const token = await getValidAccessToken();
const url = `https://webexapis.com/v1/locations/${locationId}/floors/${floorId}`;
const res = await axios.get(url, {
headers: { Authorization: `Bearer ${token}` }
});
const floor = res.data || {};
const friendlyName = floor.displayName || `Floor ${floor.floorNumber || ''}`;
// Cache it
db.prepare(`
INSERT OR REPLACE INTO floor_lookup (floorId, locationId, floorNumber, displayName)
VALUES (?, ?, ?, ?)
`).run(floorId, locationId, floor.floorNumber, friendlyName);
console.log(`✅ Cached floor: ${friendlyName} (${floorId})`);
return friendlyName;
} catch (err) {
console.error(`Failed to resolve floor ${floorId}:`, err.response?.data || err.message);
return null;
}
}
async function getWorkspaceInfo(workspaceId) {
if (!workspaceId) return { displayName: 'Unknown Room', calendarEmail: null };
// Check cache
const cached = db.prepare(`
SELECT displayName, locationName, floorId, roomType, capacity, sipAddress, calendarEmail
FROM workspace_lookup
WHERE workspaceId = ?
`).get(workspaceId);
if (cached) {
// Update existing bookings with the cached calendarEmail (this fixes old records)
if (cached.calendarEmail) {
db.prepare(`
UPDATE bookings
SET CalendarEmail = ?
WHERE WorkspaceId = ? AND (CalendarEmail IS NULL OR CalendarEmail = '')
`).run(cached.calendarEmail, workspaceId);
}
return {
displayName: cached.displayName || 'Unknown Room',
locationName: cached.locationName,
floorId: cached.floorId,
roomType: cached.roomType,
capacity: cached.capacity,
sipAddress: cached.sipAddress,
calendarEmail: cached.calendarEmail || null
};
}
// Fresh fetch from Webex
try {
const token = await getValidAccessToken();
const res = await axios.get(`https://webexapis.com/v1/workspaces/${workspaceId}`, {
headers: { Authorization: `Bearer ${token}` }
});
const ws = res.data || {};
const data = {
displayName: ws.displayName || ws.name || 'Unknown Room',
locationName: ws.workspaceLocationId ? ws.workspaceLocationId.split('/').pop() : null,
floorId: ws.floorId ? ws.floorId.split('/').pop() : null,
roomType: ws.type || null,
capacity: ws.capacity || null,
sipAddress: ws.sipAddress || null,
calendarEmail: ws.calendar?.emailAddress || null
};
// Cache it
db.prepare(`
INSERT OR REPLACE INTO workspace_lookup
(workspaceId, displayName, locationName, floorId, roomType, capacity, sipAddress, calendarEmail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
workspaceId,
data.displayName,
data.locationName,
data.floorId,
data.roomType,
data.capacity,
data.sipAddress,
data.calendarEmail
);
// Also update any existing bookings immediately
if (data.calendarEmail) {
db.prepare(`
UPDATE bookings
SET CalendarEmail = ?
WHERE WorkspaceId = ?
`).run(data.calendarEmail, workspaceId);
}
console.log(`✅ Cached workspace: ${data.displayName} | Calendar: ${data.calendarEmail || 'none'}`);
return data;
} catch (err) {
console.error(`Failed to fetch workspace ${workspaceId}:`, err.message);
return { displayName: 'Unknown Room', calendarEmail: null };
}
}
async function getXAPIRoomData(deviceId) {
if (!deviceId) {
return { peopleCount: 0, micActivity: 0, callActive: 0, endedBy: "No Device", xAPI_LastChecked: new Date().toISOString() };
}
const token = await getValidAccessToken();
try {
const headers = { Authorization: `Bearer ${token}` };
const peopleRes = await axios.get('https://webexapis.com/v1/xapi/status/', {
headers,
params: { deviceId, name: "RoomAnalytics.PeopleCount.Current" }
});
console.log(`People: `, JSON.stringify(peopleRes.data));
const voiceRes = await axios.get('https://webexapis.com/v1/xapi/status/', {
headers,
params: { deviceId, name: "Audio.Microphones.VoiceActivityDetector.Activity" }
});
console.log(`Mic: `, JSON.stringify(voiceRes.data));
const callRes = await axios.get('https://webexapis.com/v1/xapi/status/', {
headers,
params: { deviceId, name: "SystemUnit.State.NumberOfActiveCalls" }
});
console.log(`Call Active: `, JSON.stringify(callRes.data));
const peopleCount = peopleRes.data?.result?.RoomAnalytics?.PeopleCount?.Current || 0;
const voiceActivity = voiceRes.data?.result?.Audio?.Microphones?.VoiceActivityDetector?.Activity || false;
const activeCalls = callRes.data?.result?.SystemUnit?.State?.NumberOfActiveCalls || 0;
return {
peopleCount: Math.max(0, peopleCount), // -1 becomes 0
micActivity: (voiceActivity === true || voiceActivity === "True") ? 1 : 0,
callActive: activeCalls > 0,
endedBy: "xAPI",
xAPI_LastChecked: new Date().toISOString()
};
} catch (err) {
console.error(`xAPI failed for device ${deviceId}:`, err.response?.data || err.message);
return {
peopleCount: 0,
micActivity: 0,
callActive: 0,
endedBy: "xAPI_Failed",
xAPI_LastChecked: new Date().toISOString()
};
}
}
// ====================== OCCUPANCY TRACKING ======================
let occupancyPollInProgress = false;
let workspaceMetricsScopeWarned = false;
let meetingParticipantsScopeWarned = false;
const insertOccupancySample = db.prepare(`
INSERT INTO occupancy_samples (MeetingId, WorkspaceId, sampled_at, people_count, source)
VALUES (?, ?, ?, ?, ?)
`);
const updateRunningOccupancy = db.prepare(`
UPDATE bookings
SET RoomPeopleCountMax = ?,
RoomPeopleCountAvg = ?,
RoomPeopleCountSamples = ?
WHERE (MeetingId = ? OR Id = ?) AND WorkspaceId = ?
`);
function recordOccupancySample(meetingId, workspaceId, peopleCount, source) {
insertOccupancySample.run(meetingId, workspaceId, new Date().toISOString(), peopleCount, source);
}
function updateRunningOccupancyAggregates(meetingId, workspaceId, newCount) {
const booking = db.prepare(`
SELECT RoomPeopleCountMax, RoomPeopleCountAvg, RoomPeopleCountSamples
FROM bookings
WHERE (MeetingId = ? OR Id = ?) AND WorkspaceId = ?
`).get(meetingId, meetingId, workspaceId);
const samples = booking?.RoomPeopleCountSamples || 0;
const existingMax = booking?.RoomPeopleCountMax ?? 0;
const existingAvg = booking?.RoomPeopleCountAvg ?? 0;
const newMax = Math.max(existingMax, newCount);
const newAvg = samples === 0 ? newCount : ((existingAvg * samples) + newCount) / (samples + 1);
const newSamples = samples + 1;
updateRunningOccupancy.run(newMax, newAvg, newSamples, meetingId, meetingId, workspaceId);
return { max: newMax, avg: newAvg, samples: newSamples };
}
function computeTimeWeightedAvg(samples, windowStart, windowEnd) {
if (!samples || samples.length === 0) return { avg: null, max: null };
const startMs = new Date(windowStart).getTime();
const endMs = new Date(windowEnd).getTime();
const sorted = [...samples].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
let max = 0;
let weightedSum = 0;
let totalWeight = 0;
for (let i = 0; i < sorted.length; i++) {
const ts = new Date(sorted[i].timestamp).getTime();
const value = sorted[i].value;
max = Math.max(max, value);
const nextTs = i < sorted.length - 1 ? new Date(sorted[i + 1].timestamp).getTime() : endMs;
const duration = Math.max(0, Math.min(nextTs, endMs) - Math.max(ts, startMs));
if (duration > 0) {
weightedSum += value * duration;
totalWeight += duration;
}
}
if (totalWeight === 0) {
max = Math.max(...sorted.map(s => s.value));
const simpleAvg = sorted.reduce((sum, s) => sum + s.value, 0) / sorted.length;
return { avg: simpleAvg, max };
}
return { avg: weightedSum / totalWeight, max };
}
async function getWorkspacePeopleMetrics(workspaceId, from, to) {
if (!workspaceId || !from || !to) {
return { samples: [], avg: null, max: null, available: false };
}
try {
const token = await getValidAccessToken();
const res = await axios.get('https://webexapis.com/v1/workspaceMetrics', {
headers: { Authorization: `Bearer ${token}` },
params: {
workspaceId,
metricName: 'peopleCount',
aggregation: 'none',
from,
to,
sortBy: 'oldestFirst'
}
});
const items = res.data?.items || [];
const samples = items.map(item => ({
timestamp: item.timestamp,
value: Math.max(0, item.value ?? 0)
}));
const { avg, max } = computeTimeWeightedAvg(samples, from, to);
return { samples, avg, max, available: true };
} catch (err) {
if (err.response?.status === 403 && !workspaceMetricsScopeWarned) {
workspaceMetricsScopeWarned = true;
console.warn('⚠️ workspaceMetrics requires spark-admin:workspace_metrics_read scope; falling back to xAPI-only');
} else if (err.response?.status !== 403) {
console.error(`workspaceMetrics failed for ${workspaceId}:`, err.response?.data || err.message);
}
return { samples: [], avg: null, max: null, available: false };
}
}
async function finalizeMeetingOccupancy(booking, workspaceId, meetingId, endPeopleCount, isNoShow) {
if (isNoShow) {
db.prepare(`
UPDATE bookings
SET RoomPeopleCountMax = 0,
RoomPeopleCountAvg = 0,
RoomPeopleCountSamples = 0,
OccupancyPctMax = 0,
OccupancySource = 'none'
WHERE (MeetingId = ? OR Id = ?) AND WorkspaceId = ?
`).run(meetingId, meetingId, workspaceId);
return;
}
const xapiMax = booking.RoomPeopleCountMax ?? 0;
const xapiAvg = booking.RoomPeopleCountAvg ?? 0;
const xapiSamples = booking.RoomPeopleCountSamples ?? 0;
const endCount = endPeopleCount ?? 0;
recordOccupancySample(meetingId, workspaceId, endCount, 'xapi_end');
const from = booking.ActualStartTime || booking.StartTime;
const to = booking.ActualEndTime || new Date().toISOString();
await delay(WORKSPACE_METRICS_END_DELAY_MS);
let wmData = await getWorkspacePeopleMetrics(workspaceId, from, to);
if (wmData.available && wmData.samples.length === 0) {
await delay(WORKSPACE_METRICS_END_DELAY_MS);
wmData = await getWorkspacePeopleMetrics(workspaceId, from, to);
}
for (const sample of wmData.samples) {
insertOccupancySample.run(meetingId, workspaceId, sample.timestamp, sample.value, 'workspace_metrics');
}
let finalMax = Math.max(xapiMax, endCount);
let finalAvg;
let source;
if (xapiSamples >= 2) {
if (wmData.max !== null) finalMax = Math.max(finalMax, wmData.max);
finalAvg = xapiAvg;
source = wmData.available && wmData.samples.length > 0 ? 'hybrid' : 'xapi';
} else if (wmData.available && wmData.samples.length > 0) {
finalMax = Math.max(finalMax, wmData.max ?? 0);
finalAvg = wmData.avg ?? endCount;
source = 'workspaceMetrics';
} else if (xapiSamples >= 1) {
finalAvg = xapiAvg;
source = 'xapi';
} else {
finalMax = endCount;
finalAvg = endCount;
source = endCount > 0 ? 'xapi' : 'none';
}
const capacity = booking.Capacity || 0;
const occupancyPctMax = capacity > 0 ? Math.round((finalMax / capacity) * 1000) / 10 : null;
db.prepare(`
UPDATE bookings
SET RoomPeopleCountMax = ?,
RoomPeopleCountAvg = ?,
OccupancyPctMax = ?,
OccupancySource = ?
WHERE (MeetingId = ? OR Id = ?) AND WorkspaceId = ?
`).run(
finalMax,
Math.round(finalAvg * 10) / 10,
occupancyPctMax,
source,
meetingId, meetingId, workspaceId
);
console.log(`📊 Occupancy finalized - Max: ${finalMax}, Avg: ${finalAvg}, Pct: ${occupancyPctMax}%, Source: ${source}`);
}
async function resolveWebexMeetingInstanceId(webexMeetingId, startTime, hostEmail) {
if (!webexMeetingId) return null;
const tryIds = [webexMeetingId];
if (/^\d+$/.test(String(webexMeetingId))) {
try {
const token = await getValidAccessToken();
const res = await axios.get('https://webexapis.com/v1/meetings', {
headers: { Authorization: `Bearer ${token}` },
params: { meetingNumber: webexMeetingId, max: 1 }
});
const match = res.data?.items?.[0];
if (match?.id) tryIds.unshift(match.id);
} catch (err) {
// fall through to direct id
}
}
if (startTime && hostEmail) {
try {
const token = await getValidAccessToken();
const startMs = new Date(startTime).getTime();
const from = new Date(startMs - 60 * 60 * 1000).toISOString();
const to = new Date(startMs + 4 * 60 * 60 * 1000).toISOString();
const res = await axios.get('https://webexapis.com/v1/meetings', {
headers: { Authorization: `Bearer ${token}` },
params: { from, to, hostEmail, max: 25 }
});
const match = (res.data?.items || []).find(m =>
m.id === webexMeetingId ||
String(m.meetingNumber) === String(webexMeetingId)
);
if (match?.id && !tryIds.includes(match.id)) tryIds.unshift(match.id);
} catch (err) {
// fall through
}
}
return tryIds[0];
}
async function fetchWebexAttendeeCount(webexMeetingId, startTime = null, hostEmail = null) {
if (!webexMeetingId) return null;
const meetingInstanceId = await resolveWebexMeetingInstanceId(webexMeetingId, startTime, hostEmail);
if (!meetingInstanceId) return null;
try {
const token = await getValidAccessToken();
const uniqueAttendees = new Set();
let url = 'https://webexapis.com/v1/meetingParticipants';
let params = { meetingId: meetingInstanceId, max: 100 };
while (url) {
const res = await axios.get(url, {
headers: { Authorization: `Bearer ${token}` },
params: url === 'https://webexapis.com/v1/meetingParticipants' ? params : undefined
});
for (const participant of res.data?.items || []) {
uniqueAttendees.add(participant.id || participant.email || participant.displayName);
}
const next = res.data?.links?.next || res.data?.next;
if (next) {
url = next;
params = undefined;
} else {
url = null;
}
}
return uniqueAttendees.size;
} catch (err) {
if (err.response?.status === 403 && !meetingParticipantsScopeWarned) {
meetingParticipantsScopeWarned = true;
console.warn('⚠️ meetingParticipants requires meeting:admin_participants_read scope; attendee counts unavailable');
} else if (err.response?.status !== 403 && err.response?.status !== 404) {
console.error(`meetingParticipants failed for ${meetingInstanceId}:`, err.response?.data || err.message);
}
return null;
}
}
function getMeetingWebexMeta(meetingId) {
return db.prepare(`
SELECT MeetingId, StartTime,
MAX(webexMeetingId) as webexMeetingId,
MAX(WebexAttendeeCount) as WebexAttendeeCount,
MAX(OrganizerEmail) as OrganizerEmail,
MAX(ActualEndTime) as ActualEndTime
FROM bookings
WHERE MeetingId = ?
GROUP BY MeetingId, StartTime
ORDER BY StartTime DESC
LIMIT 1
`).get(meetingId);
}
async function storeWebexAttendeeCount(meetingId, startTime, webexMeetingId, hostEmail = null) {
if (!webexMeetingId) return null;
const existing = db.prepare(`
SELECT MAX(WebexAttendeeCount) as count
FROM bookings
WHERE MeetingId = ? AND StartTime = ? AND WebexAttendeeCount IS NOT NULL
`).get(meetingId, startTime);
if (existing?.count != null) return existing.count;
await delay(WORKSPACE_METRICS_END_DELAY_MS);
let count = await fetchWebexAttendeeCount(webexMeetingId, startTime, hostEmail);
if (count === null) {
await delay(WORKSPACE_METRICS_END_DELAY_MS);
count = await fetchWebexAttendeeCount(webexMeetingId, startTime, hostEmail);
}
if (count !== null) {
db.prepare(`
UPDATE bookings
SET WebexAttendeeCount = ?
WHERE MeetingId = ? AND StartTime = ?
`).run(count, meetingId, startTime);
console.log(`👥 WebexAttendeeCount saved: ${count} for meeting ${meetingId}`);
}
return count;
}
async function syncWebexAttendeeCountForMeeting(meetingId, workspaceId = null) {
const meta = workspaceId
? db.prepare(`
SELECT MeetingId, StartTime,
webexMeetingId, WebexAttendeeCount, OrganizerEmail, ActualEndTime
FROM bookings
WHERE (MeetingId = ? OR Id = ?) AND WorkspaceId = ?
`).get(meetingId, meetingId, workspaceId)
: getMeetingWebexMeta(meetingId);
if (!meta?.webexMeetingId || !meta.ActualEndTime || meta.WebexAttendeeCount != null) {
return meta?.WebexAttendeeCount ?? null;
}
return storeWebexAttendeeCount(
meta.MeetingId,
meta.StartTime,
meta.webexMeetingId,
meta.OrganizerEmail
);
}
async function backfillWebexAttendeeCounts(meetings) {
for (const meeting of meetings) {
if (!meeting.webexMeetingId || meeting.WebexAttendeeCount != null) continue;
const count = await storeWebexAttendeeCount(
meeting.MeetingId,
meeting.StartTime,
meeting.webexMeetingId,
meeting.HostEmail
);
if (count !== null) meeting.WebexAttendeeCount = count;
await delay(300);
}
}
async function pollActiveMeetingOccupancy() {
if (occupancyPollInProgress) return;
occupancyPollInProgress = true;
try {
const activeMeetings = db.prepare(`
SELECT MeetingId, WorkspaceId, DeviceId
FROM bookings
WHERE ActualStartTime IS NOT NULL
AND ActualEndTime IS NULL
AND DeviceId IS NOT NULL
AND (Cause IS NULL OR Cause = '')
`).all();
for (const meeting of activeMeetings) {
try {
const xapiData = await getXAPIRoomData(meeting.DeviceId);
recordOccupancySample(meeting.MeetingId, meeting.WorkspaceId, xapiData.peopleCount, 'xapi_poll');
updateRunningOccupancyAggregates(meeting.MeetingId, meeting.WorkspaceId, xapiData.peopleCount);
await delay(500);
} catch (err) {
console.error(`Occupancy poll failed for ${meeting.MeetingId}:`, err.message);
}
}
if (activeMeetings.length > 0) {
console.log(`📡 Occupancy poll: ${activeMeetings.length} active meeting(s)`);
}
} finally {
occupancyPollInProgress = false;
}
}
async function sendNoShowMessage(booking, minutesFreed) {
if (!WEBEX_BOT_TOKEN) return;
const markdown = `**Meeting No Show**
Title: ${booking.Title || 'N/A'}
Organizer: ${booking.OrganizerName} (${booking.OrganizerEmail})
Starting: ${new Date(booking.StartTime).toLocaleString()}
Duration: ${booking.Duration} min
MinutesFreed: ${minutesFreed} min
Recurring: ${Boolean(booking.IsRecurring)}
Workspace: ${booking.DeviceName || 'N/A'}`;
try {
await axios.post('https://webexapis.com/v1/messages', {
toPersonEmail: 'mcqueenj@ae.com',
markdown
}, {
headers: {
Authorization: `Bearer ${WEBEX_BOT_TOKEN}`,
'Content-Type': 'application/json'
}
});
console.log('✅ NoShow notification sent to Webex');
} catch (err) {
console.error('❌ Webex message failed:', err.response?.data?.message || err.message);
}
}
// ====================== AUTH MIDDLEWARE ======================
function authenticateWebhook(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || authHeader !== `${WEBHOOK_AUTH_TOKEN}`) {
console.warn(`❌ Unauthorized webhook attempt from ${req.ip}`);
return res.status(401).send('Unauthorized');
}
next();
}
// ====================== ROUTES ======================
app.get('/health', (req, res) => res.status(200).send('OK'));
// Protected Webhook Endpoint - Keep all bookings for analytics
app.post('/bookings', authenticateWebhook, async (req, res) => {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] 📥 Received webhook - Type: ${req.body.type}, Events: ${req.body.events?.length || 0}`);
try {
const payload = req.body;
if (payload.type === 'healthCheck' || !payload.events) {
console.log(`[${timestamp}] ✅ Health check received`);
return res.status(200).send('OK');
}
const incomingEvents = payload.events || [];
let processed = 0;
for (const event of incomingEvents) {
if (!event.key || !event.value) continue;
const eventKey = event.key;
const value = event.value;
const workspaceId = payload.workspaceId;
const deviceId = payload.deviceId;
const meetingId = value.MeetingId || value.Id || 'unknown';
const cause = (value.Cause || value.cause || '').toLowerCase().trim();
console.log(`[${timestamp}] → ${eventKey} | MeetingId: ${meetingId} | Cause: "${value.Cause || value.cause || 'none'}"`);
if (eventKey === 'Bookings.BookingCreated') {
try {
const workspace = await getWorkspaceInfo(workspaceId);
const calendarEmail = workspace.calendarEmail || null;
const exists = db.prepare(`SELECT 1 FROM bookings WHERE MeetingId = ? AND WorkspaceId = ?`)
.get(meetingId, workspaceId);
if (!exists) {
db.prepare(`
INSERT INTO bookings (
Title, MeetingId, OrganizerName, OrganizerEmail, OrganizerId,
StartTime, Duration, WorkspaceId, DeviceId, DeviceName,
CalendarEmail, IsRecurring, Guests, enrichmentStatus,
LocationName, Floor, RoomType, Capacity
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
value.Title,
meetingId,
value.OrganizerName,
value.OrganizerEmail,
value.OrganizerId,
value.StartTime,
value.Duration?.toString(),
workspaceId,
deviceId,
workspace.displayName || 'Unknown Room',
calendarEmail,
value.IsRecurring ? 1 : 0,
0,
null,
workspace.displayName,
workspace.floorId,
workspace.roomType,
workspace.capacity
);
console.log(`[${timestamp}] ✅ Added booking: ${meetingId}`);
processed++;
}
} catch (err) {
console.error(`[${timestamp}] ❌ BookingCreated error:`, err.message);
}
}
else if (eventKey === 'Bookings.Start') {
db.prepare(`
UPDATE bookings SET ActualStartTime = ?,
RoomPeopleCountMax = 0,
RoomPeopleCountAvg = 0,
RoomPeopleCountSamples = 0
WHERE (MeetingId = ? OR Id = ?) AND WorkspaceId = ?
`).run(value.timestamp || timestamp, meetingId, meetingId, workspaceId);
console.log(`[${timestamp}] 🕒 Recorded ActualStartTime for ${meetingId}`);
}
else if (eventKey === 'Bookings.End' || eventKey === 'Bookings.Deleted') {
const isNoShow = cause === 'noshow';
try {
const existing = db.prepare(`
SELECT Cause FROM bookings
WHERE (MeetingId = ? OR Id = ?) AND WorkspaceId = ?
`).get(meetingId, meetingId, workspaceId);
if (existing && existing.Cause === 'NoShow') {
console.log(`[${timestamp}] ⏭️ Skipping - already marked as NoShow for ${meetingId}`);
}
else if (isNoShow) {
// Mark as NoShow and capture xAPI with delay
db.prepare(`
UPDATE bookings
SET ActualEndTime = ?, MinutesFreed = ?,
Cause = 'NoShow', Guests = 0, enrichmentStatus = 'success'
WHERE (MeetingId = ? OR Id = ?) AND WorkspaceId = ?
`).run(
value.timestamp || timestamp,
value.MinutesFreed || 0,
meetingId, meetingId, workspaceId
);
const booking = db.prepare(`SELECT * FROM bookings WHERE (MeetingId = ? OR Id = ?) AND WorkspaceId = ?`)
.get(meetingId, meetingId, workspaceId);
if (booking) await sendNoShowMessage(booking, value.MinutesFreed || 0);
console.log(`[${timestamp}] ✅ NoShow recorded for ${meetingId}`);
}
else {
// NORMAL END / DELETED → Just mark as ended, DO NOT DELETE
db.prepare(`
UPDATE bookings
SET ActualEndTime = ?, Cause = 'Ended'
WHERE (MeetingId = ? OR Id = ?) AND WorkspaceId = ?
`).run(
value.timestamp || timestamp,
meetingId, meetingId, workspaceId
);
console.log(`[${timestamp}] ✅ Normal meeting ended (kept for analytics): ${meetingId}`);
}
// Always capture xAPI data at end of meeting
if (deviceId) {
console.log(`[${timestamp}] 📡 Fetching xAPI data for device ${deviceId}`);
if (isNoShow) await new Promise(r => setTimeout(r, 3000)); // 3s delay for NoShow
const xapiData = await getXAPIRoomData(deviceId);
db.prepare(`
UPDATE bookings
SET RoomPeopleCount = ?, MicActivity = ?, CallActive = ?, xAPI_LastChecked = ?
WHERE (MeetingId = ? OR Id = ?) AND WorkspaceId = ?
`).run(
xapiData.peopleCount,
xapiData.micActivity,
xapiData.callActive ? 1 : 0,
xapiData.xAPI_LastChecked,
meetingId, meetingId, workspaceId
);
const booking = db.prepare(`SELECT * FROM bookings WHERE (MeetingId = ? OR Id = ?) AND WorkspaceId = ?`)
.get(meetingId, meetingId, workspaceId);
if (booking) {
await finalizeMeetingOccupancy(booking, workspaceId, meetingId, xapiData.peopleCount, isNoShow);
}
console.log(`[${timestamp}] 📊 xAPI saved - People: ${xapiData.peopleCount}, Mic: ${xapiData.micActivity}, Call: ${xapiData.callActive}`);
}
if (!isNoShow) {
await syncWebexAttendeeCountForMeeting(meetingId, workspaceId);
}
} catch (err) {
console.error(`[${timestamp}] ❌ Error processing ${eventKey}:`, err.message);
}
}
}
console.log(`[${timestamp}] ✅ Finished processing webhook`);
res.status(200).send('OK');
} catch (err) {
console.error(`[${new Date().toISOString()}] ❌ Webhook error:`, err.message);
res.status(500).send('Internal error');
}
});
// ====================== REPORT HELPERS ======================
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function formatMinutes(mins) {
const n = Number(mins) || 0;
if (n < 60) return `${n} min`;
const h = Math.floor(n / 60);
const m = n % 60;
return m ? `${h}h ${m}m` : `${h}h`;
}
function formatMeetingDateTime(iso) {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return String(iso);
return d.toLocaleString();
}
function csvEscape(val) {
if (val == null) return '';
const s = String(val);
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
}
function rowsToCsv(headers, rows) {
let csv = headers.join(',') + '\n';
for (const row of rows) {
csv += headers.map(h => csvEscape(row[h])).join(',') + '\n';
}
return csv;
}
const REPORT_CSS = `
body { font-family: Arial, sans-serif; margin: 20px; background: #f4f6f9; color: #1f2937; }
h1 { color: #1e3a8a; margin-bottom: 4px; }
h2 { color: #1e3a8a; margin-top: 32px; }
.muted { color: #555; }
.nav { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0 8px; }
.nav a, .btn {
display: inline-block; background: #1e40af; color: white; padding: 10px 16px;
border-radius: 6px; text-decoration: none; font-size: 14px; border: none; cursor: pointer;
}
.nav a.secondary, .btn.secondary { background: #64748b; }
.nav a:hover, .btn:hover { background: #1e3a8a; }
.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin: 20px 0; }
.card { background: white; padding: 18px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.08); text-align: center; }
.card h2 { margin: 0; font-size: 2.1em; color: #1e40af; }
.card p { margin: 8px 0 0; color: #555; font-size: 0.92em; }
.card.accent h2 { color: #b45309; }
table { width: 100%; border-collapse: collapse; background: white; box-shadow: 0 2px 10px rgba(0,0,0,0.08); margin-top: 12px; font-size: 14px; }
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #e5e7eb; vertical-align: top; }
th { background: #1e40af; color: white; position: sticky; top: 0; }
tr:hover { background: #f1f5f9; }
.dates { max-width: 420px; white-space: normal; word-break: break-word; color: #374151; font-size: 12px; }
.rate-high { color: #b91c1c; font-weight: 600; }
.rate-mid { color: #b45309; font-weight: 600; }
.footer-links { margin-top: 28px; color: #555; }
.footer-links a { color: #1e40af; text-decoration: none; margin-right: 16px; }
`;
// Recurring series: flagged recurring OR Google recurring event id (_R...)
const RECURRING_WHERE = `(IsRecurring = 1 OR (googleEventId IS NOT NULL AND googleEventId LIKE '%_R%'))`;
// Per-room filter: only normally-ended bookings (excludes NoShow rows)
const UNDER_UTILIZED_ROOM_WHERE = `(Cause = 'Ended' OR (Cause IS NULL AND ActualEndTime IS NOT NULL))
AND Cause != 'NoShow'
AND Capacity > 0
AND RoomPeopleCountMax IS NOT NULL`;
// Meetings eligible for occupancy reports: no room in the meeting was a NoShow
const NON_NOSHOW_MEETING_SUBQUERY = `
SELECT MeetingId, StartTime
FROM bookings
WHERE datetime(StartTime) > datetime('now', '-30 days')
GROUP BY MeetingId, StartTime
HAVING SUM(CASE WHEN Cause = 'NoShow' THEN 1 ELSE 0 END) = 0
`;
function formatRoomLabel(booking) {
const name = booking.calendarRoomName || booking.DeviceName || booking.LocationName || 'Unknown';
const type = booking.RoomType ? ` [${booking.RoomType}]` : '';
return `${name} (${booking.Capacity || '?'})${type}`;
}
function getUnderUtilizedMeetings(threshold, limit = 200) {
const meetings = db.prepare(`
SELECT
b.MeetingId,
b.StartTime,
b.Title,
COALESCE(NULLIF(b.OrganizerName, ''), b.OrganizerEmail, 'Unknown') as Host,
COALESCE(b.OrganizerEmail, '') as HostEmail,
COUNT(*) as roomCount,
SUM(b.Capacity) as totalCapacity,
MAX(b.RoomPeopleCountMax) as maxPeople,
MIN(b.OccupancyPctMax) as worstOccupancyPct,
MAX(b.Guests) as Guests,
MAX(b.webexMeetingId) as webexMeetingId,
MAX(b.WebexAttendeeCount) as WebexAttendeeCount,
GROUP_CONCAT(DISTINCT b.OccupancySource) as OccupancySource,
MAX(b.Duration) as Duration
FROM bookings b
INNER JOIN (${NON_NOSHOW_MEETING_SUBQUERY}) eligible
ON b.MeetingId = eligible.MeetingId AND b.StartTime = eligible.StartTime
WHERE (b.Cause = 'Ended' OR (b.Cause IS NULL AND b.ActualEndTime IS NOT NULL))
AND b.Cause != 'NoShow'
AND b.Capacity > 0
AND datetime(b.StartTime) > datetime('now', '-30 days')
GROUP BY b.MeetingId, b.StartTime
HAVING SUM(CASE WHEN b.RoomPeopleCountMax IS NOT NULL AND b.OccupancyPctMax < ? THEN 1 ELSE 0 END) > 0
ORDER BY worstOccupancyPct ASC, b.StartTime DESC
LIMIT ?
`).all(threshold, limit);
const roomStmt = db.prepare(`
SELECT
calendarRoomName, DeviceName, LocationName, RoomType, Capacity,
RoomPeopleCountMax, RoomPeopleCountAvg, RoomPeopleCount, OccupancyPctMax, OccupancySource
FROM bookings
WHERE MeetingId = ? AND StartTime = ?
AND (Cause = 'Ended' OR (Cause IS NULL AND ActualEndTime IS NOT NULL))
AND Cause != 'NoShow'
AND Capacity > 0
ORDER BY OccupancyPctMax ASC, COALESCE(calendarRoomName, DeviceName, LocationName)
`);
return meetings.map(meeting => {
const rooms = roomStmt.all(meeting.MeetingId, meeting.StartTime);
return {
...meeting,
rooms: rooms.map(room => ({
label: formatRoomLabel(room),
capacity: room.Capacity,
maxPeople: room.RoomPeopleCountMax,
occupancyPct: room.OccupancyPctMax,
source: room.OccupancySource
}))
};
});
}
function getUnderUtilizedMeetingCount(threshold) {
return db.prepare(`
SELECT COUNT(*) as n FROM (
SELECT b.MeetingId, b.StartTime
FROM bookings b
INNER JOIN (${NON_NOSHOW_MEETING_SUBQUERY}) eligible
ON b.MeetingId = eligible.MeetingId AND b.StartTime = eligible.StartTime
WHERE (b.Cause = 'Ended' OR (b.Cause IS NULL AND b.ActualEndTime IS NOT NULL))
AND b.Cause != 'NoShow'
AND b.Capacity > 0
AND datetime(b.StartTime) > datetime('now', '-30 days')
GROUP BY b.MeetingId, b.StartTime
HAVING SUM(CASE WHEN b.RoomPeopleCountMax IS NOT NULL AND b.OccupancyPctMax < ? THEN 1 ELSE 0 END) > 0
)
`).get(threshold);
}
function getUnderUtilizedSummary(threshold) {
const meetings = getUnderUtilizedMeetings(threshold, 10000);
if (meetings.length === 0) {
return { totalUnderUtilized: 0, avgOccupancyPct: 0, avgCapacity: 0, avgPeakPeople: 0 };
}
const totalUnderUtilized = meetings.length;
const avgOccupancyPct = Math.round(
meetings.reduce((sum, m) => sum + (m.worstOccupancyPct || 0), 0) / meetings.length * 10
) / 10;
const avgCapacity = Math.round(
meetings.reduce((sum, m) => sum + (m.totalCapacity || 0), 0) / meetings.length * 10
) / 10;
const avgPeakPeople = Math.round(
meetings.reduce((sum, m) => sum + (m.maxPeople || 0), 0) / meetings.length * 10
) / 10;
return { totalUnderUtilized, avgOccupancyPct, avgCapacity, avgPeakPeople };
}
function getRecurringMultiNoShows() {
return db.prepare(`
SELECT
Title,
COALESCE(NULLIF(OrganizerName, ''), OrganizerEmail, 'Unknown') as Host,
COALESCE(OrganizerEmail, '') as HostEmail,
GROUP_CONCAT(DISTINCT COALESCE(NULLIF(calendarRoomName, ''), NULLIF(DeviceName, ''), NULLIF(LocationName, ''), 'Unknown')) as RoomNames,
SUM(CASE WHEN Cause = 'NoShow' THEN 1 ELSE 0 END) as noShows,
COUNT(*) as totalMeetings,
ROUND(100.0 * SUM(CASE WHEN Cause = 'NoShow' THEN 1 ELSE 0 END) / COUNT(*), 1) as noShowRate,
COALESCE(SUM(CASE WHEN Cause = 'NoShow' THEN MinutesFreed ELSE 0 END), 0) as minutesSaved,
GROUP_CONCAT(CASE WHEN Cause = 'NoShow' THEN date(StartTime) END) as noShowDates,
MIN(CASE WHEN Cause = 'NoShow' THEN date(StartTime) END) as firstNoShow,
MAX(CASE WHEN Cause = 'NoShow' THEN date(StartTime) END) as lastNoShow
FROM bookings
WHERE ${RECURRING_WHERE}
GROUP BY COALESCE(Title, ''), COALESCE(OrganizerEmail, ''), COALESCE(OrganizerName, '')
HAVING SUM(CASE WHEN Cause = 'NoShow' THEN 1 ELSE 0 END) > 1
ORDER BY noShows DESC, totalMeetings DESC
`).all();
}
// ====================== MAIN REPORTS DASHBOARD ======================
app.get('/reports', (req, res) => {
try {
const stats = db.prepare(`
SELECT
COUNT(*) as totalBookings,
SUM(CASE WHEN Cause = 'NoShow' THEN 1 ELSE 0 END) as totalNoShows,
SUM(CASE WHEN Cause = 'Ended' OR (Cause IS NULL AND ActualEndTime IS NOT NULL) THEN 1 ELSE 0 END) as normalEnded,
SUM(CASE WHEN RoomPeopleCount = 0 AND Cause IS NULL THEN 1 ELSE 0 END) as ghostedMeetings,
ROUND(AVG(CASE WHEN RoomPeopleCount > 0 THEN RoomPeopleCount ELSE NULL END), 1) as avgPeopleCount,
ROUND(100.0 * SUM(CASE WHEN Cause = 'NoShow' THEN 1 ELSE 0 END) / COUNT(*), 1) as noShowRate,
ROUND(100.0 * SUM(CASE WHEN RoomPeopleCount > 0 THEN 1 ELSE 0 END) / COUNT(*), 1) as utilizationRate,
COALESCE(SUM(CASE WHEN Cause = 'NoShow' THEN MinutesFreed ELSE 0 END), 0) as minutesSaved
FROM bookings
WHERE datetime(StartTime) > datetime('now', '-30 days')
`).get();
const roomStats = db.prepare(`
SELECT
COALESCE(w.displayName, b.LocationName, 'Unknown') as Room,
COALESCE(b.RoomType, '—') as RoomType,
COUNT(*) as totalBookings,
SUM(CASE WHEN b.Cause = 'NoShow' THEN 1 ELSE 0 END) as noShows,
SUM(CASE WHEN b.Cause = 'Ended' OR (b.Cause IS NULL AND b.ActualEndTime IS NOT NULL) THEN 1 ELSE 0 END) as normalEnded,
ROUND(100.0 * SUM(CASE WHEN b.Cause = 'NoShow' THEN 1 ELSE 0 END) / COUNT(*), 1) as noShowRate,
ROUND(AVG(b.RoomPeopleCountAvg), 1) as avgPeople,
ROUND(AVG(b.RoomPeopleCountMax), 1) as avgPeakPeople,
ROUND(AVG(COALESCE(b.Capacity, 0)), 1) as avgCapacity
FROM bookings b
LEFT JOIN workspace_lookup w ON b.WorkspaceId = w.workspaceId
WHERE datetime(b.StartTime) > datetime('now', '-30 days')
GROUP BY COALESCE(w.displayName, b.LocationName), b.RoomType
ORDER BY noShowRate DESC
LIMIT 15
`).all();
const underUtilizedCount = getUnderUtilizedMeetingCount(UNDER_UTILIZED_THRESHOLD_PCT);
const topHosts = db.prepare(`
SELECT
COALESCE(NULLIF(OrganizerName, ''), OrganizerEmail, 'Unknown') as Host,
COALESCE(OrganizerEmail, '') as HostEmail,
SUM(CASE WHEN Cause = 'NoShow' THEN 1 ELSE 0 END) as noShows,
SUM(CASE WHEN Cause = 'Ended' OR (Cause IS NULL AND ActualEndTime IS NOT NULL) THEN 1 ELSE 0 END) as normalEnded,
COALESCE(SUM(CASE WHEN Cause = 'NoShow' THEN MinutesFreed ELSE 0 END), 0) as minutesSaved
FROM bookings
WHERE datetime(StartTime) > datetime('now', '-30 days')
GROUP BY COALESCE(OrganizerEmail, OrganizerName)
HAVING SUM(CASE WHEN Cause = 'NoShow' THEN 1 ELSE 0 END) > 0
ORDER BY noShows DESC, minutesSaved DESC
LIMIT 10
`).all();
const recurringCount = db.prepare(`
SELECT COUNT(*) as n FROM (
SELECT 1 FROM bookings
WHERE ${RECURRING_WHERE}
GROUP BY COALESCE(Title, ''), COALESCE(OrganizerEmail, ''), COALESCE(OrganizerName, '')
HAVING SUM(CASE WHEN Cause = 'NoShow' THEN 1 ELSE 0 END) > 1
)
`).get();
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Room Utilization Dashboard</title>
<style>${REPORT_CSS}</style>
</head>
<body>
<h1>Room Utilization & Meeting Analytics Dashboard</h1>
<p class="muted">Last 30 days • Updated: ${escapeHtml(new Date().toLocaleString())}</p>
<div class="nav">
<a href="/reports/meeting-summary">30-Day Meeting Report</a>
<a href="/reports/under-utilized">Under-Utilized Meetings</a>
<a href="/reports/recurring-noshows">Recurring Multi-NoShow Report</a>
<a class="secondary" href="/reports/noshows">Download All NoShow CSV</a>
<a class="secondary" href="/reports/meeting-summary.csv">Download 30-Day CSV</a>
<a class="secondary" href="/reports/under-utilized.csv">Download Under-Utilized CSV</a>
<a class="secondary" href="/reports/recurring-noshows.csv">Download Recurring CSV</a>
</div>
<div class="summary-grid">
<div class="card">
<h2>${stats.totalBookings || 0}</h2>
<p>Total Bookings</p>
</div>
<div class="card">
<h2>${stats.totalNoShows || 0}</h2>
<p>NoShow Events</p>
</div>
<div class="card accent">
<h2>${formatMinutes(stats.minutesSaved)}</h2>
<p>Time Saved (NoShows)</p>
</div>
<div class="card">
<h2>${stats.noShowRate || 0}%</h2>
<p>NoShow Rate</p>
</div>
<div class="card">
<h2>${recurringCount?.n || 0}</h2>
<p>Recurring Series with 2+ NoShows</p>
</div>
<div class="card">
<h2>${stats.utilizationRate || 0}%</h2>
<p>Utilization Rate (≥1 person)</p>
</div>
<div class="card accent">
<h2>${underUtilizedCount?.n || 0}</h2>
<p>Under-Utilized Meetings (&lt;${UNDER_UTILIZED_THRESHOLD_PCT}% capacity)</p>
</div>
</div>
<h2>Top Hosts by NoShow (Last 30 Days)</h2>
<table>
<thead>
<tr>
<th>#</th>
<th>Host</th>
<th>Email</th>
<th>NoShows</th>
<th>Normal Ended</th>
<th>Time Saved</th>
</tr>
</thead>
<tbody>
${topHosts.map((h, i) => `
<tr>
<td>${i + 1}</td>
<td><strong>${escapeHtml(h.Host)}</strong></td>
<td>${escapeHtml(h.HostEmail)}</td>
<td>${h.noShows}</td>
<td>${h.normalEnded || 0}</td>
<td>${formatMinutes(h.minutesSaved)} <span class="muted">(${h.minutesSaved} min)</span></td>
</tr>
`).join('') || '<tr><td colspan="6">No NoShow data in the last 30 days.</td></tr>'}
</tbody>
</table>
<h2>All Rooms - Ranked by NoShow Rate</h2>
<table>
<thead>
<tr>
<th>Room</th>
<th>Room Type</th>
<th>Total Bookings</th>
<th>NoShows</th>
<th>Normal Ended</th>
<th>NoShow Rate</th>
<th>Avg People</th>
<th>Avg Peak People</th>
<th>Avg Capacity</th>
</tr>
</thead>
<tbody>
${roomStats.map(room => `
<tr>
<td><strong>${escapeHtml(room.Room)}</strong></td>
<td>${escapeHtml(room.RoomType)}</td>
<td>${room.totalBookings}</td>
<td>${room.noShows}</td>
<td>${room.normalEnded}</td>
<td>${room.noShowRate}%</td>
<td>${room.avgPeople || '—'}</td>
<td>${room.avgPeakPeople || '—'}</td>
<td>${room.avgCapacity || '—'}</td>
</tr>
`).join('')}
</tbody>
</table>
<div class="footer-links">
<a href="/reports/meeting-summary">→ Full 30-Day Meeting Report</a>
<a href="/reports/under-utilized">→ Under-Utilized Meetings Report</a>
<a href="/reports/recurring-noshows">→ Recurring Multi-NoShow Report</a>
</div>
</body>
</html>`;
res.send(html);
} catch (err) {
console.error('❌ Dashboard error:', err.message);
res.status(500).send('Error generating dashboard');
}
});
// ====================== UNDER-UTILIZED MEETINGS REPORT ======================
app.get('/reports/under-utilized', async (req, res) => {
try {
let meetings = getUnderUtilizedMeetings(UNDER_UTILIZED_THRESHOLD_PCT);
await backfillWebexAttendeeCounts(meetings);
const summary = getUnderUtilizedSummary(UNDER_UTILIZED_THRESHOLD_PCT);
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Under-Utilized Meetings Report</title>
<style>${REPORT_CSS}</style>
</head>
<body>
<h1>Under-Utilized Meetings</h1>
<p class="muted">Non-NoShow meetings where at least one room peaked below ${UNDER_UTILIZED_THRESHOLD_PCT}% of capacity • All rooms validated per meeting • Last 30 days • Updated: ${escapeHtml(new Date().toLocaleString())}</p>
<div class="nav">
<a class="secondary" href="/reports">← Dashboard</a>
<a class="secondary" href="/reports/under-utilized.csv">Download CSV</a>
</div>
<div class="summary-grid">
<div class="card accent">
<h2>${summary.totalUnderUtilized || 0}</h2>
<p>Under-Utilized Meetings</p>
</div>
<div class="card">
<h2>${summary.avgOccupancyPct || 0}%</h2>
<p>Avg Worst-Room Occupancy %</p>
</div>
<div class="card">
<h2>${summary.avgPeakPeople || 0}</h2>
<p>Avg Peak People (per meeting)</p>
</div>
<div class="card">
<h2>${summary.avgCapacity || 0}</h2>
<p>Avg Total Capacity</p>
</div>
</div>
<h2>Meetings Below ${UNDER_UTILIZED_THRESHOLD_PCT}% Capacity</h2>
<table>
<thead>
<tr>
<th>Date &amp; Time</th>
<th>Title</th>
<th>Host</th>
<th>Rooms</th>
<th>Room Count</th>
<th>Total Capacity</th>
<th>Max People</th>
<th>Worst Occupancy %</th>
<th>Invited Guests</th>
<th>Webex Attendees</th>
<th>Source</th>
</tr>
</thead>
<tbody>
${meetings.map(m => `
<tr>
<td>${escapeHtml(formatMeetingDateTime(m.StartTime))}</td>
<td>${escapeHtml(m.Title)}</td>
<td>${escapeHtml(m.Host)}<br><span class="muted">${escapeHtml(m.HostEmail)}</span></td>
<td class="dates">${m.rooms.map(r => `
<div>${escapeHtml(r.label)} — peak: ${r.maxPeople ?? '—'}, <span class="rate-high">${r.occupancyPct ?? '—'}%</span></div>
`).join('')}</td>
<td>${m.roomCount}</td>
<td>${m.totalCapacity ?? '—'}</td>
<td>${m.maxPeople ?? '—'}</td>
<td class="rate-high">${m.worstOccupancyPct ?? '—'}%</td>
<td>${m.Guests ?? 0}</td>
<td>${m.webexMeetingId ? (m.WebexAttendeeCount ?? '—') : '—'}</td>
<td>${escapeHtml(m.OccupancySource || '—')}</td>
</tr>
`).join('') || `<tr><td colspan="11">No under-utilized meetings found (threshold: &lt;${UNDER_UTILIZED_THRESHOLD_PCT}% of capacity).</td></tr>`}
</tbody>
</table>
</body>
</html>`;
res.send(html);
} catch (err) {
console.error('❌ Under-utilized report error:', err.message);
res.status(500).send('Error generating under-utilized report');
}
});
app.get('/reports/under-utilized.csv', async (req, res) => {
try {
let meetings = getUnderUtilizedMeetings(UNDER_UTILIZED_THRESHOLD_PCT, 10000);
await backfillWebexAttendeeCounts(meetings);
const rows = meetings.flatMap(m => m.rooms.map(room => ({
DateTime: formatMeetingDateTime(m.StartTime),
Title: m.Title,
Host: m.Host,
HostEmail: m.HostEmail,
Room: room.label,
RoomCount: m.roomCount,
RoomCapacity: room.capacity,
TotalCapacity: m.totalCapacity,
MaxPeople: room.maxPeople,
OccupancyPct: room.occupancyPct,
MeetingWorstOccupancyPct: m.worstOccupancyPct,
InvitedGuests: m.Guests,
WebexAttendeeCount: m.webexMeetingId ? (m.WebexAttendeeCount ?? '') : '',
Source: room.source || m.OccupancySource,
Duration: m.Duration
})));
const headers = ['DateTime', 'Title', 'Host', 'HostEmail', 'Room', 'RoomCount', 'RoomCapacity', 'TotalCapacity', 'MaxPeople', 'OccupancyPct', 'MeetingWorstOccupancyPct', 'InvitedGuests', 'WebexAttendeeCount', 'Source', 'Duration'];
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename="under_utilized_meetings_30d.csv"');
res.send(rowsToCsv(headers, rows));
console.log(`📊 Under-utilized CSV downloaded (${rows.length} room rows, ${meetings.length} meetings)`);
} catch (err) {
console.error('❌ Under-utilized CSV error:', err.message);
res.status(500).send('Error generating CSV');
}
});
// ====================== 30-DAY MEETING SUMMARY REPORT ======================
app.get('/reports/meeting-summary', (req, res) => {
try {
const summary = db.prepare(`
SELECT
COUNT(*) as totalBookings,
SUM(CASE WHEN Cause = 'NoShow' THEN 1 ELSE 0 END) as totalNoShows,
COALESCE(SUM(CASE WHEN Cause = 'NoShow' THEN MinutesFreed ELSE 0 END), 0) as minutesSaved,
ROUND(100.0 * SUM(CASE WHEN Cause = 'NoShow' THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0), 1) as noShowRate,
COUNT(DISTINCT CASE WHEN Cause = 'NoShow' THEN OrganizerEmail END) as hostsWithNoShows
FROM bookings
WHERE datetime(StartTime) > datetime('now', '-30 days')
`).get();
const hostsByNoShows = db.prepare(`
SELECT
COALESCE(NULLIF(OrganizerName, ''), OrganizerEmail, 'Unknown') as Host,
COALESCE(OrganizerEmail, '') as HostEmail,
COUNT(*) as noShows,
COALESCE(SUM(MinutesFreed), 0) as minutesSaved,
ROUND(AVG(CAST(MinutesFreed AS REAL)), 1) as avgMinutesFreed,
GROUP_CONCAT(DISTINCT COALESCE(NULLIF(calendarRoomName, ''), NULLIF(DeviceName, ''), 'Unknown')) as rooms
FROM bookings
WHERE Cause = 'NoShow'
AND datetime(StartTime) > datetime('now', '-30 days')
GROUP BY COALESCE(OrganizerEmail, OrganizerName)
ORDER BY noShows DESC, minutesSaved DESC
LIMIT 50
`).all();
const hostsByTimeSaved = db.prepare(`
SELECT
COALESCE(NULLIF(OrganizerName, ''), OrganizerEmail, 'Unknown') as Host,
COALESCE(OrganizerEmail, '') as HostEmail,
COUNT(*) as noShows,
COALESCE(SUM(MinutesFreed), 0) as minutesSaved
FROM bookings
WHERE Cause = 'NoShow'
AND datetime(StartTime) > datetime('now', '-30 days')
GROUP BY COALESCE(OrganizerEmail, OrganizerName)
ORDER BY minutesSaved DESC, noShows DESC
LIMIT 50
`).all();
const recentNoShows = db.prepare(`
SELECT
date(StartTime) as meetingDate,
Title,
COALESCE(NULLIF(OrganizerName, ''), OrganizerEmail, 'Unknown') as Host,
COALESCE(OrganizerEmail, '') as HostEmail,
COALESCE(NULLIF(calendarRoomName, ''), NULLIF(DeviceName, ''), NULLIF(LocationName, ''), 'Unknown') as Room,
COALESCE(MinutesFreed, 0) as MinutesFreed,
Duration
FROM bookings
WHERE Cause = 'NoShow'
AND datetime(StartTime) > datetime('now', '-30 days')
ORDER BY StartTime DESC
LIMIT 100
`).all();
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>30-Day Meeting Report — NoShows</title>
<style>${REPORT_CSS}</style>
</head>
<body>
<h1>30-Day Meeting Report</h1>
<p class="muted">NoShow totals, time saved, and host rankings • Updated: ${escapeHtml(new Date().toLocaleString())}</p>
<div class="nav">
<a class="secondary" href="/reports">← Dashboard</a>
<a href="/reports/recurring-noshows">Recurring Multi-NoShow</a>
<a class="secondary" href="/reports/meeting-summary.csv">Download CSV</a>
</div>
<div class="summary-grid">
<div class="card">
<h2>${summary.totalNoShows || 0}</h2>
<p>Total NoShow Meetings</p>
</div>
<div class="card accent">
<h2>${formatMinutes(summary.minutesSaved)}</h2>
<p>Total Time Saved<br><span class="muted">${summary.minutesSaved || 0} minutes</span></p>
</div>
<div class="card">
<h2>${summary.noShowRate || 0}%</h2>
<p>NoShow Rate of All Bookings</p>
</div>
<div class="card">
<h2>${summary.hostsWithNoShows || 0}</h2>
<p>Hosts with NoShows</p>
</div>
<div class="card">
<h2>${summary.totalBookings || 0}</h2>
<p>Total Bookings (30 days)</p>
</div>
</div>
<h2>Hosts with the Most NoShow Meetings</h2>
<table>
<thead>
<tr>
<th>#</th>
<th>Host</th>
<th>Email</th>
<th>NoShows</th>
<th>Time Saved</th>
<th>Avg Freed / NoShow</th>
<th>Rooms</th>
</tr>
</thead>
<tbody>
${hostsByNoShows.map((h, i) => `
<tr>
<td>${i + 1}</td>
<td><strong>${escapeHtml(h.Host)}</strong></td>
<td>${escapeHtml(h.HostEmail)}</td>
<td>${h.noShows}</td>
<td>${formatMinutes(h.minutesSaved)} <span class="muted">(${h.minutesSaved} min)</span></td>
<td>${h.avgMinutesFreed ?? '—'} min</td>
<td class="dates">${escapeHtml(h.rooms)}</td>
</tr>
`).join('') || '<tr><td colspan="7">No data</td></tr>'}
</tbody>
</table>
<h2>Hosts with the Most Time Saved</h2>
<table>
<thead>
<tr>
<th>#</th>
<th>Host</th>
<th>Email</th>
<th>Time Saved</th>
<th>NoShows</th>
</tr>
</thead>
<tbody>
${hostsByTimeSaved.map((h, i) => `
<tr>
<td>${i + 1}</td>
<td><strong>${escapeHtml(h.Host)}</strong></td>
<td>${escapeHtml(h.HostEmail)}</td>
<td>${formatMinutes(h.minutesSaved)} <span class="muted">(${h.minutesSaved} min)</span></td>
<td>${h.noShows}</td>
</tr>
`).join('') || '<tr><td colspan="5">No data</td></tr>'}
</tbody>
</table>
<h2>Recent NoShows (Last 30 Days)</h2>
<table>
<thead>
<tr>
<th>Date</th>
<th>Title</th>
<th>Host</th>
<th>Room</th>
<th>Duration</th>
<th>Minutes Freed</th>
</tr>
</thead>
<tbody>
${recentNoShows.map(r => `
<tr>
<td>${escapeHtml(r.meetingDate)}</td>
<td>${escapeHtml(r.Title)}</td>
<td>${escapeHtml(r.Host)}<br><span class="muted">${escapeHtml(r.HostEmail)}</span></td>
<td>${escapeHtml(r.Room)}</td>
<td>${escapeHtml(r.Duration)} min</td>
<td>${r.MinutesFreed}</td>
</tr>
`).join('') || '<tr><td colspan="6">No data</td></tr>'}
</tbody>
</table>
</body>
</html>`;
res.send(html);
} catch (err) {
console.error('❌ Meeting summary report error:', err.message);
res.status(500).send('Error generating meeting summary report');
}
});
// CSV for 30-day meeting summary (hosts + time saved)
app.get('/reports/meeting-summary.csv', (req, res) => {
try {
const rows = db.prepare(`
SELECT
COALESCE(NULLIF(OrganizerName, ''), OrganizerEmail, 'Unknown') as Host,
COALESCE(OrganizerEmail, '') as HostEmail,
COUNT(*) as NoShows,
COALESCE(SUM(MinutesFreed), 0) as MinutesSaved,
ROUND(AVG(CAST(MinutesFreed AS REAL)), 1) as AvgMinutesFreed,
GROUP_CONCAT(DISTINCT COALESCE(NULLIF(calendarRoomName, ''), NULLIF(DeviceName, ''), 'Unknown')) as Rooms
FROM bookings
WHERE Cause = 'NoShow'
AND datetime(StartTime) > datetime('now', '-30 days')
GROUP BY COALESCE(OrganizerEmail, OrganizerName)
ORDER BY NoShows DESC, MinutesSaved DESC
`).all();
const headers = ['Host', 'HostEmail', 'NoShows', 'MinutesSaved', 'AvgMinutesFreed', 'Rooms'];
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename="meeting_summary_30d.csv"');
res.send(rowsToCsv(headers, rows));
console.log(`📊 30-day meeting summary CSV downloaded (${rows.length} hosts)`);
} catch (err) {
console.error('❌ Meeting summary CSV error:', err.message);
res.status(500).send('Error generating CSV');
}
});
// ====================== RECURRING MULTI-NOSHOW REPORT ======================
app.get('/reports/recurring-noshows', (req, res) => {
try {
const series = getRecurringMultiNoShows();
const totals = series.reduce((acc, s) => {
acc.series += 1;
acc.noShows += s.noShows;
acc.meetings += s.totalMeetings;
acc.minutes += s.minutesSaved;
return acc;
}, { series: 0, noShows: 0, meetings: 0, minutes: 0 });
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Recurring Multi-NoShow Report</title>
<style>${REPORT_CSS}</style>
</head>
<body>
<h1>Recurring Meetings — Multiple NoShows</h1>
<p class="muted">
Recurring series (IsRecurring or Google recurring event) with <strong>more than one</strong> NoShow.
Shows NoShows vs total tracked meetings for that series • Updated: ${escapeHtml(new Date().toLocaleString())}
</p>
<div class="nav">
<a class="secondary" href="/reports">← Dashboard</a>
<a href="/reports/meeting-summary">30-Day Meeting Report</a>
<a class="secondary" href="/reports/recurring-noshows.csv">Download CSV</a>
</div>
<div class="summary-grid">
<div class="card">
<h2>${totals.series}</h2>
<p>Recurring Series with 2+ NoShows</p>
</div>
<div class="card">
<h2>${totals.noShows}</h2>
<p>Total NoShows in These Series</p>
</div>
<div class="card">
<h2>${totals.meetings}</h2>
<p>Total Meetings Tracked</p>
</div>
<div class="card accent">
<h2>${totals.meetings ? Math.round(1000 * totals.noShows / totals.meetings) / 10 : 0}%</h2>
<p>Overall NoShow Rate (these series)</p>
</div>
<div class="card">
<h2>${formatMinutes(totals.minutes)}</h2>
<p>Time Saved Across Series</p>
</div>
</div>
<h2>Series Detail</h2>
<table>
<thead>
<tr>
<th>Title</th>
<th>Host</th>
<th>Calendar Room</th>
<th>NoShows</th>
<th>Total Meetings</th>
<th>NoShow Rate</th>
<th>Time Saved</th>
<th>First → Last NoShow</th>
<th>NoShow Dates</th>
</tr>
</thead>
<tbody>
${series.map(s => {
const rateClass = s.noShowRate >= 75 ? 'rate-high' : (s.noShowRate >= 40 ? 'rate-mid' : '');
return `
<tr>
<td><strong>${escapeHtml(s.Title || '(no title)')}</strong></td>
<td>${escapeHtml(s.Host)}<br><span class="muted">${escapeHtml(s.HostEmail)}</span></td>
<td>${escapeHtml(s.RoomNames)}</td>
<td>${s.noShows}</td>
<td>${s.totalMeetings}</td>
<td class="${rateClass}">${s.noShowRate}%</td>
<td>${formatMinutes(s.minutesSaved)}</td>
<td>${escapeHtml(s.firstNoShow)}${escapeHtml(s.lastNoShow)}</td>
<td class="dates">${escapeHtml(s.noShowDates)}</td>
</tr>`;
}).join('') || '<tr><td colspan="9">No recurring series with more than one NoShow.</td></tr>'}
</tbody>
</table>
</body>
</html>`;
res.send(html);
} catch (err) {
console.error('❌ Recurring NoShow report error:', err.message);
res.status(500).send('Error generating recurring NoShow report');
}
});
app.get('/reports/recurring-noshows.csv', (req, res) => {
try {
const series = getRecurringMultiNoShows();
const rows = series.map(s => ({
Title: s.Title,
Host: s.Host,
HostEmail: s.HostEmail,
CalendarRoomName: s.RoomNames,
NoShows: s.noShows,
TotalMeetings: s.totalMeetings,
NoShowRate: s.noShowRate,
MinutesSaved: s.minutesSaved,
FirstNoShow: s.firstNoShow,
LastNoShow: s.lastNoShow,
NoShowDates: s.noShowDates
}));
const headers = ['Title', 'Host', 'HostEmail', 'CalendarRoomName', 'NoShows', 'TotalMeetings',
'NoShowRate', 'MinutesSaved', 'FirstNoShow', 'LastNoShow', 'NoShowDates'];
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename="recurring_multi_noshows.csv"');
res.send(rowsToCsv(headers, rows));
console.log(`📊 Recurring multi-NoShow CSV downloaded (${rows.length} series)`);
} catch (err) {
console.error('❌ Recurring NoShow CSV error:', err.message);
res.status(500).send('Error generating CSV');
}
});
// ====================== CSV DOWNLOAD ENDPOINT (all NoShows) ======================
app.get('/reports/noshows', (req, res) => {
try {
const rows = db.prepare(`
SELECT
MeetingId, Title, OrganizerName, OrganizerEmail, StartTime,
Duration, DeviceName, calendarRoomName, MinutesFreed, IsRecurring,
created_at as DetectedAt
FROM bookings
WHERE Cause = 'NoShow'
ORDER BY created_at DESC
`).all();
if (rows.length === 0) {
return res.status(404).send('No NoShow records found.');
}
const headers = ['MeetingId', 'Title', 'OrganizerName', 'OrganizerEmail', 'StartTime',
'Duration', 'DeviceName', 'calendarRoomName', 'MinutesFreed', 'IsRecurring', 'DetectedAt'];
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename="noshows_report.csv"');
res.send(rowsToCsv(headers, rows));
console.log(`📊 CSV report downloaded (${rows.length} records)`);
} catch (err) {
console.error('❌ CSV generation error:', err.message);
res.status(500).send('Error generating CSV');
}
});
app.get('/needs-enrichment', authenticateWebhook, async (req, res) => {
try {
const pending = db.prepare(`
SELECT id, MeetingId, WorkspaceId, StartTime, Title, OrganizerEmail,
CalendarEmail, DeviceName
FROM bookings
WHERE googleEventId IS NULL
AND (enrichmentStatus IS NULL OR enrichmentStatus != 'failed')
AND Cause IS NULL
AND datetime(StartTime) > datetime('now', '-72 hours') -- widened from 48h
ORDER BY StartTime ASC
LIMIT 20
`).all();
// Re-fetch CalendarEmail if missing (safety net)
for (let b of pending) {
if (!b.CalendarEmail && b.WorkspaceId) {
try {
const ws = await getWorkspaceInfo(b.WorkspaceId);
if (ws.calendarEmail) {
db.prepare(`UPDATE bookings SET CalendarEmail = ? WHERE MeetingId = ? AND WorkspaceId = ?`)
.run(ws.calendarEmail, b.MeetingId, b.WorkspaceId);
b.CalendarEmail = ws.calendarEmail;
}
} catch (e) {}
}
}
console.log(`📤 Returning ${pending.length} bookings for enrichment`);
res.json(pending);
} catch (err) {
console.error('❌ /needs-enrichment error:', err.message);
res.status(500).json({ error: 'Internal error' });
}
});
// POST /enrich → Apps Script pushes enriched data back (including Webex details)
app.post('/enrich', authenticateWebhook, (req, res) => {
try {
const enrichedList = req.body;
if (!Array.isArray(enrichedList)) {
return res.status(400).json({ error: 'Expected array' });
}
let updated = 0;
const meetingsToSync = new Set();
for (const item of enrichedList) {
db.prepare(`
UPDATE bookings
SET googleEventId = ?,
isRecurring = ?,
Guests = ?,
calendarRoomName = ?,
webexMeetingId = ?,
webexPassword = ?,
webexSipAddress = ?,
googleEventCreator = ?,
enrichmentStatus = 'success'
WHERE MeetingId = ? AND WorkspaceId = ?
`).run(
item.googleEventId || null,
item.isRecurring ? 1 : 0,
item.Guests || 0,
item.calendarRoomName || null,
item.webexMeetingId || null,
item.webexPassword || null,
item.webexSipAddress || null,
item.googleEventCreator || null, // New field
item.MeetingId,
item.WorkspaceId
);
if (item.webexMeetingId && item.MeetingId) {
meetingsToSync.add(item.MeetingId);
}
updated++;
}
console.log(`✅ Enriched ${updated} bookings (including googleEventCreator)`);
res.json({ success: true, updated });
for (const meetingId of meetingsToSync) {
syncWebexAttendeeCountForMeeting(meetingId).catch(err => {
console.error(`WebexAttendeeCount sync failed for ${meetingId}:`, err.message);
});
}
} catch (err) {
console.error('❌ /enrich error:', err.message);
res.status(500).json({ error: 'Internal error' });
}
});
// ====================== ENRICHMENT FAILURE REPORTING ======================
// Apps Script calls this to mark bookings that could not be enriched
app.post('/enrich-failed', authenticateWebhook, (req, res) => {
try {
const failedList = req.body;
if (!Array.isArray(failedList)) {
return res.status(400).json({ error: 'Expected array' });
}
let updated = 0;
for (const item of failedList) {
if (!item.MeetingId || !item.WorkspaceId) continue;
db.prepare(`
UPDATE bookings
SET enrichmentStatus = 'failed'
WHERE MeetingId = ? AND WorkspaceId = ?
`).run(item.MeetingId, item.WorkspaceId);
updated++;
}
console.log(`📛 Marked ${updated} bookings as enrichment failed`);
res.json({ success: true, updated });
} catch (err) {
console.error('❌ /enrich-failed error:', err.message);
res.status(500).json({ error: 'Internal error' });
}
});
// Manual cleanup endpoint (useful for testing)
app.post('/cleanup', authenticateWebhook, (req, res) => {
try {
const result = db.prepare(`
DELETE FROM bookings
WHERE Cause IS NULL
AND datetime(StartTime) < datetime('now', '-72 hours')
`).run();
res.json({
success: true,
removed: result.changes,
message: `Removed ${result.changes} old normal bookings`
});
console.log(`🧹 Manual cleanup requested: Removed ${result.changes} bookings`);
} catch (err) {
console.error('❌ Manual cleanup failed:', err.message);
res.status(500).json({ error: 'Cleanup failed' });
}
});
// ====================== DAILY CLEANUP ======================
// Removes normal bookings (not NoShow) older than 72 hours
function runDailyCleanup() {
try {
const result = db.prepare(`
DELETE FROM bookings
WHERE Cause IS NULL
AND datetime(StartTime) < datetime('now', '-72 hours')
`).run();
if (result.changes > 0) {
console.log(`🧹 Daily cleanup: Removed ${result.changes} normal (non-NoShow) bookings older than 72 hours`);
} else {
console.log('🧹 Daily cleanup: No bookings needed removal');
}
} catch (err) {
console.error('❌ Daily cleanup failed:', err.message);
}
}
// Run cleanup immediately on startup
console.log('🧹 Running initial cleanup on startup...');
//runDailyCleanup();
// Run cleanup every 6 hours (so we don't miss the daily window)
//setInterval(runDailyCleanup, 6 * 60 * 60 * 1000); // every 6 hours
// ... all your other functions are here (getWorkspaceInfo, getFloorInfo, getXAPIRoomData, etc.)
// ====================== FULL LOCATION + FLOOR BACKFILL ======================
// THIS MUST BE THE VERY LAST THING BEFORE app.listen
/*
console.log('🔄 Running FULL location + floor backfill...');
const uniqueWorkspaces = db.prepare(`SELECT DISTINCT WorkspaceId FROM bookings`).all();
let cachedWorkspaces = 0;
let cachedFloors = 0;
let updatedBookings = 0;
for (const row of uniqueWorkspaces) {
try {
const workspace = await getWorkspaceInfo(row.WorkspaceId);
if (workspace && workspace.displayName) {
db.prepare(`
INSERT OR REPLACE INTO workspace_lookup
(workspaceId, displayName, locationName, floorId, roomType, capacity, sipAddress)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
row.WorkspaceId,
workspace.displayName,
workspace.locationName,
workspace.floorId,
workspace.roomType,
workspace.capacity,
workspace.sipAddress
);
cachedWorkspaces++;
if (workspace.floorId && workspace.locationName) {
const friendlyFloor = await getFloorInfo(workspace.locationName, workspace.floorId);
if (friendlyFloor) cachedFloors++;
}
const result = db.prepare(`
UPDATE bookings
SET LocationName = ?,
Floor = ?,
RoomType = ?,
Capacity = ?
WHERE WorkspaceId = ?
`).run(
workspace.displayName,
workspace.floorId,
workspace.roomType,
workspace.capacity,
row.WorkspaceId
);
updatedBookings += result.changes;
}
} catch (err) {
console.error(`Failed to backfill workspace ${row.WorkspaceId}:`, err.message);
}
}
console.log(`✅ Full backfill completed:`);
console.log(` - Cached ${cachedWorkspaces} workspaces`);
console.log(` - Cached ${cachedFloors} floors`);
console.log(` - Updated ${updatedBookings} booking records`);
*/
/*
// ====================== ONE-TIME CALENDAR EMAIL BACKFILL ======================
console.log('🔄 Running one-time calendar email backfill...');
const workspacesToUpdate = db.prepare(`
SELECT workspaceId FROM workspace_lookup
WHERE calendarEmail IS NULL OR calendarEmail = ''
`).all();
let updated = 0;
for (const row of workspacesToUpdate) {
try {
const fresh = await getWorkspaceInfo(row.workspaceId); // This will re-fetch and cache the calendarEmail
if (fresh.calendarEmail) {
updated++;
console.log(`✅ Backfilled calendar email for ${fresh.displayName}`);
}
} catch (err) {
console.error(`Failed to backfill calendar for ${row.workspaceId}`);
}
}
console.log(`✅ Calendar email backfill completed: Updated ${updated} workspaces`);
*/
// ====================== START SERVER ======================
setInterval(pollActiveMeetingOccupancy, OCCUPANCY_POLL_INTERVAL_MS);
console.log(`📡 Occupancy polling enabled every ${OCCUPANCY_POLL_INTERVAL_MS / 1000}s`);
app.listen(PORT, () => {
console.log(`🚀 Webex Booking Webhook running on http://localhost:${PORT}`);
console.log(`🔐 Protected webhook: /bookings`);
console.log(`🔑 Authentication: ${WEBHOOK_AUTH_TOKEN.substring(0, 8)}...`);
});