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 <cursoragent@cursor.com>
This commit is contained in:
parent
f06dd2d09a
commit
2ec2b7a486
22 changed files with 1308 additions and 3 deletions
|
|
@ -62,6 +62,7 @@ WEBEX_BOT_TOKEN=your-bot-token-here
|
||||||
# - identity:tokens_write (offboarduser: revoke a user's authorizations)
|
# - identity:tokens_write (offboarduser: revoke a user's authorizations)
|
||||||
# - spark-admin:calling_cdr_read (/callreport + /calltest CDR via cdr_feed)
|
# - spark-admin:calling_cdr_read (/callreport + /calltest CDR via cdr_feed)
|
||||||
# - analytics:read_all (/callreport Webex Reports API — Pro Pack)
|
# - 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
|
# The authorizing admin must also hold Full / User / Device Admin role for the
|
||||||
# token-management calls to succeed.
|
# token-management calls to succeed.
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ const SHORT_HELP = {
|
||||||
vcmonitor: 'On-demand VC packet capture (start/stop/status)',
|
vcmonitor: 'On-demand VC packet capture (start/stop/status)',
|
||||||
offboarduser: 'Offboard user: revoke Webex OAuth tokens + wipe MDM CORP devices',
|
offboarduser: 'Offboard user: revoke Webex OAuth tokens + wipe MDM CORP devices',
|
||||||
webexhost: 'Check / assign Webex Meetings host license on aeo2go.webex.com',
|
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
|
// Bulk / utility
|
||||||
bulkavstatuscsv: 'Generate full AV devices report (CSV)',
|
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`).',
|
'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 <email|store|extension>'],
|
||||||
|
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 <store>'],
|
||||||
|
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: {
|
bulkavstatuscsv: {
|
||||||
title: '/bulkavstatuscsv',
|
title: '/bulkavstatuscsv',
|
||||||
usage: ['/bulkavstatuscsv'],
|
usage: ['/bulkavstatuscsv'],
|
||||||
|
|
@ -375,7 +405,7 @@ const GROUPS = [
|
||||||
{ title: 'AV & phones', keys: ['avstatus', 'voicestatus', 'wanstatus', 'atlasdiag', 'phonediag', 'dectdiag', 'voicediag', 'callreport', 'calltest'] },
|
{ title: 'AV & phones', keys: ['avstatus', 'voicestatus', 'wanstatus', 'atlasdiag', 'phonediag', 'dectdiag', 'voicediag', 'callreport', 'calltest'] },
|
||||||
{ title: 'Jira', keys: ['jirahistory', 'jiraticket', 'jirapoll'] },
|
{ title: 'Jira', keys: ['jirahistory', 'jiraticket', 'jirapoll'] },
|
||||||
{ title: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] },
|
{ 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) {
|
function renderTopLevelHelp(isGroup) {
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ import { handleTestDevicesByModel } from './testDevicesByModel.js';
|
||||||
import { handleCallTest } from './callTest.js';
|
import { handleCallTest } from './callTest.js';
|
||||||
import { handleCallReport } from './callReport.js';
|
import { handleCallReport } from './callReport.js';
|
||||||
import { handleAtlasDiag } from './atlasDiag.js';
|
import { handleAtlasDiag } from './atlasDiag.js';
|
||||||
|
import { handleResetVmPin } from './resetVmPin.js';
|
||||||
|
import { handleResetVpPin } from './resetVpPin.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Each entry:
|
* Each entry:
|
||||||
|
|
@ -82,6 +84,10 @@ export const commands = [
|
||||||
{ name: 'calltest', handler: handleCallTest, mutating: true },
|
{ name: 'calltest', handler: handleCallTest, mutating: true },
|
||||||
{ name: 'offboarduser', handler: handleOffboardUser, mutating: true },
|
{ name: 'offboarduser', handler: handleOffboardUser, mutating: true },
|
||||||
{ name: 'webexhost', handler: handleWebexHost, 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,
|
// bulkavstatuscsv delivers a CSV attachment via BotClient.sendWithAttachment,
|
||||||
// which requires a Webex roomId. The HTTP mock trigger has no roomId, so
|
// 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.
|
// the command is chat-only; the runtime guard in the handler backstops this.
|
||||||
|
|
|
||||||
196
commands/resetVmPin.js
Normal file
196
commands/resetVmPin.js
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
// commands/resetVmPin.js
|
||||||
|
//
|
||||||
|
// /resetvmpin <target> — 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 <email|store|extension>`\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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
186
commands/resetVpPin.js
Normal file
186
commands/resetVpPin.js
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
// commands/resetVpPin.js
|
||||||
|
//
|
||||||
|
// /resetvppin <store> — 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 <store>`\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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
96
index.js
96
index.js
|
|
@ -45,6 +45,16 @@ import {
|
||||||
cancelAllVoiceDiagRemediations,
|
cancelAllVoiceDiagRemediations,
|
||||||
} from './commands/voiceDiag.js';
|
} from './commands/voiceDiag.js';
|
||||||
import { pendingVoiceFixes } from './utils/pendingVoiceFixes.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 { extractRequester } from './utils/requester.js';
|
||||||
import { getDectRelayHub } from './services/dectRelayHub.js';
|
import { getDectRelayHub } from './services/dectRelayHub.js';
|
||||||
import {
|
import {
|
||||||
|
|
@ -392,6 +402,8 @@ const VOICEDIAG_ACTIONS = new Set([
|
||||||
'confirm_voicediag_all',
|
'confirm_voicediag_all',
|
||||||
'cancel_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.
|
// Best-effort delete of the adaptive-card message that fired this action.
|
||||||
// Removing the card prevents users from clicking Confirm/Cancel a second time
|
// Removing the card prevents users from clicking Confirm/Cancel a second time
|
||||||
|
|
@ -657,6 +669,90 @@ framework.on('attachmentAction', async (bot, trigger) => {
|
||||||
return;
|
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');
|
logger('action', `Ignoring unhandled attachmentAction: ${actionType}`, 'debug');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ async function buildNumbersCache() {
|
||||||
const numbers = await fetchAllNumbers();
|
const numbers = await fetchAllNumbers();
|
||||||
const byPhoneDigits = new Map();
|
const byPhoneDigits = new Map();
|
||||||
const byPersonId = new Map();
|
const byPersonId = new Map();
|
||||||
|
const byExtension = new Map();
|
||||||
const records = [];
|
const records = [];
|
||||||
|
|
||||||
for (const n of numbers) {
|
for (const n of numbers) {
|
||||||
|
|
@ -93,13 +94,21 @@ async function buildNumbersCache() {
|
||||||
if (!byPersonId.has(ownerId)) byPersonId.set(ownerId, []);
|
if (!byPersonId.has(ownerId)) byPersonId.set(ownerId, []);
|
||||||
byPersonId.get(ownerId).push(entry);
|
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;
|
_numbersCacheAt = now;
|
||||||
logger(
|
logger(
|
||||||
LOG_SCOPE,
|
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',
|
'debug',
|
||||||
);
|
);
|
||||||
return _numbersCache;
|
return _numbersCache;
|
||||||
|
|
@ -218,7 +227,29 @@ export async function buildPhoneIndex() {
|
||||||
return cache.byPhoneDigits;
|
return cache.byPhoneDigits;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find telephony number records assigned to a Webex extension.
|
||||||
|
* @param {string} extension
|
||||||
|
* @returns {Promise<object[]>}
|
||||||
|
*/
|
||||||
|
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() {
|
export function _clearLocationCacheForTests() {
|
||||||
_numbersCache = null;
|
_numbersCache = null;
|
||||||
_numbersCacheAt = 0;
|
_numbersCacheAt = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function _setNumbersCacheForTests(cache) {
|
||||||
|
_numbersCache = cache;
|
||||||
|
_numbersCacheAt = Date.now();
|
||||||
|
}
|
||||||
|
|
|
||||||
76
services/voicePin/generatePin.js
Normal file
76
services/voicePin/generatePin.js
Normal file
|
|
@ -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`);
|
||||||
|
}
|
||||||
25
services/voicePin/parseVmPinTarget.js
Normal file
25
services/voicePin/parseVmPinTarget.js
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
13
services/voicePin/parseVpPinStore.js
Normal file
13
services/voicePin/parseVpPinStore.js
Normal file
|
|
@ -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 };
|
||||||
|
}
|
||||||
79
services/voicePin/resetVoicePortalPin.js
Normal file
79
services/voicePin/resetVoicePortalPin.js
Normal file
|
|
@ -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<string>} 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');
|
||||||
|
}
|
||||||
54
services/voicePin/resetVoicemailPin.js
Normal file
54
services/voicePin/resetVoicemailPin.js
Normal file
|
|
@ -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<string>} 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');
|
||||||
|
}
|
||||||
126
services/voicePin/resolveVmPinTarget.js
Normal file
126
services/voicePin/resolveVmPinTarget.js
Normal file
|
|
@ -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<typeof parseVmPinTarget>} parsed
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
56
services/voicePin/resolveVpPinStore.js
Normal file
56
services/voicePin/resolveVpPinStore.js
Normal file
|
|
@ -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<object>}
|
||||||
|
*/
|
||||||
|
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 || {},
|
||||||
|
};
|
||||||
|
}
|
||||||
48
tests/resetVmPin.extensionIndex.test.js
Normal file
48
tests/resetVmPin.extensionIndex.test.js
Normal file
|
|
@ -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();
|
||||||
|
});
|
||||||
39
tests/resetVmPin.generatePin.test.js
Normal file
39
tests/resetVmPin.generatePin.test.js
Normal file
|
|
@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
28
tests/resetVmPin.parse.test.js
Normal file
28
tests/resetVmPin.parse.test.js
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
44
tests/resetVmPin.reset.test.js
Normal file
44
tests/resetVmPin.reset.test.js
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
18
tests/resetVpPin.parse.test.js
Normal file
18
tests/resetVpPin.parse.test.js
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
61
tests/resetVpPin.reset.test.js
Normal file
61
tests/resetVpPin.reset.test.js
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
46
utils/pendingVmPinResets.js
Normal file
46
utils/pendingVmPinResets.js
Normal file
|
|
@ -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?.();
|
||||||
46
utils/pendingVpPinResets.js
Normal file
46
utils/pendingVpPinResets.js
Normal file
|
|
@ -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?.();
|
||||||
Loading…
Reference in a new issue