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>
2414 lines
No EOL
86 KiB
JavaScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
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 (<${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 & 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: <${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)}...`);
|
|
}); |