@velaro/cli 1.4.106 → 1.4.108

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,188 @@
1
+ // MCP parity command groups: ai-followups, business-action, call, conversation, conversion, email, ivr.
2
+ // Each subcommand calls the same REST route as the MCP tool of the same name (mcp/server.js).
3
+ import { get, post, put, request, messagingGet, messagingPost, messagingPut } from '../api.js';
4
+ import { show, qs, needYes, group } from '../parity-util.js';
5
+
6
+ const exclusiveEnd = (d) => { const x = new Date(`${d}T00:00:00Z`); x.setUTCDate(x.getUTCDate() + 1); return x.toISOString().slice(0, 10); };
7
+ const rangeQs = (a, extra = {}) => qs({ start: a.start, end: a.end ? exclusiveEnd(a.end) : undefined, ...extra });
8
+ const rangeOpts = (y) => y.option('start', { type: 'string', describe: 'yyyy-MM-dd' }).option('end', { type: 'string', describe: 'yyyy-MM-dd (inclusive)' });
9
+
10
+ export const aiFollowupsCommand = { command: 'ai-followups', ...group('AI follow-ups site setting', [
11
+ ['get', 'Show whether AI follow-ups are enabled for this site', null,
12
+ async () => { const r = await messagingGet('/SiteSettings/AiFollowUps'); show({ enabled: r?.enabled ?? null }); }],
13
+ ['set', 'Enable/disable AI follow-ups (omit --enabled to clear back to the default)',
14
+ (y) => y.option('enabled', { type: 'boolean' }),
15
+ async (a) => {
16
+ await messagingPut('/SiteSettings/AiFollowUps', { enabled: typeof a.enabled === 'boolean' ? a.enabled : null });
17
+ const r = await messagingGet('/SiteSettings/AiFollowUps');
18
+ show({ enabled: r?.enabled ?? null });
19
+ }],
20
+ ]) };
21
+
22
+ export const businessActionCommand = { command: 'business-action', ...group('Business actions (list, route free text, execute)', [
23
+ ['list', 'List available business actions', null, async () => show(await messagingGet('/BusinessActions'))],
24
+ ['route', 'Route free text to the best matching business actions',
25
+ (y) => y.option('text', { type: 'string', demandOption: true }).option('max', { type: 'number' }),
26
+ async (a) => show(await messagingPost('/BusinessActions/Route', { text: a.text, max: a.max }))],
27
+ ['execute <name>', 'Execute a business action (needs --yes)',
28
+ (y) => y.positional('name', { type: 'string' })
29
+ .option('request', { type: 'string', demandOption: true, describe: 'Request body as a JSON object' })
30
+ .option('idempotency-key', { type: 'string' })
31
+ .option('conversation-id', { type: 'string' })
32
+ .option('yes', { type: 'boolean' }),
33
+ async (a) => {
34
+ needYes(a, `execute business action ${a.name}`);
35
+ const body = JSON.parse(a.request);
36
+ if (!body || typeof body !== 'object') throw new Error('--request must be a JSON object.');
37
+ show(await messagingPost(`/BusinessActions/${encodeURIComponent(a.name)}/Execute${qs({ idempotencyKey: a['idempotency-key'], conversationId: a['conversation-id'] })}`, body));
38
+ }],
39
+ ]) };
40
+
41
+ export const callCommand = { command: 'call', ...group('Call center analytics and call activity', [
42
+ ['center-report', 'Call center report: volume, speed, abandon, bot', rangeOpts,
43
+ async (a) => show(await get(`/CallCenterAnalytics/Report${rangeQs(a)}`))],
44
+ ['team-breakdown', 'Per-team call center breakdown (Advanced Analytics add-on)',
45
+ (y) => rangeOpts(y).option('sla-threshold-seconds', { type: 'number' }),
46
+ async (a) => show(await get(`/CallCenterAnalytics/TeamBreakdown${rangeQs(a, { slaThresholdSeconds: a['sla-threshold-seconds'] })}`))],
47
+ ['channel-breakdown', 'Per-channel call center breakdown (Advanced Analytics add-on)',
48
+ (y) => rangeOpts(y).option('sla-threshold-seconds', { type: 'number' }),
49
+ async (a) => show(await get(`/CallCenterAnalytics/ChannelBreakdown${rangeQs(a, { slaThresholdSeconds: a['sla-threshold-seconds'] })}`))],
50
+ ['activity', 'Call activity history (voice call records plus auto-dialer campaign calls)',
51
+ (y) => y.option('agent-id', { type: 'number' }).option('direction', { type: 'string' }).option('origin', { type: 'string' })
52
+ .option('provider-slug', { type: 'string' }).option('disposition', { type: 'string' })
53
+ .option('days', { type: 'number', default: 30 }).option('page', { type: 'number', default: 1 }).option('page-size', { type: 'number', default: 50 }),
54
+ async (a) => {
55
+ const q = qs({ agentId: a['agent-id'], direction: a.direction, origin: a.origin, providerSlug: a['provider-slug'], dateRangeDays: a.days, disposition: a.disposition, page: a.page, pageSize: a['page-size'] });
56
+ let items = [];
57
+ let failures = 0;
58
+ try { const r = await messagingGet(`/Voice/CallRecords/history${q}`); items = items.concat(Array.isArray(r?.items) ? r.items : []); } catch { failures++; }
59
+ if (!a.origin || a.origin === 'AutoDialerCampaign') {
60
+ try { const r = await get(`/CallActivity/CampaignCalls${q}`); items = items.concat(Array.isArray(r?.items) ? r.items : []); } catch { failures++; }
61
+ } else failures += 0;
62
+ if (failures >= 2) throw new Error('Failed to load call activity from either source.');
63
+ items.sort((x, y) => new Date(y.startedAt).getTime() - new Date(x.startedAt).getTime());
64
+ show(items);
65
+ }],
66
+ ]) };
67
+
68
+ // conversation_* run through the messaging admin-tools gateway (same JSON-RPC the MCP server uses).
69
+ async function adminTool(name, args) {
70
+ const r = await messagingPost('/mcp/v1/admin-tools/call', { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } });
71
+ if (r?.error) throw new Error(r.error.message || JSON.stringify(r.error));
72
+ const text = r?.result?.content?.[0]?.text;
73
+ if (text === undefined) throw new Error('Empty response from admin-tools gateway');
74
+ return text;
75
+ }
76
+
77
+ export const conversationCommand = { command: 'conversation', ...group('Conversations (list, get, search)', [
78
+ ['list', 'List recent conversations',
79
+ (y) => y.option('limit', { type: 'number' }).option('channel', { type: 'string' }).option('status', { type: 'string', describe: 'open or closed' }).option('agent-id', { type: 'number' }),
80
+ async (a) => show(await adminTool('conversation_list', { limit: a.limit, channel: a.channel, status: a.status, agentId: a['agent-id'] }))],
81
+ ['get <id>', 'Get one conversation', (y) => y.positional('id', { type: 'number' }),
82
+ async (a) => show(await adminTool('conversation_get', { id: a.id }))],
83
+ ['search <q>', 'Search conversations by phone number or call SID',
84
+ (y) => y.positional('q', { type: 'string' }).option('limit', { type: 'number' }),
85
+ async (a) => show(await adminTool('conversation_search', { q: a.q, limit: a.limit }))],
86
+ ]) };
87
+
88
+ export const conversionCommand = { command: 'conversion', ...group('Conversion goals and attribution report', [
89
+ ['list-goals', 'List conversion goals', null, async () => show(await get('/Conversions/goals'))],
90
+ ['create-goal', 'Create a conversion goal',
91
+ (y) => y.option('name', { type: 'string', demandOption: true })
92
+ .option('trigger-type', { type: 'string', demandOption: true })
93
+ .option('trigger-url', { type: 'string' }).option('event-name', { type: 'string' })
94
+ .option('default-amount', { type: 'number', default: 0 })
95
+ .option('revenue-variable-name', { type: 'string' })
96
+ .option('allow-once', { type: 'boolean', default: false })
97
+ .option('attribution-window-hours', { type: 'number', default: 24 }),
98
+ async (a) => show(await post('/Conversions/goals', {
99
+ name: a.name, triggerType: a['trigger-type'], triggerUrl: a['trigger-url'] || null, eventName: a['event-name'] || null,
100
+ defaultAmount: a['default-amount'] ?? 0, revenueVariableName: a['revenue-variable-name'] || null,
101
+ allowOnce: a['allow-once'] ?? false, attributionWindowHours: a['attribution-window-hours'] ?? 24, isEnabled: true,
102
+ }))],
103
+ ['report', 'Conversion attribution report', (y) => y.option('days', { type: 'number', default: 30 }),
104
+ async (a) => show(await get(`/Conversions/report?days=${a.days ?? 30}`))],
105
+ ]) };
106
+
107
+ export const emailCommand = { command: 'email', ...group('Velaro staff: email delivery queue and stats', [
108
+ ['queue-status', 'Email delivery queue status', (y) => y.option('site-id', { type: 'number' }),
109
+ async (a) => show(await messagingGet(`/SuperAdmin/email-delivery/status${qs({ siteId: a['site-id'] })}`))],
110
+ ['delivery-stats', 'Email delivery stats', (y) => y.option('site-id', { type: 'number' }).option('days', { type: 'number' }).option('hours', { type: 'number' }),
111
+ async (a) => show(await messagingGet(`/SuperAdmin/email-delivery/stats${qs({ siteId: a['site-id'], days: a.days, hours: a.hours })}`))],
112
+ ['queue-replay', 'Replay stuck emails (dry run unless --apply --yes)',
113
+ (y) => y.option('apply', { type: 'boolean', describe: 'Actually replay (default is a dry run)' }).option('yes', { type: 'boolean' }),
114
+ async (a) => {
115
+ if (a.apply) needYes(a, 'replay the email queue');
116
+ show(await messagingPost(`/SuperAdmin/email-delivery/replay?dryRun=${a.apply ? 'false' : 'true'}`));
117
+ }],
118
+ ]) };
119
+
120
+ export const ivrCommand = { command: 'ivr', ...group('IVR voice configurations, phone lines and business hours', [
121
+ ['list-voice-configs', 'List IVR voice configurations', null, async () => show(await get('/IVRVoiceConfigurations'))],
122
+ ['get-voice-config <id>', 'Show one voice configuration', (y) => y.positional('id', { type: 'number' }),
123
+ async (a) => show(await get(`/IVRVoiceConfigurations/${a.id}`))],
124
+ ['create-voice-config', 'Create a voice configuration with the standard ElevenLabs/Deepgram defaults',
125
+ (y) => y.option('name', { type: 'string', demandOption: true })
126
+ .option('voice-id', { type: 'string' }).option('welcome-greeting', { type: 'string' })
127
+ .option('bridge-number', { type: 'string' }).option('no-agent-fallback', { type: 'string' })
128
+ .option('business-hours-json', { type: 'string', describe: 'ivrBusinessHoursJson, e.g. {"tz":"America/Los_Angeles","days":{"mon":{"start":"09:00","end":"17:00"}}}; omit for 24/7' }),
129
+ async (a) => show(await post('/IVRVoiceConfigurations', {
130
+ name: a.name, voiceProvider: 'ElevenLabs', voiceId: a['voice-id'] ?? 'XB0fDUnXU5powFXDhCwa', model: 'eleven_turbo_v2_5',
131
+ speed: 0.85, stability: 1.0, similarity: 0.90, transcriptionProvider: 'Deepgram', transcriptionLanguage: 'en-US',
132
+ speechModel: 'nova-3-general', interruptible: 'speech', interruptSensitivity: 'medium', transferMode: 'triage',
133
+ escalationBridgeEnabled: true, escalationCallbackEnabled: true, noAgentFallback: a['no-agent-fallback'] ?? 'take_message',
134
+ afterHoursFallback: 'take_message', ivrBusinessHoursJson: a['business-hours-json'] ?? null,
135
+ welcomeGreeting: a['welcome-greeting'] ?? null, alwaysTransferNumber: a['bridge-number'] ?? null,
136
+ }))],
137
+ ['update-voice-config <id>', 'Update fields on a voice configuration (only the flags you pass change)',
138
+ (y) => y.positional('id', { type: 'number' })
139
+ .option('idle-check-in-seconds', { type: 'number' }).option('idle-check-in-message', { type: 'string' })
140
+ .option('idle-hangup-seconds', { type: 'number' })
141
+ .option('escalation-bridge-enabled', { type: 'boolean' }).option('escalation-callback-enabled', { type: 'boolean' })
142
+ .option('transfer-no-answer-message', { type: 'string' }).option('welcome-greeting', { type: 'string' })
143
+ .option('always-transfer-number', { type: 'string' }).option('transfer-mode', { type: 'string' })
144
+ .option('no-agent-fallback', { type: 'string' }).option('after-hours-fallback', { type: 'string' })
145
+ .option('patch-json', { type: 'string', describe: 'Extra fields to merge, as a JSON object' }),
146
+ async (a) => {
147
+ const existing = await get(`/IVRVoiceConfigurations/${a.id}`);
148
+ if (!existing) throw new Error(`Voice config ${a.id} not found.`);
149
+ const map = { 'idle-check-in-seconds': 'idleCheckInSeconds', 'idle-check-in-message': 'idleCheckInMessage', 'idle-hangup-seconds': 'idleHangupSeconds',
150
+ 'escalation-bridge-enabled': 'escalationBridgeEnabled', 'escalation-callback-enabled': 'escalationCallbackEnabled',
151
+ 'transfer-no-answer-message': 'transferNoAnswerMessage', 'welcome-greeting': 'welcomeGreeting', 'always-transfer-number': 'alwaysTransferNumber',
152
+ 'transfer-mode': 'transferMode', 'no-agent-fallback': 'noAgentFallback', 'after-hours-fallback': 'afterHoursFallback' };
153
+ const body = { ...existing, ...(a['patch-json'] ? JSON.parse(a['patch-json']) : {}) };
154
+ for (const [flag, key] of Object.entries(map)) if (a[flag] !== undefined) body[key] = a[flag];
155
+ show(await put(`/IVRVoiceConfigurations/${a.id}`, body));
156
+ }],
157
+ ['set-business-hours <id>', 'Set (or clear) the business-hours JSON on a voice configuration',
158
+ (y) => y.positional('id', { type: 'number' })
159
+ .option('hours-json', { type: 'string', describe: 'ivrBusinessHoursJson string, or 24/7 to clear' }),
160
+ async (a) => {
161
+ const existing = await get(`/IVRVoiceConfigurations/${a.id}`);
162
+ if (!existing) throw new Error(`Voice config ${a.id} not found.`);
163
+ const h = a['hours-json'];
164
+ if (h === undefined) throw new Error('--hours-json is required (use 24/7 to clear).');
165
+ if (!['24/7', 'always', 'none', 'all'].includes(h.trim().toLowerCase())) JSON.parse(h);
166
+ const clear = ['24/7', 'always', 'none', 'all'].includes(h.trim().toLowerCase());
167
+ show(await put(`/IVRVoiceConfigurations/${a.id}`, { ...existing, ivrBusinessHoursJson: clear ? null : h }));
168
+ }],
169
+ ['agent-availability', 'Agent availability plus each voice config fallback actions', null, async () => {
170
+ const snap = await get('/LiveDashboard/snapshot');
171
+ const cfgs = await get('/IVRVoiceConfigurations');
172
+ show({
173
+ agentsOnline: snap?.agentsOnline ?? 0, agentsAvailable: snap?.agentsAvailable ?? snap?.agentsOnline ?? 0, agentsTotal: snap?.agentsTotal ?? 0,
174
+ fallbacks: (Array.isArray(cfgs) ? cfgs : []).map((c) => ({ id: c.id, name: c.name, noAgentFallback: c.noAgentFallback ?? 'take_message', afterHoursFallback: c.afterHoursFallback ?? 'take_message' })),
175
+ });
176
+ }],
177
+ ['list-lines', 'List IVR phone lines', null, async () => show(await get('/IVRVoiceConfigurations/lines'))],
178
+ ['update-line <id>', 'Set a phone line bridge number and/or voice configuration',
179
+ (y) => y.positional('id', { type: 'number' })
180
+ .option('default-transfer-number', { type: 'string' }).option('voice-config-id', { type: 'number' }),
181
+ async (a) => {
182
+ const body = {};
183
+ if (a['default-transfer-number'] !== undefined) body.defaultTransferNumber = a['default-transfer-number'];
184
+ if (a['voice-config-id'] !== undefined) body.ivrVoiceConfigurationId = a['voice-config-id'];
185
+ if (!Object.keys(body).length) throw new Error('Provide --default-transfer-number and/or --voice-config-id.');
186
+ show(await request('PATCH', `/IVRVoiceConfigurations/lines/${a.id}`, body));
187
+ }],
188
+ ]) };
@@ -21,6 +21,7 @@ function parseLine(spec) {
21
21
 
22
22
  function printForm(f) {
23
23
  console.log(`Order form ${f.id}: ${f.customerName} [${f.status}]`);
24
+ if (f.annualIncreasePercent != null) console.log(` annual increase ${f.annualIncreasePercent}% on ${f.increaseBasis ?? 'site default'} price`);
24
25
  console.log(` payment=${f.paymentMode} invoiceDetail=${f.invoiceDetailMode} applyOnSign=${f.applyOnSign} contractId=${f.contractId ?? '-'}`);
25
26
  for (const l of f.lines ?? []) {
26
27
  console.log(` - ${l.catalogSlug} x${l.quantity} @ ${money(l.unitPriceCents)} (list ${money(l.listPriceCents)}) setup ${money(l.setupFeeCents)} ${l.termMonths}mo ${l.cadence}`);
@@ -30,7 +31,7 @@ function printForm(f) {
30
31
  async function createForm(args) {
31
32
  const lines = [].concat(args.line ?? []).map(parseLine);
32
33
  if (!args.customer || lines.length === 0) {
33
- console.error('Usage: velaro order-form create --customer "Acme" --line slug:qty[:unitCents[:setupCents[:termMonths[:cadence]]]] [--line ...] [--site-id N] [--target v20] [--target-account-id X] [--payment-mode manual|card_checkout|invoice_net_terms] [--invoice-detail LineItemized|TermsReference] [--apply-on-sign]');
34
+ console.error('Usage: velaro order-form create --customer "Acme" --line slug:qty[:unitCents[:setupCents[:termMonths[:cadence]]]] [--line ...] [--site-id N] [--target v20] [--target-account-id X] [--payment-mode manual|card_checkout|invoice_net_terms] [--invoice-detail LineItemized|TermsReference] [--apply-on-sign] [--increase-percent 8] [--increase-basis list|net]');
34
35
  process.exit(1);
35
36
  }
36
37
  const body = {
@@ -41,6 +42,8 @@ async function createForm(args) {
41
42
  paymentMode: args.paymentMode,
42
43
  invoiceDetailMode: args.invoiceDetail,
43
44
  applyOnSign: !!args.applyOnSign,
45
+ annualIncreasePercent: args.increasePercent,
46
+ increaseBasis: args.increaseBasis,
44
47
  lines,
45
48
  };
46
49
  const f = await request('POST', '/api/OrderForm', body);
@@ -91,6 +94,8 @@ export const orderFormCommand = {
91
94
  'payment-mode': { type: 'string', choices: ['manual', 'card_checkout', 'invoice_net_terms'] },
92
95
  'invoice-detail': { type: 'string', choices: ['LineItemized', 'TermsReference'] },
93
96
  'apply-on-sign': { type: 'boolean', default: false },
97
+ 'increase-percent': { type: 'number', describe: 'annual price increase percent stated in the contract' },
98
+ 'increase-basis': { type: 'string', choices: ['list', 'net'], describe: 'calculate the increase on the undiscounted (list) or discounted (net) price; omit for the site default' },
94
99
  }, (args) => createForm(args))
95
100
  .command('get <id>', 'Show an order form', {}, (args) => getForm(args.id))
96
101
  .command('list', 'List order forms', { status: { type: 'string' } }, (args) => listForms(args))
@@ -0,0 +1,35 @@
1
+ // velaro package-builder: package graph, drafts, simulation, audit (staff only; `my` is customer-safe).
2
+ import { request } from '../api.js';
3
+
4
+ const show = (o) => console.log(JSON.stringify(o, null, 2));
5
+ const get = (path) => async () => show(await request('GET', path));
6
+ const enc = encodeURIComponent;
7
+
8
+ export function packageBuilderCommand(yargs) {
9
+ return yargs
10
+ .command('graph', 'Features, packages, add-ons and standalone flags', () => {}, get('/PackageBuilder/graph'))
11
+ .command('site <siteId>', 'Per-flag provenance for a site', y => y.positional('siteId', { type: 'number' }), async a => show(await request('GET', `/PackageBuilder/site/${a.siteId}`)))
12
+ .command('versions <planKey>', 'Version history of a plan', y => y.positional('planKey', { type: 'string' }), async a => show(await request('GET', `/PackageBuilder/versions/${enc(a.planKey)}`)))
13
+ .command('diff', 'Diff two plan versions', y => y.option('plan', { type: 'string', demandOption: true }).option('from', { type: 'number', demandOption: true }).option('to', { type: 'number', demandOption: true }),
14
+ async a => show(await request('GET', `/PackageBuilder/diff?planKey=${enc(a.plan)}&from=${a.from}&to=${a.to}`)))
15
+ .command('drafts', 'List drafts', y => y.option('status', { type: 'string' }), async a => show(await request('GET', `/PackageBuilder/drafts${a.status ? `?status=${enc(a.status)}` : ''}`)))
16
+ .command('draft-create', 'Create a draft (from scratch or --clone-of plan.key)', y => y
17
+ .option('name', { type: 'string' }).option('clone-of', { type: 'string' }).option('features', { type: 'string', desc: 'comma-separated feature keys' }),
18
+ async a => show(await request('POST', '/PackageBuilder/drafts', { name: a.name, cloneOf: a['clone-of'], definition: a.features ? { features: a.features.split(',') } : undefined })))
19
+ .command('draft-update <id>', 'Replace a draft definition', y => y.positional('id', { type: 'number' }).option('features', { type: 'string', demandOption: true }).option('price-cents', { type: 'number' }).option('name', { type: 'string' }),
20
+ async a => show(await request('PUT', `/PackageBuilder/drafts/${a.id}`, { name: a.name, definition: { features: a.features.split(','), priceCents: a['price-cents'] } })))
21
+ .command('submit <id>', 'Submit draft for approval', y => y.positional('id', { type: 'number' }), async a => show(await request('POST', `/PackageBuilder/drafts/${a.id}/submit`, {})))
22
+ .command('approve <id>', 'Approve draft (approver must differ from creator)', y => y.positional('id', { type: 'number' }), async a => show(await request('POST', `/PackageBuilder/drafts/${a.id}/approve`, {})))
23
+ .command('publish <id>', 'Publish approved draft as new PlanEntitlement version rows', y => y.positional('id', { type: 'number' }), async a => show(await request('POST', `/PackageBuilder/drafts/${a.id}/publish`, {})))
24
+ .command('simulate', 'What-if across a cohort (pure read)', y => y.option('draft', { type: 'number' }).option('plan', { type: 'string' }).option('cohort', { type: 'string' }),
25
+ async a => show(await request('POST', '/PackageBuilder/simulate', { draftId: a.draft, basePlanKey: a.plan, cohort: a.cohort })))
26
+ .command('audit', 'Entitlement/package change timeline', y => y.option('site', { type: 'number' }).option('plan', { type: 'string' }),
27
+ async a => show(await request('GET', `/PackageBuilder/audit?${new URLSearchParams({ ...(a.site ? { siteId: a.site } : {}), ...(a.plan ? { planKey: a.plan } : {}) })}`)))
28
+ .command('health', 'Orphan flags, overlap, drift, cart parity', () => {}, get('/PackageBuilder/health'))
29
+ .command('fit', 'Cheapest package covering the given features', y => y.option('features', { type: 'string', demandOption: true }),
30
+ async a => show(await request('POST', '/PackageBuilder/fit', { featureKeys: a.features.split(',') })))
31
+ .command('proration', 'Upgrade/downgrade proration preview', y => y.option('from', { type: 'string', demandOption: true }).option('to', { type: 'string', demandOption: true }).option('days-remaining', { type: 'number', demandOption: true }).option('days-in-period', { type: 'number', default: 30 }),
32
+ async a => show(await request('GET', `/PackageBuilder/proration?fromPlan=${enc(a.from)}&toPlan=${enc(a.to)}&daysRemaining=${a['days-remaining']}&daysInPeriod=${a['days-in-period']}`)))
33
+ .command('my', 'Your own package and what it includes (customer-safe)', () => {}, get('/PackageBuilder/my'))
34
+ .demandCommand(1, 'Specify a subcommand');
35
+ }
@@ -0,0 +1,192 @@
1
+ // CLI parity for MCP tool groups that previously had no CLI command group (parity-close 2026-10-23).
2
+ // Every subcommand calls the SAME REST route the matching MCP tool handler calls (mcp/server.js,
3
+ // mcp/staff-server.js). Admin-API routes go through request(); messaging-API routes through
4
+ // messagingRequest(). Staff-only tools are enforced server-side (the CLI only passes your token).
5
+ // Impactful subcommands need --yes. Naming: `velaro <group> <tool-name-without-group-prefix>`.
6
+ import { request, messagingRequest } from '../api.js';
7
+ import { supportCommand } from './support.js';
8
+ import { pricingCommand } from './pricing.js';
9
+ import { scraperCommand } from './scraper.js';
10
+ import { tourPolicyCommand } from './tour-policy.js';
11
+ import fs from 'node:fs';
12
+ import crypto from 'node:crypto';
13
+
14
+ const out = (r) => console.log(typeof r === 'string' ? r : JSON.stringify(r, null, 2));
15
+ const S = (describe, req) => ({ type: 'string', describe, ...(req ? { demandOption: true } : {}) });
16
+ const N = (describe, req) => ({ type: 'number', describe, ...(req ? { demandOption: true } : {}) });
17
+ const B = (describe) => ({ type: 'boolean', describe });
18
+ const csv = (v) => (v === undefined ? undefined : String(v).split(',').map((x) => x.trim()).filter(Boolean));
19
+ const csvN = (v) => csv(v)?.map(Number);
20
+ const enc = encodeURIComponent;
21
+ // '@file.json' reads a file; otherwise the string is parsed as JSON.
22
+ const json = (v) => (v === undefined ? undefined : JSON.parse(String(v).startsWith('@') ? fs.readFileSync(String(v).slice(1), 'utf8') : v));
23
+ const qs = (o) => {
24
+ const sp = new URLSearchParams();
25
+ for (const [k, v] of Object.entries(o)) if (v !== undefined && v !== null && v !== '') sp.set(k, String(v));
26
+ const s = sp.toString();
27
+ return s ? `?${s}` : '';
28
+ };
29
+ const pick = (a, map) => Object.fromEntries(Object.entries(map).filter(([f]) => a[f] !== undefined).map(([f, k]) => [k, a[f]]));
30
+ const exclusiveEnd = (d) => (/^\d{4}-\d{2}-\d{2}$/.test(d || '') ? new Date(Date.parse(d) + 86400000).toISOString().slice(0, 10) : d);
31
+
32
+ // Messaging admin-tools gateway (same endpoint the MCP uses for ms_bookings_*/outlook_calendar_* range/cancel tools).
33
+ async function gatewayTool(name, args) {
34
+ const r = await messagingRequest('POST', '/mcp/v1/admin-tools/call', { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } });
35
+ if (r?.error) throw new Error(`${name}: ${r.error.message || JSON.stringify(r.error)}`);
36
+ return r?.result?.content?.[0]?.text ?? r;
37
+ }
38
+
39
+ async function call(s, a) {
40
+ const fn = s.api === 'msg' ? messagingRequest : request;
41
+ return fn(s.method || 'GET', s.path(a), s.body ? s.body(a) : undefined);
42
+ }
43
+
44
+ export function addRoutes(y, specs) {
45
+ for (const s of specs) {
46
+ y.command(s.name, s.desc, (yy) => {
47
+ for (const [k, v] of Object.entries(s.opts || {})) yy.option(k, v);
48
+ if (s.yes || s.needsYes) yy.option('yes', B('Confirm this impactful action'));
49
+ return yy;
50
+ }, async (a) => {
51
+ try {
52
+ if ((s.yes || (s.needsYes && s.needsYes(a))) && !a.yes) { console.error(`Refusing without --yes: ${s.desc}`); process.exit(1); }
53
+ out(s.run ? await s.run(a) : await call(s, a));
54
+ } catch (e) { console.error(e.message); process.exit(1); }
55
+ });
56
+ }
57
+ return y;
58
+ }
59
+
60
+ const site = { site: N('Site id', true) };
61
+ const SP = (a) => `SupportTools/sites/${a.site}`;
62
+ const rpcOpts = { args: S('Extra tool arguments as JSON or @file.json') };
63
+ const rpcArgs = (a, map) => ({ ...pick(a, map), ...(json(a.args) || {}) });
64
+
65
+ export const SUPPORT_EXTRA = [
66
+ { name: 'user-lookup', desc: 'Search users by email/name across sites (staff)', opts: { query: S('Search text', true) }, path: (a) => `SupportTools/users/search?q=${enc(a.query)}` },
67
+ { name: 'site-lookup', desc: 'Search sites by name/URL/admin (staff)', opts: { query: S('Search text', true) }, path: (a) => `SupportTools/sites/search?q=${enc(a.query)}` },
68
+ { name: 'change-username', desc: 'Change a user login email (staff)', yes: true, method: 'POST', opts: { 'user-id': N('User id', true), 'new-email': S('New email', true) }, path: (a) => `SupportTools/users/${a.userId}/change-username`, body: (a) => ({ newEmail: a.newEmail }) },
69
+ { name: 'list-admin-users', desc: 'List Velaro staff/admin users', path: () => 'SupportTools/admin-users' },
70
+ { name: 'list-staff', desc: 'List Velaro staff with roles', path: () => 'SupportTools/admin-users' },
71
+ { name: 'add-admin-user', desc: 'Grant staff admin access to an email', yes: true, method: 'POST', opts: { email: S('Email', true) }, path: () => 'SupportTools/admin-users', body: (a) => ({ email: a.email }) },
72
+ { name: 'remove-admin-user', desc: 'Remove staff admin access for a user id', yes: true, method: 'DELETE', opts: { 'user-id': N('User id', true) }, path: (a) => `SupportTools/admin-users/${a.userId}` },
73
+ { name: 'set-staff-role', desc: 'Set a staff user role', yes: true, method: 'PATCH', opts: { 'user-id': N('User id', true), role: S('Role', true) }, path: (a) => `SupportTools/admin-users/${a.userId}/role`, body: (a) => ({ role: a.role }) },
74
+ { name: 'integration-logs', desc: 'Integration log entries for a site', opts: { ...site, page: N('Page'), 'page-size': N('Page size (max 100)'), integration: S('Integration'), severity: S('Severity') }, path: (a) => `${SP(a)}/integration-logs${qs({ page: a.page ?? 1, pageSize: Math.min(100, a.pageSize ?? 50), integration: a.integration, severity: a.severity && a.severity !== 'All' ? a.severity : undefined })}` },
75
+ { name: 'site-health', desc: 'Site health: subscription, agents, last login, 7-day errors', opts: site, path: (a) => `${SP(a)}/health` },
76
+ { name: 'site-agents', desc: 'Agents on a site', opts: site, path: (a) => `${SP(a)}/agents` },
77
+ { name: 'site-activity', desc: 'Activity log for a site', opts: { ...site, page: N('Page'), 'page-size': N('Page size (max 200)'), category: S('Category') }, path: (a) => `${SP(a)}/activity${qs({ page: a.page ?? 1, pageSize: Math.min(200, a.pageSize ?? 50), category: a.category })}` },
78
+ { name: 'extend-subscription', desc: 'Extend a site subscription by N days', yes: true, method: 'POST', opts: { ...site, days: N('Days', true) }, path: (a) => `${SP(a)}/extend-subscription`, body: (a) => ({ days: a.days }) },
79
+ { name: 'set-user-active', desc: 'Activate/deactivate a user on a site', yes: true, method: 'POST', opts: { ...site, 'user-id': N('User id', true), active: { type: 'boolean', demandOption: true, describe: 'true or false' } }, path: (a) => `SupportTools/users/${a.userId}/sites/${a.site}/set-active`, body: (a) => ({ isActive: a.active }) },
80
+ { name: 'bulk-set-flags', desc: 'Set several subscription flags at once (JSON object)', yes: true, method: 'POST', opts: { ...site, flags: S('JSON object or @file.json', true) }, path: (a) => `${SP(a)}/subscription/bulk-flags`, body: (a) => ({ flags: json(a.flags) }) },
81
+ { name: 'create-agent', desc: 'Create an agent on any site (staff)', api: 'msg', method: 'POST', opts: { ...site, email: S('Email', true), 'first-name': S('First name'), 'last-name': S('Last name'), roles: S('Comma list of roles'), 'team-ids': S('Comma list of team ids'), 'max-chats': N('Max chats (0 = default)') }, path: (a) => `/SupportTools/sites/${a.site}/agents`, body: (a) => ({ email: a.email, firstName: a.firstName, lastName: a.lastName, roles: csv(a.roles), teamIds: csvN(a.teamIds), maxChats: a.maxChats ?? 0 }) },
82
+ { name: 'schedule-adherence', desc: 'Schedule adherence diagnostics for a site', api: 'msg', opts: { ...site, role: S('role'), 'agent-id': N('Agent id (required when role=agent)'), start: S('Start'), end: S('End'), tz: S('Timezone') }, path: (a) => `/Reports/sites/${a.site}/schedule-adherence-diagnostics${qs({ detail: 'true', role: a.role, agentId: a.agentId, start: a.start, end: a.end, tz: a.tz })}` },
83
+ { name: 'appointments-list-sites', desc: 'Sites with Velaro Appointments enabled', api: 'msg', path: () => '/Appointments/admin/sites-with-appointments' },
84
+ { name: 'diagnose-after-hours', desc: 'After-hours routing diagnostic data for a site', opts: site, path: (a) => `${SP(a)}/after-hours-routing` },
85
+ { name: 'diagnose', desc: 'One-shot site diagnosis: health, recent errors, activity, failed Hangfire jobs', opts: site, run: async (a) => {
86
+ const g = (p) => request('GET', p).catch((e) => ({ error: e.message }));
87
+ const [health, integrationErrors, aiToolErrors, activity, failedJobs] = await Promise.all([
88
+ g(`${SP(a)}/health`), g(`${SP(a)}/integration-logs?severity=Error&pageSize=10`), g(`${SP(a)}/integration-logs?severity=Error&category=AiTool&pageSize=10`), g(`${SP(a)}/activity?pageSize=5`), g('DatabaseTool/FailedJobs')]);
89
+ return { health, integrationErrors, aiToolErrors, activity, failedJobs };
90
+ } },
91
+ ];
92
+
93
+ const SCRAPER_EXTRA = [
94
+ { name: 'staff-crawl', desc: 'Trigger a crawl for any site (staff)', method: 'POST', opts: { ...site, urls: S('Comma list of URLs', true), name: S('Job name'), 'crawl-depth': N('Crawl depth (default 1)'), 'max-pages': N('Max pages (default 25)') }, path: (a) => `ScraperJobs/StaffCreate/${a.site}`, body: (a) => ({ name: a.name || `staff-triggered-${a.site}`, urls: csv(a.urls), crawlDepth: a.crawlDepth ?? 1, maxPages: a.maxPages ?? 25 }) },
95
+ ];
96
+
97
+ const LIVEFLUENCE = [
98
+ { name: 'export', desc: 'Export V10 routing rules for a V10 site', opts: { 'v10-site-id': N('V10 site id', true), 'v20-site-id': N('V20 site id'), 'include-non-routing': B('Include non-routing rules') }, path: (a) => `DatabaseTool/V10Export/${a.v10SiteId}${qs({ v20SiteId: a.v20SiteId, includeNonRouting: a.includeNonRouting ? 'true' : undefined })}` },
99
+ { name: 'create-teams', desc: 'Create V20 teams from V10 groups (use --dry-run first)', method: 'POST', needsYes: (a) => !a.dryRun, opts: { 'v10-site-id': N('V10 site id', true), 'v20-site-id': N('V20 site id', true), 'dry-run': B('Preview only'), 'group-names': S('Comma list of V10 group names') }, path: () => 'DatabaseTool/V10CreateTeams', body: (a) => ({ v10SiteId: a.v10SiteId, v20SiteId: a.v20SiteId, dryRun: !!a.dryRun, groupNames: csv(a.groupNames) ?? [] }) },
100
+ { name: 'site-workflows-list', desc: 'List a site workflows', opts: { 'site-id': N('Site id', true) }, path: (a) => `DatabaseTool/SiteWorkflows/${a.siteId}` },
101
+ { name: 'site-workflows-set-enabled', desc: 'Enable/disable workflows by id or name (use --dry-run first)', method: 'POST', needsYes: (a) => !a.dryRun, opts: { 'site-id': N('Site id', true), 'workflow-ids': S('Comma list of ids'), names: S('Comma list of names'), enabled: B('Enable (omit to disable)'), 'dry-run': B('Preview only'), 'force-partial': B('Allow partial match'), 'v10-site-id': N('V10 site id') }, path: () => 'DatabaseTool/SiteWorkflows/SetEnabled', body: (a) => ({ siteId: a.siteId, workflowIds: csvN(a.workflowIds) ?? [], names: csv(a.names) ?? [], enabled: !!a.enabled, dryRun: !!a.dryRun, forcePartial: !!a.forcePartial, v10SiteId: a.v10SiteId ?? null }) },
102
+ { name: 'site-workflow-create', desc: 'Create a workflow from a nodes blob', method: 'POST', needsYes: (a) => !a.dryRun, opts: { 'site-id': N('Site id', true), name: S('Name', true), 'trigger-type': S('Trigger (default conversation_opened)'), 'nodes-blob': S('Nodes JSON or @file.json', true), enabled: B('Enable on create'), 'dry-run': B('Preview only') }, path: () => 'DatabaseTool/SiteWorkflows/Create', body: (a) => ({ siteId: a.siteId, name: a.name, triggerType: a.triggerType ?? 'conversation_opened', nodesBlob: String(a.nodesBlob).startsWith('@') ? fs.readFileSync(String(a.nodesBlob).slice(1), 'utf8') : a.nodesBlob, enabled: !!a.enabled, dryRun: !!a.dryRun }) },
103
+ { name: 'site-workflow-set-action', desc: 'Point a workflow action at a target', method: 'POST', needsYes: (a) => !a.dryRun, opts: { 'site-id': N('Site id', true), 'workflow-id': N('Workflow id', true), 'action-type': S('Action type', true), 'target-id': N('Target id', true), 'dry-run': B('Preview only') }, path: () => 'DatabaseTool/SiteWorkflows/SetAction', body: (a) => ({ siteId: a.siteId, workflowId: a.workflowId, actionType: a.actionType, targetId: a.targetId, dryRun: !!a.dryRun }) },
104
+ { name: 'site-rules-list', desc: 'List a site routing rules', opts: { 'site-id': N('Site id', true) }, path: (a) => `DatabaseTool/SiteRules/${a.siteId}` },
105
+ { name: 'site-rules-set-enabled', desc: 'Enable/disable routing rules', method: 'POST', needsYes: (a) => !a.dryRun, opts: { 'site-id': N('Site id', true), 'rule-ids': S('Comma list of ids'), trigger: S('Trigger filter'), enabled: B('Enable (omit to disable)'), 'dry-run': B('Preview only') }, path: () => 'DatabaseTool/SiteRules/SetEnabled', body: (a) => ({ siteId: a.siteId, ruleIds: csvN(a.ruleIds) ?? [], trigger: a.trigger ?? null, enabled: !!a.enabled, dryRun: !!a.dryRun }) },
106
+ { name: 'seed-url-rules', desc: 'Seed URL rules for an AI config', method: 'POST', needsYes: (a) => !a.dryRun, opts: { 'site-id': N('Site id', true), 'ai-config-id': N('AI config id', true), rules: S('Rules JSON or @file.json', true), 'dry-run': B('Preview only') }, path: () => 'DatabaseTool/V10SeedUrlRules', body: (a) => ({ siteId: a.siteId, aiConfigId: a.aiConfigId, dryRun: !!a.dryRun, rules: json(a.rules) }) },
107
+ { name: 'conversion-advisor', desc: 'Advise on V10 to V20 conversion (apply with --apply --yes)', method: 'POST', needsYes: (a) => !!a.apply, opts: { 'v10-site-id': N('V10 site id'), 'v20-site-id': N('V20 site id', true), apply: B('Apply recommendations'), 'include-non-routing': B('Include non-routing'), intents: S('Intents JSON array') }, path: () => 'DatabaseTool/ConversionAdvisor', body: (a) => { const intents = json(a.intents); return { v10SiteId: intents?.length ? null : (a.v10SiteId ?? null), v20SiteId: a.v20SiteId, apply: !!a.apply, includeNonRouting: !!a.includeNonRouting, intents: intents ?? null }; } },
108
+ { name: 'migrate', desc: 'Migrate V10 routing to V20 workflows (use --dry-run first)', method: 'POST', needsYes: (a) => !a.dryRun, opts: { 'v10-site-id': N('V10 site id', true), 'v20-site-id': N('V20 site id'), 'dry-run': B('Preview only'), enable: B('Enable created workflows'), 'include-non-routing': B('Include non-routing'), 'route-bot-groups-to-team': B('Route bot groups to team'), 'team-mappings': S('JSON or @file'), 'ai-config-mappings': S('JSON or @file') }, path: () => 'DatabaseTool/V10Migrate', body: (a) => ({ v10SiteId: a.v10SiteId, v20SiteId: a.v20SiteId ?? null, dryRun: !!a.dryRun, enable: !!a.enable, includeNonRouting: !!a.includeNonRouting, routeBotGroupsToTeam: !!a.routeBotGroupsToTeam, teamMappings: json(a.teamMappings) ?? [], aiConfigMappings: json(a.aiConfigMappings) ?? [] }) },
109
+ ];
110
+
111
+ const TABLES = {
112
+ missed: [{ name: 'chat-diagnostics', desc: 'Missed chat diagnostics with routing snapshots', opts: { start: S('Start date'), end: S('End date'), reason: S('Reason filter'), 'team-id': N('Team id'), site: N('Site id (staff)') }, path: (a) => `CallCenterAnalytics/MissedChatDiagnostics${qs({ start: a.start, end: a.end, reason: a.reason, teamId: a.teamId, siteId: a.site })}` }],
113
+ service: [{ name: 'level-report', desc: 'Service level (SLA) report', opts: { 'start-date': S('Start'), 'end-date': S('End (inclusive date)'), granularity: S('day|week|month') }, path: (a) => `ServiceLevel${qs({ startDate: a.startDate, endDate: exclusiveEnd(a.endDate), granularity: a.granularity })}` }],
114
+ page: [
115
+ { name: 'widget-list', desc: 'List page widgets', path: () => 'PageWidgets' },
116
+ { name: 'widget-get', desc: 'Get a page widget', opts: { id: N('Widget id', true) }, path: (a) => `PageWidgets/${a.id}` },
117
+ { name: 'widget-toggle', desc: 'Enable or disable a page widget', opts: { id: N('Widget id', true), enabled: { type: 'boolean', demandOption: true, describe: 'true or false' } }, run: async (a) => { const w = await request('GET', `PageWidgets/${a.id}`); w.isEnabled = !!a.enabled; await request('PUT', `PageWidgets/${a.id}`, w); return `Widget ${a.id} ${a.enabled ? 'enabled' : 'disabled'}.`; } },
118
+ ],
119
+ uploaded: [
120
+ { name: 'files-list', desc: 'List uploaded AI training files', api: 'msg', path: () => '/uploadedFiles/list/AI' },
121
+ { name: 'file-reindex', desc: 'Re-index an uploaded file', api: 'msg', method: 'POST', opts: { 'file-id': N('File id'), 'file-name': S('File name'), 'index-name': S('Index name') }, path: () => '/uploadedFiles/reindex', body: (a) => { if (!a.fileId && !a.fileName) throw new Error('--file-id or --file-name is required'); return pick(a, { fileId: 'id', fileName: 'fileName', indexName: 'indexName' }); } },
122
+ ],
123
+ ms: [
124
+ { name: 'bookings-status', desc: 'Microsoft Bookings status for a site (staff)', opts: site, path: (a) => `MicrosoftBookings/superadmin/status?siteId=${a.site}` },
125
+ { name: 'bookings-configure', desc: 'Configure Microsoft Bookings for a site (staff)', method: 'PUT', opts: { ...site, 'business-id': S('Business id', true), 'business-display-name': S('Display name'), disabled: B('Disable the integration') }, path: (a) => `MicrosoftBookings/superadmin/config?siteId=${a.site}`, body: (a) => ({ businessId: a.businessId, businessDisplayName: a.businessDisplayName ?? '', isEnabled: !a.disabled }) },
126
+ { name: 'bookings-check-availability-range', desc: 'Bookings availability range for a service', opts: { 'service-id': S('Service id', true), ...rpcOpts }, run: (a) => gatewayTool('ms_bookings_check_availability_range', rpcArgs(a, { serviceId: 'service_id' })) },
127
+ { name: 'bookings-cancel-appointment', desc: 'Cancel a Bookings appointment', yes: true, opts: { 'appointment-id': S('Appointment id', true), ...rpcOpts }, run: (a) => gatewayTool('ms_bookings_cancel_appointment', rpcArgs(a, { appointmentId: 'appointment_id' })) },
128
+ { name: 'bookings-reschedule-appointment', desc: 'Reschedule a Bookings appointment', yes: true, opts: { 'appointment-id': S('Appointment id', true), 'new-date': S('YYYY-MM-DD', true), 'new-start-time': S('HH:mm', true), ...rpcOpts }, run: (a) => gatewayTool('ms_bookings_reschedule_appointment', rpcArgs(a, { appointmentId: 'appointment_id', newDate: 'new_date', newStartTime: 'new_start_time' })) },
129
+ ],
130
+ outlook: [
131
+ { name: 'calendar-status', desc: 'Outlook calendar status for a site (staff)', opts: site, path: (a) => `OutlookCalendar/superadmin/status?siteId=${a.site}` },
132
+ { name: 'calendar-test-connection', desc: 'Test the Outlook calendar connection (staff)', opts: { ...site, 'organizer-email': S('Organizer email') }, path: (a) => `OutlookCalendar/superadmin/test-connection?siteId=${a.site}${a.organizerEmail ? `&organizerEmail=${enc(a.organizerEmail)}` : ''}` },
133
+ { name: 'calendar-get-available-range', desc: 'Available calendar time range', opts: rpcOpts, run: (a) => gatewayTool('outlook_calendar_get_available_range', rpcArgs(a, {})) },
134
+ { name: 'calendar-cancel', desc: 'Cancel an Outlook calendar event', yes: true, opts: { 'event-id': S('Event id', true), ...rpcOpts }, run: (a) => gatewayTool('outlook_calendar_cancel', rpcArgs(a, { eventId: 'event_id' })) },
135
+ ],
136
+ oauth: [
137
+ { name: 'list', desc: 'List stored OAuth app configs (staff)', path: () => 'SupportTools/oauth-configs' },
138
+ { name: 'update', desc: 'Update an integration OAuth client id/secret (staff)', yes: true, method: 'PUT', opts: { integration: S('Integration key', true), 'client-id': S('Client id'), 'client-secret': S('Client secret') }, path: (a) => `SupportTools/oauth-configs/${enc(a.integration)}`, body: (a) => { if (!a.clientId && !a.clientSecret) throw new Error('Pass --client-id and/or --client-secret'); return { clientId: a.clientId, clientSecret: a.clientSecret }; } },
139
+ ],
140
+ provision: [{ name: 'health', desc: 'Provisioning health checks (run after every staging deploy)', api: 'msg', path: () => '/SuperAdmin/provision-health' }],
141
+ sse: [{ name: 'visitor-diagnostic', desc: 'SSE visitor transport diagnostic', api: 'msg', path: () => '/Diagnostics/sse' }],
142
+ skill: [{ name: 'logs-for-site', desc: 'AI skill call logs for a site (staff)', opts: { ...site, take: N('Entries (max 200)'), 'errors-only': B('Errors only') }, path: (a) => `SuperAdmin/Diagnostics/skill-logs?siteId=${a.site}&take=${Math.min(a.take ?? 50, 200)}&errorsOnly=${!!a.errorsOnly}` }],
143
+ v10: [{ name: 'bot-turn-limits', desc: 'v10 sites with a MaximumBotTurnsPerMonth limit (staff)', path: () => 'BotAnalytics/V10BotTurnLimits' }],
144
+ whatsapp: [{ name: 'campaign-diagnostic', desc: 'WhatsApp campaign diagnostic for a site (staff)', api: 'msg', opts: site, path: (a) => `/Diagnostics/whatsapp-campaign/${a.site}` }],
145
+ mobile: [{ name: 'ota-trigger', desc: 'Trigger the mobile OTA build (GitHub Actions mobile-build.yml; needs GITHUB_TOKEN)', yes: true, run: async () => {
146
+ const token = process.env.GITHUB_TOKEN;
147
+ if (!token) throw new Error('GITHUB_TOKEN env var is not set.');
148
+ const r = await fetch('https://api.github.com/repos/velaro/velaro-messaging/actions/workflows/mobile-build.yml/dispatches', { method: 'POST', headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', 'Content-Type': 'application/json' }, body: JSON.stringify({ ref: 'master', inputs: { profile: 'preview' } }) });
149
+ if (r.status !== 204) throw new Error(`GitHub API returned ${r.status}: ${await r.text()}`);
150
+ return 'Mobile OTA triggered on GitHub Actions (mobile-build.yml -> master).';
151
+ } }],
152
+ signalr: [{ name: 'diagnostics', desc: 'Azure SignalR health (needs SIGNALR_INFRA_CONN / SIGNALR_PROD_CONN env)', opts: { instance: { type: 'string', choices: ['infra', 'legacy', 'both'], default: 'both' }, hub: S('Hub name, e.g. MessengerHub') }, run: async (a) => {
153
+ const targets = [['infra', 'velaro-eastus-prd-signalr (infra)', process.env.SIGNALR_INFRA_CONN], ['legacy', 'velaro-prod-us (legacy)', process.env.SIGNALR_PROD_CONN]].filter(([k]) => a.instance === 'both' || a.instance === k);
154
+ const res = {};
155
+ for (const [, label, conn] of targets) {
156
+ const ep = (/Endpoint=([^;]+)/i.exec(conn || '') || [])[1]; const key = (/AccessKey=([^;]+)/i.exec(conn || '') || [])[1];
157
+ if (!ep || !key) { res[label] = 'connection string not set in env'; continue; }
158
+ const endpoint = ep.replace(/\/+$/, '');
159
+ const jwt = (url) => { const h = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url'); const n = Math.floor(Date.now() / 1000); const p = Buffer.from(JSON.stringify({ aud: url, iat: n, exp: n + 300 })).toString('base64url'); return `${h}.${p}.${crypto.createHmac('sha256', Buffer.from(key, 'base64')).update(`${h}.${p}`).digest('base64url')}`; };
160
+ const get = async (url) => { const r = await fetch(url, { headers: { Authorization: `Bearer ${jwt(url)}` } }); return { status: r.status, body: (await r.text()).slice(0, 2000) }; };
161
+ res[label] = { health: await get(`${endpoint}/api/v1/health`), ...(a.hub ? { hub: await get(`${endpoint}/api/v1/hubs/${a.hub}`) } : {}) };
162
+ }
163
+ return res;
164
+ } }],
165
+ };
166
+
167
+ const demand = (y, msg) => y.demandCommand(1, msg);
168
+
169
+ export const livefluenceCommand = {
170
+ command: 'livefluence', describe: 'Velaro staff: V10 (LiveFluence) to V20 migration and per-site workflow/rule tools',
171
+ builder: (y) => demand(addRoutes(y, LIVEFLUENCE), 'Specify a subcommand'), handler: () => {},
172
+ };
173
+ export const missedCommand = { command: 'missed', describe: 'Missed chat diagnostics', builder: (y) => demand(addRoutes(y, TABLES.missed), 'Specify a subcommand'), handler: () => {} };
174
+ export const serviceCommand = { command: 'service', describe: 'Service level (SLA) report', builder: (y) => demand(addRoutes(y, TABLES.service), 'Specify a subcommand'), handler: () => {} };
175
+ export const pageCommand = { command: 'page', describe: 'Page widgets (list, get, toggle)', builder: (y) => demand(addRoutes(y, TABLES.page), 'Specify a subcommand'), handler: () => {} };
176
+ export const uploadedCommand = { command: 'uploaded', describe: 'Uploaded AI training files (list, reindex)', builder: (y) => demand(addRoutes(y, TABLES.uploaded), 'Specify a subcommand'), handler: () => {} };
177
+ export const msCommand = { command: 'ms', describe: 'Microsoft Bookings (status, configure, availability, cancel, reschedule)', builder: (y) => demand(addRoutes(y, TABLES.ms), 'Specify a subcommand'), handler: () => {} };
178
+ export const outlookCommand = { command: 'outlook', describe: 'Outlook calendar (status, test connection, availability, cancel)', builder: (y) => demand(addRoutes(y, TABLES.outlook), 'Specify a subcommand'), handler: () => {} };
179
+ export const oauthCommand = { command: 'oauth', describe: 'Velaro staff: stored OAuth app configs', builder: (y) => demand(addRoutes(y, TABLES.oauth), 'Specify a subcommand'), handler: () => {} };
180
+ export const provisionCommand = { command: 'provision', describe: 'Velaro staff: provisioning health', builder: (y) => demand(addRoutes(y, TABLES.provision), 'Specify a subcommand'), handler: () => {} };
181
+ export const sseCommand = { command: 'sse', describe: 'Velaro staff: SSE visitor transport diagnostic', builder: (y) => demand(addRoutes(y, TABLES.sse), 'Specify a subcommand'), handler: () => {} };
182
+ export const skillCommand = { command: 'skill', describe: 'Velaro staff: AI skill logs for a site', builder: (y) => demand(addRoutes(y, TABLES.skill), 'Specify a subcommand'), handler: () => {} };
183
+ export const v10Command = { command: 'v10', describe: 'Velaro staff: v10 bot turn limits', builder: (y) => demand(addRoutes(y, TABLES.v10), 'Specify a subcommand'), handler: () => {} };
184
+ export const whatsappCommand = { command: 'whatsapp', describe: 'Velaro staff: WhatsApp campaign diagnostic', builder: (y) => demand(addRoutes(y, TABLES.whatsapp), 'Specify a subcommand'), handler: () => {} };
185
+ export const mobileCommand = { command: 'mobile', describe: 'Velaro staff: mobile OTA build trigger', builder: (y) => demand(addRoutes(y, TABLES.mobile), 'Specify a subcommand'), handler: () => {} };
186
+ export const signalrCommand = { command: 'signalr', describe: 'Velaro staff: Azure SignalR diagnostics', builder: (y) => demand(addRoutes(y, TABLES.signalr), 'Specify a subcommand'), handler: () => {} };
187
+
188
+ // Existing inline-registered groups, re-exposed as detectable command objects with the missing subcommands added.
189
+ export const supportGroupCommand = { command: 'support', describe: 'Staff SupportTools actions on a site (superadmin only)', builder: (y) => addRoutes(supportCommand(y), SUPPORT_EXTRA), handler: () => {} };
190
+ export const pricingGroupCommand = { command: 'pricing', describe: 'View and manage the pricing catalog', builder: pricingCommand, handler: () => {} };
191
+ export const scraperGroupCommand = { command: 'scraper', describe: 'Velaro staff: scraper / upload / chunk usage and crawl for any site', builder: (y) => addRoutes(scraperCommand(y), SCRAPER_EXTRA), handler: () => {} };
192
+ export const tourPolicyGroupCommand = { command: 'tour-policy', describe: 'Velaro staff: guided-tour auto-open policy (versioned, rollback-able)', builder: tourPolicyCommand, handler: () => {} };
@@ -0,0 +1,93 @@
1
+ // velaro price-increase -- staff-only annual price increase basis (PriceIncreaseController).
2
+ // See docs/architecture/price-increase-basis.md.
3
+ // basis "list": increase = % of the undiscounted price, added to what the customer pays (discount stays the same dollars)
4
+ // basis "net": increase = % of what the customer pays (discount stays the same percent)
5
+ // Nothing here appears on an invoice; every applied increase is written to an internal audit log.
6
+ import { request } from '../api.js';
7
+
8
+ const BASE = '/api/PriceIncrease';
9
+
10
+ function money(cents) {
11
+ if (cents === null || cents === undefined) return '-';
12
+ return `$${(Number(cents) / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
13
+ }
14
+
15
+ async function getSettings() {
16
+ const s = await request('GET', `${BASE}/settings`);
17
+ console.log(`Default increase basis: ${s.defaultBasis}${s.isExplicitlySet ? '' : ' (never set, using the built-in default)'}`);
18
+ }
19
+
20
+ async function setDefault(args) {
21
+ if (!['list', 'net', 'clear'].includes(args.basis)) {
22
+ console.error('Usage: velaro price-increase set-default <list|net|clear>');
23
+ process.exit(1);
24
+ }
25
+ const r = await request('PUT', `${BASE}/settings`, { defaultBasis: args.basis === 'clear' ? null : args.basis });
26
+ console.log(`Default increase basis is now: ${r.defaultBasis}`);
27
+ }
28
+
29
+ async function showSubscription(id) {
30
+ const t = await request('GET', `${BASE}/subscriptions/${id}`);
31
+ console.log(`Subscription ${t.subscriptionId}`);
32
+ console.log(` net per period: ${money(t.recurringAmountCents)} list per period: ${money(t.recurringListAmountCents)}`);
33
+ console.log(` basis: ${t.effectiveBasis} (from ${t.effectiveBasisSource}${t.basis ? '' : ', inherited'}) annual increase: ${t.annualIncreasePercent ?? '-'}%`);
34
+ console.log(` next increase: ${t.nextPriceIncreaseDate?.slice(0, 10) ?? '-'} applied so far: ${t.priceIncreasesApplied}${t.needsReview ? ' NEEDS REVIEW (list price missing)' : ''}`);
35
+ for (const l of t.log ?? []) {
36
+ console.log(` year ${l.contractYearIndex} ${l.anniversaryDate?.slice(0, 10)} ${l.applied ? 'APPLIED' : 'SKIPPED'}: ${l.formula}${l.invoiceId ? ` (invoice ${l.invoiceId})` : ''}`);
37
+ }
38
+ }
39
+
40
+ async function setSubscription(id, args) {
41
+ const body = {};
42
+ if (args.basis !== undefined) body.basis = args.basis === 'inherit' ? null : args.basis;
43
+ if (args.percent !== undefined) body.annualIncreasePercent = args.percent === 'none' ? null : Number(args.percent);
44
+ if (args.listCents !== undefined) body.recurringListAmountCents = Number(args.listCents);
45
+ if (args.next !== undefined) body.nextPriceIncreaseDate = args.next === 'none' ? null : args.next;
46
+ if (Object.keys(body).length === 0) {
47
+ console.error('Usage: velaro price-increase set <subscriptionId> [--basis list|net|inherit] [--percent 8|none] [--list-cents 1000000] [--next 2027-01-01|none]');
48
+ process.exit(1);
49
+ }
50
+ const r = await request('PUT', `${BASE}/subscriptions/${id}`, body);
51
+ console.log(r.changes?.length ? `Updated: ${r.changes.join('; ')}` : 'No change.');
52
+ }
53
+
54
+ async function linkOrderForm(id, formId) {
55
+ const r = await request('POST', `${BASE}/subscriptions/${id}/link-order-form/${formId}`, {});
56
+ console.log(r.summary ?? 'Linked.');
57
+ }
58
+
59
+ async function preview(args) {
60
+ const r = await request('POST', `${BASE}/preview`, {
61
+ listCents: Number(args.listCents), netCents: Number(args.netCents), percent: Number(args.percent), years: args.years,
62
+ });
63
+ console.log('Year LIST basis (net / discount) NET basis (net / discount)');
64
+ r.list.forEach((row, i) => {
65
+ const n = r.net[i];
66
+ console.log(`${String(row.year).padEnd(5)} ${(money(row.netCents) + ' / ' + money(row.discountCents)).padEnd(34)} ${money(n.netCents)} / ${money(n.discountCents)}`);
67
+ });
68
+ }
69
+
70
+ export const priceIncreaseCommand = {
71
+ command: 'price-increase <subcommand>',
72
+ describe: 'Annual price increase basis: list vs discounted price (staff only)',
73
+ builder: (yargs) =>
74
+ yargs
75
+ .command('settings', 'Show the site-wide default basis', {}, () => getSettings())
76
+ .command('set-default <basis>', 'Set the site-wide default basis: list | net | clear', {}, (args) => setDefault(args))
77
+ .command('show <id>', 'Show a subscription\'s increase terms and its audit log', {}, (args) => showSubscription(args.id))
78
+ .command('set <id>', 'Update a subscription\'s increase terms (only the flags you pass change)', {
79
+ basis: { type: 'string', choices: ['list', 'net', 'inherit'] },
80
+ percent: { type: 'string', describe: 'annual percent, or "none" to clear' },
81
+ 'list-cents': { type: 'number', describe: 'undiscounted price per billing period, in cents' },
82
+ next: { type: 'string', describe: 'next anniversary date (YYYY-MM-DD) or "none"' },
83
+ }, (args) => setSubscription(args.id, args))
84
+ .command('link-order-form <id> <formId>', 'Copy the increase terms of a SIGNED order form (basis, percent, per-line exceptions) onto a subscription so billing follows the contract', {}, (args) => linkOrderForm(args.id, args.formId))
85
+ .command('preview', 'Compare both bases side by side (no changes made)', {
86
+ 'list-cents': { type: 'number', demandOption: true },
87
+ 'net-cents': { type: 'number', demandOption: true },
88
+ percent: { type: 'number', demandOption: true },
89
+ years: { type: 'number' },
90
+ }, (args) => preview(args))
91
+ .demandCommand(1),
92
+ handler: () => {},
93
+ };