Retire /buildStore, rename /migrateStore -> /finalizeStore, harden flows
Some checks are pending
CI / verify (push) Waiting to run

- Delete /buildStore command + flow (dead since phone numbers now have
  to be purchased before provisioning); drop the only cascading dead
  helper (updateLocationRouteGroup) that was only called by that flow
- Rename /migrateStore -> /finalizeStore. "Migrate" was a leftover from
  the legacy-system era; the command finishes what stage started
- Rename buildStoreInfoCard -> storeConfirmationCard (misleading name
  since all flows share it); rename btnBuildStore/btnBadInfo ->
  btnConfirm/btnCancel to match
- Fix the double-write of storeInfo.extension: siw.js sets a 5XXXX
  default and both command handlers were overriding it (stage to 8XXXX,
  finalize back to 5XXXX). Drop both overrides; single source of truth
- Add /finalizeStore preflight that runs findWebexLocation in parallel
  with findWebexUser; if the location doesn't exist, bail before showing
  the confirmation card with an actionable "Run /stageStore first" line
- Add post-run next-step summaries: stage points at
  /finalizeStore <n>, finalize confirms the location is live
- Update README (commands, mermaid) and deviceCustomizations header

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jmcqueen 2026-07-06 16:33:51 -04:00
parent 7a9f5c96f8
commit b5610acc8e
12 changed files with 75 additions and 165 deletions

View file

@ -79,10 +79,10 @@ the flag Node throws `SELF_SIGNED_CERT_IN_CHAIN` even for Google's public
certs. Trust is delegated to that proxy by policy, so the scoped bypass is certs. Trust is delegated to that proxy by policy, so the scoped bypass is
consistent across all proxied traffic. consistent across all proxied traffic.
Once the agent is connected, the bot logs `Remote agent connected` and any Once the agent is connected, the bot logs `Remote agent connected` and
`/buildStore`, `/stageStore`, `/migrateStore` command will succeed. If the `/stageStore` and `/finalizeStore` will succeed. If the agent is not
agent is not connected, those commands fail immediately with connected, those commands fail immediately with a preflight message
`No remote SIW agent connected` rather than silently timing out. rather than silently timing out mid-flow.
### Deploying the agent ### Deploying the agent
@ -127,17 +127,22 @@ for the full operator guide (upgrades, troubleshooting, coexistence with
Registered in [src/commands](src/commands): Registered in [src/commands](src/commands):
- `/buildStore <storeNumber>` — full green-field build (create location, calling, - `/stageStore <storeNumber>` — creates the Webex location, enables calling,
greeting, attach user, license cleanup). configures the schedule, greeting, music-on-hold, voicemail, voice portal,
- `/stageStore <storeNumber>` — pre-migration setup: same as buildStore but and attaches the Webex Calling license to the store user. Everything
without phone-number attachment or licensing cleanup. except the phone number, which has to be purchased separately in Control Hub.
- `/migrateStore <storeNumber>` — cut-over for a staged store: attach the phone - `/finalizeStore <storeNumber>` — cut-over for a previously staged store:
number, set caller ID, create the auto-attendant, finalize licensing. attaches the purchased phone number, sets caller ID, creates the
auto-attendant, and finalizes user licensing. Preflights that the store
was actually staged first.
- `/storeinfo <storeNumber>` — show current Webex info for the store user - `/storeinfo <storeNumber>` — show current Webex info for the store user
(`ae<5-digit>@ae.com`). (`ae<5-digit>@ae.com`).
- `/userinfo <email>` — show current Webex info for any user by email. - `/userinfo <email>` — show current Webex info for any user by email.
- `/help` — bot's own help output. - `/help` — bot's own help output.
Typical provisioning workflow: `/stageStore 499` → purchase a phone number
in Control Hub → `/finalizeStore 499`.
Card confirmations post `attachmentAction` events, dispatched in Card confirmations post `attachmentAction` events, dispatched in
[src/commands/attachmentActions.js](src/commands/attachmentActions.js). [src/commands/attachmentActions.js](src/commands/attachmentActions.js).
@ -153,7 +158,7 @@ src/
webex/ all Webex API calls, one module per resource family webex/ all Webex API calls, one module per resource family
integrations/ SIW / Twilio / Google integrations/ SIW / Twilio / Google
cards/ Adaptive Card builders cards/ Adaptive Card builders
flows/ multi-step provisioning (build, stage, migrate, ...) flows/ multi-step provisioning (stage, finalize)
commands/ framework.hears handlers + attachmentAction dispatch commands/ framework.hears handlers + attachmentAction dispatch
scripts/ one-off maintenance scripts (911 CSV, phone fix-up) scripts/ one-off maintenance scripts (911 CSV, phone fix-up)
greetings/ WAV files uploaded as location announcements greetings/ WAV files uploaded as location announcements
@ -164,12 +169,12 @@ Data flow for a store provisioning:
```mermaid ```mermaid
flowchart LR flowchart LR
User[Webex user] -->|/buildStore 1234| Commands[commands/*] User[Webex user] -->|/stageStore 1234| Commands[commands/*]
Commands --> SIW[integrations/siw.js] Commands --> SIW[integrations/siw.js]
Commands --> Users[webex/users.js] Commands --> Users[webex/users.js]
Commands --> Card[cards/storeInfoCard.js] Commands --> Card[cards/storeConfirmationCard.js]
Card -->|confirm| Attachment[attachmentActions.js] Card -->|confirm| Attachment[attachmentActions.js]
Attachment --> Flow[flows/buildStore.js] Attachment --> Flow[flows/stageStore.js]
Flow --> Locations[webex/locations.js] Flow --> Locations[webex/locations.js]
Flow --> Devices[webex/devices.js] Flow --> Devices[webex/devices.js]
Flow --> Announce[webex/announcements.js] Flow --> Announce[webex/announcements.js]

View file

@ -1,8 +1,9 @@
/** /**
* Build the confirmation card shown before build/stage/migrate operations. * Build the confirmation card shown before stage/finalize operations.
* `action` is what the "Yes" button posts back to the bot (buildStore, stageStore, migrateStore). * `action` is what the "Yes" button posts back to the bot (e.g. "stageStore",
* "finalizeStore"). The attachmentAction handler switches on that value.
*/ */
export function buildStoreInfoCard(storeInfo, userInfo, action) { export function storeConfirmationCard(storeInfo, userInfo, action) {
const storeFacts = [ const storeFacts = [
{ title: 'Name', value: storeInfo.name }, { title: 'Name', value: storeInfo.name },
{ title: 'Brand', value: storeInfo.brand }, { title: 'Brand', value: storeInfo.brand },
@ -52,13 +53,13 @@ export function buildStoreInfoCard(storeInfo, userInfo, action) {
{ {
type: 'Action.Submit', type: 'Action.Submit',
title: 'Yes', title: 'Yes',
id: 'btnBuildStore', id: 'btnConfirm',
data: { action, storeInfo, userInfo }, data: { action, storeInfo, userInfo },
}, },
{ {
type: 'Action.Submit', type: 'Action.Submit',
title: 'No', title: 'No',
id: 'btnBadInfo', id: 'btnCancel',
data: { action: 'deleteCard' }, data: { action: 'deleteCard' },
}, },
], ],

View file

@ -1,7 +1,6 @@
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { buildStoreLocation } from '../flows/buildStore.js';
import { stageStoreLocation } from '../flows/stageStore.js'; import { stageStoreLocation } from '../flows/stageStore.js';
import { migrateStoreLocation } from '../flows/migrateStore.js'; import { finalizeStoreLocation } from '../flows/finalizeStore.js';
import { getWebexDeviceDetail } from '../webex/devices.js'; import { getWebexDeviceDetail } from '../webex/devices.js';
async function runFlow(bot, trigger, verb, fn) { async function runFlow(bot, trigger, verb, fn) {
@ -36,14 +35,11 @@ export function register(framework) {
const action = trigger.attachmentAction.inputs.action; const action = trigger.attachmentAction.inputs.action;
switch (action) { switch (action) {
case 'buildStore':
await runFlow(bot, trigger, 'Building', buildStoreLocation);
break;
case 'stageStore': case 'stageStore':
await runFlow(bot, trigger, 'Staging', stageStoreLocation); await runFlow(bot, trigger, 'Staging', stageStoreLocation);
break; break;
case 'migrateStore': case 'finalizeStore':
await runFlow(bot, trigger, 'Migrating store', migrateStoreLocation); await runFlow(bot, trigger, 'Finalizing', finalizeStoreLocation);
break; break;
case 'showDevice': case 'showDevice':
await showDevices(bot, trigger); await showDevices(bot, trigger);

View file

@ -1,30 +0,0 @@
import { logger } from '../logger.js';
import { getStoreInfo } from '../integrations/siw.js';
import { findWebexUser } from '../webex/users.js';
import { buildStoreInfoCard } from '../cards/storeInfoCard.js';
import { parseStoreNumber, requireAgent, storeEmail } from './helpers.js';
export function register(framework) {
framework.hears(
/\/buildstore/i,
async (bot, trigger) => {
logger.info(`${trigger.person.displayName} ran the buildStore command.`);
if (!requireAgent(bot)) return;
const storeNumber = parseStoreNumber(trigger);
if (!storeNumber) {
bot.say('Usage: `/buildStore <storeNumber>` — e.g. `/buildStore 499`');
return;
}
try {
const storeInfo = await getStoreInfo(storeNumber);
const userInfo = await findWebexUser(storeEmail(storeNumber));
const card = buildStoreInfoCard(storeInfo, userInfo, 'buildStore');
bot.sendCard(card, 'Please use another client');
} catch (error) {
logger.error('buildStore command failed:', error);
bot.say('markdown', `Error running /buildstore:\n\`\`\`\n${error.message}\n\`\`\``);
}
},
'**/buildStore** <storeNumber> - Builds a store location for Webex Calling (New and remodels).',
);
}

View file

@ -1,34 +1,49 @@
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { getStoreInfo } from '../integrations/siw.js'; import { getStoreInfo } from '../integrations/siw.js';
import { findWebexUser } from '../webex/users.js'; import { findWebexUser } from '../webex/users.js';
import { buildStoreInfoCard } from '../cards/storeInfoCard.js'; import { findWebexLocation } from '../webex/locations.js';
import { storeConfirmationCard } from '../cards/storeConfirmationCard.js';
import { parseStoreNumber, requireAgent, storeEmail } from './helpers.js'; import { parseStoreNumber, requireAgent, storeEmail } from './helpers.js';
export function register(framework) { export function register(framework) {
framework.hears( framework.hears(
/\/migratestore/i, /\/finalizestore/i,
async (bot, trigger) => { async (bot, trigger) => {
logger.info(`${trigger.person.displayName} ran the migrateStore command.`); logger.info(`${trigger.person.displayName} ran the finalizeStore command.`);
if (!requireAgent(bot)) return; if (!requireAgent(bot)) return;
const storeNumber = parseStoreNumber(trigger); const storeNumber = parseStoreNumber(trigger);
if (!storeNumber) { if (!storeNumber) {
bot.say('Usage: `/migrateStore <storeNumber>` — e.g. `/migrateStore 499`'); bot.say('Usage: `/finalizeStore <storeNumber>` — e.g. `/finalizeStore 499`');
return; return;
} }
try { try {
const storeInfo = await getStoreInfo(storeNumber); const storeInfo = await getStoreInfo(storeNumber);
storeInfo.extension = `5${String(storeNumber).padStart(4, '0')}`; const [userInfo, locationMatches] = await Promise.all([
const userInfo = await findWebexUser(storeEmail(storeNumber)); findWebexUser(storeEmail(storeNumber)),
const card = buildStoreInfoCard(storeInfo, userInfo, 'migrateStore'); findWebexLocation(storeInfo.name),
bot.sendCard(card, 'Please use another client'); ]);
} catch (error) { // Preflight: if /stageStore was never run, the location won't
logger.error('migrateStore command failed:', error); // exist in Webex, and every downstream mutation would fail.
// Bail out here with an actionable message instead of showing
// a confirmation card that's guaranteed to blow up on click.
if (!locationMatches.length) {
bot.say( bot.say(
'markdown', 'markdown',
`Error running /migratestore:\n\`\`\`\n${error.message}\n\`\`\``, `No Webex location found for **${storeInfo.name}**. ` +
`Run \`/stageStore ${storeNumber}\` first.`,
);
return;
}
const card = storeConfirmationCard(storeInfo, userInfo, 'finalizeStore');
bot.sendCard(card, 'Please use another client');
} catch (error) {
logger.error('finalizeStore command failed:', error);
bot.say(
'markdown',
`Error running /finalizeStore:\n\`\`\`\n${error.message}\n\`\`\``,
); );
} }
}, },
'**/migrateStore** <storeNumber> - Completes the store migration for an open store.', '**/finalizeStore** <storeNumber> - Finalizes a staged store: attaches the phone number, sets caller ID, creates the auto-attendant, and cleans up licensing.',
); );
} }

View file

@ -1,7 +1,7 @@
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { getStoreInfo } from '../integrations/siw.js'; import { getStoreInfo } from '../integrations/siw.js';
import { findWebexUser } from '../webex/users.js'; import { findWebexUser } from '../webex/users.js';
import { buildStoreInfoCard } from '../cards/storeInfoCard.js'; import { storeConfirmationCard } from '../cards/storeConfirmationCard.js';
import { parseStoreNumber, requireAgent, storeEmail } from './helpers.js'; import { parseStoreNumber, requireAgent, storeEmail } from './helpers.js';
export function register(framework) { export function register(framework) {
@ -17,15 +17,14 @@ export function register(framework) {
} }
try { try {
const storeInfo = await getStoreInfo(storeNumber); const storeInfo = await getStoreInfo(storeNumber);
storeInfo.extension = `8${String(storeNumber).padStart(4, '0')}`;
const userInfo = await findWebexUser(storeEmail(storeNumber)); const userInfo = await findWebexUser(storeEmail(storeNumber));
const card = buildStoreInfoCard(storeInfo, userInfo, 'stageStore'); const card = storeConfirmationCard(storeInfo, userInfo, 'stageStore');
bot.sendCard(card, 'Please use another client'); bot.sendCard(card, 'Please use another client');
} catch (error) { } catch (error) {
logger.error('stageStore command failed:', error); logger.error('stageStore command failed:', error);
bot.say('markdown', `Error running /stagestore:\n\`\`\`\n${error.message}\n\`\`\``); bot.say('markdown', `Error running /stagestore:\n\`\`\`\n${error.message}\n\`\`\``);
} }
}, },
'**/stageStore** <storeNumber> - Stages a store location for Webex Calling (Pre-migration).', '**/stageStore** <storeNumber> - Stages a store location for Webex Calling (everything except the phone number).',
); );
} }

View file

@ -1,73 +0,0 @@
import {
addPhoneNumbersToLocation,
createLocation,
enableLocationForCalling,
updateInternalDialing,
updateLocationOutgoingPermission,
updateLocationRouteGroup,
updateLocationVoicemail,
updateLocationVoicePortal,
updateMusicOnHold,
} from '../webex/locations.js';
import { scheduleStoreDeviceSettings } from '../webex/devices.js';
import { createAllHoursSchedule } from '../webex/schedules.js';
import { uploadGreeting } from '../webex/announcements.js';
import { addWebexCallingToStoreUser, normalizeStoreUserLicenses } from '../webex/licensing.js';
import { updateUserCallExperience } from '../webex/users.js';
import { logger } from '../logger.js';
import { runStep } from './stepRunner.js';
import { greetingForBrand } from './greetingSelector.js';
/**
* "Build store" full green-field provisioning: create location, calling,
* greeting, user attach, license cleanup.
*/
export async function buildStoreLocation(bot, locationInfo, userInfo) {
logger.info(`Building location ${locationInfo.name}`);
const location = await createLocation(locationInfo);
bot.say(
'markdown',
`<blockquote class='success'>Created location ${location.name}.</blockquote>`,
);
await runStep(
bot,
'Enabled location for Webex Calling',
() => enableLocationForCalling(location),
{ critical: true },
);
await runStep(bot, 'Updated location Webex Calling connection', () =>
updateLocationRouteGroup(location),
);
await runStep(bot, 'Added phone numbers to location', () =>
addPhoneNumbersToLocation(location, locationInfo.phoneNumber),
);
await runStep(bot, 'Updated internal dialing', () => updateInternalDialing(location));
await runStep(bot, 'Updated location outgoing permission', () =>
updateLocationOutgoingPermission(location),
);
await runStep(bot, 'Updated music on hold', () => updateMusicOnHold(location));
await runStep(bot, 'Updated location voicemail', () => updateLocationVoicemail(location));
await runStep(bot, 'Updated location voice portal', () =>
updateLocationVoicePortal(location, locationInfo.vpExtension),
);
await runStep(bot, 'Created a schedule', () => createAllHoursSchedule(location));
await runStep(bot, 'Scheduled device changes', () => scheduleStoreDeviceSettings(location));
const { file, fileName } = greetingForBrand(locationInfo);
const greeting = await runStep(bot, 'Succeeded uploading greeting', () =>
uploadGreeting(location, file, fileName),
);
if (greeting) location.announcementId = greeting.id;
await runStep(bot, 'Updated store user', () =>
addWebexCallingToStoreUser(location, userInfo, locationInfo.extension),
);
await runStep(bot, 'Updated user call application experience', () =>
updateUserCallExperience(userInfo),
);
await runStep(bot, 'Fixed Licenses', () => normalizeStoreUserLicenses(userInfo));
bot.say('Build Complete!');
return location;
}

View file

@ -17,16 +17,21 @@ import { logger } from '../logger.js';
import { runStep } from './stepRunner.js'; import { runStep } from './stepRunner.js';
/** /**
* "Migrate store" finish the cut-over for a store that was previously staged. * "Finalize store" finish the cut-over for a store that was previously staged.
* Attach the phone number, set caller ID, create the auto-attendant, and clean * Attach the phone number, set caller ID, create the auto-attendant, and clean
* up the user's licensing/voicemail. * up the user's licensing/voicemail.
*
* Precondition: the location already exists in Webex (i.e. `/stageStore` has
* been run). The command handler preflight rejects unstaged stores before we
* get here, but we defensively re-check because the flow can also be invoked
* directly from the attachmentAction submission.
*/ */
export async function migrateStoreLocation(bot, locationInfo, userInfo) { export async function finalizeStoreLocation(bot, locationInfo, userInfo) {
logger.info(`Migrating location ${locationInfo.name}`); logger.info(`Finalizing location ${locationInfo.name}`);
const matches = await findWebexLocation(locationInfo.name); const matches = await findWebexLocation(locationInfo.name);
if (!matches.length) { if (!matches.length) {
throw new Error( throw new Error(
`No existing Webex location found for ${locationInfo.name}. Did you stage first?`, `No existing Webex location found for ${locationInfo.name}. Run /stageStore first.`,
); );
} }
const location = matches[0]; const location = matches[0];
@ -70,6 +75,6 @@ export async function migrateStoreLocation(bot, locationInfo, userInfo) {
await runStep(bot, 'Fixed Licenses', () => normalizeStoreUserLicenses(userInfo)); await runStep(bot, 'Fixed Licenses', () => normalizeStoreUserLicenses(userInfo));
bot.say('Migration Complete!'); bot.say('markdown', `**Finalize complete for ${locationInfo.name}.** Location is now live.`);
return location; return location;
} }

View file

@ -55,6 +55,11 @@ export async function stageStoreLocation(bot, locationInfo, userInfo) {
addWebexCallingToStoreUser(location, userInfo, locationInfo.extension), addWebexCallingToStoreUser(location, userInfo, locationInfo.extension),
); );
bot.say('Staging Complete!'); bot.say(
'markdown',
`**Staging complete for ${locationInfo.name}.** ` +
`Next: purchase a phone number in Webex Control Hub, then run ` +
`\`/finalizeStore ${locationInfo.storeNumber}\`.`,
);
return location; return location;
} }

View file

@ -4,9 +4,8 @@ import { config } from './config.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { isAccessTokenExpiring, refreshAccessToken } from './webex/auth.js'; import { isAccessTokenExpiring, refreshAccessToken } from './webex/auth.js';
import { startWebSocketServer, stopWebSocketServer } from './services/websocket.js'; import { startWebSocketServer, stopWebSocketServer } from './services/websocket.js';
import { register as registerBuildStore } from './commands/buildStore.js';
import { register as registerStageStore } from './commands/stageStore.js'; import { register as registerStageStore } from './commands/stageStore.js';
import { register as registerMigrateStore } from './commands/migrateStore.js'; import { register as registerFinalizeStore } from './commands/finalizeStore.js';
import { register as registerStoreInfo } from './commands/storeInfo.js'; import { register as registerStoreInfo } from './commands/storeInfo.js';
import { register as registerUserInfo } from './commands/userInfo.js'; import { register as registerUserInfo } from './commands/userInfo.js';
import { register as registerAttachmentActions } from './commands/attachmentActions.js'; import { register as registerAttachmentActions } from './commands/attachmentActions.js';
@ -21,9 +20,8 @@ framework.on('log', (msg) => {
logger.info(msg); logger.info(msg);
}); });
registerBuildStore(framework);
registerStageStore(framework); registerStageStore(framework);
registerMigrateStore(framework); registerFinalizeStore(framework);
registerStoreInfo(framework); registerStoreInfo(framework);
registerUserInfo(framework); registerUserInfo(framework);
registerAttachmentActions(framework); registerAttachmentActions(framework);

View file

@ -1,5 +1,5 @@
// Per-device-family calling customizations applied to every store location // Per-device-family calling customizations applied to every store location
// as part of buildStore / stageStore. Sent verbatim to // as part of stageStore. Sent verbatim to
// POST /telephony/config/jobs/devices/callDeviceSettings under `customizations`. // POST /telephony/config/jobs/devices/callDeviceSettings under `customizations`.
// //
// This is intentionally a data-only module so device config changes are a // This is intentionally a data-only module so device config changes are a

View file

@ -65,17 +65,6 @@ export async function addPhoneNumbersToLocation(location, phoneNumber) {
); );
} }
/**
* Point the location at its route group (used before phone numbers exist).
*/
export async function updateLocationRouteGroup(location) {
const routeGroup = routeGroupFor(location);
const body = {
connection: { id: routeGroup.id, type: 'ROUTE_GROUP' },
};
return webexJson('PUT', `/telephony/config/locations/${encodeURIComponent(location.id)}`, body);
}
/** /**
* Set the location's caller-ID/external-caller-ID after phone numbers exist. * Set the location's caller-ID/external-caller-ID after phone numbers exist.
*/ */