From 5dce057ce7ce65f77e839501e316eb4d2219d135 Mon Sep 17 00:00:00 2001 From: jmcqueen Date: Tue, 7 Jul 2026 07:29:21 -0400 Subject: [PATCH] Add /provisionDect + create DECT network in /finalizeStore New /provisionDect slash command manages DECT basestations and handsets for a store via a single card: multi-select checkbox list doubles as the display, MACs render as AA:BB:CC:DD:EE:FF, Add/Remove sit side-by-side per section, and every removal goes through an explicit confirm card. Handsets always auto-pair (no bind-to-basestation input) so they roam. /finalizeStore now idempotently creates the "Store XXXX" DECT network (DBS-210) with the per-store default access code, so new stores are DECT-ready the moment finalize completes. Location-scoped lookup (findDectNetworkInLocation) handles both the finalize idempotency check and the /provisionDect fallback for freshly-created empty networks. Non-critical: a store can still go live if the DECT step fails, and /provisionDect keeps a recovery "create network" prompt for legacy stores. Pure-logic helpers (generateDectAccessCode, MAC normalize/format/ display, dectNetworkName) are unit-tested via node:test. Co-authored-by: Cursor --- README.md | 13 +- src/cards/dectCards.js | 324 ++++++++++++++++++++++++ src/commands/attachmentActions.js | 7 + src/commands/dectActions.js | 214 ++++++++++++++++ src/commands/provisionDect.js | 63 +++++ src/flows/finalizeStore.js | 40 +++ src/index.js | 2 + src/webex/dect.js | 406 ++++++++++++++++++++++++++++++ test/dect.test.js | 102 ++++++++ 9 files changed, 1169 insertions(+), 2 deletions(-) create mode 100644 src/cards/dectCards.js create mode 100644 src/commands/dectActions.js create mode 100644 src/commands/provisionDect.js create mode 100644 src/webex/dect.js create mode 100644 test/dect.test.js diff --git a/README.md b/README.md index 8d6b509..f8af23a 100644 --- a/README.md +++ b/README.md @@ -133,8 +133,17 @@ Registered in [src/commands](src/commands): except the phone number, which has to be purchased separately in Control Hub. - `/finalizeStore ` — cut-over for a previously staged store: attaches the purchased phone number, sets caller ID, creates the - auto-attendant, and finalizes user licensing. Preflights that the store - was actually staged first. + `Store XXXX` DECT network (DBS-210) with the per-store default access + code, creates the auto-attendant, and finalizes user licensing. + Preflights that the store was actually staged first, and is idempotent + on the DECT network (skips if one already exists). +- `/provisionDect ` — manage DECT phones for a store: card + for adding basestations by MAC and adding/removing handsets. Every + removal goes through an explicit confirmation card. New stores have + their DECT network created by `/finalizeStore`; this command still + offers a recovery "create network" prompt for legacy stores or if + finalize's DECT step was skipped. 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/dectCards.js b/src/cards/dectCards.js new file mode 100644 index 0000000..e888fa5 --- /dev/null +++ b/src/cards/dectCards.js @@ -0,0 +1,324 @@ +// Adaptive Cards for the /provisionDect command. +// +// Three related card builders live here because they form one UI flow: +// dectCreateNetworkCard - shown when the store has no DECT network yet +// dectManagementCard - main list + add + multi-select-remove card +// dectConfirmRemoveCard - destructive-op confirmation before delete +// +// All action strings are namespaced `dect-*` so attachmentActions.js can +// dispatch them without collision with the existing stage/finalize actions. + +import { displayMac } from '../webex/dect.js'; + +const SCHEMA = 'http://adaptivecards.io/schemas/adaptive-card.json'; + +/** + * Prompt to create the "Store XXXX" DECT network. Shown when the store user + * has no DECT network attached yet. Includes the auto-generated 4-digit + * access code, which the operator can edit before confirming. + */ +export function dectCreateNetworkCard( + storeNumber, + { locationId, locationName, suggestedAccessCode }, +) { + return { + type: 'AdaptiveCard', + $schema: SCHEMA, + version: '1.3', + body: [ + { + type: 'TextBlock', + text: `No DECT network exists for Store ${String(storeNumber).padStart(4, '0')}`, + weight: 'Bolder', + size: 'Medium', + wrap: true, + }, + { + type: 'TextBlock', + text: `Create it in location **${locationName}**?`, + wrap: true, + }, + { + type: 'TextBlock', + text: + 'New stores get their DECT network created automatically by ' + + '`/finalizeStore`. Use this recovery form only for legacy ' + + 'stores or if that step was skipped.', + wrap: true, + isSubtle: true, + size: 'Small', + }, + { + type: 'Input.Text', + id: 'defaultAccessCode', + label: 'Default access code (4 digits)', + value: suggestedAccessCode, + regex: '^\\d{4}$', + errorMessage: 'Must be exactly 4 digits.', + }, + { + type: 'TextBlock', + text: 'This code is shared by every handset that pairs with the network. Model is fixed to DBS-210.', + wrap: true, + isSubtle: true, + size: 'Small', + }, + ], + actions: [ + { + type: 'Action.Submit', + title: 'Create network', + id: 'btnDectCreate', + data: { action: 'dect-create-network', storeNumber, locationId }, + }, + { + type: 'Action.Submit', + title: 'Cancel', + id: 'btnCancel', + data: { action: 'deleteCard' }, + }, + ], + }; +} + +/** + * Full DECT management card: current bases + handsets, plus add / remove + * controls for both. Every action carries the storeNumber/locationId/ + * networkId in its `data` so the dispatcher never has to re-resolve them. + */ +export function dectManagementCard(storeNumber, { network, basestations, handsets }, resultBanner) { + const padded = String(storeNumber).padStart(4, '0'); + const dataCtx = { + storeNumber, + locationId: network.locationId, + networkId: network.id, + }; + + const basesSection = renderBases(basestations, dataCtx); + const handsetsSection = renderHandsets(handsets, dataCtx); + + return { + type: 'AdaptiveCard', + $schema: SCHEMA, + version: '1.3', + body: [ + { + type: 'TextBlock', + text: `DECT — Store ${padded}`, + weight: 'Bolder', + size: 'Large', + }, + ...(resultBanner ? [resultBannerBlock(resultBanner)] : []), + ...basesSection, + ...handsetsSection, + ], + actions: [ + { + type: 'Action.Submit', + title: 'Refresh', + id: 'btnDectRefresh', + data: { action: 'dect-refresh', storeNumber }, + }, + { + type: 'Action.Submit', + title: 'Close', + id: 'btnCancel', + data: { action: 'deleteCard' }, + }, + ], + }; +} + +function renderBases(basestations, dataCtx) { + return [ + { + type: 'TextBlock', + text: `Basestations (${basestations.length})`, + weight: 'Bolder', + size: 'Medium', + separator: true, + }, + // Single list. Each row is a checkbox whose label carries the full + // display info. Checking + clicking Remove is the removal path; the + // list itself doubles as the read-only view for operators who just + // want to see what's there. + basestations.length === 0 + ? { type: 'TextBlock', text: '_None yet._', wrap: true, isSubtle: true } + : { + type: 'Input.ChoiceSet', + id: 'removeBases', + isMultiSelect: true, + style: 'expanded', + choices: basestations.map((b) => ({ + title: `${displayMac(b.mac)} · ${b.status} · ${b.linesRegistered} line${b.linesRegistered === 1 ? '' : 's'}`, + value: b.id, + })), + }, + { + type: 'Input.Text', + id: 'basesInput', + label: 'Add basestation MAC(s)', + placeholder: 'AA:BB:CC:DD:EE:FF, another-mac ...', + }, + { + type: 'ActionSet', + actions: [ + { + type: 'Action.Submit', + title: 'Add', + id: 'btnDectAddBases', + data: { action: 'dect-add-bases', ...dataCtx }, + }, + ...(basestations.length > 0 + ? [ + { + type: 'Action.Submit', + title: 'Remove checked', + id: 'btnDectRemoveBases', + style: 'destructive', + data: { action: 'dect-remove-bases', ...dataCtx }, + }, + ] + : []), + ], + }, + ]; +} + +function renderHandsets(handsets, dataCtx) { + return [ + { + type: 'TextBlock', + text: `Handsets (${handsets.length})`, + weight: 'Bolder', + size: 'Medium', + separator: true, + }, + handsets.length === 0 + ? { type: 'TextBlock', text: '_None yet._', wrap: true, isSubtle: true } + : { + type: 'Input.ChoiceSet', + id: 'removeHandsets', + isMultiSelect: true, + style: 'expanded', + choices: handsets.map((h) => { + const idx = h.index ? `#${h.index} ` : ''; + return { + title: `${idx}${h.name} · ext ${h.extension ?? '—'}`, + value: h.id, + }; + }), + }, + { + type: 'Input.Text', + id: 'handsetAccessCode', + label: 'Access code (4 digits)', + regex: '^\\d{4}$', + errorMessage: 'Must be exactly 4 digits.', + }, + { + type: 'Input.Text', + id: 'handsetDisplayName', + label: 'Display name (optional)', + placeholder: 'defaults to access code', + }, + // Note: no "bind to basestation" input by design. Handsets always + // auto-pair against the network so they can roam between bases. + { + type: 'ActionSet', + actions: [ + { + type: 'Action.Submit', + title: 'Add', + id: 'btnDectAddHandset', + data: { action: 'dect-add-handset', ...dataCtx }, + }, + ...(handsets.length > 0 + ? [ + { + type: 'Action.Submit', + title: 'Remove checked', + id: 'btnDectRemoveHandsets', + style: 'destructive', + data: { action: 'dect-remove-handsets', ...dataCtx }, + }, + ] + : []), + ], + }, + ]; +} + +/** + * Confirmation card before a destructive removal. `items` is an array of + * `{ id, label }` describing what will be deleted; `kind` is 'base' or + * 'handset' for wording; `confirmAction` is the action string the Yes + * button posts back (e.g. 'dect-confirm-remove-bases'). + */ +export function dectConfirmRemoveCard({ + storeNumber, + locationId, + networkId, + kind, + items, + confirmAction, +}) { + const noun = kind === 'base' ? 'basestation' : 'handset'; + const plural = items.length === 1 ? noun : `${noun}s`; + return { + type: 'AdaptiveCard', + $schema: SCHEMA, + version: '1.3', + body: [ + { + type: 'TextBlock', + text: `Confirm removal of ${items.length} ${plural}`, + weight: 'Bolder', + size: 'Large', + color: 'Attention', + }, + { + type: 'TextBlock', + text: `Store ${String(storeNumber).padStart(4, '0')} — this cannot be undone.`, + wrap: true, + }, + { + type: 'TextBlock', + text: items.map((i) => `- ${i.label}`).join('\n'), + wrap: true, + }, + ], + actions: [ + { + type: 'Action.Submit', + title: `Yes, remove ${items.length}`, + id: 'btnDectConfirmRemove', + style: 'destructive', + data: { + action: confirmAction, + storeNumber, + locationId, + networkId, + ids: items.map((i) => i.id), + }, + }, + { + type: 'Action.Submit', + title: 'Cancel', + id: 'btnCancel', + data: { action: 'dect-refresh', storeNumber }, + }, + ], + }; +} + +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 3af12b2..86aef92 100644 --- a/src/commands/attachmentActions.js +++ b/src/commands/attachmentActions.js @@ -2,6 +2,7 @@ import { logger } from '../logger.js'; import { stageStoreLocation } from '../flows/stageStore.js'; import { finalizeStoreLocation } from '../flows/finalizeStore.js'; import { getWebexDeviceDetail } from '../webex/devices.js'; +import { handleDectAction } from './dectActions.js'; async function runFlow(bot, trigger, verb, fn) { const { storeInfo, userInfo } = trigger.attachmentAction.inputs; @@ -34,6 +35,12 @@ export function register(framework) { logger.debug('attachmentAction', JSON.stringify(trigger)); const action = trigger.attachmentAction.inputs.action; + // DECT flow has its own sub-dispatcher. + if (typeof action === 'string' && action.startsWith('dect-')) { + await handleDectAction(bot, trigger, action); + return; + } + switch (action) { case 'stageStore': await runFlow(bot, trigger, 'Staging', stageStoreLocation); diff --git a/src/commands/dectActions.js b/src/commands/dectActions.js new file mode 100644 index 0000000..fc1ae43 --- /dev/null +++ b/src/commands/dectActions.js @@ -0,0 +1,214 @@ +// Attachment-action handlers for the /provisionDect card flow. +// +// Every dect-* action posted by the DECT cards routes here. The dispatcher +// keeps a single shared shape: pull storeNumber/locationId/networkId out +// of `inputs`, do the work, then re-render the management card so the user +// sees the updated state without having to re-run /provisionDect. +// +// Errors are caught locally and reported inline in the refreshed card's +// result banner — a failed remove shouldn't take the whole session down. + +import { logger } from '../logger.js'; +import { + addDectBasestation, + addDectHandset, + createDectNetwork, + displayMac, + getDectProvisioningStatus, + removeDectBasestation, + removeDectHandset, +} from '../webex/dect.js'; +import { dectConfirmRemoveCard, dectManagementCard } from '../cards/dectCards.js'; + +/** + * Dispatch a `dect-*` action. Returns true if handled, false if the action + * isn't one of ours (caller should keep switching). + */ +export async function handleDectAction(bot, trigger, action) { + const inputs = trigger.attachmentAction.inputs || {}; + const messageId = trigger.attachmentAction.messageId; + const storeNumber = inputs.storeNumber; + + try { + switch (action) { + case 'dect-create-network': + await doCreateNetwork(bot, inputs, messageId); + return true; + + case 'dect-add-bases': + await doAddBases(bot, inputs, messageId); + return true; + + case 'dect-add-handset': + await doAddHandset(bot, inputs, messageId); + return true; + + case 'dect-remove-bases': + case 'dect-remove-handsets': + await doStageRemoval(bot, inputs, action, messageId); + return true; + + case 'dect-confirm-remove-bases': + await doConfirmRemove(bot, inputs, 'base', messageId); + return true; + + case 'dect-confirm-remove-handsets': + await doConfirmRemove(bot, inputs, 'handset', messageId); + return true; + + case 'dect-refresh': + await refreshCard(bot, storeNumber, messageId); + return true; + + default: + return false; + } + } catch (error) { + logger.error(`DECT action ${action} failed:`, error); + bot.say('markdown', `Error handling **${action}**:\n\`\`\`\n${error.message}\n\`\`\``); + return true; + } +} + +async function doCreateNetwork(bot, inputs, messageId) { + const storeNumber = inputs.storeNumber; + const locationId = inputs.locationId; + const overrideCode = String(inputs.defaultAccessCode || '').trim(); + if (overrideCode && !/^\d{4}$/.test(overrideCode)) { + bot.say('Access code must be exactly 4 digits.'); + return; + } + await createDectNetwork(locationId, storeNumber, { + defaultAccessCode: overrideCode || undefined, + }); + bot.censor(messageId); + await refreshCard(bot, storeNumber, null, { + text: `Created DECT network for Store ${padded(storeNumber)}.`, + tone: 'good', + }); +} + +async function doAddBases(bot, inputs, messageId) { + const { storeNumber, locationId, networkId } = inputs; + const macs = String(inputs.basesInput || '').trim(); + if (!macs) { + bot.say('Enter one or more MAC addresses in the input before clicking Add.'); + return; + } + 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); + bot.censor(messageId); + const banner = { + text: `Bases: 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 doAddHandset(bot, inputs, messageId) { + const { storeNumber, locationId, networkId } = inputs; + const accessCode = String(inputs.handsetAccessCode || '').trim(); + const displayName = String(inputs.handsetDisplayName || '').trim() || undefined; + + if (!/^\d{4}$/.test(accessCode)) { + bot.say('Access code must be exactly 4 digits.'); + return; + } + // Handsets are always added without a baseStationId so they auto-pair + // and can roam between all bases in the network. + const res = await addDectHandset(locationId, networkId, { accessCode, displayName }); + bot.censor(messageId); + const banner = res?.alreadyExists + ? { text: `Handset with access code ${accessCode} already exists.`, tone: 'default' } + : { text: `Added handset ${accessCode}.`, tone: 'good' }; + await refreshCard(bot, storeNumber, null, banner); +} + +async function doStageRemoval(bot, inputs, action, messageId) { + const { storeNumber, locationId, networkId } = inputs; + const kind = action === 'dect-remove-bases' ? 'base' : 'handset'; + const raw = kind === 'base' ? inputs.removeBases : inputs.removeHandsets; + const selected = Array.isArray(raw) ? raw : raw ? String(raw).split(',') : []; + if (selected.length === 0) { + bot.say(`Select at least one ${kind} using the checkboxes above before clicking Remove.`); + return; + } + + // Enrich selection with friendly labels for the confirm card. + const status = await getDectProvisioningStatus(storeNumber); + const source = kind === 'base' ? status.basestations : status.handsets; + const items = selected.map((id) => { + const hit = source.find((x) => x.id === id); + if (!hit) return { id, label: id }; + return kind === 'base' + ? { id, label: `${displayMac(hit.mac)} (${hit.status})` } + : { + id, + label: `${hit.index ? `#${hit.index} ` : ''}${hit.name} (${hit.extension ?? '—'})`, + }; + }); + + bot.censor(messageId); + bot.sendCard( + dectConfirmRemoveCard({ + storeNumber, + locationId, + networkId, + kind, + items, + confirmAction: + kind === 'base' ? 'dect-confirm-remove-bases' : 'dect-confirm-remove-handsets', + }), + 'Confirm removal in a client that supports Adaptive Cards', + ); +} + +async function doConfirmRemove(bot, inputs, kind, messageId) { + const { storeNumber, locationId, networkId } = inputs; + const ids = Array.isArray(inputs.ids) ? inputs.ids : []; + if (ids.length === 0) { + bot.say('Nothing selected to remove.'); + return; + } + const remove = kind === 'base' ? removeDectBasestation : removeDectHandset; + const results = await Promise.allSettled(ids.map((id) => remove(locationId, networkId, 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); + const noun = kind === 'base' ? 'base' : 'handset'; + let text = `Removed ${succeeded} ${noun}${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); + const status = await getDectProvisioningStatus(storeNumber); + if (!status.network) { + bot.say( + 'markdown', + `DECT network for Store ${padded(storeNumber)} no longer exists. ` + + `Run \`/provisionDect ${storeNumber}\` to re-create it.`, + ); + return; + } + bot.sendCard(dectManagementCard(storeNumber, status, banner), 'Please use another client'); +} + +function padded(storeNumber) { + return String(storeNumber).padStart(4, '0'); +} diff --git a/src/commands/provisionDect.js b/src/commands/provisionDect.js new file mode 100644 index 0000000..dc7ab15 --- /dev/null +++ b/src/commands/provisionDect.js @@ -0,0 +1,63 @@ +import { logger } from '../logger.js'; +import { findWebexLocation } from '../webex/locations.js'; +import { + dectNetworkName, + generateDectAccessCode, + getDectProvisioningStatus, +} from '../webex/dect.js'; +import { dectCreateNetworkCard, dectManagementCard } from '../cards/dectCards.js'; +import { parseStoreNumber } from './helpers.js'; + +// /provisionDect does NOT require the remote agent — every operation is +// against public Webex Calling APIs, which the bot reaches directly. + +export function register(framework) { + framework.hears( + /\/provisiondect/i, + async (bot, trigger) => { + logger.info(`${trigger.person.displayName} ran the provisionDect command.`); + const storeNumber = parseStoreNumber(trigger); + if (!storeNumber) { + bot.say('Usage: `/provisionDect ` — e.g. `/provisionDect 499`'); + return; + } + try { + const status = await getDectProvisioningStatus(storeNumber); + if (status.network) { + bot.sendCard( + dectManagementCard(storeNumber, status), + 'Please use another client', + ); + return; + } + + // No network yet — need the store's Webex location to create in. + const matches = await findWebexLocation(dectNetworkName(storeNumber)); + if (!matches.length) { + bot.say( + 'markdown', + `No Webex location found for **${dectNetworkName(storeNumber)}**. ` + + `Run \`/stageStore ${storeNumber}\` first.`, + ); + return; + } + const location = matches[0]; + bot.sendCard( + dectCreateNetworkCard(storeNumber, { + locationId: location.id, + locationName: location.name, + suggestedAccessCode: generateDectAccessCode(storeNumber), + }), + 'Please use another client', + ); + } catch (error) { + logger.error('provisionDect command failed:', error); + bot.say( + 'markdown', + `Error running /provisionDect:\n\`\`\`\n${error.message}\n\`\`\``, + ); + } + }, + '**/provisionDect** - Add or remove DECT basestations and handsets for a store. New stores get their DECT network created by /finalizeStore; this command offers a recovery-create prompt if the network is missing.', + ); +} diff --git a/src/flows/finalizeStore.js b/src/flows/finalizeStore.js index 3d020e3..0bf5c08 100644 --- a/src/flows/finalizeStore.js +++ b/src/flows/finalizeStore.js @@ -12,6 +12,12 @@ import { updateUserExtension, updateUserVoicemailSettings, } from '../webex/users.js'; +import { + createDectNetwork, + dectNetworkName, + findDectNetworkInLocation, + generateDectAccessCode, +} from '../webex/dect.js'; import { CALLER_ID } from '../constants.js'; import { logger } from '../logger.js'; import { runStep } from './stepRunner.js'; @@ -42,6 +48,7 @@ export async function finalizeStoreLocation(bot, locationInfo, userInfo) { await runStep(bot, 'Updated location Webex Calling Details', () => updateLocationCallingIdentity(location, locationInfo.phoneNumber), ); + await ensureDectNetwork(bot, location, locationInfo.storeNumber); await runStep(bot, 'Updated user extension', () => updateUserExtension(userInfo, locationInfo.extension), ); @@ -78,3 +85,36 @@ export async function finalizeStoreLocation(bot, locationInfo, userInfo) { bot.say('markdown', `**Finalize complete for ${locationInfo.name}.** Location is now live.`); return location; } + +/** + * Idempotently ensure the store's DECT network exists in this location. + * Skipped-because-exists is a first-class outcome (shown as its own + * success blockquote) so operators can see at a glance whether finalize + * actually created the network or found an existing one from a prior + * /provisionDect run. Non-critical: a store can go live without DECT. + */ +async function ensureDectNetwork(bot, location, storeNumber) { + const netName = dectNetworkName(storeNumber); + try { + const existing = await findDectNetworkInLocation(location.id, storeNumber); + if (existing) { + bot.say( + 'markdown', + `
DECT network **${netName}** already exists — skipped.
`, + ); + return existing; + } + } catch (error) { + logger.warn(`DECT network pre-check failed for ${netName}: ${error.message}`); + } + // The default access code (the "DECT pin") is derived from the store + // number via generateDectAccessCode — 4-digit stores use their own + // digits, shorter stores prefix "8". Passing it explicitly is + // redundant (createDectNetwork defaults to the same rule) but makes + // the intent visible when reading the finalize flow top-to-bottom. + return runStep(bot, `Created DECT network ${netName}`, () => + createDectNetwork(location.id, storeNumber, { + defaultAccessCode: generateDectAccessCode(storeNumber), + }), + ); +} diff --git a/src/index.js b/src/index.js index ad04329..791c158 100644 --- a/src/index.js +++ b/src/index.js @@ -6,6 +6,7 @@ import { isAccessTokenExpiring, refreshAccessToken } from './webex/auth.js'; import { startWebSocketServer, stopWebSocketServer } from './services/websocket.js'; 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 registerStoreInfo } from './commands/storeInfo.js'; import { register as registerUserInfo } from './commands/userInfo.js'; import { register as registerAttachmentActions } from './commands/attachmentActions.js'; @@ -22,6 +23,7 @@ framework.on('log', (msg) => { registerStageStore(framework); registerFinalizeStore(framework); +registerProvisionDect(framework); registerStoreInfo(framework); registerUserInfo(framework); registerAttachmentActions(framework); diff --git a/src/webex/dect.js b/src/webex/dect.js new file mode 100644 index 0000000..6ffa0b9 --- /dev/null +++ b/src/webex/dect.js @@ -0,0 +1,406 @@ +// DECT provisioning helpers for the /provisionDect command. +// +// Ported from collabFinder's services/phoneService.js (bottom section: +// "DECT Provisioning helpers"), with two changes: +// - axios calls swapped for our existing webexJson (native fetch + auth) +// - added createDectNetwork(), which collabFinder didn't cover (they +// required the network to exist first) +// +// AE conventions (identical to collabFinder): +// - Store user email: ae<5-digit-padded>@ae.com +// - DECT network name: "Store <4-digit-padded>" +// - Access code: 4 digits. Stores with 4+ digits use their first 4; +// shorter stores prefix "8" (e.g. 347 -> 8347, 67 -> 8067). +// +// The DECT network access code is set once at network-create time as the +// default and applies to every handset that pairs against the network. + +import { webexJson } from './client.js'; +import { findWebexLocation } from './locations.js'; +import { findWebexUser } from './users.js'; +import { logger } from '../logger.js'; + +const DECT_MODEL = 'DMS Cisco DBS210'; + +// Pure helpers (exported so we can unit-test them without touching Webex) + +/** + * AE-specific access code rule: 4-digit stores use their store number as-is, + * shorter stores prefix "8" and pad the rest to 3 digits. + * 500 -> "8500" + * 67 -> "8067" + * 499 -> "8499" + * 1234 -> "1234" + * 12345 -> "1234" (first 4) + */ +export function generateDectAccessCode(storeNumber) { + const digits = String(storeNumber).replace(/\D/g, ''); + if (digits.length >= 4) return digits.slice(0, 4); + 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')}`; +} + +// Person + network lookup + +async function findPersonIdForStore(storeNumber) { + const email = `ae${String(storeNumber).padStart(5, '0')}@ae.com`; + try { + const user = await findWebexUser(email); + return { personId: user.id, email }; + } catch { + return { personId: null, email }; + } +} + +/** + * List the DECT networks a person has line memberships on. Returns [] when + * the person has no DECT lines (common for freshly staged stores). + */ +export async function getDectNetworksForPerson(personId) { + if (!personId) return []; + const data = await webexJson('GET', `/telephony/config/people/${personId}/dectNetworks`); + const networks = data?.dectNetworks ?? []; + return networks.map((net) => ({ + id: net.id, + name: net.name || 'Unknown', + handsetsCount: net.numberOfHandsetsAssigned ?? 0, + locationName: net.location?.name ?? '—', + locationId: net.location?.id ?? null, + })); +} + +/** + * Resolve the store's DECT network via person -> networks -> name match. + * Returns null if the store user doesn't exist, has no DECT lines, or the + * network name doesn't match "Store <4-digit>". + * + * NOTE: this misses newly-created empty networks (nobody has a line on + * them yet). Use findDectNetworkInLocation() for the idempotency check + * when creating; use getDectProvisioningStatus() from the UI (it falls + * back to the location lookup automatically). + */ +export async function findDectNetworkForStore(storeNumber) { + const { personId } = await findPersonIdForStore(storeNumber); + if (!personId) return null; + + const networks = await getDectNetworksForPerson(personId); + const padded4 = String(storeNumber).padStart(4, '0'); + const target = dectNetworkName(storeNumber).toLowerCase(); + + return ( + networks.find((n) => { + const nm = (n.name ?? '').trim().toLowerCase(); + return nm === target || nm.includes(padded4); + }) ?? null + ); +} + +/** + * Location-scoped DECT network lookup. Unlike findDectNetworkForStore + * this doesn't rely on the store user having lines on the network, so it + * safely finds freshly-created empty networks (e.g. right after finalize + * creates one and before any handsets are added). Returns the raw match + * or null. + */ +export async function findDectNetworkInLocation(locationId, storeNumber) { + if (!locationId) return null; + const data = await webexJson('GET', `/telephony/config/locations/${locationId}/dectNetworks`); + const items = data?.dectNetworks ?? data?.items ?? []; + const target = dectNetworkName(storeNumber).toLowerCase(); + return items.find((n) => (n.name ?? '').trim().toLowerCase() === target) ?? null; +} + +// Read-side helpers + +export async function getDectBasestations(locationId, dectNetworkId) { + if (!locationId || !dectNetworkId) return []; + const data = await webexJson( + 'GET', + `/telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/baseStations`, + ); + const items = data?.items ?? data?.baseStations ?? []; + return items.map((base) => ({ + id: base.id, + mac: base.mac || base.macAddress || '—', + name: base.displayName || `Basestation ${base.mac || 'Unknown'}`, + status: base.status || 'unknown', + firmware: base.softwareVersion || '—', + linesRegistered: base.numberOfLinesRegistered ?? 0, + })); +} + +export async function getDectHandsets(locationId, dectNetworkId) { + if (!locationId || !dectNetworkId) return []; + const data = await webexJson( + 'GET', + `/telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/handsets`, + ); + const items = data?.items ?? data?.handsets ?? []; + return items.map((handset) => ({ + id: handset.id, + index: handset.index, + name: handset.defaultDisplayName || handset.displayName || `Handset ${handset.index ?? ''}`, + accessCode: handset.accessCode || null, + extension: handset.accessCode || handset.lines?.[0]?.esn || null, + lines: handset.lines || [], + })); +} + +export async function getDectHandsetDetails(locationId, dectNetworkId, handsetId) { + if (!locationId || !dectNetworkId || !handsetId) return null; + const handset = await webexJson( + 'GET', + `/telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/handsets/${handsetId}`, + ); + return { + id: handset.id, + index: handset.index, + name: handset.defaultDisplayName || handset.displayName || `Handset ${handset.index ?? ''}`, + extension: handset.lines?.[0]?.extension ?? null, + baseStationId: handset.baseStationId ?? null, + }; +} + +/** + * One-shot status for the /provisionDect card: network + bases + handsets. + * Returns { network: null, ... } when the store has no DECT network yet. + * + * Deliberately does NOT enrich handsets via getDectHandsetDetails: that + * was N extra sequential API calls per card render just to derive the + * currently-bound base MAC, and the UI no longer displays that (handsets + * roam between bases by design). If we ever need per-handset detail + * again, call getDectHandsetDetails on demand for that one handset. + */ +export async function getDectProvisioningStatus(storeNumber) { + let network = await findDectNetworkForStore(storeNumber); + + // Fallback: person-based lookup misses empty networks (no line + // memberships yet). Try resolving via the store's Webex location, which + // finalizeStore now creates. This keeps /provisionDect useful the + // moment a store is finalized, before any handsets exist. + if (!network) { + const target = dectNetworkName(storeNumber); + const [loc] = (await findWebexLocation(target)).filter((l) => l.name === target); + if (loc) { + const match = await findDectNetworkInLocation(loc.id, storeNumber); + if (match) { + network = { + id: match.id, + name: match.name || target, + handsetsCount: 0, + locationName: loc.name, + locationId: loc.id, + }; + } + } + } + + if (!network || !network.locationId || !network.id) { + return { network, basestations: [], handsets: [] }; + } + + const [basesRes, handsRes] = await Promise.allSettled([ + getDectBasestations(network.locationId, network.id), + getDectHandsets(network.locationId, network.id), + ]); + const basestations = basesRes.status === 'fulfilled' ? basesRes.value : []; + const handsets = handsRes.status === 'fulfilled' ? handsRes.value : []; + + return { network, basestations, handsets }; +} + +// Mutating helpers + +/** + * Create a multi-cell DECT network for this store's location. Uses the + * "Store <4-digit>" name convention. Access code defaults to the + * generateDectAccessCode(storeNumber) rule but can be overridden by the + * caller (the /provisionDect create card lets the operator edit it). + * + * Idempotency is the caller's responsibility — Webex will 409 if a network + * with the same name already exists in this location. + */ +export async function createDectNetwork(locationId, storeNumber, { defaultAccessCode } = {}) { + if (!locationId) throw new Error('locationId required'); + const code = defaultAccessCode ?? generateDectAccessCode(storeNumber); + if (!/^\d{4}$/.test(code)) throw new Error('defaultAccessCode must be exactly 4 digits'); + const name = dectNetworkName(storeNumber); + const body = { + name, + displayName: name, + model: DECT_MODEL, + defaultAccessCodeEnabled: true, + defaultAccessCode: code, + }; + const res = await webexJson( + 'POST', + `/telephony/config/locations/${locationId}/dectNetworks`, + body, + ); + logger.info(`Created DECT network ${name} (id=${res?.dectNetworkId})`); + return { id: res?.dectNetworkId, name, locationId }; +} + +/** + * Add one or more basestations. `macInput` may be a single MAC or several + * separated by commas/whitespace. Idempotent: MACs already on the network + * are skipped without an API call. Returns a per-MAC result array. + */ +export async function addDectBasestation(locationId, dectNetworkId, macInput) { + if (!locationId || !dectNetworkId || !macInput) { + throw new Error('locationId, dectNetworkId and mac(s) required'); + } + + const rawMacs = String(macInput) + .split(/[\s,]+/) + .map((m) => m.trim()) + .filter(Boolean); + + // Snapshot existing bases once; the loop is small enough that we don't + // need to re-fetch between iterations, and we already checked upstream + // that the network exists. + const existing = await getDectBasestations(locationId, dectNetworkId); + const existingCleanMacs = new Set(existing.map((b) => normalizeMac(b.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(`Basestation ${formatted} already present — skipping`); + results.push({ mac: formatted, alreadyExists: true }); + continue; + } + try { + const body = { mac: formatted, displayName: `Basestation ${formatted}` }; + const res = await webexJson( + 'POST', + `/telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/baseStations`, + body, + ); + logger.debug(`Added basestation ${formatted}`); + results.push({ mac: formatted, success: true, id: res?.id }); + existingCleanMacs.add(clean); + } catch (err) { + logger.error(`Add basestation ${formatted} failed:`, err); + results.push({ mac: formatted, error: err.message }); + } + } + return results; +} + +/** + * Remove a basestation by id, or resolve a MAC to an id then remove. + */ +export async function removeDectBasestation(locationId, dectNetworkId, baseIdOrMac) { + if (!locationId || !dectNetworkId || !baseIdOrMac) { + throw new Error('locationId, networkId and base id/mac required'); + } + + let baseId = baseIdOrMac; + // MAC shape check: 12 hex chars (with any of `.` `-` `:` separators). + if (/^[0-9a-fA-F.:-]{12,17}$/.test(String(baseIdOrMac))) { + const clean = normalizeMac(baseIdOrMac); + if (clean) { + const bases = await getDectBasestations(locationId, dectNetworkId); + const match = bases.find((b) => normalizeMac(b.mac) === clean); + if (!match) throw new Error(`Basestation with MAC ${baseIdOrMac} not found`); + baseId = match.id; + } + } + + await webexJson( + 'DELETE', + `/telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/baseStations/${baseId}`, + ); + logger.debug(`Removed basestation ${baseId}`); + return { success: true, id: baseId }; +} + +/** + * Add a handset. `accessCode` must be exactly 4 digits (AE always uses the + * store's shared code). Idempotent: existing handsets with the same code + * are detected and returned as `{ alreadyExists: true }` without an API call. + * `baseStationId` is optional; when omitted, the handset auto-pairs against + * the network's default. + */ +export async function addDectHandset( + locationId, + dectNetworkId, + { displayName, accessCode, baseStationId } = {}, +) { + if (!locationId || !dectNetworkId || !accessCode) { + throw new Error('locationId, networkId and accessCode required'); + } + const code = String(accessCode).trim(); + if (!/^\d{4}$/.test(code)) throw new Error('Access code must be exactly 4 digits'); + + const existing = await getDectHandsets(locationId, dectNetworkId); + if (existing.some((h) => (h.extension || h.accessCode) === code)) { + logger.debug(`Handset with accessCode ${code} already exists`); + return { alreadyExists: true }; + } + + const body = { + defaultDisplayName: displayName || code, + accessCode: code, + lines: [{ index: 1, extension: code }], + }; + if (baseStationId) body.baseStationId = baseStationId; + + const res = await webexJson( + 'POST', + `/telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/handsets`, + body, + ); + logger.debug(`Added handset ${code}${baseStationId ? ` (base ${baseStationId})` : ''}`); + return res; +} + +export async function removeDectHandset(locationId, dectNetworkId, handsetId) { + if (!locationId || !dectNetworkId || !handsetId) { + throw new Error('locationId, networkId and handsetId required'); + } + await webexJson( + 'DELETE', + `/telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/handsets/${handsetId}`, + ); + logger.debug(`Removed handset ${handsetId}`); + return { success: true, id: handsetId }; +} diff --git a/test/dect.test.js b/test/dect.test.js new file mode 100644 index 0000000..d3f6cfc --- /dev/null +++ b/test/dect.test.js @@ -0,0 +1,102 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + dectNetworkName, + displayMac, + formatMac, + generateDectAccessCode, + normalizeMac, +} 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 +// verbatim so cross-project behaviour stays in sync. +describe('generateDectAccessCode', () => { + it('uses the store number verbatim for 4-digit stores', () => { + assert.equal(generateDectAccessCode(1234), '1234'); + assert.equal(generateDectAccessCode('0499'), '0499'); + }); + + it('truncates 5+ digit stores to the first 4', () => { + assert.equal(generateDectAccessCode(12345), '1234'); + }); + + it("prefixes '8' and pads for shorter stores", () => { + assert.equal(generateDectAccessCode(347), '8347'); + assert.equal(generateDectAccessCode(67), '8067'); + assert.equal(generateDectAccessCode(1), '8001'); + }); + + it('strips non-digit characters from the input', () => { + assert.equal(generateDectAccessCode('4-9-9'), '8499'); + assert.equal(generateDectAccessCode('Store 1234'), '1234'); + }); +}); + +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'); + assert.equal(dectNetworkName(1), 'Store 0001'); + assert.equal(dectNetworkName(1234), 'Store 1234'); + assert.equal(dectNetworkName('67'), 'Store 0067'); + }); +});