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, '"'); } 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 = ` Room Utilization Dashboard

Room Utilization & Meeting Analytics Dashboard

Last 30 days โ€ข Updated: ${escapeHtml(new Date().toLocaleString())}

${stats.totalBookings || 0}

Total Bookings

${stats.totalNoShows || 0}

NoShow Events

${formatMinutes(stats.minutesSaved)}

Time Saved (NoShows)

${stats.noShowRate || 0}%

NoShow Rate

${recurringCount?.n || 0}

Recurring Series with 2+ NoShows

${stats.utilizationRate || 0}%

Utilization Rate (โ‰ฅ1 person)

${underUtilizedCount?.n || 0}

Under-Utilized Meetings (<${UNDER_UTILIZED_THRESHOLD_PCT}% capacity)

Top Hosts by NoShow (Last 30 Days)

${topHosts.map((h, i) => ` `).join('') || ''}
# Host Email NoShows Normal Ended Time Saved
${i + 1} ${escapeHtml(h.Host)} ${escapeHtml(h.HostEmail)} ${h.noShows} ${h.normalEnded || 0} ${formatMinutes(h.minutesSaved)} (${h.minutesSaved} min)
No NoShow data in the last 30 days.

All Rooms - Ranked by NoShow Rate

${roomStats.map(room => ` `).join('')}
Room Room Type Total Bookings NoShows Normal Ended NoShow Rate Avg People Avg Peak People Avg Capacity
${escapeHtml(room.Room)} ${escapeHtml(room.RoomType)} ${room.totalBookings} ${room.noShows} ${room.normalEnded} ${room.noShowRate}% ${room.avgPeople || 'โ€”'} ${room.avgPeakPeople || 'โ€”'} ${room.avgCapacity || 'โ€”'}
`; 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 = ` Under-Utilized Meetings Report

Under-Utilized Meetings

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())}

${summary.totalUnderUtilized || 0}

Under-Utilized Meetings

${summary.avgOccupancyPct || 0}%

Avg Worst-Room Occupancy %

${summary.avgPeakPeople || 0}

Avg Peak People (per meeting)

${summary.avgCapacity || 0}

Avg Total Capacity

Meetings Below ${UNDER_UTILIZED_THRESHOLD_PCT}% Capacity

${meetings.map(m => ` `).join('') || ``}
Date & Time Title Host Rooms Room Count Total Capacity Max People Worst Occupancy % Invited Guests Webex Attendees Source
${escapeHtml(formatMeetingDateTime(m.StartTime))} ${escapeHtml(m.Title)} ${escapeHtml(m.Host)}
${escapeHtml(m.HostEmail)}
${m.rooms.map(r => `
${escapeHtml(r.label)} โ€” peak: ${r.maxPeople ?? 'โ€”'}, ${r.occupancyPct ?? 'โ€”'}%
`).join('')}
${m.roomCount} ${m.totalCapacity ?? 'โ€”'} ${m.maxPeople ?? 'โ€”'} ${m.worstOccupancyPct ?? 'โ€”'}% ${m.Guests ?? 0} ${m.webexMeetingId ? (m.WebexAttendeeCount ?? 'โ€”') : 'โ€”'} ${escapeHtml(m.OccupancySource || 'โ€”')}
No under-utilized meetings found (threshold: <${UNDER_UTILIZED_THRESHOLD_PCT}% of capacity).
`; 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 = ` 30-Day Meeting Report โ€” NoShows

30-Day Meeting Report

NoShow totals, time saved, and host rankings โ€ข Updated: ${escapeHtml(new Date().toLocaleString())}

${summary.totalNoShows || 0}

Total NoShow Meetings

${formatMinutes(summary.minutesSaved)}

Total Time Saved
${summary.minutesSaved || 0} minutes

${summary.noShowRate || 0}%

NoShow Rate of All Bookings

${summary.hostsWithNoShows || 0}

Hosts with NoShows

${summary.totalBookings || 0}

Total Bookings (30 days)

Hosts with the Most NoShow Meetings

${hostsByNoShows.map((h, i) => ` `).join('') || ''}
# Host Email NoShows Time Saved Avg Freed / NoShow Rooms
${i + 1} ${escapeHtml(h.Host)} ${escapeHtml(h.HostEmail)} ${h.noShows} ${formatMinutes(h.minutesSaved)} (${h.minutesSaved} min) ${h.avgMinutesFreed ?? 'โ€”'} min ${escapeHtml(h.rooms)}
No data

Hosts with the Most Time Saved

${hostsByTimeSaved.map((h, i) => ` `).join('') || ''}
# Host Email Time Saved NoShows
${i + 1} ${escapeHtml(h.Host)} ${escapeHtml(h.HostEmail)} ${formatMinutes(h.minutesSaved)} (${h.minutesSaved} min) ${h.noShows}
No data

Recent NoShows (Last 30 Days)

${recentNoShows.map(r => ` `).join('') || ''}
Date Title Host Room Duration Minutes Freed
${escapeHtml(r.meetingDate)} ${escapeHtml(r.Title)} ${escapeHtml(r.Host)}
${escapeHtml(r.HostEmail)}
${escapeHtml(r.Room)} ${escapeHtml(r.Duration)} min ${r.MinutesFreed}
No data
`; 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 = ` Recurring Multi-NoShow Report

Recurring Meetings โ€” Multiple NoShows

Recurring series (IsRecurring or Google recurring event) with more than one NoShow. Shows NoShows vs total tracked meetings for that series โ€ข Updated: ${escapeHtml(new Date().toLocaleString())}

${totals.series}

Recurring Series with 2+ NoShows

${totals.noShows}

Total NoShows in These Series

${totals.meetings}

Total Meetings Tracked

${totals.meetings ? Math.round(1000 * totals.noShows / totals.meetings) / 10 : 0}%

Overall NoShow Rate (these series)

${formatMinutes(totals.minutes)}

Time Saved Across Series

Series Detail

${series.map(s => { const rateClass = s.noShowRate >= 75 ? 'rate-high' : (s.noShowRate >= 40 ? 'rate-mid' : ''); return ` `; }).join('') || ''}
Title Host Calendar Room NoShows Total Meetings NoShow Rate Time Saved First โ†’ Last NoShow NoShow Dates
${escapeHtml(s.Title || '(no title)')} ${escapeHtml(s.Host)}
${escapeHtml(s.HostEmail)}
${escapeHtml(s.RoomNames)} ${s.noShows} ${s.totalMeetings} ${s.noShowRate}% ${formatMinutes(s.minutesSaved)} ${escapeHtml(s.firstNoShow)} โ†’ ${escapeHtml(s.lastNoShow)} ${escapeHtml(s.noShowDates)}
No recurring series with more than one NoShow.
`; 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)}...`); });