diff --git a/README.md b/README.md index f8af23a..b742709 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,14 @@ Registered in [src/commands](src/commands): offers a recovery "create network" prompt for legacy stores or if finalize's DECT step was skipped. All Webex API — no remote agent required. +- `/provisionPhone ` — manage wired desk phones (Cisco 7841 + / 7821) owned by the store user (resolved via `ae<5-digit>@ae.com`). + The card shows the store user's current wired phones (DECT handsets + are filtered out — those live in `/provisionDect`), with an add form + (MAC + model) and a checkbox list for removal. Adds are idempotent + (MACs already registered are skipped), and every removal goes through + an explicit confirmation card. All Webex API — no remote agent + required. - `/storeinfo ` — show current Webex info for the store user (`ae<5-digit>@ae.com`). - `/userinfo ` — show current Webex info for any user by email. diff --git a/src/cards/phoneCards.js b/src/cards/phoneCards.js new file mode 100644 index 0000000..0245bec --- /dev/null +++ b/src/cards/phoneCards.js @@ -0,0 +1,196 @@ +// Adaptive Cards for the /provisionPhone command. +// +// Two builders live here for one UI flow: +// phoneManagementCard - list + add + multi-select-remove for wired phones +// phoneConfirmRemoveCard - destructive-op confirmation before delete +// +// All action strings are namespaced `phone-*` so attachmentActions.js +// can dispatch them without colliding with stage/finalize/DECT actions. + +import { displayMac } from '../utils/mac.js'; +import { WIRED_PHONE_MODELS } from '../webex/phones.js'; + +const SCHEMA = 'http://adaptivecards.io/schemas/adaptive-card.json'; + +/** + * Main management card: shows the store user's current wired phones + * (checkbox list doubles as read-only display), plus an add form and + * side-by-side Add / Remove-checked actions. Optional `resultBanner` + * is rendered inline at the top after a mutation so operators see + * whether the previous submission succeeded or failed. + * + * Header shows the store number (matches /provisionDect) with the + * resolved user's displayName + email as a subtle subtitle so + * operators can eyeball that the right person was matched. + */ +export function phoneManagementCard(storeNumber, { user, phones }, resultBanner) { + const padded = String(storeNumber).padStart(4, '0'); + const dataCtx = { storeNumber, personId: user.id }; + + return { + type: 'AdaptiveCard', + $schema: SCHEMA, + version: '1.3', + body: [ + { + type: 'TextBlock', + text: `Phones — Store ${padded}`, + weight: 'Bolder', + size: 'Large', + }, + { + type: 'TextBlock', + text: `${user.displayName ?? '—'} · \`${user.emails?.[0] ?? ''}\``, + wrap: true, + spacing: 'Small', + isSubtle: true, + }, + ...(resultBanner ? [resultBannerBlock(resultBanner)] : []), + ...renderPhones(phones, dataCtx), + ], + actions: [ + { + type: 'Action.Submit', + title: 'Refresh', + id: 'btnPhoneRefresh', + data: { action: 'phone-refresh', storeNumber }, + }, + { + type: 'Action.Submit', + title: 'Close', + id: 'btnCancel', + data: { action: 'deleteCard' }, + }, + ], + }; +} + +function renderPhones(phones, dataCtx) { + return [ + { + type: 'TextBlock', + text: `Wired phones (${phones.length})`, + weight: 'Bolder', + size: 'Medium', + separator: true, + }, + // One list. Each row is a checkbox whose label carries the full + // display info; the list doubles as the read-only view for + // operators who just want to see what's registered. + phones.length === 0 + ? { type: 'TextBlock', text: '_None yet._', wrap: true, isSubtle: true } + : { + type: 'Input.ChoiceSet', + id: 'removePhones', + isMultiSelect: true, + style: 'expanded', + choices: phones.map((p) => ({ + title: `${p.model} · ${displayMac(p.mac)} · ${p.connectionStatus}`, + value: p.id, + })), + }, + { + type: 'Input.ChoiceSet', + id: 'phoneModel', + label: 'Model', + value: WIRED_PHONE_MODELS[0].value, + choices: WIRED_PHONE_MODELS.map((m) => ({ title: m.label, value: m.value })), + }, + { + type: 'Input.Text', + id: 'phoneMacs', + label: 'Add phone MAC(s)', + placeholder: 'AA:BB:CC:DD:EE:FF, another-mac ...', + }, + { + type: 'ActionSet', + actions: [ + { + type: 'Action.Submit', + title: 'Add', + id: 'btnPhoneAdd', + data: { action: 'phone-add', ...dataCtx }, + }, + ...(phones.length > 0 + ? [ + { + type: 'Action.Submit', + title: 'Remove checked', + id: 'btnPhoneRemove', + style: 'destructive', + data: { action: 'phone-remove', ...dataCtx }, + }, + ] + : []), + ], + }, + ]; +} + +/** + * Confirmation card before a destructive removal. `items` is an array of + * `{ id, label }` describing what will be deleted; `confirmAction` is + * the action string the Yes button posts back + * (e.g. 'phone-confirm-remove'). + */ +export function phoneConfirmRemoveCard({ storeNumber, personId, items, confirmAction }) { + const plural = items.length === 1 ? 'phone' : 'phones'; + return { + type: 'AdaptiveCard', + $schema: SCHEMA, + version: '1.3', + body: [ + { + type: 'TextBlock', + text: `Remove ${items.length} ${plural}?`, + weight: 'Bolder', + size: 'Medium', + }, + { + type: 'TextBlock', + text: items.map((i) => `- ${i.label}`).join('\n'), + wrap: true, + }, + { + type: 'TextBlock', + text: 'This unregisters the device from the user in Webex Control Hub. The physical phone will drop registration until re-added.', + wrap: true, + isSubtle: true, + size: 'Small', + }, + ], + actions: [ + { + type: 'Action.Submit', + title: 'Yes, remove', + id: 'btnPhoneConfirm', + style: 'destructive', + data: { + action: confirmAction, + storeNumber, + personId, + ids: items.map((i) => i.id), + }, + }, + { + type: 'Action.Submit', + title: 'Cancel', + id: 'btnCancel', + data: { action: 'deleteCard' }, + }, + ], + }; +} + +// Match dectCards.resultBannerBlock so the two flows use identical +// visual language for post-mutation feedback. +function resultBannerBlock({ text, tone = 'default' }) { + return { + type: 'TextBlock', + text, + wrap: true, + color: tone === 'error' ? 'Attention' : tone === 'good' ? 'Good' : 'Default', + weight: 'Bolder', + spacing: 'Medium', + }; +} diff --git a/src/commands/attachmentActions.js b/src/commands/attachmentActions.js index 86aef92..a29ee88 100644 --- a/src/commands/attachmentActions.js +++ b/src/commands/attachmentActions.js @@ -3,6 +3,7 @@ import { stageStoreLocation } from '../flows/stageStore.js'; import { finalizeStoreLocation } from '../flows/finalizeStore.js'; import { getWebexDeviceDetail } from '../webex/devices.js'; import { handleDectAction } from './dectActions.js'; +import { handlePhoneAction } from './phoneActions.js'; async function runFlow(bot, trigger, verb, fn) { const { storeInfo, userInfo } = trigger.attachmentAction.inputs; @@ -35,11 +36,16 @@ export function register(framework) { logger.debug('attachmentAction', JSON.stringify(trigger)); const action = trigger.attachmentAction.inputs.action; - // DECT flow has its own sub-dispatcher. + // DECT and wired-phone flows each have their own sub-dispatcher + // so this file stays focused on the store lifecycle actions. if (typeof action === 'string' && action.startsWith('dect-')) { await handleDectAction(bot, trigger, action); return; } + if (typeof action === 'string' && action.startsWith('phone-')) { + await handlePhoneAction(bot, trigger, action); + return; + } switch (action) { case 'stageStore': diff --git a/src/commands/helpers.js b/src/commands/helpers.js index 3583e9e..a65bd97 100644 --- a/src/commands/helpers.js +++ b/src/commands/helpers.js @@ -37,6 +37,25 @@ export function parseStoreNumber(trigger) { return raw; } +// Loose email format check — anything with `local@domain.tld` shape. +// Intent is to catch obvious typos early rather than to enforce full +// RFC 5322 (Webex will reject bad addresses anyway). +export const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** + * Parse and validate an email argument. Returns the trimmed lowercase + * email when it looks like an email, `null` when missing/malformed. + * Callers should show a usage message on null. Lowercasing matches + * Webex's own normalization of email addresses. + */ +export function parseEmailArg(trigger) { + const raw = parseStoreArg(trigger); + if (!raw) return null; + const trimmed = raw.trim(); + if (!EMAIL_SHAPE.test(trimmed)) return null; + return trimmed.toLowerCase(); +} + /** * Pull the first argument out of a webex-node-bot-framework trigger. * diff --git a/src/commands/phoneActions.js b/src/commands/phoneActions.js new file mode 100644 index 0000000..5763540 --- /dev/null +++ b/src/commands/phoneActions.js @@ -0,0 +1,157 @@ +// Attachment-action handlers for the /provisionPhone card flow. +// +// Every phone-* action posted by the phone cards routes here. Mirrors +// dectActions.js: pull storeNumber/personId out of `inputs`, do the +// work, then re-render the management card so operators see the +// updated state without having to re-run /provisionPhone. +// +// Errors are caught locally and surfaced in the refreshed card's +// result banner — a failed remove shouldn't take the whole session +// down. + +import { logger } from '../logger.js'; +import { addWiredPhone, getWiredPhoneStatus, removeWiredPhone } from '../webex/phones.js'; +import { displayMac } from '../utils/mac.js'; +import { phoneConfirmRemoveCard, phoneManagementCard } from '../cards/phoneCards.js'; +import { storeEmail } from './helpers.js'; + +/** + * Dispatch a `phone-*` action. Returns true if handled, false if the + * action isn't one of ours (caller should keep switching). + */ +export async function handlePhoneAction(bot, trigger, action) { + const inputs = trigger.attachmentAction.inputs || {}; + const messageId = trigger.attachmentAction.messageId; + const storeNumber = inputs.storeNumber; + + try { + switch (action) { + case 'phone-add': + await doAddPhone(bot, inputs, messageId); + return true; + + case 'phone-remove': + await doStageRemoval(bot, inputs, messageId); + return true; + + case 'phone-confirm-remove': + await doConfirmRemove(bot, inputs, messageId); + return true; + + case 'phone-refresh': + await refreshCard(bot, storeNumber, messageId); + return true; + + default: + return false; + } + } catch (error) { + logger.error(`Phone action ${action} failed:`, error); + bot.say('markdown', `Error handling **${action}**:\n\`\`\`\n${error.message}\n\`\`\``); + return true; + } +} + +async function doAddPhone(bot, inputs, messageId) { + const { storeNumber, personId } = inputs; + const model = String(inputs.phoneModel || '').trim(); + const macs = String(inputs.phoneMacs || '').trim(); + if (!model) { + bot.say('Select a phone model before clicking Add.'); + return; + } + if (!macs) { + bot.say('Enter one or more MAC addresses in the input before clicking Add.'); + return; + } + const results = await addWiredPhone(personId, model, macs); + const added = results.filter((r) => r.success).length; + const skipped = results.filter((r) => r.alreadyExists).length; + const failed = results.filter((r) => r.error); + bot.censor(messageId); + const banner = { + text: `${model}: added ${added}, skipped ${skipped}${failed.length ? `, failed ${failed.length}` : ''}.`, + tone: failed.length ? 'error' : 'good', + }; + if (failed.length) { + banner.text += '\n' + failed.map((f) => `- ${f.mac}: ${f.error}`).join('\n'); + } + await refreshCard(bot, storeNumber, null, banner); +} + +async function doStageRemoval(bot, inputs, messageId) { + const { storeNumber, personId } = inputs; + // Adaptive Cards return multi-select ChoiceSets as either an array + // or a comma-separated string depending on client; normalize both. + const raw = inputs.removePhones; + const selected = Array.isArray(raw) ? raw : raw ? String(raw).split(',') : []; + if (selected.length === 0) { + bot.say('Select at least one phone using the checkboxes above before clicking Remove.'); + return; + } + + // Enrich with friendly labels for the confirm card. + const { phones } = await getWiredPhoneStatus(storeEmail(storeNumber)); + const items = selected.map((id) => { + const hit = phones.find((p) => p.id === id); + if (!hit) return { id, label: id }; + return { id, label: `${hit.model} · ${displayMac(hit.mac)}` }; + }); + + bot.censor(messageId); + bot.sendCard( + phoneConfirmRemoveCard({ + storeNumber, + personId, + items, + confirmAction: 'phone-confirm-remove', + }), + 'Confirm removal in a client that supports Adaptive Cards', + ); +} + +async function doConfirmRemove(bot, inputs, messageId) { + const { storeNumber } = inputs; + const ids = Array.isArray(inputs.ids) ? inputs.ids : []; + if (ids.length === 0) { + bot.say('Nothing selected to remove.'); + return; + } + const results = await Promise.allSettled(ids.map((id) => removeWiredPhone(id))); + const succeeded = results.filter((r) => r.status === 'fulfilled').length; + const failed = results + .map((r, i) => ({ r, id: ids[i] })) + .filter(({ r }) => r.status === 'rejected'); + + bot.censor(messageId); + let text = `Removed ${succeeded} phone${succeeded === 1 ? '' : 's'}.`; + let tone = 'good'; + if (failed.length) { + tone = 'error'; + text += ` Failed ${failed.length}:\n${failed + .map(({ r, id }) => `- ${id}: ${r.reason?.message ?? r.reason}`) + .join('\n')}`; + } + await refreshCard(bot, storeNumber, null, { text, tone }); +} + +async function refreshCard(bot, storeNumber, messageIdToCensor, banner) { + if (messageIdToCensor) bot.censor(messageIdToCensor); + if (!storeNumber) { + bot.say('Cannot refresh phone card: no store number in card context.'); + return; + } + try { + const status = await getWiredPhoneStatus(storeEmail(storeNumber)); + bot.sendCard(phoneManagementCard(storeNumber, status, banner), 'Please use another client'); + } catch (error) { + // Refresh should never take the whole session down. If the + // store user resolution fails (e.g. account deleted mid-session) + // give the operator a clean explanation instead of a stack trace. + logger.error(`Phone refresh failed for store ${storeNumber}:`, error); + bot.say( + 'markdown', + `Could not refresh phones for store ${storeNumber} (\`${storeEmail(storeNumber)}\`):\n\`\`\`\n${error.message}\n\`\`\``, + ); + } +} diff --git a/src/commands/provisionPhone.js b/src/commands/provisionPhone.js new file mode 100644 index 0000000..f48b1de --- /dev/null +++ b/src/commands/provisionPhone.js @@ -0,0 +1,35 @@ +import { logger } from '../logger.js'; +import { getWiredPhoneStatus } from '../webex/phones.js'; +import { phoneManagementCard } from '../cards/phoneCards.js'; +import { parseStoreNumber, storeEmail } from './helpers.js'; + +// /provisionPhone is Webex-Calling-only — no remote agent required. +// Takes a store number and resolves to the store user via the AE +// convention `ae<5-digit>@ae.com` (matches /stageStore, /finalizeStore, +// /provisionDect). The card then lets operators add or remove the +// store user's wired desk phones (Cisco 7841 / 7821). + +export function register(framework) { + framework.hears( + /\/provisionPhone/i, + async (bot, trigger) => { + logger.info(`${trigger.person.displayName} ran the provisionPhone command.`); + const storeNumber = parseStoreNumber(trigger); + if (!storeNumber) { + bot.say('Usage: `/provisionPhone ` — e.g. `/provisionPhone 499`'); + return; + } + try { + const status = await getWiredPhoneStatus(storeEmail(storeNumber)); + bot.sendCard(phoneManagementCard(storeNumber, status), 'Please use another client'); + } catch (error) { + logger.error('provisionPhone command failed:', error); + bot.say( + 'markdown', + `Error running /provisionPhone:\n\`\`\`\n${error.message}\n\`\`\``, + ); + } + }, + '**/provisionPhone** - Add or remove wired desk phones (Cisco 7841/7821) owned by the store user. Every removal is confirmed on a second card.', + ); +} diff --git a/src/commands/userInfo.js b/src/commands/userInfo.js index ca9203f..656ca8e 100644 --- a/src/commands/userInfo.js +++ b/src/commands/userInfo.js @@ -1,24 +1,18 @@ import { logger } from '../logger.js'; import { buildUserInfoCard } from '../cards/userInfoCard.js'; -import { parseStoreArg } from './helpers.js'; - -// Loose email format check — anything with `local@domain` shape. Intent -// is to catch obvious typos like `/userInfo joe` early rather than to -// enforce full RFC 5322 (Webex will reject bad addresses anyway). -const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +import { parseEmailArg } from './helpers.js'; export function register(framework) { framework.hears( /\/userInfo/i, async (bot, trigger) => { logger.info(`${trigger.person.displayName} ran the userInfo command.`); - const email = parseStoreArg(trigger); + const email = parseEmailArg(trigger); if (!email) { - bot.say('Usage: `/userInfo ` — e.g. `/userInfo mcqueenj@ae.com`'); - return; - } - if (!EMAIL_SHAPE.test(email)) { - bot.say(`\`${email}\` doesn't look like an email address.`); + bot.say( + 'Usage: `/userInfo ` — e.g. `/userInfo mcqueenj@ae.com`. ' + + 'The argument must look like an email (`local@domain.tld`).', + ); return; } try { diff --git a/src/index.js b/src/index.js index 791c158..1a06a57 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,7 @@ import { startWebSocketServer, stopWebSocketServer } from './services/websocket. import { register as registerStageStore } from './commands/stageStore.js'; import { register as registerFinalizeStore } from './commands/finalizeStore.js'; import { register as registerProvisionDect } from './commands/provisionDect.js'; +import { register as registerProvisionPhone } from './commands/provisionPhone.js'; import { register as registerStoreInfo } from './commands/storeInfo.js'; import { register as registerUserInfo } from './commands/userInfo.js'; import { register as registerAttachmentActions } from './commands/attachmentActions.js'; @@ -24,6 +25,7 @@ framework.on('log', (msg) => { registerStageStore(framework); registerFinalizeStore(framework); registerProvisionDect(framework); +registerProvisionPhone(framework); registerStoreInfo(framework); registerUserInfo(framework); registerAttachmentActions(framework); diff --git a/src/utils/mac.js b/src/utils/mac.js new file mode 100644 index 0000000..c4bbda7 --- /dev/null +++ b/src/utils/mac.js @@ -0,0 +1,34 @@ +// MAC-address helpers shared by DECT and wired-phone flows. +// +// Kept minimal and dependency-free so it can be reused wherever a Webex +// device MAC needs to be parsed, sent to an API, or shown in a card. + +/** + * Normalize a MAC to 12 uppercase hex chars (no separators). Accepts any + * of the shapes Webex returns (colon-, hyphen-, or dot-separated, or + * unseparated). Returns null when the input isn't 12 hex chars after + * stripping non-hex — callers should treat null as "invalid MAC". + */ +export function normalizeMac(rawMac) { + if (rawMac === undefined || rawMac === null) return null; + const clean = String(rawMac) + .replace(/[^0-9a-fA-F]/g, '') + .toUpperCase(); + return clean.length === 12 ? clean : null; +} + +/** Insert colons every two chars: "AABBCCDDEEFF" -> "AA:BB:CC:DD:EE:FF". */ +export function formatMac(cleanMac) { + return cleanMac.match(/.{1,2}/g).join(':'); +} + +/** + * Best-effort MAC display formatter for UI rendering. Accepts any shape + * and always produces AA:BB:CC:DD:EE:FF. Falls through to the raw value + * when the input isn't 12 hex chars, and returns "—" when missing, so + * cards never throw on unexpected API responses. + */ +export function displayMac(raw) { + const clean = normalizeMac(raw); + return clean ? formatMac(clean) : (raw ?? '—'); +} diff --git a/src/webex/dect.js b/src/webex/dect.js index 6ffa0b9..75961a6 100644 --- a/src/webex/dect.js +++ b/src/webex/dect.js @@ -19,6 +19,12 @@ import { webexJson } from './client.js'; import { findWebexLocation } from './locations.js'; import { findWebexUser } from './users.js'; import { logger } from '../logger.js'; +import { formatMac, normalizeMac } from '../utils/mac.js'; + +// Re-exported for existing callers (dectCards, dectActions, tests) so +// they don't need import churn now that the helpers live in utils/mac.js. +// New non-DECT code should import from '../utils/mac.js' directly. +export { displayMac, formatMac, normalizeMac } from '../utils/mac.js'; const DECT_MODEL = 'DMS Cisco DBS210'; @@ -39,34 +45,6 @@ export function generateDectAccessCode(storeNumber) { return '8' + digits.padStart(3, '0'); } -/** - * Normalize a MAC address to 12 uppercase hex chars (no separators). - * Returns null if it isn't 12 hex chars after stripping non-hex. - */ -export function normalizeMac(rawMac) { - if (rawMac === undefined || rawMac === null) return null; - const clean = String(rawMac) - .replace(/[^0-9a-fA-F]/g, '') - .toUpperCase(); - return clean.length === 12 ? clean : null; -} - -/** "AABBCCDDEEFF" -> "AA:BB:CC:DD:EE:FF" for API + display consistency. */ -export function formatMac(cleanMac) { - return cleanMac.match(/.{1,2}/g).join(':'); -} - -/** - * Best-effort MAC display formatter for UI rendering. Accepts any of the - * shapes Webex returns (unseparated, colon-separated, hyphen-separated) and - * always produces AA:BB:CC:DD:EE:FF. Falls through to the raw value when - * the input isn't 12 hex chars, so it never throws on unexpected responses. - */ -export function displayMac(raw) { - const clean = normalizeMac(raw); - return clean ? formatMac(clean) : (raw ?? '—'); -} - /** DECT network name for a given store number (matches Control Hub). */ export function dectNetworkName(storeNumber) { return `Store ${String(storeNumber).padStart(4, '0')}`; diff --git a/src/webex/phones.js b/src/webex/phones.js new file mode 100644 index 0000000..8f8a301 --- /dev/null +++ b/src/webex/phones.js @@ -0,0 +1,145 @@ +// Webex Calling helpers for wired desk phones (Cisco 78xx MPP). +// +// Mirrors the shape of src/webex/dect.js so /provisionPhone can follow +// the same "one card lists + adds + removes" pattern as /provisionDect. +// Uses: +// POST /devices add a phone owned by a person +// DELETE /devices/{deviceId} remove any device +// GET /devices?personId={id} list devices owned by a person +// (already wrapped by webex/users.findWebexUserPhones) + +import { webexJson } from './client.js'; +import { findWebexUser, findWebexUserPhones } from './users.js'; +import { logger } from '../logger.js'; +import { formatMac, normalizeMac } from '../utils/mac.js'; + +/** + * Supported wired phone models for /provisionPhone. The `value` field is + * sent verbatim to Webex's `POST /devices { model }` and must match + * exactly what Control Hub uses; add rows here as AE rolls out new + * hardware. Ordering matters: the first entry is the default in the UI. + */ +export const WIRED_PHONE_MODELS = [ + { label: 'Cisco 7841', value: 'Cisco 7841' }, + { label: 'Cisco 7821', value: 'Cisco 7821' }, +]; + +export function isSupportedWiredModel(model) { + return WIRED_PHONE_MODELS.some((m) => m.value === model); +} + +// Anything Webex returns whose product/type contains one of these markers +// is treated as a DECT device and filtered out of /provisionPhone views +// (DECT lives in its own /provisionDect card). Kept as a case-insensitive +// regex so future DBS-2xx / DBS-1xx variants also match without a code +// change. +const DECT_MARKERS = /dect|dbs-?\d{3}/i; + +/** + * Reduce a raw /devices response to just the wired phones. Drops DECT + * handsets/basestations, keeps everything else (78xx MPPs, ATAs, etc.). + * Exported for unit-testing the filter in isolation. + */ +export function filterWiredPhones(items = []) { + return items.filter((d) => { + const model = String(d.product || d.model || '').trim(); + if (!model) return false; + if (DECT_MARKERS.test(model)) return false; + return true; + }); +} + +/** + * List the wired phones registered to a Webex person, normalized to a + * card-friendly shape. + */ +export async function listWiredPhonesForUser(personId) { + if (!personId) return []; + const res = await findWebexUserPhones(personId); + const items = res?.items ?? []; + return filterWiredPhones(items).map((d) => ({ + id: d.id, + mac: d.mac || '', + model: d.product || d.model || 'unknown', + displayName: + d.displayName || `${d.product || d.model || 'Phone'}${d.mac ? ` - ${d.mac}` : ''}`, + connectionStatus: d.connectionStatus || 'unknown', + })); +} + +/** + * One-shot status for the /provisionPhone card: user + their wired + * phones. Throws if the email doesn't resolve to exactly one Webex user + * so the command handler can surface a clean error message. + */ +export async function getWiredPhoneStatus(email) { + const user = await findWebexUser(email); + const phones = await listWiredPhonesForUser(user.id); + return { user, phones }; +} + +/** + * Add one or more wired phones owned by `personId`. `macInput` may be a + * single MAC or several separated by commas/whitespace. Idempotent: + * MACs already registered to the user are skipped without an API call + * (Webex would 409 otherwise). Returns a per-MAC result array shaped + * like addDectBasestation for consistent action-handler code. + */ +export async function addWiredPhone(personId, model, macInput) { + if (!personId) throw new Error('personId required'); + if (!model) throw new Error('model required'); + if (!macInput) throw new Error('mac(s) required'); + if (!isSupportedWiredModel(model)) { + const supported = WIRED_PHONE_MODELS.map((m) => m.value).join(', '); + throw new Error(`Unsupported model "${model}". Supported: ${supported}`); + } + + const rawMacs = String(macInput) + .split(/[\s,]+/) + .map((m) => m.trim()) + .filter(Boolean); + + // Snapshot existing phones once. Small loop, no need to re-fetch + // between iterations; upstream caller has already validated the + // person exists. + const existing = await listWiredPhonesForUser(personId); + const existingCleanMacs = new Set(existing.map((p) => normalizeMac(p.mac)).filter(Boolean)); + + const results = []; + for (const raw of rawMacs) { + const clean = normalizeMac(raw); + if (!clean) { + results.push({ mac: raw, error: 'invalid MAC (need 12 hex chars)' }); + continue; + } + const formatted = formatMac(clean); + if (existingCleanMacs.has(clean)) { + logger.debug(`Wired phone ${formatted} already assigned — skipping`); + results.push({ mac: formatted, model, alreadyExists: true }); + continue; + } + try { + const body = { mac: formatted, model, personId }; + const res = await webexJson('POST', '/devices', body); + logger.debug(`Added ${model} ${formatted} to person ${personId}`); + results.push({ mac: formatted, model, success: true, id: res?.id }); + existingCleanMacs.add(clean); + } catch (err) { + logger.error(`Add wired phone ${formatted} (${model}) failed:`, err); + results.push({ mac: formatted, model, error: err.message }); + } + } + return results; +} + +/** + * Remove a wired phone by device id. Symmetric with removeDectBasestation + * so the action handler can call this by id straight out of the checkbox + * ChoiceSet value. + */ +export async function removeWiredPhone(deviceId) { + if (!deviceId) throw new Error('deviceId required'); + await webexJson('DELETE', `/devices/${encodeURIComponent(deviceId)}`); + logger.debug(`Removed device ${deviceId}`); + return { success: true, id: deviceId }; +} diff --git a/test/dect.test.js b/test/dect.test.js index d3f6cfc..c35a516 100644 --- a/test/dect.test.js +++ b/test/dect.test.js @@ -1,13 +1,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { - dectNetworkName, - displayMac, - formatMac, - generateDectAccessCode, - normalizeMac, -} from '../src/webex/dect.js'; +import { dectNetworkName, generateDectAccessCode } from '../src/webex/dect.js'; // AE's access code convention: 4-digit stores use their own digits; anything // shorter prefixes '8' and pads the rest to 3. Ports collabFinder's rule @@ -34,64 +28,6 @@ describe('generateDectAccessCode', () => { }); }); -describe('normalizeMac', () => { - it('accepts colon-separated MACs', () => { - assert.equal(normalizeMac('AA:BB:CC:DD:EE:FF'), 'AABBCCDDEEFF'); - }); - - it('accepts hyphen-separated MACs', () => { - assert.equal(normalizeMac('aa-bb-cc-dd-ee-ff'), 'AABBCCDDEEFF'); - }); - - it('accepts unseparated MACs and case-normalizes', () => { - assert.equal(normalizeMac('aabbccddeeff'), 'AABBCCDDEEFF'); - }); - - it('rejects too-short strings', () => { - assert.equal(normalizeMac('AA:BB:CC'), null); - }); - - it('rejects too-long strings', () => { - assert.equal(normalizeMac('AA:BB:CC:DD:EE:FF:11'), null); - }); - - it('rejects non-hex characters', () => { - // "Z" is not hex; after stripping we're left with 10 chars, not 12. - assert.equal(normalizeMac('AA:BB:CC:ZZ:EE:FF'), null); - }); - - it('returns null for missing input', () => { - assert.equal(normalizeMac(''), null); - assert.equal(normalizeMac(null), null); - assert.equal(normalizeMac(undefined), null); - }); -}); - -describe('formatMac', () => { - it('inserts colons every two chars', () => { - assert.equal(formatMac('AABBCCDDEEFF'), 'AA:BB:CC:DD:EE:FF'); - }); -}); - -describe('displayMac', () => { - it('formats unseparated MACs (the Webex list-endpoint default)', () => { - assert.equal(displayMac('aabbccddeeff'), 'AA:BB:CC:DD:EE:FF'); - }); - - it('re-formats already-separated MACs consistently', () => { - assert.equal(displayMac('aa-bb-cc-dd-ee-ff'), 'AA:BB:CC:DD:EE:FF'); - }); - - it('passes unrecognised input through instead of throwing', () => { - assert.equal(displayMac('not-a-mac'), 'not-a-mac'); - }); - - it('renders an em-dash for missing values', () => { - assert.equal(displayMac(null), '—'); - assert.equal(displayMac(undefined), '—'); - }); -}); - describe('dectNetworkName', () => { it('produces the canonical "Store XXXX" name (4-digit padding)', () => { assert.equal(dectNetworkName(499), 'Store 0499'); diff --git a/test/helpers.test.js b/test/helpers.test.js index f4368ce..2862a13 100644 --- a/test/helpers.test.js +++ b/test/helpers.test.js @@ -1,7 +1,12 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { parseStoreArg, parseStoreNumber, storeEmail } from '../src/commands/helpers.js'; +import { + parseEmailArg, + parseStoreArg, + parseStoreNumber, + storeEmail, +} from '../src/commands/helpers.js'; // parseStoreArg regressed once already: the framework's trigger.prompt is // everything AFTER the matched command (e.g. " 792" for "/storeInfo 792"), @@ -72,3 +77,22 @@ describe('storeEmail', () => { assert.equal(storeEmail('42'), 'ae00042@ae.com'); }); }); + +describe('parseEmailArg', () => { + it('returns a lowercased email when the shape looks valid', () => { + assert.equal(parseEmailArg({ prompt: ' McQueenJ@AE.com' }), 'mcqueenj@ae.com'); + assert.equal(parseEmailArg({ prompt: ' ae00499@ae.com' }), 'ae00499@ae.com'); + }); + + it('rejects non-email input', () => { + assert.equal(parseEmailArg({ prompt: ' joe' }), null); + assert.equal(parseEmailArg({ prompt: ' joe@' }), null); + assert.equal(parseEmailArg({ prompt: ' joe@ae' }), null); + assert.equal(parseEmailArg({ prompt: ' @ae.com' }), null); + }); + + it('returns null when the arg is missing', () => { + assert.equal(parseEmailArg({}), null); + assert.equal(parseEmailArg({ prompt: '' }), null); + }); +}); diff --git a/test/mac.test.js b/test/mac.test.js new file mode 100644 index 0000000..64218bc --- /dev/null +++ b/test/mac.test.js @@ -0,0 +1,62 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { displayMac, formatMac, normalizeMac } from '../src/utils/mac.js'; + +describe('normalizeMac', () => { + it('accepts colon-separated MACs', () => { + assert.equal(normalizeMac('AA:BB:CC:DD:EE:FF'), 'AABBCCDDEEFF'); + }); + + it('accepts hyphen-separated MACs', () => { + assert.equal(normalizeMac('aa-bb-cc-dd-ee-ff'), 'AABBCCDDEEFF'); + }); + + it('accepts unseparated MACs and case-normalizes', () => { + assert.equal(normalizeMac('aabbccddeeff'), 'AABBCCDDEEFF'); + }); + + it('rejects too-short strings', () => { + assert.equal(normalizeMac('AA:BB:CC'), null); + }); + + it('rejects too-long strings', () => { + assert.equal(normalizeMac('AA:BB:CC:DD:EE:FF:11'), null); + }); + + it('rejects non-hex characters', () => { + // "Z" is not hex; after stripping we're left with 10 chars, not 12. + assert.equal(normalizeMac('AA:BB:CC:ZZ:EE:FF'), null); + }); + + it('returns null for missing input', () => { + assert.equal(normalizeMac(''), null); + assert.equal(normalizeMac(null), null); + assert.equal(normalizeMac(undefined), null); + }); +}); + +describe('formatMac', () => { + it('inserts colons every two chars', () => { + assert.equal(formatMac('AABBCCDDEEFF'), 'AA:BB:CC:DD:EE:FF'); + }); +}); + +describe('displayMac', () => { + it('formats unseparated MACs (the Webex list-endpoint default)', () => { + assert.equal(displayMac('aabbccddeeff'), 'AA:BB:CC:DD:EE:FF'); + }); + + it('re-formats already-separated MACs consistently', () => { + assert.equal(displayMac('aa-bb-cc-dd-ee-ff'), 'AA:BB:CC:DD:EE:FF'); + }); + + it('passes unrecognised input through instead of throwing', () => { + assert.equal(displayMac('not-a-mac'), 'not-a-mac'); + }); + + it('renders an em-dash for missing values', () => { + assert.equal(displayMac(null), '—'); + assert.equal(displayMac(undefined), '—'); + }); +}); diff --git a/test/phones.test.js b/test/phones.test.js new file mode 100644 index 0000000..805c331 --- /dev/null +++ b/test/phones.test.js @@ -0,0 +1,83 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + WIRED_PHONE_MODELS, + filterWiredPhones, + isSupportedWiredModel, +} from '../src/webex/phones.js'; + +// Sanity check: the supported models list stays in sync with the two +// models AE actually deploys. Add a case below when a new model lands. +describe('WIRED_PHONE_MODELS', () => { + it('includes both Cisco 7841 and 7821', () => { + const values = WIRED_PHONE_MODELS.map((m) => m.value); + assert.ok(values.includes('Cisco 7841'), '7841 missing'); + assert.ok(values.includes('Cisco 7821'), '7821 missing'); + }); + + it('7841 is first (used as the default in the add card)', () => { + assert.equal(WIRED_PHONE_MODELS[0].value, 'Cisco 7841'); + }); +}); + +describe('isSupportedWiredModel', () => { + it('accepts the exact strings in WIRED_PHONE_MODELS', () => { + assert.equal(isSupportedWiredModel('Cisco 7841'), true); + assert.equal(isSupportedWiredModel('Cisco 7821'), true); + }); + + it('is case-sensitive so we always send Webex the canonical name', () => { + assert.equal(isSupportedWiredModel('cisco 7841'), false); + assert.equal(isSupportedWiredModel('CISCO 7841'), false); + }); + + it('rejects unknown models', () => { + assert.equal(isSupportedWiredModel('Cisco 8845'), false); + assert.equal(isSupportedWiredModel(''), false); + assert.equal(isSupportedWiredModel(undefined), false); + }); +}); + +describe('filterWiredPhones', () => { + it('keeps 78xx MPP phones', () => { + const items = [ + { id: '1', product: 'Cisco 7841' }, + { id: '2', product: 'Cisco 7821' }, + ]; + const kept = filterWiredPhones(items).map((d) => d.id); + assert.deepEqual(kept, ['1', '2']); + }); + + it('drops DECT devices by product name', () => { + const items = [ + { id: '1', product: 'Cisco 7841' }, + { id: '2', product: 'DMS Cisco DBS210' }, + { id: '3', product: 'DECT Handset' }, + ]; + const kept = filterWiredPhones(items).map((d) => d.id); + assert.deepEqual(kept, ['1']); + }); + + it('is case-insensitive on the DECT marker so future firmware names still match', () => { + const items = [{ id: '1', product: 'cisco dbs-210' }]; + assert.equal(filterWiredPhones(items).length, 0); + }); + + it('falls back to `model` when `product` is missing', () => { + // Some Webex endpoints report `model` instead of `product`; we + // accept either so filterWiredPhones works against both shapes. + const items = [{ id: '1', model: 'Cisco 7841' }]; + assert.equal(filterWiredPhones(items).length, 1); + }); + + it("drops entries with no model info (can't classify)", () => { + const items = [{ id: '1' }, { id: '2', product: '' }]; + assert.equal(filterWiredPhones(items).length, 0); + }); + + it('accepts an empty array', () => { + assert.deepEqual(filterWiredPhones([]), []); + assert.deepEqual(filterWiredPhones(), []); + }); +});