From 2ec2b7a48667662e9ea22cdfcfdf7a855d29c70b Mon Sep 17 00:00:00 2001 From: jmcqueen Date: Fri, 31 Jul 2026 16:34:09 -0400 Subject: [PATCH] Add /resetvmpin and /resetvppin with confirmation cards for Webex PIN resets. Chat-only commands reset voicemail mailbox and voice portal passcodes via telephony_config_write, with extension lookup and unit tests. Co-authored-by: Cursor --- .env.example | 1 + commands/help.js | 32 +++- commands/registry.js | 6 + commands/resetVmPin.js | 196 +++++++++++++++++++++++ commands/resetVpPin.js | 186 +++++++++++++++++++++ index.js | 96 +++++++++++ services/callTest/locationResolver.js | 35 +++- services/voicePin/generatePin.js | 76 +++++++++ services/voicePin/parseVmPinTarget.js | 25 +++ services/voicePin/parseVpPinStore.js | 13 ++ services/voicePin/resetVoicePortalPin.js | 79 +++++++++ services/voicePin/resetVoicemailPin.js | 54 +++++++ services/voicePin/resolveVmPinTarget.js | 126 +++++++++++++++ services/voicePin/resolveVpPinStore.js | 56 +++++++ tests/resetVmPin.extensionIndex.test.js | 48 ++++++ tests/resetVmPin.generatePin.test.js | 39 +++++ tests/resetVmPin.parse.test.js | 28 ++++ tests/resetVmPin.reset.test.js | 44 +++++ tests/resetVpPin.parse.test.js | 18 +++ tests/resetVpPin.reset.test.js | 61 +++++++ utils/pendingVmPinResets.js | 46 ++++++ utils/pendingVpPinResets.js | 46 ++++++ 22 files changed, 1308 insertions(+), 3 deletions(-) create mode 100644 commands/resetVmPin.js create mode 100644 commands/resetVpPin.js create mode 100644 services/voicePin/generatePin.js create mode 100644 services/voicePin/parseVmPinTarget.js create mode 100644 services/voicePin/parseVpPinStore.js create mode 100644 services/voicePin/resetVoicePortalPin.js create mode 100644 services/voicePin/resetVoicemailPin.js create mode 100644 services/voicePin/resolveVmPinTarget.js create mode 100644 services/voicePin/resolveVpPinStore.js create mode 100644 tests/resetVmPin.extensionIndex.test.js create mode 100644 tests/resetVmPin.generatePin.test.js create mode 100644 tests/resetVmPin.parse.test.js create mode 100644 tests/resetVmPin.reset.test.js create mode 100644 tests/resetVpPin.parse.test.js create mode 100644 tests/resetVpPin.reset.test.js create mode 100644 utils/pendingVmPinResets.js create mode 100644 utils/pendingVpPinResets.js diff --git a/.env.example b/.env.example index 75e0762..63c48ae 100644 --- a/.env.example +++ b/.env.example @@ -62,6 +62,7 @@ WEBEX_BOT_TOKEN=your-bot-token-here # - identity:tokens_write (offboarduser: revoke a user's authorizations) # - spark-admin:calling_cdr_read (/callreport + /calltest CDR via cdr_feed) # - analytics:read_all (/callreport Webex Reports API — Pro Pack) +# - spark-admin:telephony_config_write (/resetvmpin + /resetvppin PIN resets) # The authorizing admin must also hold Full / User / Device Admin role for the # token-management calls to succeed. # diff --git a/commands/help.js b/commands/help.js index 7de8a7d..ab21aa8 100644 --- a/commands/help.js +++ b/commands/help.js @@ -38,6 +38,8 @@ const SHORT_HELP = { vcmonitor: 'On-demand VC packet capture (start/stop/status)', offboarduser: 'Offboard user: revoke Webex OAuth tokens + wipe MDM CORP devices', webexhost: 'Check / assign Webex Meetings host license on aeo2go.webex.com', + resetvmpin: 'Reset a user voicemail mailbox PIN (email, store, or 5-digit extension)', + resetvppin: 'Reset a store voice portal PIN for auto-attendant greeting access', // Bulk / utility bulkavstatuscsv: 'Generate full AV devices report (CSV)', @@ -350,6 +352,34 @@ const LONG_HELP = { 'Every request and outcome is logged under the `offboard:audit` scope with the requesting user (chat email or `via HTTP API`).', ], }, + resetvmpin: { + title: '/resetvmpin', + usage: ['/resetvmpin '], + examples: [ + '/resetvmpin mcqueenj@ae.com', + '/resetvmpin 782', + '/resetvmpin 12345', + ], + notes: [ + 'Resets a **voicemail mailbox PIN** for a Webex Calling user. Target can be an email, 2–4 digit store number (`ae{store}@ae.com`), or a 5-digit extension (unique match on `telephony/config/numbers` only).', + 'Posts a confirmation adaptive card showing user identity, match source, location, and voicemail status. A new **6-digit PIN** is generated at confirm time and posted in the same Webex space.', + 'Requires `spark-admin:telephony_config_write` (+ existing `people_read` for lookups). Re-authorize the service app after adding the scope.', + 'Use trusted spaces — the new PIN is visible to everyone in the room after confirm.', + 'Audit log: `resetvmpin:audit` (PIN values are never logged).', + ], + }, + resetvppin: { + title: '/resetvppin', + usage: ['/resetvppin '], + examples: ['/resetvppin 782'], + notes: [ + 'Resets the **voice portal passcode** for a store location. Store number only (2–4 digits) — no email or extension lookup.', + 'The voice portal PIN is used to dial in and manage **auto-attendant greetings** for the whole location. The confirmation card shows location name, portal dial-in number, and extension before you confirm.', + 'PIN length follows the location `passcodeRules` (not hardcoded 6). Requires `spark-admin:telephony_config_write`.', + 'Use trusted spaces — the new PIN is visible to everyone in the room after confirm.', + 'Audit log: `resetvppin:audit` (PIN values are never logged).', + ], + }, bulkavstatuscsv: { title: '/bulkavstatuscsv', usage: ['/bulkavstatuscsv'], @@ -375,7 +405,7 @@ const GROUPS = [ { title: 'AV & phones', keys: ['avstatus', 'voicestatus', 'wanstatus', 'atlasdiag', 'phonediag', 'dectdiag', 'voicediag', 'callreport', 'calltest'] }, { title: 'Jira', keys: ['jirahistory', 'jiraticket', 'jirapoll'] }, { title: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] }, - { title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] }, + { title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'resetvmpin', 'resetvppin', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] }, ]; function renderTopLevelHelp(isGroup) { diff --git a/commands/registry.js b/commands/registry.js index b1083da..044021d 100644 --- a/commands/registry.js +++ b/commands/registry.js @@ -35,6 +35,8 @@ import { handleTestDevicesByModel } from './testDevicesByModel.js'; import { handleCallTest } from './callTest.js'; import { handleCallReport } from './callReport.js'; import { handleAtlasDiag } from './atlasDiag.js'; +import { handleResetVmPin } from './resetVmPin.js'; +import { handleResetVpPin } from './resetVpPin.js'; /** * Each entry: @@ -82,6 +84,10 @@ export const commands = [ { name: 'calltest', handler: handleCallTest, mutating: true }, { name: 'offboarduser', handler: handleOffboardUser, mutating: true }, { name: 'webexhost', handler: handleWebexHost, mutating: true }, + // /resetvmpin and /resetvppin post confirmation adaptive cards and perform + // Webex telephony_config_write PUTs on confirm. Chat-only (http: false). + { name: 'resetvmpin', handler: handleResetVmPin, mutating: true, http: false }, + { name: 'resetvppin', handler: handleResetVpPin, mutating: true, http: false }, // bulkavstatuscsv delivers a CSV attachment via BotClient.sendWithAttachment, // which requires a Webex roomId. The HTTP mock trigger has no roomId, so // the command is chat-only; the runtime guard in the handler backstops this. diff --git a/commands/resetVmPin.js b/commands/resetVmPin.js new file mode 100644 index 0000000..2df7f75 --- /dev/null +++ b/commands/resetVmPin.js @@ -0,0 +1,196 @@ +// commands/resetVmPin.js +// +// /resetvmpin — reset a user's voicemail mailbox PIN with confirmation card. +// +// Targets: email, 2–4 digit store number, or 5-digit extension. + +import { logger } from '../utils/logger.js'; +import { pendingVmPinResets } from '../utils/pendingVmPinResets.js'; +import { extractRequester, describeRequester } from '../utils/requester.js'; +import { + parseVmPinTarget, + resolveVmPinTarget, +} from '../services/voicePin/resolveVmPinTarget.js'; +import { resetVoicemailPin } from '../services/voicePin/resetVoicemailPin.js'; + +function matchSourceLabel(source) { + switch (source) { + case 'email': return 'Email lookup'; + case 'store': return 'Store user'; + case 'extension': return 'Extension lookup'; + default: return source || '—'; + } +} + +function buildConfirmationCard(cardId, resolved) { + const facts = [ + { title: 'User', value: resolved.displayName }, + { title: 'Email', value: resolved.email }, + { title: 'Person ID', value: resolved.personId }, + { title: 'Match source', value: matchSourceLabel(resolved.matchSource) }, + ]; + + if (resolved.storeNum) { + facts.push({ title: 'Store', value: resolved.storeNum }); + } + if (resolved.locationName) { + facts.push({ title: 'Location', value: resolved.locationName }); + } + if (resolved.mainNumber) { + facts.push({ title: 'Main number', value: resolved.mainNumber }); + } + if (resolved.extension) { + facts.push({ title: 'Extension', value: resolved.extension }); + } + facts.push({ + title: 'Voicemail enabled', + value: resolved.voicemailEnabled == null ? 'Unknown' : (resolved.voicemailEnabled ? 'Yes' : 'No'), + }); + + return { + type: 'AdaptiveCard', + version: '1.3', + body: [ + { + type: 'TextBlock', + text: '🔐 RESET VOICEMAIL PIN', + weight: 'Bolder', + size: 'Large', + color: 'Attention', + }, + { + type: 'TextBlock', + text: + 'Confirming will generate a new **6-digit voicemail PIN** and apply it immediately. ' + + 'The new PIN will be posted in this space after confirmation.', + wrap: true, + spacing: 'Small', + }, + { + type: 'FactSet', + spacing: 'Medium', + facts, + }, + ], + actions: [ + { + type: 'Action.Submit', + title: '✅ Confirm Reset', + data: { action: 'confirm_reset_vmpin', cardId }, + }, + { + type: 'Action.Submit', + title: '❌ Cancel', + data: { action: 'cancel_reset_vmpin', cardId }, + }, + ], + }; +} + +export async function applyResetVmPinConfirmation(bot, data, _roomId, requester) { + logger( + 'resetvmpin:audit', + `CONFIRMED voicemail PIN reset for ${data.email} (person=${data.personId}) by ${describeRequester(requester)}`, + ); + + try { + const newPin = await resetVoicemailPin(data.personId); + await bot.say( + 'markdown', + `✅ Voicemail PIN reset for **${data.displayName}** (\`${data.email}\`).\n\n` + + `**New voicemail PIN:** \`${newPin}\`\n\n` + + '_Share this PIN only in trusted spaces._', + ); + logger( + 'resetvmpin:audit', + `COMPLETED voicemail PIN reset for ${data.email} (person=${data.personId}) by ${describeRequester(requester)}`, + ); + } catch (err) { + const apiMsg = err?.response?.data?.message || err.message; + logger( + 'resetvmpin:audit', + `FAILED voicemail PIN reset for ${data.email}: ${apiMsg}`, + 'error', + ); + await bot.say( + 'markdown', + `❌ Voicemail PIN reset failed for **${data.displayName}**: ${apiMsg}`, + ); + } +} + +export async function cancelResetVmPinCard(bot, data, _roomId, requester) { + await bot.say( + 'markdown', + `❌ Voicemail PIN reset cancelled for **${data.displayName}** (\`${data.email}\`). No changes were made.`, + ); + logger( + 'resetvmpin:audit', + `CANCELLED voicemail PIN reset for ${data.email} by ${describeRequester(requester)}`, + ); +} + +export async function handleResetVmPin(bot, trigger) { + const args = trigger.args || []; + const query = trigger.query || {}; + const target = (args[0] || query.target || query.email || query.store || query.extension || '').trim(); + + if (!target) { + await bot.say( + 'markdown', + '**Usage:** `/resetvmpin `\n\n' + + '• Email: `user@ae.com`\n' + + '• Store: `782` (2–4 digits)\n' + + '• Extension: `12345` (5 digits)', + ); + return; + } + + const parsed = parseVmPinTarget(target); + if (!parsed) { + await bot.say( + 'markdown', + `❌ Invalid target \`${target}\`. Use email, 2–4 digit store number, or 5-digit extension.`, + ); + return; + } + + const requester = extractRequester(trigger); + logger( + 'resetvmpin:audit', + `REQUESTED voicemail PIN reset card for ${target} by ${describeRequester(requester)}`, + ); + + try { + const resolved = await resolveVmPinTarget(parsed); + + if (!trigger.person) { + await bot.say( + 'markdown', + `Resolved **${resolved.displayName}** (\`${resolved.email}\`). ` + + 'Confirmation cards are only available in Webex chat.', + ); + return; + } + + const cardId = `vmpin-${Date.now()}`; + pendingVmPinResets.set(cardId, { + ...resolved, + roomId: trigger.roomId || trigger.message?.roomId, + requester, + }); + + const adaptiveCard = buildConfirmationCard(cardId, resolved); + await bot.say({ + markdown: + `Review voicemail PIN reset for **${resolved.displayName}** (\`${resolved.email}\`):`, + attachments: [{ + contentType: 'application/vnd.microsoft.card.adaptive', + content: adaptiveCard, + }], + }); + } catch (err) { + logger('resetvmpin', `Resolve failed for ${target}: ${err.message}`, 'warn'); + await bot.say('markdown', `❌ ${err.message}`); + } +} diff --git a/commands/resetVpPin.js b/commands/resetVpPin.js new file mode 100644 index 0000000..da54023 --- /dev/null +++ b/commands/resetVpPin.js @@ -0,0 +1,186 @@ +// commands/resetVpPin.js +// +// /resetvppin — reset a location's voice portal PIN with confirmation card. +// +// Store number only (2–4 digits). Used to manage auto-attendant greetings. + +import { logger } from '../utils/logger.js'; +import { pendingVpPinResets } from '../utils/pendingVpPinResets.js'; +import { extractRequester, describeRequester } from '../utils/requester.js'; +import { + parseVpPinStore, + resolveVpPinStore, +} from '../services/voicePin/resolveVpPinStore.js'; +import { resetVoicePortalPin } from '../services/voicePin/resetVoicePortalPin.js'; + +function buildConfirmationCard(cardId, resolved) { + const portal = resolved.voicePortal || {}; + const dialIn = portal.phoneNumber + ? `${portal.phoneNumber}${portal.extension ? ` ext ${portal.extension}` : ''}` + : '—'; + + return { + type: 'AdaptiveCard', + version: '1.3', + body: [ + { + type: 'TextBlock', + text: '🔐 RESET VOICE PORTAL PIN', + weight: 'Bolder', + size: 'Large', + color: 'Attention', + }, + { + type: 'TextBlock', + text: + 'Confirming will generate a new voice portal passcode for this location. ' + + 'This PIN is used to dial in and manage **auto-attendant greetings**.', + wrap: true, + spacing: 'Small', + }, + { + type: 'FactSet', + spacing: 'Medium', + facts: [ + { title: 'Store', value: resolved.storeNum }, + { title: 'Location', value: resolved.locationName || '—' }, + { title: 'Location ID', value: resolved.locationId }, + { title: 'Voice portal', value: portal.name || '—' }, + { title: 'Dial-in', value: dialIn }, + { title: 'Main number', value: resolved.mainNumber || '—' }, + ], + }, + ], + actions: [ + { + type: 'Action.Submit', + title: '✅ Confirm Reset', + data: { action: 'confirm_reset_vppin', cardId }, + }, + { + type: 'Action.Submit', + title: '❌ Cancel', + data: { action: 'cancel_reset_vppin', cardId }, + }, + ], + }; +} + +export async function applyResetVpPinConfirmation(bot, data, _roomId, requester) { + logger( + 'resetvppin:audit', + `CONFIRMED voice portal PIN reset for store ${data.storeNum} (location=${data.locationId}) ` + + `by ${describeRequester(requester)}`, + ); + + try { + const newPin = await resetVoicePortalPin(data.locationId, { + passcodeRules: data.passcodeRules, + voicePortal: data.voicePortal, + }); + const portal = data.voicePortal || {}; + const dialIn = portal.phoneNumber + ? `${portal.phoneNumber}${portal.extension ? ` ext ${portal.extension}` : ''}` + : null; + + await bot.say( + 'markdown', + `✅ Voice portal PIN reset for **Store ${data.storeNum}** ` + + `(\`${data.locationName || data.locationId}\`).\n\n` + + `**New voice portal PIN:** \`${newPin}\`\n` + + (dialIn ? `\nDial in at **${dialIn}** to manage auto-attendant greetings.\n` : '\n') + + '_Share this PIN only in trusted spaces._', + ); + logger( + 'resetvppin:audit', + `COMPLETED voice portal PIN reset for store ${data.storeNum} (location=${data.locationId}) ` + + `by ${describeRequester(requester)}`, + ); + } catch (err) { + const apiMsg = err?.response?.data?.message || err.message; + logger( + 'resetvppin:audit', + `FAILED voice portal PIN reset for store ${data.storeNum}: ${apiMsg}`, + 'error', + ); + await bot.say( + 'markdown', + `❌ Voice portal PIN reset failed for **Store ${data.storeNum}**: ${apiMsg}`, + ); + } +} + +export async function cancelResetVpPinCard(bot, data, _roomId, requester) { + await bot.say( + 'markdown', + `❌ Voice portal PIN reset cancelled for **Store ${data.storeNum}**. No changes were made.`, + ); + logger( + 'resetvppin:audit', + `CANCELLED voice portal PIN reset for store ${data.storeNum} by ${describeRequester(requester)}`, + ); +} + +export async function handleResetVpPin(bot, trigger) { + const args = trigger.args || []; + const query = trigger.query || {}; + const raw = (args[0] || query.store || query.storeNum || query.s || '').trim(); + + if (!raw) { + await bot.say( + 'markdown', + '**Usage:** `/resetvppin `\n\n' + + 'Provide a 2–4 digit store number (e.g. `782`).', + ); + return; + } + + const parsed = parseVpPinStore(raw); + if (!parsed) { + await bot.say( + 'markdown', + `❌ Invalid store \`${raw}\`. Use a 2–4 digit store number.`, + ); + return; + } + + const requester = extractRequester(trigger); + logger( + 'resetvppin:audit', + `REQUESTED voice portal PIN reset card for store ${parsed.storeNum} by ${describeRequester(requester)}`, + ); + + try { + const resolved = await resolveVpPinStore(parsed); + + if (!trigger.person) { + await bot.say( + 'markdown', + `Resolved **Store ${resolved.storeNum}** → \`${resolved.locationName || resolved.locationId}\`. ` + + 'Confirmation cards are only available in Webex chat.', + ); + return; + } + + const cardId = `vppin-${Date.now()}`; + pendingVpPinResets.set(cardId, { + ...resolved, + roomId: trigger.roomId || trigger.message?.roomId, + requester, + }); + + const adaptiveCard = buildConfirmationCard(cardId, resolved); + await bot.say({ + markdown: + `Review voice portal PIN reset for **Store ${resolved.storeNum}** ` + + `(\`${resolved.locationName || resolved.locationId}\`):`, + attachments: [{ + contentType: 'application/vnd.microsoft.card.adaptive', + content: adaptiveCard, + }], + }); + } catch (err) { + logger('resetvppin', `Resolve failed for store ${raw}: ${err.message}`, 'warn'); + await bot.say('markdown', `❌ ${err.message}`); + } +} diff --git a/index.js b/index.js index 0e3ac82..b5541f0 100644 --- a/index.js +++ b/index.js @@ -45,6 +45,16 @@ import { cancelAllVoiceDiagRemediations, } from './commands/voiceDiag.js'; import { pendingVoiceFixes } from './utils/pendingVoiceFixes.js'; +import { + applyResetVmPinConfirmation, + cancelResetVmPinCard, +} from './commands/resetVmPin.js'; +import { pendingVmPinResets } from './utils/pendingVmPinResets.js'; +import { + applyResetVpPinConfirmation, + cancelResetVpPinCard, +} from './commands/resetVpPin.js'; +import { pendingVpPinResets } from './utils/pendingVpPinResets.js'; import { extractRequester } from './utils/requester.js'; import { getDectRelayHub } from './services/dectRelayHub.js'; import { @@ -392,6 +402,8 @@ const VOICEDIAG_ACTIONS = new Set([ 'confirm_voicediag_all', 'cancel_voicediag_all', ]); +const RESET_VMPIN_ACTIONS = new Set(['confirm_reset_vmpin', 'cancel_reset_vmpin']); +const RESET_VPPIN_ACTIONS = new Set(['confirm_reset_vppin', 'cancel_reset_vppin']); // Best-effort delete of the adaptive-card message that fired this action. // Removing the card prevents users from clicking Confirm/Cancel a second time @@ -657,6 +669,90 @@ framework.on('attachmentAction', async (bot, trigger) => { return; } + // ── /resetvmpin confirm / cancel ── + if (RESET_VMPIN_ACTIONS.has(actionType)) { + const { cardId } = action.inputs; + const roomId = trigger.roomId || action.roomId; + + if (!cardId) { + logger('resetvmpin:action', `Missing cardId on ${actionType} — ignoring`); + return; + } + if (!pendingVmPinResets.has(cardId)) { + logger('resetvmpin:action', `Card ${cardId} is expired or unknown`); + return; + } + + const vmPinData = pendingVmPinResets.get(cardId); + pendingVmPinResets.delete(cardId); + logger( + 'resetvmpin:action', + `Received ${actionType} for card ${cardId} (user: ${vmPinData.email})`, + ); + + await censorActionCard(bot, trigger, 'resetvmpin:action'); + + const requester = extractRequester(trigger); + + try { + if (actionType === 'confirm_reset_vmpin') { + await applyResetVmPinConfirmation(bot, vmPinData, roomId, requester); + } else { + await cancelResetVmPinCard(bot, vmPinData, roomId, requester); + } + } catch (err) { + logger( + 'resetvmpin:action', + `Error processing ${actionType} for ${vmPinData.email}: ${err.message}`, + 'error', + ); + await bot.say('markdown', `⚠️ Error during voicemail PIN reset: ${err.message}`); + } + return; + } + + // ── /resetvppin confirm / cancel ── + if (RESET_VPPIN_ACTIONS.has(actionType)) { + const { cardId } = action.inputs; + const roomId = trigger.roomId || action.roomId; + + if (!cardId) { + logger('resetvppin:action', `Missing cardId on ${actionType} — ignoring`); + return; + } + if (!pendingVpPinResets.has(cardId)) { + logger('resetvppin:action', `Card ${cardId} is expired or unknown`); + return; + } + + const vpPinData = pendingVpPinResets.get(cardId); + pendingVpPinResets.delete(cardId); + logger( + 'resetvppin:action', + `Received ${actionType} for card ${cardId} (store: ${vpPinData.storeNum})`, + ); + + await censorActionCard(bot, trigger, 'resetvppin:action'); + + const requester = extractRequester(trigger); + + try { + if (actionType === 'confirm_reset_vppin') { + await applyResetVpPinConfirmation(bot, vpPinData, roomId, requester); + } else { + await cancelResetVpPinCard(bot, vpPinData, roomId, requester); + } + } catch (err) { + logger( + 'resetvppin:action', + `Error processing ${actionType} for store ${vpPinData.storeNum}: ${err.message}`, + 'error', + ); + await bot.say('markdown', `⚠️ Error during voice portal PIN reset: ${err.message}`); + } + return; + } + logger('action', `Ignoring unhandled attachmentAction: ${actionType}`, 'debug'); }); diff --git a/services/callTest/locationResolver.js b/services/callTest/locationResolver.js index 2436035..ecb6764 100644 --- a/services/callTest/locationResolver.js +++ b/services/callTest/locationResolver.js @@ -76,6 +76,7 @@ async function buildNumbersCache() { const numbers = await fetchAllNumbers(); const byPhoneDigits = new Map(); const byPersonId = new Map(); + const byExtension = new Map(); const records = []; for (const n of numbers) { @@ -93,13 +94,21 @@ async function buildNumbersCache() { if (!byPersonId.has(ownerId)) byPersonId.set(ownerId, []); byPersonId.get(ownerId).push(entry); } + + if (entry.extension) { + const ext = String(entry.extension).trim(); + if (ext) { + if (!byExtension.has(ext)) byExtension.set(ext, []); + byExtension.get(ext).push(entry); + } + } } - _numbersCache = { byPhoneDigits, byPersonId, records }; + _numbersCache = { byPhoneDigits, byPersonId, byExtension, records }; _numbersCacheAt = now; logger( LOG_SCOPE, - `Phone index built: ${byPhoneDigits.size} phone keys, ${byPersonId.size} owners from ${numbers.length} numbers`, + `Phone index built: ${byPhoneDigits.size} phone keys, ${byPersonId.size} owners, ${byExtension.size} extensions from ${numbers.length} numbers`, 'debug', ); return _numbersCache; @@ -218,7 +227,29 @@ export async function buildPhoneIndex() { return cache.byPhoneDigits; } +/** + * Find telephony number records assigned to a Webex extension. + * @param {string} extension + * @returns {Promise} + */ +export async function resolveRecordsByExtension(extension) { + if (!extension) return []; + try { + const cache = await buildNumbersCache(); + const ext = String(extension).trim(); + return cache.byExtension.get(ext) || []; + } catch (err) { + logger(LOG_SCOPE, `resolveRecordsByExtension failed: ${err.message}`, 'warn'); + return []; + } +} + export function _clearLocationCacheForTests() { _numbersCache = null; _numbersCacheAt = 0; } + +export function _setNumbersCacheForTests(cache) { + _numbersCache = cache; + _numbersCacheAt = Date.now(); +} diff --git a/services/voicePin/generatePin.js b/services/voicePin/generatePin.js new file mode 100644 index 0000000..e128191 --- /dev/null +++ b/services/voicePin/generatePin.js @@ -0,0 +1,76 @@ +// services/voicePin/generatePin.js +// Crypto-random PIN generation with configurable length and passcode rules. + +import { randomInt } from 'node:crypto'; + +const DEFAULT_MIN = 6; +const DEFAULT_MAX = 6; + +function digitPin(length) { + let pin = ''; + for (let i = 0; i < length; i += 1) { + pin += String(randomInt(0, 10)); + } + return pin; +} + +function hasSequentialDigits(pin, maxRun = 3) { + for (let i = 0; i <= pin.length - maxRun; i += 1) { + const slice = pin.slice(i, i + maxRun); + const asc = slice.split('').every((d, idx) => idx === 0 || Number(d) === Number(slice[idx - 1]) + 1); + const desc = slice.split('').every((d, idx) => idx === 0 || Number(d) === Number(slice[idx - 1]) - 1); + if (asc || desc) return true; + } + return false; +} + +function isAllSameDigit(pin) { + return /^(\d)\1+$/.test(pin); +} + +function violatesRules(pin, rules = {}) { + const length = pin.length; + const minLength = rules.minLength ?? rules.length?.min ?? DEFAULT_MIN; + const maxLength = rules.maxLength ?? rules.length?.max ?? DEFAULT_MAX; + if (length < minLength || length > maxLength) return true; + + if (rules.disallowSequential === true && hasSequentialDigits(pin)) return true; + if (rules.disallowRepeating === true && isAllSameDigit(pin)) return true; + if (rules.disallowSameAsExtension === true && rules.extension && pin === String(rules.extension)) { + return true; + } + + return false; +} + +/** + * Generate a numeric PIN that satisfies the provided rules. + * + * @param {object} [opts] + * @param {number} [opts.minLength] + * @param {number} [opts.maxLength] + * @param {object} [opts.rules] full passcodeRules object from Webex + * @param {number} [opts.maxAttempts] + * @returns {string} + */ +export function generatePin(opts = {}) { + const rules = opts.rules || {}; + const minLength = opts.minLength ?? rules.length?.min ?? DEFAULT_MIN; + const maxLength = opts.maxLength ?? rules.length?.max ?? minLength; + const targetLength = Math.max(minLength, Math.min(maxLength, opts.length ?? minLength)); + const maxAttempts = opts.maxAttempts ?? 50; + + const mergedRules = { + ...rules, + minLength, + maxLength, + extension: opts.extension ?? rules.extension, + }; + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const pin = digitPin(targetLength); + if (!violatesRules(pin, mergedRules)) return pin; + } + + throw new Error(`Could not generate a compliant PIN after ${maxAttempts} attempts`); +} diff --git a/services/voicePin/parseVmPinTarget.js b/services/voicePin/parseVmPinTarget.js new file mode 100644 index 0000000..6e3f3bd --- /dev/null +++ b/services/voicePin/parseVmPinTarget.js @@ -0,0 +1,25 @@ +// services/voicePin/parseVmPinTarget.js + +const STORE_RE = /^\d{2,4}$/; +const EXTENSION_RE = /^\d{5}$/; +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/i; + +/** + * @param {string} raw + * @returns {{ kind: 'email', email: string } | { kind: 'store', storeNum: string } | { kind: 'extension', extension: string } | null} + */ +export function parseVmPinTarget(raw) { + const token = String(raw || '').trim(); + if (!token) return null; + + if (EMAIL_RE.test(token)) { + return { kind: 'email', email: token.toLowerCase() }; + } + if (STORE_RE.test(token)) { + return { kind: 'store', storeNum: token }; + } + if (EXTENSION_RE.test(token)) { + return { kind: 'extension', extension: token }; + } + return null; +} diff --git a/services/voicePin/parseVpPinStore.js b/services/voicePin/parseVpPinStore.js new file mode 100644 index 0000000..afec3a5 --- /dev/null +++ b/services/voicePin/parseVpPinStore.js @@ -0,0 +1,13 @@ +// services/voicePin/parseVpPinStore.js + +const STORE_RE = /^\d{2,4}$/; + +/** + * @param {string} raw + * @returns {{ storeNum: string } | null} + */ +export function parseVpPinStore(raw) { + const storeNum = String(raw || '').trim(); + if (!STORE_RE.test(storeNum)) return null; + return { storeNum }; +} diff --git a/services/voicePin/resetVoicePortalPin.js b/services/voicePin/resetVoicePortalPin.js new file mode 100644 index 0000000..4266b3d --- /dev/null +++ b/services/voicePin/resetVoicePortalPin.js @@ -0,0 +1,79 @@ +// services/voicePin/resetVoicePortalPin.js +// GET passcodeRules + PUT /v1/telephony/config/locations/{id}/voicePortal passcode + +import { logger } from '../../utils/logger.js'; +import { generatePin } from './generatePin.js'; + +const LOG_SCOPE = 'resetvppin:service'; +const MAX_PUT_ATTEMPTS = 5; + +/** + * Reset a location's voice portal passcode. + * + * @param {string} locationId + * @param {object} [opts] + * @param {object} [opts.passcodeRules] cached rules from resolve step + * @param {object} [opts.voicePortal] cached portal snapshot from resolve step + * @param {object} [opts.webexClient] injectable client for tests + * @returns {Promise} new PIN (never log this value) + */ +export async function resetVoicePortalPin(locationId, opts = {}) { + if (!locationId) { + throw new Error('locationId is required'); + } + + const client = opts.webexClient || (await import('../../integrations/webex/WebexClient.js')).default; + + const passcodeRules = opts.passcodeRules + || await client.request('GET', `telephony/config/locations/${locationId}/voicePortal/passcodeRules`); + + let lastError = null; + for (let attempt = 0; attempt < MAX_PUT_ATTEMPTS; attempt += 1) { + const passcode = generatePin({ + rules: passcodeRules, + extension: opts.voicePortal?.extension, + }); + + const body = { + passcode: { + newPasscode: passcode, + confirmPasscode: passcode, + }, + }; + + try { + await client.request('PUT', `telephony/config/locations/${locationId}/voicePortal`, body); + logger(LOG_SCOPE, `Voice portal PIN reset succeeded for location ${locationId}`, 'info'); + return passcode; + } catch (err) { + lastError = err; + const status = err?.response?.status; + if (status === 400 && attempt < MAX_PUT_ATTEMPTS - 1) { + logger(LOG_SCOPE, `Voice portal PIN rejected for ${locationId} (attempt ${attempt + 1}), retrying`, 'warn'); + continue; + } + + if (status === 400 || status === 422) { + const existing = opts.voicePortal?.raw + || await client.request('GET', `telephony/config/locations/${locationId}/voicePortal`).catch(() => null); + if (existing) { + const merged = { + ...existing, + passcode: body.passcode, + }; + try { + await client.request('PUT', `telephony/config/locations/${locationId}/voicePortal`, merged); + logger(LOG_SCOPE, `Voice portal PIN reset succeeded (merged PUT) for location ${locationId}`, 'info'); + return passcode; + } catch (mergeErr) { + lastError = mergeErr; + } + } + } + + throw lastError; + } + } + + throw lastError || new Error('Voice portal PIN reset failed'); +} diff --git a/services/voicePin/resetVoicemailPin.js b/services/voicePin/resetVoicemailPin.js new file mode 100644 index 0000000..d336016 --- /dev/null +++ b/services/voicePin/resetVoicemailPin.js @@ -0,0 +1,54 @@ +// services/voicePin/resetVoicemailPin.js +// PUT /v1/telephony/config/people/{personId}/voicemail/passcode +// +// Admin API to set a custom voicemail PIN (spark-admin:telephony_config_write). +// Do NOT use people/{id}/features/voicemail/passcode — that path does not exist. +// For org-default reset only: POST people/{id}/features/voicemail/actions/resetPin/invoke + +import { logger } from '../../utils/logger.js'; +import { generatePin } from './generatePin.js'; + +const LOG_SCOPE = 'resetvmpin:service'; +const VM_PIN_LENGTH = 6; +const MAX_PUT_ATTEMPTS = 5; + +function vmPasscodeEndpoint(personId) { + return `telephony/config/people/${personId}/voicemail/passcode`; +} + +/** + * Reset a user's voicemail mailbox PIN. + * + * @param {string} personId + * @param {object} [opts] + * @param {object} [opts.webexClient] injectable client for tests + * @returns {Promise} new PIN (never log this value) + */ +export async function resetVoicemailPin(personId, opts = {}) { + if (!personId) { + throw new Error('personId is required'); + } + + const client = opts.webexClient || (await import('../../integrations/webex/WebexClient.js')).default; + const endpoint = vmPasscodeEndpoint(personId); + + let lastError = null; + for (let attempt = 0; attempt < MAX_PUT_ATTEMPTS; attempt += 1) { + const passcode = generatePin({ minLength: VM_PIN_LENGTH, maxLength: VM_PIN_LENGTH }); + try { + await client.request('PUT', endpoint, { passcode }); + logger(LOG_SCOPE, `Voicemail PIN reset succeeded for person ${personId}`, 'info'); + return passcode; + } catch (err) { + lastError = err; + const status = err?.response?.status; + if (status === 400 && attempt < MAX_PUT_ATTEMPTS - 1) { + logger(LOG_SCOPE, `Voicemail PIN rejected for ${personId} (attempt ${attempt + 1}), retrying`, 'warn'); + continue; + } + throw err; + } + } + + throw lastError || new Error('Voicemail PIN reset failed'); +} diff --git a/services/voicePin/resolveVmPinTarget.js b/services/voicePin/resolveVmPinTarget.js new file mode 100644 index 0000000..d266dac --- /dev/null +++ b/services/voicePin/resolveVmPinTarget.js @@ -0,0 +1,126 @@ +// services/voicePin/resolveVmPinTarget.js +// Parse and resolve /resetvmpin targets: email, store (2–4 digits), or extension (5 digits). + +import { logger } from '../../utils/logger.js'; +import { getPersonDetails } from '../phoneService.js'; +import { + resolvePersonLocation, + resolveRecordsByExtension, +} from '../callTest/locationResolver.js'; + +export { parseVmPinTarget } from './parseVmPinTarget.js'; + +const LOG_SCOPE = 'resetvmpin:resolve'; + +/** + * @typedef {'email'|'store'|'extension'} VmPinMatchSource + */ + +async function getWebex() { + const mod = await import('../../integrations/webex/WebexClient.js'); + return mod.default; +} + +async function fetchVoicemailSnapshot(personId) { + if (!personId) return { enabled: null }; + try { + const webex = await getWebex(); + const vm = await webex.request('GET', `people/${personId}/features/voicemail`); + return { enabled: vm?.enabled === true, raw: vm }; + } catch (err) { + logger(LOG_SCOPE, `Voicemail feature fetch failed for ${personId}: ${err.message}`, 'warn'); + return { enabled: null }; + } +} + +/** + * Resolve a parsed VM PIN target to a Webex person + context for the confirmation card. + * + * @param {ReturnType} parsed + * @returns {Promise} + */ +export async function resolveVmPinTarget(parsed) { + if (!parsed) { + throw new Error('Invalid target. Use email, 2–4 digit store number, or 5-digit extension.'); + } + + const webex = await getWebex(); + let personId = null; + let email = null; + let matchSource; + let storeNum = null; + let extension = null; + let numberRecord = null; + + if (parsed.kind === 'email') { + matchSource = 'email'; + email = parsed.email; + const person = await webex.findPersonByEmail(email); + if (!person) { + throw new Error(`No Webex person found for ${email}`); + } + personId = person.id; + } else if (parsed.kind === 'store') { + matchSource = 'store'; + storeNum = parsed.storeNum; + const padded = storeNum.padStart(5, '0'); + email = `ae${padded}@ae.com`; + const person = await webex.findPersonByEmail(email); + if (!person) { + throw new Error(`No Webex person found for store ${storeNum} (${email})`); + } + personId = person.id; + } else { + matchSource = 'extension'; + extension = parsed.extension; + const records = await resolveRecordsByExtension(extension); + if (records.length === 0) { + throw new Error(`No telephony number found for extension ${extension}`); + } + const ownerIds = [...new Set(records.map((r) => r.owner?.id).filter(Boolean))]; + if (ownerIds.length !== 1) { + throw new Error( + `Extension ${extension} matches ${records.length} number(s) across ${ownerIds.length} owner(s) — cannot resolve a unique user`, + ); + } + personId = ownerIds[0]; + numberRecord = records[0]; + } + + const [person, locationInfo, voicemail] = await Promise.all([ + getPersonDetails(personId), + resolvePersonLocation(personId), + fetchVoicemailSnapshot(personId), + ]); + + if (!person) { + throw new Error(`Could not load Webex person ${personId}`); + } + + if (!email) { + email = (person.emails || []).find((e) => typeof e === 'string' && e.includes('@')) + || person.emails?.[0]?.value + || person.emails?.[0] + || null; + } + + const ownedNumbers = locationInfo?.ownedNumbers || []; + const primaryNumber = numberRecord + || ownedNumbers.find((n) => n.extension === extension) + || ownedNumbers[0] + || null; + + return { + personId, + person, + email: email || person.displayName || personId, + displayName: person.displayName || email || personId, + matchSource, + storeNum, + extension: extension || primaryNumber?.extension || null, + locationId: locationInfo?.locationId || primaryNumber?.locationId || null, + locationName: locationInfo?.locationName || primaryNumber?.locationName || null, + mainNumber: primaryNumber?.phoneNumber || null, + voicemailEnabled: voicemail.enabled, + }; +} diff --git a/services/voicePin/resolveVpPinStore.js b/services/voicePin/resolveVpPinStore.js new file mode 100644 index 0000000..e10e56c --- /dev/null +++ b/services/voicePin/resolveVpPinStore.js @@ -0,0 +1,56 @@ +// services/voicePin/resolveVpPinStore.js +// Resolve a store number to location + voice portal snapshot for /resetvppin. + +import { logger } from '../../utils/logger.js'; +import { resolveStoreMainNumber } from '../callTest/storeResolver.js'; + +export { parseVpPinStore } from './parseVpPinStore.js'; + +const LOG_SCOPE = 'resetvppin:resolve'; + +async function getWebex() { + const mod = await import('../../integrations/webex/WebexClient.js'); + return mod.default; +} + +/** + * @param {{ storeNum: string }} parsed + * @returns {Promise} + */ +export async function resolveVpPinStore(parsed) { + if (!parsed?.storeNum) { + throw new Error('Invalid store number. Use a 2–4 digit store number.'); + } + + const store = await resolveStoreMainNumber(parsed.storeNum); + const { locationId, locationName, storeNum, dialNumber } = store; + + let voicePortal = null; + let passcodeRules = null; + + try { + const webex = await getWebex(); + [voicePortal, passcodeRules] = await Promise.all([ + webex.request('GET', `telephony/config/locations/${locationId}/voicePortal`), + webex.request('GET', `telephony/config/locations/${locationId}/voicePortal/passcodeRules`), + ]); + } catch (err) { + logger(LOG_SCOPE, `Voice portal fetch failed for location ${locationId}: ${err.message}`, 'warn'); + throw new Error(`Could not load voice portal for store ${storeNum}: ${err.message}`); + } + + return { + storeNum, + locationId, + locationName, + mainNumber: dialNumber, + voicePortal: { + name: voicePortal?.name || null, + extension: voicePortal?.extension || null, + phoneNumber: voicePortal?.phoneNumber || null, + language: voicePortal?.language || null, + raw: voicePortal, + }, + passcodeRules: passcodeRules || {}, + }; +} diff --git a/tests/resetVmPin.extensionIndex.test.js b/tests/resetVmPin.extensionIndex.test.js new file mode 100644 index 0000000..7644909 --- /dev/null +++ b/tests/resetVmPin.extensionIndex.test.js @@ -0,0 +1,48 @@ +// tests/resetVmPin.extensionIndex.test.js + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + resolveRecordsByExtension, + _clearLocationCacheForTests, + _setNumbersCacheForTests, +} from '../services/callTest/locationResolver.js'; + +test('resolveRecordsByExtension returns records from byExtension index', async () => { + _clearLocationCacheForTests(); + const record = { + locationId: 'LOC1', + locationName: 'Store 0782', + phoneNumber: '+17247795574', + extension: '50782', + owner: { id: 'PERSON1', name: 'Store 0782' }, + }; + _setNumbersCacheForTests({ + byPhoneDigits: new Map(), + byPersonId: new Map([['PERSON1', [record]]]), + byExtension: new Map([['50782', [record]]]), + records: [record], + }); + + const matches = await resolveRecordsByExtension('50782'); + assert.equal(matches.length, 1); + assert.equal(matches[0].owner.id, 'PERSON1'); + + _clearLocationCacheForTests(); +}); + +test('resolveRecordsByExtension returns empty for unknown extension', async () => { + _clearLocationCacheForTests(); + _setNumbersCacheForTests({ + byPhoneDigits: new Map(), + byPersonId: new Map(), + byExtension: new Map(), + records: [], + }); + + const matches = await resolveRecordsByExtension('99999'); + assert.deepEqual(matches, []); + + _clearLocationCacheForTests(); +}); diff --git a/tests/resetVmPin.generatePin.test.js b/tests/resetVmPin.generatePin.test.js new file mode 100644 index 0000000..6ea6de7 --- /dev/null +++ b/tests/resetVmPin.generatePin.test.js @@ -0,0 +1,39 @@ +// tests/resetVmPin.generatePin.test.js + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { generatePin } from '../services/voicePin/generatePin.js'; + +test('generatePin returns fixed 6-digit PIN by default', () => { + const pin = generatePin(); + assert.match(pin, /^\d{6}$/); +}); + +test('generatePin respects min and max length from rules', () => { + const pin = generatePin({ rules: { length: { min: 4, max: 4 } } }); + assert.match(pin, /^\d{4}$/); +}); + +test('generatePin avoids all-same-digit when disallowRepeating is set', () => { + for (let i = 0; i < 20; i += 1) { + const pin = generatePin({ + minLength: 4, + maxLength: 4, + rules: { disallowRepeating: true }, + }); + assert.notEqual(/^(\d)\1+$/.test(pin), true); + } +}); + +test('generatePin avoids matching extension when disallowSameAsExtension is set', () => { + for (let i = 0; i < 20; i += 1) { + const pin = generatePin({ + minLength: 5, + maxLength: 5, + extension: '12345', + rules: { disallowSameAsExtension: true }, + }); + assert.notEqual(pin, '12345'); + } +}); diff --git a/tests/resetVmPin.parse.test.js b/tests/resetVmPin.parse.test.js new file mode 100644 index 0000000..2122d2e --- /dev/null +++ b/tests/resetVmPin.parse.test.js @@ -0,0 +1,28 @@ +// tests/resetVmPin.parse.test.js + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { parseVmPinTarget } from '../services/voicePin/parseVmPinTarget.js'; + +test('parseVmPinTarget recognizes email addresses', () => { + assert.deepEqual(parseVmPinTarget('mcqueenj@ae.com'), { + kind: 'email', + email: 'mcqueenj@ae.com', + }); +}); + +test('parseVmPinTarget recognizes store numbers', () => { + assert.deepEqual(parseVmPinTarget('782'), { kind: 'store', storeNum: '782' }); + assert.deepEqual(parseVmPinTarget('24'), { kind: 'store', storeNum: '24' }); +}); + +test('parseVmPinTarget recognizes 5-digit extensions', () => { + assert.deepEqual(parseVmPinTarget('12345'), { kind: 'extension', extension: '12345' }); +}); + +test('parseVmPinTarget rejects invalid tokens', () => { + assert.equal(parseVmPinTarget(''), null); + assert.equal(parseVmPinTarget('123456'), null); + assert.equal(parseVmPinTarget('not-a-target'), null); +}); diff --git a/tests/resetVmPin.reset.test.js b/tests/resetVmPin.reset.test.js new file mode 100644 index 0000000..067037b --- /dev/null +++ b/tests/resetVmPin.reset.test.js @@ -0,0 +1,44 @@ +// tests/resetVmPin.reset.test.js + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { resetVoicemailPin } from '../services/voicePin/resetVoicemailPin.js'; + +test('resetVoicemailPin PUTs a 6-digit passcode', async () => { + const calls = []; + const webexClient = { + async request(method, endpoint, body) { + calls.push({ method, endpoint, body }); + return {}; + }, + }; + + const pin = await resetVoicemailPin('PERSON123', { webexClient }); + + assert.match(pin, /^\d{6}$/); + assert.equal(calls.length, 1); + assert.equal(calls[0].method, 'PUT'); + assert.equal(calls[0].endpoint, 'telephony/config/people/PERSON123/voicemail/passcode'); + assert.equal(calls[0].body.passcode, pin); +}); + +test('resetVoicemailPin retries on HTTP 400', async () => { + let attempt = 0; + const webexClient = { + async request() { + attempt += 1; + if (attempt === 1) { + const err = new Error('rejected'); + err.response = { status: 400 }; + throw err; + } + return {}; + }, + }; + + const pin = await resetVoicemailPin('PERSON123', { webexClient }); + + assert.match(pin, /^\d{6}$/); + assert.equal(attempt, 2); +}); diff --git a/tests/resetVpPin.parse.test.js b/tests/resetVpPin.parse.test.js new file mode 100644 index 0000000..44ea695 --- /dev/null +++ b/tests/resetVpPin.parse.test.js @@ -0,0 +1,18 @@ +// tests/resetVpPin.parse.test.js + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { parseVpPinStore } from '../services/voicePin/parseVpPinStore.js'; + +test('parseVpPinStore recognizes store numbers', () => { + assert.deepEqual(parseVpPinStore('782'), { storeNum: '782' }); + assert.deepEqual(parseVpPinStore('24'), { storeNum: '24' }); +}); + +test('parseVpPinStore rejects non-store tokens', () => { + assert.equal(parseVpPinStore(''), null); + assert.equal(parseVpPinStore('12345'), null); + assert.equal(parseVpPinStore('mcqueenj@ae.com'), null); + assert.equal(parseVpPinStore('50782'), null); +}); diff --git a/tests/resetVpPin.reset.test.js b/tests/resetVpPin.reset.test.js new file mode 100644 index 0000000..2f02e75 --- /dev/null +++ b/tests/resetVpPin.reset.test.js @@ -0,0 +1,61 @@ +// tests/resetVpPin.reset.test.js + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { resetVoicePortalPin } from '../services/voicePin/resetVoicePortalPin.js'; + +test('resetVoicePortalPin PUTs passcode using location rules length', async () => { + const calls = []; + const webexClient = { + async request(method, endpoint, body) { + calls.push({ method, endpoint, body }); + return {}; + }, + }; + + const pin = await resetVoicePortalPin('LOC123', { + passcodeRules: { length: { min: 4, max: 4 } }, + voicePortal: { extension: '9000', raw: { name: 'Portal', extension: '9000' } }, + webexClient, + }); + + assert.match(pin, /^\d{4}$/); + assert.equal(calls.length, 1); + assert.equal(calls[0].method, 'PUT'); + assert.equal(calls[0].endpoint, 'telephony/config/locations/LOC123/voicePortal'); + assert.deepEqual(calls[0].body.passcode, { + newPasscode: pin, + confirmPasscode: pin, + }); +}); + +test('resetVoicePortalPin merges full portal object on partial PUT failure', async () => { + let putAttempts = 0; + const webexClient = { + async request(method, endpoint, body) { + if (method === 'PUT') { + putAttempts += 1; + if (putAttempts === 1) { + const err = new Error('partial rejected'); + err.response = { status: 400 }; + throw err; + } + return body; + } + return { name: 'Store Portal', extension: '9000', phoneNumber: '+15551234567' }; + }, + }; + + const pin = await resetVoicePortalPin('LOC123', { + passcodeRules: { length: { min: 6, max: 6 } }, + voicePortal: { + extension: '9000', + raw: { name: 'Store Portal', extension: '9000', phoneNumber: '+15551234567' }, + }, + webexClient, + }); + + assert.match(pin, /^\d{6}$/); + assert.equal(putAttempts, 2); +}); diff --git a/utils/pendingVmPinResets.js b/utils/pendingVmPinResets.js new file mode 100644 index 0000000..0eca19e --- /dev/null +++ b/utils/pendingVmPinResets.js @@ -0,0 +1,46 @@ +// utils/pendingVmPinResets.js +// +// In-memory store for pending voicemail PIN reset confirmation cards. + +import { logger } from './logger.js'; + +const TTL_MS = 10 * 60 * 1000; +const SWEEP_INTERVAL_MS = 60 * 1000; + +const _store = new Map(); + +export const pendingVmPinResets = { + set(cardId, data) { + _store.set(cardId, { ...data, timestamp: Date.now() }); + return this; + }, + + get(cardId) { + return _store.get(cardId); + }, + + has(cardId) { + return _store.has(cardId); + }, + + delete(cardId) { + return _store.delete(cardId); + }, + + get size() { + return _store.size; + }, +}; + +const sweepHandle = setInterval(() => { + const now = Date.now(); + for (const [cardId, data] of _store.entries()) { + const stamped = typeof data?.timestamp === 'number' ? data.timestamp : 0; + if (now - stamped > TTL_MS) { + logger('resetvmpin:cleanup', `Expired card ${cardId} for ${data?.email || 'unknown'}`); + _store.delete(cardId); + } + } +}, SWEEP_INTERVAL_MS); + +sweepHandle.unref?.(); diff --git a/utils/pendingVpPinResets.js b/utils/pendingVpPinResets.js new file mode 100644 index 0000000..c451bba --- /dev/null +++ b/utils/pendingVpPinResets.js @@ -0,0 +1,46 @@ +// utils/pendingVpPinResets.js +// +// In-memory store for pending voice portal PIN reset confirmation cards. + +import { logger } from './logger.js'; + +const TTL_MS = 10 * 60 * 1000; +const SWEEP_INTERVAL_MS = 60 * 1000; + +const _store = new Map(); + +export const pendingVpPinResets = { + set(cardId, data) { + _store.set(cardId, { ...data, timestamp: Date.now() }); + return this; + }, + + get(cardId) { + return _store.get(cardId); + }, + + has(cardId) { + return _store.has(cardId); + }, + + delete(cardId) { + return _store.delete(cardId); + }, + + get size() { + return _store.size; + }, +}; + +const sweepHandle = setInterval(() => { + const now = Date.now(); + for (const [cardId, data] of _store.entries()) { + const stamped = typeof data?.timestamp === 'number' ? data.timestamp : 0; + if (now - stamped > TTL_MS) { + logger('resetvppin:cleanup', `Expired card ${cardId} for store ${data?.storeNum || 'unknown'}`); + _store.delete(cardId); + } + } +}, SWEEP_INTERVAL_MS); + +sweepHandle.unref?.();