// src/commands/provisionDect.js import { findDectNetworkForStore, getDectProvisioningStatus, addDectBasestation, removeDectBasestation, addDectHandset, removeDectHandset, generateDectAccessCode } from '../services/phoneService.js'; import { logger } from '../utils/logger.js'; function buildProvisioningCard(storeNum, network, basestations = [], handsets = []) { const padded = String(storeNum).padStart(4, '0'); const networkName = network?.name || `Store ${padded}`; const baseFacts = [ { title: 'Basestations', value: String(basestations.length) }, { title: 'Handsets', value: String(handsets.length) }, { title: 'Location', value: network?.locationName || network?.location?.name || '—' } ]; const baseList = basestations.length ? basestations.map(b => ({ type: 'TextBlock', text: `• ${b.mac || '—'} | ${b.status || 'unknown'} | IP: ${b.ipAddress || '—'}`, size: 'Small' })) : [{ type: 'TextBlock', text: 'None', size: 'Small', color: 'Attention' }]; const handsetList = handsets.length ? handsets.map(h => ({ type: 'TextBlock', text: `• ${h.index ? h.index + '-' : ''}${h.extension || h.accessCode || '—'} (${h.name || ''}) @ Base ${h.baseMac || h.baseStationId || 'unassigned'}`, size: 'Small' })) : [{ type: 'TextBlock', text: 'None', size: 'Small', color: 'Attention' }]; // Choices for remove (use ids) - will be turned into checkboxes const baseChoices = basestations.map(b => ({ title: `${b.mac} (${b.status})`, value: b.id })); const handsetChoices = handsets.map(h => ({ title: `${h.index ? h.index + '-' : ''}${h.extension || h.accessCode || '—'}`, value: h.id })); return { type: 'AdaptiveCard', version: '1.3', body: [ { type: 'TextBlock', text: `DECT Provisioning - Store ${padded}`, weight: 'Bolder', size: 'Large' }, { type: 'TextBlock', text: `Network: ${networkName}`, size: 'Medium' }, { type: 'FactSet', facts: baseFacts }, { type: 'TextBlock', text: 'Current Basestations', weight: 'Bolder', spacing: 'Medium' }, ...baseList, { type: 'TextBlock', text: 'Current Handsets', weight: 'Bolder', spacing: 'Medium' }, ...handsetList, { type: 'TextBlock', text: 'Add Basestation(s) (comma/space separated MACs)', weight: 'Bolder', spacing: 'Medium' }, { type: 'Input.Text', id: 'baseMacs', placeholder: '001122334455, AABBCCDDEEFF' }, { type: 'TextBlock', text: 'Add Handset', weight: 'Bolder', spacing: 'Medium' }, { type: 'TextBlock', text: 'Remove - use checkboxes (multi-select supported). Confirmation will be required.', weight: 'Bolder', spacing: 'Medium' }, { type: 'Input.ChoiceSet', id: 'removeBases', isMultiSelect: true, style: 'expanded', choices: baseChoices.length ? baseChoices : [{ title: 'None', value: '' }] }, { type: 'Input.ChoiceSet', id: 'removeHandsets', isMultiSelect: true, style: 'expanded', choices: handsetChoices.length ? handsetChoices : [{ title: 'None', value: '' }] }, ], actions: [ { type: 'Action.Submit', title: '➕ Add Basestation(s)', data: { action: 'add-bases', storeNumber: String(storeNum) } }, { type: 'Action.Submit', title: '➕ Add Handset', data: { action: 'add-handset', storeNumber: String(storeNum) } }, { type: 'Action.Submit', title: '🗑 Remove Selected Basestations (will confirm)', data: { action: 'remove-bases', storeNumber: String(storeNum) } }, { type: 'Action.Submit', title: '🗑 Remove Selected Handsets (will confirm)', data: { action: 'remove-handsets', storeNumber: String(storeNum) } }, { type: 'Action.Submit', title: '🔄 Refresh Status', data: { action: 'refresh', storeNumber: String(storeNum) } } ] }; } export async function handleProvisionDect(bot, trigger) { logger('phone:provision', 'Handler entered'); const args = trigger.args || []; const query = trigger.query || {}; let storeNum = (args[0] || query.storeNum || query.store || query.s || '').trim(); if (!storeNum || !/^\d{3,4}$/.test(storeNum)) { await bot.say('markdown', '**Usage:** `/provision-dect 1234` (3-4 digit store number)\nNetwork must already exist as "Store XXXX".'); return; } logger('phone:provision', `Starting provisioning for store ${storeNum}`); try { const status = await getDectProvisioningStatus(storeNum); const { network, basestations, handsets } = status; if (!network) { await bot.say('markdown', `❌ No DECT network found for Store ${storeNum} (looked up via 5-digit person). Ensure the network "Store ${storeNum.padStart(4, '0')}" exists.`); return; } const card = buildProvisioningCard(storeNum, network, basestations, handsets); await bot.say({ markdown: `DECT Provisioning for Store ${storeNum}`, attachments: [{ contentType: 'application/vnd.microsoft.card.adaptive', content: card }] }); } catch (err) { logger('phone:provision', `Error in handler for ${storeNum}: ${err.message}`, 'error'); await bot.say('markdown', `❌ Error: ${err.message}`); } } // Handle attachment actions for provisioning export async function handleDectProvisionAction(bot, trigger) { const action = trigger.attachmentAction; if (!action || !action.inputs) return; const inputs = action.inputs; const actionType = inputs.action; const storeNumber = inputs.storeNumber; if (!actionType || !storeNumber) return; const roomId = trigger.roomId || action.roomId; logger('phone:provision', `Action ${actionType} for store ${storeNumber}`); try { const status = await getDectProvisioningStatus(storeNumber); let { network, basestations = [], handsets = [] } = status; if (!network) { await bot.say('markdown', '❌ Network no longer found. Ignore any stale cards.', roomId); return; } const locationId = network.locationId || network.location?.id; const networkId = network.id; // Remove the card message that was acted on (to prevent stale clicks) // Use bot.censor which uses the bot's token and checks permissions const messageId = trigger.attachmentAction.messageId; if (messageId) { try { await bot.censor(messageId); logger('phone:provision', `Removed stale provisioning card message ${messageId}`); } catch (delErr) { logger('phone:provision', `Could not remove previous card message: ${delErr.message}`, 'warn'); } } let resultTitle = ''; let resultMsg = ''; let isRemoveAction = false; let selectedForConfirm = []; const staleNote = '⚠️ This is a fresh updated view. Any previous DECT provisioning cards for this store are now stale — please ignore them.'; if (actionType === 'refresh') { resultTitle = '✅ Status Refreshed'; resultMsg = 'Current DECT status updated below.'; } else if (actionType === 'add-bases') { const macs = inputs.baseMacs || ''; if (!macs) { resultTitle = '⚠️ No MACs provided'; resultMsg = 'Enter MAC(s) to add.'; } else { const results = await addDectBasestation(locationId, networkId, macs); const added = results.filter(r => r.success).length; const skipped = results.filter(r => r.alreadyExists).length; const failed = results.filter(r => r.error).length; resultTitle = '✅ Add Basestation(s)'; resultMsg = `Added: ${added}, Skipped (exists): ${skipped}, Failed: ${failed}`; } } else if (actionType === 'add-handset') { // Auto extension: always 5 + 4-digit padded store number (e.g. store 782 -> 50782) const padded4 = String(storeNumber).padStart(4, '0'); const ext = '5' + padded4; const code = generateDectAccessCode(storeNumber); // display name = extension (per requirements) const res = await addDectHandset(locationId, networkId, { displayName: ext, accessCode: code // no baseStationId }); if (res?.alreadyExists) { resultTitle = 'ℹ️ Already exists'; resultMsg = `Handset with access code or similar for extension ${ext} already present.`; } else { resultTitle = '✅ Handset Added'; resultMsg = `Added handset for extension ${ext}. Access code: ${code} (per store rules). Refresh to see updated list.`; } } else if (actionType === 'remove-bases' || actionType === 'remove-handsets') { isRemoveAction = true; const isBase = actionType === 'remove-bases'; let rawSelected = isBase ? inputs.removeBases : inputs.removeHandsets; const selected = Array.isArray(rawSelected) ? rawSelected : (rawSelected ? [rawSelected] : []); if (!selected || selected.length === 0) { resultTitle = '⚠️ Nothing selected'; resultMsg = 'Select items using checkboxes to remove.'; } else { // Send confirmation card instead of deleting immediately const itemsList = selected.map(id => { if (isBase) { const b = basestations.find(x => x.id === id); return b ? `${b.mac} (${b.status})` : id; } else { const h = handsets.find(x => x.id === id); const label = h ? `${h.index ? h.index + '-' : ''}${h.extension || h.accessCode || ''}` : id; return label; } }).join(', '); const confirmAction = isBase ? 'confirm-remove-bases' : 'confirm-remove-handsets'; const confirmCard = { type: 'AdaptiveCard', version: '1.3', body: [ { type: 'TextBlock', text: `⚠️ CONFIRM DELETE`, weight: 'Bolder', size: 'Large', color: 'Attention' }, { type: 'TextBlock', text: `You are about to permanently remove the following ${isBase ? 'basestation(s)' : 'handset(s)'} for Store ${storeNumber}:`, wrap: true }, { type: 'TextBlock', text: itemsList, wrap: true }, { type: 'TextBlock', text: 'This action cannot be undone. The provisioning card will be refreshed after.', wrap: true, color: 'Attention' } ], actions: [ { type: 'Action.Submit', title: '✅ Yes, Delete', data: { action: confirmAction, storeNumber, selected } }, { type: 'Action.Submit', title: '❌ Cancel', data: { action: 'cancel-remove', storeNumber } } ] }; await bot.say({ markdown: 'Confirm removal', attachments: [{ contentType: 'application/vnd.microsoft.card.adaptive', content: confirmCard }] }, roomId); return; // don't send the main card yet } } else if (actionType === 'confirm-remove-bases' || actionType === 'confirm-remove-handsets') { const isBase = actionType === 'confirm-remove-bases'; let rawSelected = inputs.selected; const selected = Array.isArray(rawSelected) ? rawSelected : (rawSelected ? [rawSelected] : []); const results = []; for (const id of selected) { try { if (isBase) { await removeDectBasestation(locationId, networkId, id); results.push(`✅ base ${id}`); } else { await removeDectHandset(locationId, networkId, id); results.push(`✅ handset ${id}`); } } catch (e) { results.push(`❌ ${id}: ${e.message}`); } } resultTitle = '✅ Remove Completed'; resultMsg = results.join('\n'); } else if (actionType === 'cancel-remove') { resultTitle = 'ℹ️ Remove cancelled'; resultMsg = 'No changes made.'; } else { resultTitle = 'ℹ️ Unknown action'; resultMsg = actionType; } // Re-fetch fresh status const fresh = await getDectProvisioningStatus(storeNumber); // Build fresh card and add banners (stale note + result) const updatedCard = buildProvisioningCard(storeNumber, fresh.network, fresh.basestations, fresh.handsets); updatedCard.body.unshift({ type: 'TextBlock', text: `${resultTitle}\n${resultMsg}`, weight: 'Bolder', color: resultTitle.includes('✅') ? 'Good' : 'Attention' }); updatedCard.body.unshift({ type: 'TextBlock', text: staleNote, wrap: true, color: 'Attention' }); await bot.say({ markdown: resultTitle, attachments: [{ contentType: 'application/vnd.microsoft.card.adaptive', content: updatedCard }] }, roomId); } catch (err) { logger('phone:provision', `Action ${actionType} error for ${storeNumber}: ${err.message}`, 'error'); await bot.say('markdown', `❌ Action failed: ${err.message}. Previous card may be stale.`, roomId); } }