Retire /buildStore, rename /migrateStore -> /finalizeStore, harden flows
Some checks are pending
CI / verify (push) Waiting to run
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:
parent
7a9f5c96f8
commit
b5610acc8e
12 changed files with 75 additions and 165 deletions
33
README.md
33
README.md
|
|
@ -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
|
||||
consistent across all proxied traffic.
|
||||
|
||||
Once the agent is connected, the bot logs `Remote agent connected` and any
|
||||
`/buildStore`, `/stageStore`, `/migrateStore` command will succeed. If the
|
||||
agent is not connected, those commands fail immediately with
|
||||
`No remote SIW agent connected` rather than silently timing out.
|
||||
Once the agent is connected, the bot logs `Remote agent connected` and
|
||||
`/stageStore` and `/finalizeStore` will succeed. If the agent is not
|
||||
connected, those commands fail immediately with a preflight message
|
||||
rather than silently timing out mid-flow.
|
||||
|
||||
### Deploying the agent
|
||||
|
||||
|
|
@ -127,17 +127,22 @@ for the full operator guide (upgrades, troubleshooting, coexistence with
|
|||
|
||||
Registered in [src/commands](src/commands):
|
||||
|
||||
- `/buildStore <storeNumber>` — full green-field build (create location, calling,
|
||||
greeting, attach user, license cleanup).
|
||||
- `/stageStore <storeNumber>` — pre-migration setup: same as buildStore but
|
||||
without phone-number attachment or licensing cleanup.
|
||||
- `/migrateStore <storeNumber>` — cut-over for a staged store: attach the phone
|
||||
number, set caller ID, create the auto-attendant, finalize licensing.
|
||||
- `/stageStore <storeNumber>` — creates the Webex location, enables calling,
|
||||
configures the schedule, greeting, music-on-hold, voicemail, voice portal,
|
||||
and attaches the Webex Calling license to the store user. Everything
|
||||
except the phone number, which has to be purchased separately in Control Hub.
|
||||
- `/finalizeStore <storeNumber>` — cut-over for a previously staged store:
|
||||
attaches the purchased phone number, sets caller ID, creates the
|
||||
auto-attendant, and finalizes user licensing. Preflights that the store
|
||||
was actually staged first.
|
||||
- `/storeinfo <storeNumber>` — show current Webex info for the store user
|
||||
(`ae<5-digit>@ae.com`).
|
||||
- `/userinfo <email>` — show current Webex info for any user by email.
|
||||
- `/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
|
||||
[src/commands/attachmentActions.js](src/commands/attachmentActions.js).
|
||||
|
||||
|
|
@ -153,7 +158,7 @@ src/
|
|||
webex/ all Webex API calls, one module per resource family
|
||||
integrations/ SIW / Twilio / Google
|
||||
cards/ Adaptive Card builders
|
||||
flows/ multi-step provisioning (build, stage, migrate, ...)
|
||||
flows/ multi-step provisioning (stage, finalize)
|
||||
commands/ framework.hears handlers + attachmentAction dispatch
|
||||
scripts/ one-off maintenance scripts (911 CSV, phone fix-up)
|
||||
greetings/ WAV files uploaded as location announcements
|
||||
|
|
@ -164,12 +169,12 @@ Data flow for a store provisioning:
|
|||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
User[Webex user] -->|/buildStore 1234| Commands[commands/*]
|
||||
User[Webex user] -->|/stageStore 1234| Commands[commands/*]
|
||||
Commands --> SIW[integrations/siw.js]
|
||||
Commands --> Users[webex/users.js]
|
||||
Commands --> Card[cards/storeInfoCard.js]
|
||||
Commands --> Card[cards/storeConfirmationCard.js]
|
||||
Card -->|confirm| Attachment[attachmentActions.js]
|
||||
Attachment --> Flow[flows/buildStore.js]
|
||||
Attachment --> Flow[flows/stageStore.js]
|
||||
Flow --> Locations[webex/locations.js]
|
||||
Flow --> Devices[webex/devices.js]
|
||||
Flow --> Announce[webex/announcements.js]
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
/**
|
||||
* Build the confirmation card shown before build/stage/migrate operations.
|
||||
* `action` is what the "Yes" button posts back to the bot (buildStore, stageStore, migrateStore).
|
||||
* Build the confirmation card shown before stage/finalize operations.
|
||||
* `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 = [
|
||||
{ title: 'Name', value: storeInfo.name },
|
||||
{ title: 'Brand', value: storeInfo.brand },
|
||||
|
|
@ -52,13 +53,13 @@ export function buildStoreInfoCard(storeInfo, userInfo, action) {
|
|||
{
|
||||
type: 'Action.Submit',
|
||||
title: 'Yes',
|
||||
id: 'btnBuildStore',
|
||||
id: 'btnConfirm',
|
||||
data: { action, storeInfo, userInfo },
|
||||
},
|
||||
{
|
||||
type: 'Action.Submit',
|
||||
title: 'No',
|
||||
id: 'btnBadInfo',
|
||||
id: 'btnCancel',
|
||||
data: { action: 'deleteCard' },
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { logger } from '../logger.js';
|
||||
import { buildStoreLocation } from '../flows/buildStore.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';
|
||||
|
||||
async function runFlow(bot, trigger, verb, fn) {
|
||||
|
|
@ -36,14 +35,11 @@ export function register(framework) {
|
|||
const action = trigger.attachmentAction.inputs.action;
|
||||
|
||||
switch (action) {
|
||||
case 'buildStore':
|
||||
await runFlow(bot, trigger, 'Building', buildStoreLocation);
|
||||
break;
|
||||
case 'stageStore':
|
||||
await runFlow(bot, trigger, 'Staging', stageStoreLocation);
|
||||
break;
|
||||
case 'migrateStore':
|
||||
await runFlow(bot, trigger, 'Migrating store', migrateStoreLocation);
|
||||
case 'finalizeStore':
|
||||
await runFlow(bot, trigger, 'Finalizing', finalizeStoreLocation);
|
||||
break;
|
||||
case 'showDevice':
|
||||
await showDevices(bot, trigger);
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
);
|
||||
}
|
||||
|
|
@ -1,34 +1,49 @@
|
|||
import { logger } from '../logger.js';
|
||||
import { getStoreInfo } from '../integrations/siw.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';
|
||||
|
||||
export function register(framework) {
|
||||
framework.hears(
|
||||
/\/migratestore/i,
|
||||
/\/finalizestore/i,
|
||||
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;
|
||||
const storeNumber = parseStoreNumber(trigger);
|
||||
if (!storeNumber) {
|
||||
bot.say('Usage: `/migrateStore <storeNumber>` — e.g. `/migrateStore 499`');
|
||||
bot.say('Usage: `/finalizeStore <storeNumber>` — e.g. `/finalizeStore 499`');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const storeInfo = await getStoreInfo(storeNumber);
|
||||
storeInfo.extension = `5${String(storeNumber).padStart(4, '0')}`;
|
||||
const userInfo = await findWebexUser(storeEmail(storeNumber));
|
||||
const card = buildStoreInfoCard(storeInfo, userInfo, 'migrateStore');
|
||||
const [userInfo, locationMatches] = await Promise.all([
|
||||
findWebexUser(storeEmail(storeNumber)),
|
||||
findWebexLocation(storeInfo.name),
|
||||
]);
|
||||
// Preflight: if /stageStore was never run, the location won't
|
||||
// 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(
|
||||
'markdown',
|
||||
`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('migrateStore command failed:', error);
|
||||
logger.error('finalizeStore command failed:', error);
|
||||
bot.say(
|
||||
'markdown',
|
||||
`Error running /migratestore:\n\`\`\`\n${error.message}\n\`\`\``,
|
||||
`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.',
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { logger } from '../logger.js';
|
||||
import { getStoreInfo } from '../integrations/siw.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';
|
||||
|
||||
export function register(framework) {
|
||||
|
|
@ -17,15 +17,14 @@ export function register(framework) {
|
|||
}
|
||||
try {
|
||||
const storeInfo = await getStoreInfo(storeNumber);
|
||||
storeInfo.extension = `8${String(storeNumber).padStart(4, '0')}`;
|
||||
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');
|
||||
} catch (error) {
|
||||
logger.error('stageStore command failed:', error);
|
||||
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).',
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -17,16 +17,21 @@ import { logger } from '../logger.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
|
||||
* 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) {
|
||||
logger.info(`Migrating location ${locationInfo.name}`);
|
||||
export async function finalizeStoreLocation(bot, locationInfo, userInfo) {
|
||||
logger.info(`Finalizing location ${locationInfo.name}`);
|
||||
const matches = await findWebexLocation(locationInfo.name);
|
||||
if (!matches.length) {
|
||||
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];
|
||||
|
|
@ -70,6 +75,6 @@ export async function migrateStoreLocation(bot, locationInfo, 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,11 @@ export async function stageStoreLocation(bot, locationInfo, userInfo) {
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,8 @@ import { config } from './config.js';
|
|||
import { logger } from './logger.js';
|
||||
import { isAccessTokenExpiring, refreshAccessToken } from './webex/auth.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 registerMigrateStore } from './commands/migrateStore.js';
|
||||
import { register as registerFinalizeStore } from './commands/finalizeStore.js';
|
||||
import { register as registerStoreInfo } from './commands/storeInfo.js';
|
||||
import { register as registerUserInfo } from './commands/userInfo.js';
|
||||
import { register as registerAttachmentActions } from './commands/attachmentActions.js';
|
||||
|
|
@ -21,9 +20,8 @@ framework.on('log', (msg) => {
|
|||
logger.info(msg);
|
||||
});
|
||||
|
||||
registerBuildStore(framework);
|
||||
registerStageStore(framework);
|
||||
registerMigrateStore(framework);
|
||||
registerFinalizeStore(framework);
|
||||
registerStoreInfo(framework);
|
||||
registerUserInfo(framework);
|
||||
registerAttachmentActions(framework);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// 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`.
|
||||
//
|
||||
// This is intentionally a data-only module so device config changes are a
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue