From cba047cb4e7a9c5494d32302674e758ca3f7344f Mon Sep 17 00:00:00 2001 From: jmcqueen Date: Tue, 25 Aug 2026 12:59:54 -0400 Subject: [PATCH] Add invoice approval cards, /confirmed close-out fallback, and /addNote. Replace /completed with /confirmed that tries SC CONFIRMED then falls back to SC notes and ServChan close-out records when status is locked. Post invoice approval cards on PDF attach, track close-outs for cleanup, and add WO-space /addNote. Co-authored-by: Cursor --- .env.example | 5 +- index.js | 18 + src/bot/index.js | 20 +- src/commands/help.js | 3 +- src/commands/woAddNote.js | 37 ++ src/commands/woCompleted.js | 113 ------ src/commands/woConfirmed.js | 159 ++++++++ src/integrations/serviceChannel/client.js | 97 ++++- src/server/app.js | 5 +- src/services/attachmentService.js | 13 + src/services/invoiceApprovalService.js | 438 ++++++++++++++++++++++ src/services/spaceCleanupService.js | 66 +++- src/services/webhookProcessor.js | 9 + src/services/woCloseoutService.js | 38 ++ src/utils/woRoomContext.js | 57 +++ 15 files changed, 953 insertions(+), 125 deletions(-) create mode 100644 src/commands/woAddNote.js delete mode 100644 src/commands/woCompleted.js create mode 100644 src/commands/woConfirmed.js create mode 100644 src/services/invoiceApprovalService.js create mode 100644 src/services/woCloseoutService.js create mode 100644 src/utils/woRoomContext.js diff --git a/.env.example b/.env.example index e070581..7484657 100644 --- a/.env.example +++ b/.env.example @@ -39,10 +39,13 @@ CS_API_BASE=https://bot.joesjavajoint.com/CollabSupport # OPTISIGN_API_KEY=... # --- Space cleanup (optional) --- -# Webex room for daily consolidated close-out digest and /completed ops notifications. +# Webex room for daily consolidated close-out digest and /confirmed ops notifications. # COMPLETED_OPERATIONS_ROOM_ID=Y2lzY29zcGFyazovL3VzL1JPT00vNjdmZmYxZTAtZmM3Ny0xMWYwLWE2MzUtZGY0ZmQ4NWUwMGMz # Days after any non-terminal COMPLETED status before including in the daily digest. # Auto-remove still applies only to COMPLETED/CONFIRMED, COMPLETED/CANCELLED, and COMPLETED/NO CHARGE. +# Only work orders for this service provider are included in cleanup (default: Pro-Motion). +# SPACE_CLEANUP_PROVIDER_NAME=Pro-Motion Technology Group, LLC +# SPACE_CLEANUP_PROVIDER_ID=2000002215 # SPACE_CLEANUP_REMINDER_DAYS=60 # Cron for daily consolidated digest (default 14:00 UTC). Digest only — no auto-delete. # SPACE_CLEANUP_REMINDER_CRON=0 0 14 * * * diff --git a/index.js b/index.js index 8be4afa..680e609 100644 --- a/index.js +++ b/index.js @@ -85,12 +85,30 @@ db.serialize(() => { postedAt TEXT NOT NULL ) `); + db.run(` + CREATE TABLE IF NOT EXISTS pending_invoice_approval_cards ( + workOrderId INTEGER PRIMARY KEY, + roomId TEXT NOT NULL, + messageId TEXT NOT NULL, + invoiceId INTEGER, + postedAt TEXT NOT NULL + ) + `); db.run(` CREATE TABLE IF NOT EXISTS space_cleanup_digest ( id INTEGER PRIMARY KEY CHECK (id = 1), lastPostedAt TEXT NOT NULL ) `); + db.run(` + CREATE TABLE IF NOT EXISTS wo_closeouts ( + workOrderId INTEGER PRIMARY KEY, + confirmedAt TEXT NOT NULL, + confirmedBy TEXT NOT NULL, + scStatusUpdated INTEGER NOT NULL DEFAULT 0, + noteText TEXT + ) + `); console.log(`[DB] Connected to ${DB_PATH}`); }); diff --git a/src/bot/index.js b/src/bot/index.js index 4754015..e3bf8d3 100644 --- a/src/bot/index.js +++ b/src/bot/index.js @@ -16,8 +16,10 @@ import { handleWoAttachments } from '../commands/woAttachments.js'; import { handleWoHistory } from '../commands/woHistory.js'; import { handleAvStatus } from '../commands/avStatus.js'; import { handleWoApprove } from '../commands/woApprove.js'; -import { handleWoCompleted } from '../commands/woCompleted.js'; +import { handleWoConfirmed } from '../commands/woConfirmed.js'; +import { handleWoAddNote } from '../commands/woAddNote.js'; import approvalService from '../services/approvalService.js'; +import invoiceApprovalService from '../services/invoiceApprovalService.js'; import { installMercuryGuard } from './mercuryGuard.js'; import db from '../db/mappings.js'; @@ -64,6 +66,15 @@ export function initializeBot({ webexConfig }) { // NEW: Handle Adaptive Card submissions (e.g. proposal approvals) Framework.on('attachmentAction', async (bot, trigger) => { try { + const actionType = trigger?.attachmentAction?.inputs?.action; + if ( + actionType === 'approveInvoice' || + actionType === 'rejectInvoice' || + actionType === 'dismissInvoiceCard' + ) { + await invoiceApprovalService.handleInvoiceApprovalSubmit(bot, trigger, { db }); + return; + } await approvalService.handleApprovalSubmit(bot, trigger, { db }); } catch (e) { logger('bot:approvalAction', `Handler error: ${e.message}`, 'error'); @@ -122,8 +133,11 @@ export function initializeBot({ webexConfig }) { case 'requestapproval': await handleWoApprove(bot, trigger); break; - case 'completed': - await handleWoCompleted(bot, trigger); + case 'confirmed': + await handleWoConfirmed(bot, trigger); + break; + case 'addnote': + await handleWoAddNote(bot, trigger); break; default: await handleUnknown(bot, trigger); diff --git a/src/commands/help.js b/src/commands/help.js index 207fbc1..6ebae26 100644 --- a/src/commands/help.js +++ b/src/commands/help.js @@ -10,7 +10,8 @@ export async function handleHelp(bot, trigger) { text += `- **/woAttachments** — download attachments for the workorder in this space\n`; text += `- **/avStatus** — AV device status for the store linked to this space\n`; text += `- **/woApprove** — (re)post proposal approval card for current WO (when WAITING FOR APPROVAL)\n`; - text += `- **/completed** — confirm WO resolution verified and notify ops space for close-out\n`; + text += `- **/confirmed** — confirm WO resolution (SC CONFIRMED if allowed, else note + ops)\n`; + text += `- **/addNote** — add a note to this work order in ServiceChannel (include your text after the command)\n`; } else { text += `- **/woSummary ** — status of any work order\n`; text += `- **/woHistory ** — History of AV issues.\n`; diff --git a/src/commands/woAddNote.js b/src/commands/woAddNote.js new file mode 100644 index 0000000..4036748 --- /dev/null +++ b/src/commands/woAddNote.js @@ -0,0 +1,37 @@ +// src/commands/woAddNote.js +import botClient from '../integrations/webex/botClient.js'; +import { addWorkOrderNote } from '../integrations/serviceChannel/client.js'; +import db from '../db/mappings.js'; +import { logger } from '../utils/logger.js'; +import { resolveDisplayName, resolveWoRoomContext } from '../utils/woRoomContext.js'; + +export async function handleWoAddNote(bot, trigger) { + logger('wo:addNote', 'HANDLER ENTERED'); + + const roomId = trigger.message?.roomId; + const isGroup = trigger.message?.roomType === 'group'; + const noteText = (trigger.args || []).join(' ').trim(); + + if (!noteText) { + await bot.say('Usage: `/addNote your note text here` (WO space only).'); + return; + } + + const ctx = await resolveWoRoomContext({ db, botClient, roomId, isGroup }); + if (!ctx.ok) { + await bot.say(ctx.error); + return; + } + + const displayName = await resolveDisplayName(botClient, trigger.personId); + const scNote = `${displayName}: ${noteText}`; + + try { + await addWorkOrderNote(ctx.workOrderId, scNote); + logger('wo:addNote', `Posted note to SC WO ${ctx.workOrderId} by ${displayName}`); + await bot.say(`Note added to the work order in ServiceChannel.`); + } catch (err) { + logger('wo:addNote', `Failed to post SC note for WO ${ctx.workOrderId}: ${err.message}`, 'error'); + await bot.say(`Could not add the note in ServiceChannel: ${err.message}`); + } +} diff --git a/src/commands/woCompleted.js b/src/commands/woCompleted.js deleted file mode 100644 index 12c36a2..0000000 --- a/src/commands/woCompleted.js +++ /dev/null @@ -1,113 +0,0 @@ -// src/commands/woCompleted.js -import botClient from '../integrations/webex/botClient.js'; -import { addWorkOrderNote } from '../integrations/serviceChannel/client.js'; -import db from '../db/mappings.js'; -import { logger } from '../utils/logger.js'; -import { - getCompletedOperationsRoomId, - parseServChanRoomTitle, -} from '../utils/servchanRoomTitle.js'; - -function lookupWorkOrderIdByRoom(roomId) { - return new Promise((resolve, reject) => { - db.get( - 'SELECT workOrderId FROM mappings WHERE roomId = ?', - [roomId], - (err, row) => (err ? reject(err) : resolve(row?.workOrderId ?? null)) - ); - }); -} - -function buildConfirmationText(displayName) { - return `${displayName} has confirmed this WO resolution verified and approves closing it out.`; -} - -export async function handleWoCompleted(bot, trigger) { - logger('wo:completed', 'HANDLER ENTERED'); - - const roomId = trigger.message?.roomId; - const isGroup = trigger.message?.roomType === 'group'; - - if (!isGroup || !roomId) { - await bot.say('This command can only be used in a ServChan work order space.'); - return; - } - - const mappedWorkOrderId = await lookupWorkOrderIdByRoom(roomId); - if (!mappedWorkOrderId) { - await bot.say('This command can only be used in a ServChan work order space.'); - return; - } - - let room; - try { - room = await botClient.getRoom(roomId); - } catch (err) { - logger('wo:completed', `Failed to fetch room ${roomId}: ${err.message}`, 'warn'); - await bot.say('Could not load this Webex space. Please try again.'); - return; - } - - const parsed = parseServChanRoomTitle(room?.title); - if (!parsed) { - await bot.say('This command can only be used in a ServChan work order space.'); - return; - } - - const opsRoomId = getCompletedOperationsRoomId(); - if (!opsRoomId) { - logger('wo:completed', 'COMPLETED_OPERATIONS_ROOM_ID not configured', 'warn'); - await bot.say('The close-out ops space is not configured on this bot (`COMPLETED_OPERATIONS_ROOM_ID`).'); - return; - } - - const personId = trigger.personId; - const person = personId - ? await botClient.getPersonDetails(personId) - : { displayName: 'Unknown User' }; - const displayName = person.displayName || 'Unknown User'; - const confirmationText = buildConfirmationText(displayName); - - await bot.say({ markdown: `**${confirmationText}**` }); - - let scNotePosted = false; - try { - await addWorkOrderNote(mappedWorkOrderId, confirmationText); - scNotePosted = true; - logger( - 'wo:completed', - `Posted close-out note to SC WO ${mappedWorkOrderId} by ${displayName}` - ); - } catch (err) { - logger('wo:completed', `Failed to post SC note for WO ${mappedWorkOrderId}: ${err.message}`, 'error'); - } - - const opsMessage = `${parsed.headerLine}\n${confirmationText}`; - - try { - await botClient.sendMarkdown(opsRoomId, opsMessage); - logger( - 'wo:completed', - `Posted close-out approval for WO-${parsed.woNumber} to ops room ${opsRoomId} by ${displayName}` - ); - - if (scNotePosted) { - await bot.say('Close-out approval posted to ServiceChannel and the ServChan ops space.'); - } else { - await bot.say( - 'Close-out approval posted to the ServChan ops space, but adding the ServiceChannel note failed — please add it manually in SC.' - ); - } - } catch (err) { - logger('wo:completed', `Failed to post to ops room: ${err.message}`, 'error'); - if (scNotePosted) { - await bot.say( - `Your confirmation was posted here and noted on the work order in ServiceChannel, but notifying the ops space failed: ${err.message}` - ); - } else { - await bot.say( - `Your confirmation was posted here, but the ServiceChannel note and ops space notification failed: ${err.message}` - ); - } - } -} diff --git a/src/commands/woConfirmed.js b/src/commands/woConfirmed.js new file mode 100644 index 0000000..be92f6c --- /dev/null +++ b/src/commands/woConfirmed.js @@ -0,0 +1,159 @@ +// src/commands/woConfirmed.js +import botClient from '../integrations/webex/botClient.js'; +import { + addWorkOrderNote, + getWorkOrderStatus, + updateWorkOrderStatus, +} from '../integrations/serviceChannel/client.js'; +import db from '../db/mappings.js'; +import { logger } from '../utils/logger.js'; +import { getCompletedOperationsRoomId } from '../utils/servchanRoomTitle.js'; +import { resolveDisplayName, resolveWoRoomContext } from '../utils/woRoomContext.js'; +import { getWoCloseout, saveWoCloseout } from '../services/woCloseoutService.js'; + +function normalizeExtended(value) { + return (value || '').trim().toUpperCase(); +} + +function isTerminalExtended(extendedRaw) { + const extended = normalizeExtended(extendedRaw); + if (extended === 'CONFIRMED' || extended === 'CANCELLED') return true; + return extended === 'NO CHARGE' || extended.includes('NO CHARGE'); +} + +function buildConfirmationText(displayName) { + return `${displayName} has confirmed this WO resolution verified and approves closing it out.`; +} + +function isScStatusLockedError(err) { + const msg = String(err?.message || err).toLowerCase(); + return msg.includes('921') || msg.includes('invoiced') || msg.includes('status'); +} + +export async function handleWoConfirmed(bot, trigger) { + logger('wo:confirmed', 'HANDLER ENTERED'); + + const roomId = trigger.message?.roomId; + const isGroup = trigger.message?.roomType === 'group'; + + const ctx = await resolveWoRoomContext({ db, botClient, roomId, isGroup }); + if (!ctx.ok) { + await bot.say(ctx.error); + return; + } + + const { workOrderId: mappedWorkOrderId, parsed } = ctx; + + const opsRoomId = getCompletedOperationsRoomId(); + if (!opsRoomId) { + logger('wo:confirmed', 'COMPLETED_OPERATIONS_ROOM_ID not configured', 'warn'); + await bot.say('The close-out ops space is not configured on this bot (`COMPLETED_OPERATIONS_ROOM_ID`).'); + return; + } + + const existingCloseout = await getWoCloseout(db, mappedWorkOrderId); + if (existingCloseout) { + await bot.say( + `Close-out was already recorded for this work order on ${new Date(existingCloseout.confirmedAt).toLocaleDateString('en-US')} by **${existingCloseout.confirmedBy}**.` + ); + return; + } + + const woStatus = await getWorkOrderStatus(mappedWorkOrderId); + if (!woStatus) { + await bot.say('Could not load this work order from ServiceChannel. Please try again.'); + return; + } + + const primary = (woStatus.primaryStatus || '').trim().toUpperCase(); + if (primary !== 'COMPLETED') { + await bot.say( + `This command applies only to **COMPLETED** work orders. Current status: **${woStatus.primaryStatus || 'unknown'}**${woStatus.extendedStatus ? ` / ${woStatus.extendedStatus}` : ''}.` + ); + return; + } + + if (isTerminalExtended(woStatus.extendedStatus)) { + await bot.say( + `This work order is already in a terminal status (**${woStatus.extendedStatus}**). No action needed.` + ); + return; + } + + const displayName = await resolveDisplayName(botClient, trigger.personId); + const confirmationText = buildConfirmationText(displayName); + const scNote = `${displayName} confirmed WO resolution verified and approves closing it out.`; + + let scStatusUpdated = false; + + try { + await updateWorkOrderStatus(mappedWorkOrderId, { + primary: 'COMPLETED', + extended: 'CONFIRMED', + note: scNote, + }); + scStatusUpdated = true; + logger( + 'wo:confirmed', + `Set WO ${mappedWorkOrderId} to COMPLETED/CONFIRMED by ${displayName}` + ); + } catch (err) { + logger( + 'wo:confirmed', + `SC status update failed for WO ${mappedWorkOrderId}: ${err.message} — falling back to note-only close-out`, + 'warn' + ); + + try { + await addWorkOrderNote(mappedWorkOrderId, scNote); + logger( + 'wo:confirmed', + `Posted close-out note to SC WO ${mappedWorkOrderId} by ${displayName} (status API blocked)` + ); + } catch (noteErr) { + logger('wo:confirmed', `Failed to post SC note for WO ${mappedWorkOrderId}: ${noteErr.message}`, 'error'); + const hint = isScStatusLockedError(err) + ? ' ServiceChannel does not allow CONFIRMED after invoice on this work order.' + : ''; + await bot.say( + `Could not set ServiceChannel status to CONFIRMED and adding a work order note also failed:${hint} ${noteErr.message}` + ); + return; + } + } + + try { + await saveWoCloseout(db, { + workOrderId: mappedWorkOrderId, + confirmedBy: displayName, + scStatusUpdated, + noteText: scNote, + }); + } catch (err) { + logger('wo:confirmed', `Failed to save close-out record for WO ${mappedWorkOrderId}: ${err.message}`, 'error'); + } + + const opsMessage = `${parsed.headerLine}\n${confirmationText}`; + + try { + await botClient.sendMarkdown(opsRoomId, opsMessage); + logger( + 'wo:confirmed', + `Posted close-out confirmation for WO-${parsed.woNumber} to ops room ${opsRoomId} by ${displayName}` + ); + } catch (err) { + logger('wo:confirmed', `Failed to post to ops room: ${err.message}`, 'error'); + await bot.say( + scStatusUpdated + ? `ServiceChannel status was updated to CONFIRMED, but notifying the ops space failed: ${err.message}` + : `Close-out was noted on the work order in ServiceChannel, but notifying the ops space failed: ${err.message}` + ); + return; + } + + if (!scStatusUpdated) { + await bot.say( + 'ServiceChannel would not allow CONFIRMED on this work order — close-out noted on the WO, recorded in ServChan, and ops notified. The SC webhook should post the note here shortly.' + ); + } +} diff --git a/src/integrations/serviceChannel/client.js b/src/integrations/serviceChannel/client.js index d0818bb..ec5c145 100644 --- a/src/integrations/serviceChannel/client.js +++ b/src/integrations/serviceChannel/client.js @@ -270,10 +270,21 @@ export async function getTicketsReadyForSpaceCleanup(daysAfterCompletion = 7) { export async function getWorkOrderStatus(woId) { try { const response = await scAxios.get(`/workorders/${woId}`, { - params: { $select: 'Id,WorkorderNumber,Status,UpdatedDate,Description' } + params: { + $select: 'Id,WorkorderNumber,Status,UpdatedDate,Description,Provider,ProviderName,ProviderId,IsInvoiced,Invoice,ApprovalCode,Category', + }, }); const ticket = response.data; + const invoiceId = ticket.Invoice?.Id ?? null; + let invoiceTotal = ticket.Invoice?.InvoiceTotal ?? ticket.Invoice?.Total ?? null; + + if (invoiceId && (invoiceTotal == null || Number(invoiceTotal) === 0)) { + const inv = await getInvoice(invoiceId); + if (inv) { + invoiceTotal = inv.InvoiceTotal ?? inv.Total ?? invoiceTotal; + } + } return { workOrderId: ticket.Id, @@ -281,13 +292,95 @@ export async function getWorkOrderStatus(woId) { primaryStatus: ticket.Status?.Primary, extendedStatus: ticket.Status?.Extended, updatedDate: ticket.UpdatedDate, - description: ticket.Description?.substring(0, 100) || '' + description: ticket.Description?.substring(0, 100) || '', + providerName: ticket.Provider?.Name || ticket.ProviderName || '', + providerId: ticket.Provider?.Id ?? ticket.ProviderId ?? null, + isInvoiced: ticket.IsInvoiced === true, + invoiceId, + invoiceNumber: ticket.Invoice?.Number ?? null, + invoiceTotal: invoiceTotal != null ? Number(invoiceTotal) : null, + approvalCode: ticket.ApprovalCode ?? '', + category: ticket.Category ?? '', }; } catch (err) { console.error(`[SC-CLIENT] Error getting status for WO ${woId}:`, err.message); return null; } } + +/** + * Update work order primary/extended status. + * PUT /workorders/{woId}/status + */ +export async function updateWorkOrderStatus(woId, { primary, extended, note }) { + if (!woId || !primary) { + throw new Error('woId and primary status are required'); + } + + const body = { + Status: { + Primary: primary, + Extended: extended || '', + }, + Note: String(note || '').trim(), + }; + + await scAxios.put(`/workorders/${woId}/status`, body); + logger( + 'sc:updateWorkOrderStatus', + `WO ${woId} status → ${primary}${extended ? ` / ${extended}` : ''}` + ); +} + +/** + * Approve an invoice in ServiceChannel. + * PUT /invoices/{invoiceId}/approve + */ +export async function approveInvoice(invoiceId, { approvalCode = '', comments = '', category = '' } = {}) { + if (!invoiceId) { + throw new Error('invoiceId is required to approve'); + } + + const params = new URLSearchParams(); + params.set('approvalCode', String(approvalCode ?? '')); + params.set('comments', String(comments ?? '')); + params.set('category', String(category ?? '')); + + await scAxios.put(`/invoices/${invoiceId}/approve?${params.toString()}`); + logger('sc:approveInvoice', `Approved invoice ${invoiceId}`); +} + +/** + * Reject an invoice in ServiceChannel. + * PUT /invoices/{invoiceId}/reject + */ +export async function rejectInvoice(invoiceId, { comments = '', isNotifyProvider = true } = {}) { + if (!invoiceId) { + throw new Error('invoiceId is required to reject'); + } + + const params = new URLSearchParams(); + params.set('comments', String(comments ?? '')); + params.set('isNotifyProvider', String(isNotifyProvider)); + + await scAxios.put(`/invoices/${invoiceId}/reject?${params.toString()}`); + logger('sc:rejectInvoice', `Rejected invoice ${invoiceId}`); +} + +/** + * Fetch invoice details by id. + * GET /invoices/{invoiceId} + */ +export async function getInvoice(invoiceId) { + if (!invoiceId) return null; + try { + const res = await fetchWithRetry(`/invoices/${invoiceId}`, { timeout: 30000 }); + return res.data; + } catch (err) { + logger('sc:getInvoice', `Failed for invoice ${invoiceId}: ${err.message}`, 'warn'); + return null; + } +} export default scAxios; // ────────────────────────────────────────────── diff --git a/src/server/app.js b/src/server/app.js index 5067592..fcdb82b 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -197,11 +197,14 @@ export function createApp({ : 'Destructive actions were performed against Webex and the mappings DB.'}

Rules: + Cleanup applies only to service provider ${esc(process.env.SPACE_CLEANUP_PROVIDER_NAME || 'Pro-Motion Technology Group, LLC')} + (ID ${esc(process.env.SPACE_CLEANUP_PROVIDER_ID || '2000002215')}). Auto-remove (prune ≥14d / delete ≥60d) for COMPLETED / CONFIRMED, COMPLETED / CANCELLED, and COMPLETED / NO CHARGE. All other COMPLETED variants are held until one of those terminal statuses; a consolidated daily digest posts to the ops room after ${esc(process.env.SPACE_CLEANUP_REMINDER_DAYS || '60')} days. - Use /completed in a ServChan WO space to confirm resolution verified. + Invoice PDFs trigger an approval card in the WO room; use /confirmed after invoice + (sets SC CONFIRMED when allowed, otherwise records close-out via SC note + ServChan DB).

Summary

${esc(JSON.stringify(summary, null, 2))}
diff --git a/src/services/attachmentService.js b/src/services/attachmentService.js index 66b3ad4..a20a777 100644 --- a/src/services/attachmentService.js +++ b/src/services/attachmentService.js @@ -5,6 +5,7 @@ */ import { logger } from '../utils/logger.js'; +import invoiceApprovalService from './invoiceApprovalService.js'; import { listWorkOrderAttachments, downloadAttachment, @@ -224,6 +225,18 @@ export async function postWorkOrderAttachments({ await dbMarkPosted(db, workOrderId, att.id); } posted++; + + if (att.isInvoice) { + invoiceApprovalService.postInvoiceApprovalCard({ + db, + webex, + roomId, + workOrderId, + woNumber: woNumber || workOrderId, + }).catch((err) => { + logger('attachments', `Invoice approval card failed for WO ${workOrderId}: ${err.message}`, 'warn'); + }); + } } } catch (err) { logger('attachments', `Failed to post ${att.name} (${att.id}): ${err.message}`, 'warn'); diff --git a/src/services/invoiceApprovalService.js b/src/services/invoiceApprovalService.js new file mode 100644 index 0000000..04abe02 --- /dev/null +++ b/src/services/invoiceApprovalService.js @@ -0,0 +1,438 @@ +/** + * src/services/invoiceApprovalService.js + * + * Invoice approval Adaptive Card flow when invoice PDFs are posted to WO rooms. + */ + +import { logger } from '../utils/logger.js'; +import { + getWorkOrderStatus, + getInvoice, + approveInvoice, + rejectInvoice, +} from '../integrations/serviceChannel/client.js'; +import webexService from './webexService.js'; + +const CARD_DEDUP_TTL_MS = 5 * 60 * 1000; +const _recentInvoiceCards = new Map(); + +function _pruneDedupCache(now = Date.now()) { + for (const [k, ts] of _recentInvoiceCards.entries()) { + if (now - ts > CARD_DEDUP_TTL_MS) _recentInvoiceCards.delete(k); + } +} + +function _formatMoney(n) { + const v = Number(n); + return Number.isFinite(v) ? `$${v.toFixed(2)}` : 'N/A'; +} + +function _invoiceStatusPrimary(invoice) { + if (!invoice) return ''; + if (typeof invoice.Status === 'string') return invoice.Status; + return invoice.Status?.Primary || invoice.Status?.Name || ''; +} + +function _isInvoiceApproved(invoice) { + const status = _invoiceStatusPrimary(invoice).toLowerCase(); + return status === 'approved' || status.includes('approved'); +} + +function _isInvoiceRejected(invoice) { + const status = _invoiceStatusPrimary(invoice).toLowerCase(); + return status === 'rejected' || status.includes('rejected'); +} + +function getPendingInvoiceCard(db, workOrderId) { + return new Promise((resolve, reject) => { + db.get( + 'SELECT workOrderId, roomId, messageId, invoiceId, postedAt FROM pending_invoice_approval_cards WHERE workOrderId = ?', + [workOrderId], + (err, row) => (err ? reject(err) : resolve(row || null)) + ); + }); +} + +function savePendingInvoiceCard(db, { workOrderId, roomId, messageId, invoiceId }) { + return new Promise((resolve, reject) => { + db.run( + `INSERT OR REPLACE INTO pending_invoice_approval_cards (workOrderId, roomId, messageId, invoiceId, postedAt) + VALUES (?, ?, ?, ?, ?)`, + [workOrderId, roomId, messageId, invoiceId ?? null, new Date().toISOString()], + (err) => (err ? reject(err) : resolve()) + ); + }); +} + +function clearPendingInvoiceCard(db, workOrderId) { + return new Promise((resolve, reject) => { + db.run( + 'DELETE FROM pending_invoice_approval_cards WHERE workOrderId = ?', + [workOrderId], + (err) => (err ? reject(err) : resolve()) + ); + }); +} + +async function deleteInvoiceCardMessage(messageId) { + if (!messageId) return; + try { + const botClientMod = await import('../integrations/webex/botClient.js'); + await botClientMod.default.deleteMessage(messageId); + logger('invoiceApproval', `Deleted invoice card message ${messageId}`); + } catch (err) { + logger('invoiceApproval', `Could not delete invoice card ${messageId}: ${err.message}`, 'warn'); + } +} + +async function _removeInvoiceCard(bot, action) { + const messageId = action?.messageId; + if (!messageId) return; + + if (typeof bot?.censor === 'function') { + try { + await bot.censor(messageId); + logger('invoiceApproval:submit', `Removed invoice card message ${messageId}`); + return; + } catch (err) { + logger('invoiceApproval:submit', `bot.censor failed for ${messageId}: ${err.message}`, 'warn'); + } + } + + await deleteInvoiceCardMessage(messageId); +} + +export function buildInvoiceApprovalAdaptiveCard(woContext, invoiceContext = {}) { + const woId = woContext?.workOrderId ?? woContext?.Id; + const woNum = woContext?.woNumber ?? woContext?.WorkorderNumber ?? woId ?? '???'; + const woLink = `https://www.servicechannel.com/sc/wo/Workorders/index?id=${woId || woNum}`; + const invoiceNumber = invoiceContext.invoiceNumber ?? '?'; + const invoiceTotal = invoiceContext.invoiceTotal; + const approvalCode = String(invoiceContext.approvalCode ?? '').trim(); + const category = String(invoiceContext.category ?? '').trim(); + const invoiceId = invoiceContext.invoiceId; + + const body = [ + { + type: 'TextBlock', + text: 'Invoice Approval Required', + size: 'medium', + weight: 'bolder', + }, + { + type: 'TextBlock', + text: `[WO-${woNum}](${woLink})`, + isSubtle: true, + wrap: true, + }, + { + type: 'FactSet', + facts: [ + { title: 'Invoice #', value: String(invoiceNumber) }, + { title: 'Amount', value: _formatMoney(invoiceTotal) }, + { title: 'Approval Code', value: approvalCode || '(missing — approve in SC)' }, + ], + }, + ]; + + if (!approvalCode) { + body.push({ + type: 'TextBlock', + text: 'This work order has no Approval Code in ServiceChannel. Add one in SC before approving here.', + color: 'attention', + wrap: true, + spacing: 'medium', + }); + } + + body.push( + { + type: 'TextBlock', + text: 'Category (for SC invoice approval)', + weight: 'bolder', + spacing: 'medium', + }, + { + type: 'Input.Text', + id: 'category', + value: category, + placeholder: 'Category', + }, + { + type: 'TextBlock', + text: 'Optional comment', + spacing: 'small', + }, + { + type: 'Input.Text', + id: 'comment', + placeholder: 'Approval or rejection notes', + isMultiline: true, + } + ); + + const actions = [ + { + type: 'Action.Submit', + title: 'Approve Invoice', + data: { + action: 'approveInvoice', + workOrderId: woId, + invoiceId, + approvalCode, + category, + }, + }, + { + type: 'Action.Submit', + title: 'Reject Invoice', + data: { + action: 'rejectInvoice', + workOrderId: woId, + invoiceId, + }, + }, + { + type: 'Action.Submit', + title: 'Cancel', + associatedInputs: 'none', + data: { + action: 'dismissInvoiceCard', + workOrderId: woId, + invoiceId, + }, + }, + ]; + + return { + $schema: 'http://adaptivecards.io/schemas/adaptive-card.json', + type: 'AdaptiveCard', + version: '1.3', + body, + actions, + }; +} + +export async function postInvoiceApprovalCard({ db, webex, roomId, workOrderId, woNumber = null }) { + if (!db || !webex || !roomId || !workOrderId) return; + + const dedupKey = `wo:${workOrderId}:invoice`; + const now = Date.now(); + _pruneDedupCache(now); + const lastPostedAt = _recentInvoiceCards.get(dedupKey); + if (lastPostedAt && (now - lastPostedAt) < CARD_DEDUP_TTL_MS) { + logger( + 'invoiceApproval', + `Skipping duplicate invoice card for WO ${workOrderId} (posted ${Math.round((now - lastPostedAt) / 1000)}s ago)` + ); + return; + } + _recentInvoiceCards.set(dedupKey, now); + + try { + const woStatus = await getWorkOrderStatus(workOrderId); + if (!woStatus?.invoiceId) { + logger('invoiceApproval', `No invoice on WO ${workOrderId} — skipping card`); + _recentInvoiceCards.delete(dedupKey); + return; + } + + const invoiceDetails = await getInvoice(woStatus.invoiceId); + if (invoiceDetails && (_isInvoiceApproved(invoiceDetails) || _isInvoiceRejected(invoiceDetails))) { + logger( + 'invoiceApproval', + `Invoice ${woStatus.invoiceId} already ${_invoiceStatusPrimary(invoiceDetails)} — skipping card` + ); + _recentInvoiceCards.delete(dedupKey); + return; + } + + const invoiceContext = { + invoiceId: woStatus.invoiceId, + invoiceNumber: woStatus.invoiceNumber ?? invoiceDetails?.Number, + invoiceTotal: woStatus.invoiceTotal ?? invoiceDetails?.InvoiceTotal ?? invoiceDetails?.Total, + approvalCode: woStatus.approvalCode, + category: woStatus.category, + }; + + const card = buildInvoiceApprovalAdaptiveCard( + { workOrderId, woNumber: woNumber || woStatus.woNumber }, + invoiceContext + ); + + const sender = webex && typeof webex.sendAdaptiveCard === 'function' ? webex : webexService; + const fallback = + `Invoice approval for WO-${woNumber || woStatus.woNumber}. Invoice #${invoiceContext.invoiceNumber || woStatus.invoiceId}.`; + + const existing = await getPendingInvoiceCard(db, workOrderId); + if (existing?.messageId) { + await deleteInvoiceCardMessage(existing.messageId); + } + + const cardMsg = await sender.sendAdaptiveCard(roomId, card, fallback); + if (cardMsg?.id) { + await savePendingInvoiceCard(db, { + workOrderId, + roomId, + messageId: cardMsg.id, + invoiceId: woStatus.invoiceId, + }); + } + + logger( + 'invoiceApproval', + `Posted invoice approval card for WO-${woNumber || woStatus.woNumber} in room ${roomId} (invoiceId=${woStatus.invoiceId}, messageId=${cardMsg?.id || 'n/a'})` + ); + } catch (err) { + _recentInvoiceCards.delete(dedupKey); + logger('invoiceApproval', `Failed to post invoice card for WO ${workOrderId}: ${err.message}`, 'error'); + throw err; + } +} + +function _isInvoiceResolved(noteData) { + const note = String(noteData || ''); + if (/Invoice\s*#?\s*\d+\s+has been approved/i.test(note)) return true; + if (/Invoice\s*#?\s*\d+\s+has been rejected/i.test(note)) return true; + if (/Invoice has been approved/i.test(note)) return true; + if (/Invoice has been rejected/i.test(note)) return true; + return false; +} + +export async function removeInvoiceCardIfResolved({ db, workOrderId, noteData }) { + if (!db || !workOrderId) return; + if (!_isInvoiceResolved(noteData)) return; + + try { + const row = await getPendingInvoiceCard(db, workOrderId); + if (!row?.messageId) return; + + await deleteInvoiceCardMessage(row.messageId); + await clearPendingInvoiceCard(db, workOrderId); + logger('invoiceApproval', `Removed stale invoice card for WO ${workOrderId} (resolved in ServiceChannel)`); + } catch (err) { + logger('invoiceApproval', `Failed to remove resolved invoice card for WO ${workOrderId}: ${err.message}`, 'warn'); + } +} + +export async function handleInvoiceApprovalSubmit(bot, trigger, { db = null } = {}) { + const action = trigger?.attachmentAction; + if (!action || !action.inputs) return; + + const inputs = action.inputs; + const actionType = inputs.action; + + if (actionType === 'dismissInvoiceCard') { + const woId = inputs.workOrderId; + await _removeInvoiceCard(bot, action); + if (db && woId) { + try { + await clearPendingInvoiceCard(db, woId); + } catch (err) { + logger('invoiceApproval:submit', `Could not clear pending invoice card for WO ${woId}: ${err.message}`, 'warn'); + } + } + try { + await bot.say('Invoice approval card dismissed. Approve in ServiceChannel when ready.'); + } catch (_) {} + return; + } + + if (actionType !== 'approveInvoice' && actionType !== 'rejectInvoice') return; + + const woId = inputs.workOrderId; + const invoiceId = inputs.invoiceId; + const comment = String(inputs.comment || '').trim(); + const category = String(inputs.category ?? '').trim(); + + if (!woId || !invoiceId) { + try { + await bot.say('Missing work order or invoice in submission.'); + } catch (_) {} + return; + } + + let approver = 'Webex user'; + let approverEmail = ''; + try { + const botClientMod = await import('../integrations/webex/botClient.js'); + const details = await botClientMod.default.getPersonDetails(action.personId); + approverEmail = details?.emails?.[0] || ''; + approver = details?.displayName || approverEmail || action.personId || 'Webex user'; + } catch (e) { + logger('invoiceApproval:submit', `Could not resolve person ${action.personId}: ${e.message}`, 'warn'); + } + + if (actionType === 'approveInvoice') { + const approvalCode = String(inputs.approvalCode ?? '').trim(); + if (!approvalCode) { + try { + await bot.say('Cannot approve: this work order has no Approval Code in ServiceChannel. Add one in SC or approve manually.'); + } catch (_) {} + return; + } + + try { + await approveInvoice(invoiceId, { + approvalCode, + comments: comment || `Approved by ${approver} via ServChan`, + category, + }); + + await _removeInvoiceCard(bot, action); + if (db) { + await clearPendingInvoiceCard(db, woId); + } + + await bot.say({ + markdown: + `✅ **Invoice approved** by **${approver}** for WO **${woId}**.` + + (comment ? `\n\nComment: ${comment}` : ''), + }); + logger('invoiceApproval:submit', `Approved invoice ${invoiceId} for WO ${woId} by ${approver}`); + } catch (err) { + const msg = err.message || String(err); + const isTokenError = msg.includes('token fetch failed'); + const isMli = msg.includes('503') || msg.toLowerCase().includes('mli'); + const reply = isTokenError + ? `❌ **ServiceChannel login failed** — fix SC credentials and restart ServChan, then retry.` + : isMli + ? `❌ Invoice approval via API is unavailable (MLI enabled). Approve manually in ServiceChannel.\n\nDetails: ${msg}` + : `❌ Failed to approve invoice: ${msg}. Approve manually in ServiceChannel.`; + await bot.say({ markdown: reply }); + logger('invoiceApproval:submit', `Approve failed for invoice ${invoiceId}: ${msg}`, 'error'); + } + return; + } + + // rejectInvoice + try { + await rejectInvoice(invoiceId, { + comments: comment || `Rejected by ${approver} via ServChan`, + isNotifyProvider: true, + }); + + await _removeInvoiceCard(bot, action); + if (db) { + await clearPendingInvoiceCard(db, woId); + } + + await bot.say({ + markdown: + `Invoice **rejected** by **${approver}** for WO **${woId}**.` + + (comment ? `\n\nComment: ${comment}` : ''), + }); + logger('invoiceApproval:submit', `Rejected invoice ${invoiceId} for WO ${woId} by ${approver}`); + } catch (err) { + const msg = err.message || String(err); + await bot.say({ markdown: `❌ Failed to reject invoice: ${msg}. Reject manually in ServiceChannel.` }); + logger('invoiceApproval:submit', `Reject failed for invoice ${invoiceId}: ${msg}`, 'error'); + } +} + +export default { + buildInvoiceApprovalAdaptiveCard, + postInvoiceApprovalCard, + removeInvoiceCardIfResolved, + handleInvoiceApprovalSubmit, +}; diff --git a/src/services/spaceCleanupService.js b/src/services/spaceCleanupService.js index 0b44e70..3351a69 100644 --- a/src/services/spaceCleanupService.js +++ b/src/services/spaceCleanupService.js @@ -4,10 +4,13 @@ import botClient from '../integrations/webex/botClient.js'; import { logger } from '../utils/logger.js'; import defaultDb from '../db/mappings.js'; import { getCompletedOperationsRoomId, parseServChanRoomTitle } from '../utils/servchanRoomTitle.js'; +import { getWoCloseout } from './woCloseoutService.js'; const REMOVE_OTHERS_AFTER_DAYS = 14; const DELETE_AFTER_DAYS = 60; const REMINDER_AFTER_DAYS = parseInt(process.env.SPACE_CLEANUP_REMINDER_DAYS || '60', 10); +const CLEANUP_PROVIDER_NAME = (process.env.SPACE_CLEANUP_PROVIDER_NAME || 'Pro-Motion Technology Group, LLC').trim(); +const CLEANUP_PROVIDER_ID = parseInt(process.env.SPACE_CLEANUP_PROVIDER_ID || '2000002215', 10); /** COMPLETED + these extended values → auto-remove eligible. */ const AUTO_REMOVE_EXTENDED = new Set(['CONFIRMED', 'CANCELLED']); @@ -25,6 +28,22 @@ function isNoChargeExtended(extendedRaw) { return extended === 'NO CHARGE' || extended.includes('NO CHARGE'); } +export function matchesCleanupProvider(providerName, providerId = null) { + const hasNameFilter = Boolean(CLEANUP_PROVIDER_NAME); + const hasIdFilter = Number.isFinite(CLEANUP_PROVIDER_ID) && CLEANUP_PROVIDER_ID > 0; + if (!hasNameFilter && !hasIdFilter) return true; + + if (hasIdFilter && providerId != null && Number(providerId) === CLEANUP_PROVIDER_ID) { + return true; + } + + if (hasNameFilter) { + return (providerName || '').trim().toLowerCase() === CLEANUP_PROVIDER_NAME.toLowerCase(); + } + + return false; +} + /** * Classify a work order for space cleanup. * @returns {{ category: 'auto_remove'|'remind'|'skip', reason: string }} @@ -76,12 +95,17 @@ function buildConsolidatedDigestMessage(digestRows) { for (const row of digestRows) { const header = row.headerLine || `WO-${row.woNumber || row.workOrderId}`; - md += `- **${header}** — ${row.statusDisplay} — ${row.ageDays} days\n`; + let line = `- **${header}** — ${row.statusDisplay}`; + if (row.isInvoiced) { + line += row.invoiceNumber ? ` — invoiced #${row.invoiceNumber}` : ' — invoiced'; + } + line += ` — ${row.ageDays} days\n`; + md += line; } md += '\nTerminal statuses (Confirmed, Cancelled, No Charge) trigger auto-remove. ' + - 'Use `/completed` in the WO space when verified working.'; + 'After an invoice is posted, use `/confirmed` in the WO space when resolution is verified.'; return md; } @@ -134,6 +158,7 @@ export async function runSpaceCleanup(dryRun = true, options = {}) { let deleted = 0; let digestPosted = 0; let skipped = 0; + let providerFiltered = 0; let failed = 0; try { @@ -159,10 +184,36 @@ export async function runSpaceCleanup(dryRun = true, options = {}) { continue; } + if (!matchesCleanupProvider(statusInfo.providerName, statusInfo.providerId)) { + const providerLabel = statusInfo.providerName + ? `${statusInfo.providerName}${statusInfo.providerId ? ` (${statusInfo.providerId})` : ''}` + : (statusInfo.providerId ? `ID ${statusInfo.providerId}` : 'UNKNOWN PROVIDER'); + results.push({ + woId: mapping.workOrderId, + roomId: mapping.roomId, + action: 'skipped', + status: providerLabel, + reason: `provider excluded (cleanup limited to ${CLEANUP_PROVIDER_NAME}${Number.isFinite(CLEANUP_PROVIDER_ID) ? ` / ID ${CLEANUP_PROVIDER_ID}` : ''})`, + }); + providerFiltered++; + skipped++; + continue; + } + const primary = normalizePrimary(statusInfo.primaryStatus); const extended = normalizeExtended(statusInfo.extendedStatus); const statusDisplay = extended ? `${primary} / ${extended}` : primary; - const classification = classifyWorkOrderCleanup(primary, extended); + let classification = classifyWorkOrderCleanup(primary, extended); + + const closeout = await getWoCloseout(db, mapping.workOrderId); + if (closeout && primary === 'COMPLETED') { + classification = { + category: 'auto_remove', + reason: closeout.scStatusUpdated + ? 'ServChan close-out recorded (SC CONFIRMED)' + : 'ServChan close-out recorded (SC note only)', + }; + } if (classification.category === 'skip') { results.push({ @@ -176,7 +227,9 @@ export async function runSpaceCleanup(dryRun = true, options = {}) { continue; } - const ageDays = daysSince(statusInfo.updatedDate); + const ageDays = closeout?.confirmedAt + ? daysSince(closeout.confirmedAt) + : daysSince(statusInfo.updatedDate); if (classification.category === 'remind') { remindEligible++; @@ -214,6 +267,8 @@ export async function runSpaceCleanup(dryRun = true, options = {}) { ageDays, roomId: mapping.roomId, headerLine, + isInvoiced: statusInfo.isInvoiced === true, + invoiceNumber: statusInfo.invoiceNumber ?? null, }); results.push({ @@ -378,6 +433,9 @@ export async function runSpaceCleanup(dryRun = true, options = {}) { digestPosted, digestWouldPost: dryRun && digestWouldPost && digestRows.length > 0, digestSkippedReason, + providerFiltered, + cleanupProvider: CLEANUP_PROVIDER_NAME || null, + cleanupProviderId: Number.isFinite(CLEANUP_PROVIDER_ID) ? CLEANUP_PROVIDER_ID : null, removedOthers, deleted, skipped, diff --git a/src/services/webhookProcessor.js b/src/services/webhookProcessor.js index ef178dc..adea58e 100644 --- a/src/services/webhookProcessor.js +++ b/src/services/webhookProcessor.js @@ -34,6 +34,7 @@ import { Mutex } from 'async-mutex'; import { logger } from '../utils/logger.js'; import approvalService from './approvalService.js'; +import invoiceApprovalService from './invoiceApprovalService.js'; import attachmentService from './attachmentService.js'; export function createWebhookProcessor(options = {}) { @@ -232,6 +233,14 @@ export function createWebhookProcessor(options = {}) { logger('webhookProcessor:approval', `Card remove error for ${workOrderId}: ${e.message}`, 'warn'); }); + invoiceApprovalService.removeInvoiceCardIfResolved({ + db, + workOrderId, + noteData: noteDataForApproval, + }).catch((e) => { + logger('webhookProcessor:invoiceApproval', `Card remove error for ${workOrderId}: ${e.message}`, 'warn'); + }); + // NEW: Proposal approval card for WAITING FOR APPROVAL status (additive to text note) const ext = (p.Object?.Status?.Extended || '').toUpperCase(); if (ext.includes('WAITING FOR APPROVAL')) { diff --git a/src/services/woCloseoutService.js b/src/services/woCloseoutService.js new file mode 100644 index 0000000..cbc9f85 --- /dev/null +++ b/src/services/woCloseoutService.js @@ -0,0 +1,38 @@ +/** + * ServChan-recorded WO close-outs (when SC status API cannot set CONFIRMED). + */ + +export function getWoCloseout(db, workOrderId) { + return new Promise((resolve, reject) => { + db.get( + `SELECT workOrderId, confirmedAt, confirmedBy, scStatusUpdated, noteText + FROM wo_closeouts WHERE workOrderId = ?`, + [workOrderId], + (err, row) => (err ? reject(err) : resolve(row || null)) + ); + }); +} + +export function saveWoCloseout(db, { workOrderId, confirmedBy, scStatusUpdated, noteText }) { + return new Promise((resolve, reject) => { + db.run( + `INSERT OR REPLACE INTO wo_closeouts (workOrderId, confirmedAt, confirmedBy, scStatusUpdated, noteText) + VALUES (?, ?, ?, ?, ?)`, + [ + workOrderId, + new Date().toISOString(), + confirmedBy, + scStatusUpdated ? 1 : 0, + noteText ?? null, + ], + (err) => (err ? reject(err) : resolve()) + ); + }); +} + +export async function hasWoCloseout(db, workOrderId) { + const row = await getWoCloseout(db, workOrderId); + return Boolean(row); +} + +export default { getWoCloseout, saveWoCloseout, hasWoCloseout }; diff --git a/src/utils/woRoomContext.js b/src/utils/woRoomContext.js new file mode 100644 index 0000000..62821af --- /dev/null +++ b/src/utils/woRoomContext.js @@ -0,0 +1,57 @@ +/** + * Shared guards for commands that only run in mapped ServChan WO spaces. + */ + +import { parseServChanRoomTitle } from './servchanRoomTitle.js'; + +const WO_SPACE_MSG = 'This command can only be used in a ServChan work order space.'; + +function lookupWorkOrderIdByRoom(db, roomId) { + return new Promise((resolve, reject) => { + db.get( + 'SELECT workOrderId FROM mappings WHERE roomId = ?', + [roomId], + (err, row) => (err ? reject(err) : resolve(row?.workOrderId ?? null)) + ); + }); +} + +/** + * @returns {Promise<{ ok: true, workOrderId, roomId, parsed, room } | { ok: false, error: string }>} + */ +export async function resolveWoRoomContext({ db, botClient, roomId, isGroup }) { + if (!isGroup || !roomId) { + return { ok: false, error: WO_SPACE_MSG }; + } + + const workOrderId = await lookupWorkOrderIdByRoom(db, roomId); + if (!workOrderId) { + return { ok: false, error: WO_SPACE_MSG }; + } + + let room; + try { + room = await botClient.getRoom(roomId); + } catch { + return { ok: false, error: 'Could not load this Webex space. Please try again.' }; + } + + const parsed = parseServChanRoomTitle(room?.title); + if (!parsed) { + return { ok: false, error: WO_SPACE_MSG }; + } + + return { ok: true, workOrderId, roomId, parsed, room }; +} + +export async function resolveDisplayName(botClient, personId) { + if (!personId) return 'Unknown User'; + try { + const person = await botClient.getPersonDetails(personId); + return person?.displayName || person?.emails?.[0] || 'Unknown User'; + } catch { + return 'Unknown User'; + } +} + +export default { resolveWoRoomContext, resolveDisplayName };