@velaro/cli 1.4.88 → 1.4.90

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.
package/bin/velaro.js CHANGED
@@ -44,6 +44,7 @@ import { reportCommand } from '../lib/commands/report.js';
44
44
  import { scheduleAdherenceCommand } from '../lib/commands/schedule-adherence.js';
45
45
  import { reportSubscriptionCommand } from '../lib/commands/report-subscription.js';
46
46
  import { experienceImpactCommand } from '../lib/commands/experience-impact.js';
47
+ import { enterpriseReportsCommand } from '../lib/commands/enterprise-reports.js';
47
48
  import { reportBackfillCommand } from '../lib/commands/report-backfill.js';
48
49
  import { staffCommand } from '../lib/commands/staff.js';
49
50
  import { reportExplainCommand } from '../lib/commands/report-explain.js';
@@ -54,6 +55,8 @@ import { skillRoutingCommand } from '../lib/commands/skill-routing.js';
54
55
  import { keywordIntelligenceCommand } from '../lib/commands/keyword-intelligence.js';
55
56
  import { aiFieldExtractionCommand } from '../lib/commands/ai-field-extraction.js';
56
57
  import { sentimentCommand, qualityCommand, chatIntelligenceThemesCommand } from '../lib/commands/quality-reports.js';
58
+ import { cannedLearningCommand } from '../lib/commands/canned-learning.js';
59
+ import { chatIntelligenceTopicsCommand } from '../lib/commands/chat-intelligence-topics.js';
57
60
  import { focusCommand } from '../lib/commands/focus.js';
58
61
  import { triageCommand } from '../lib/commands/triage.js';
59
62
  import { automationDraftCommand } from '../lib/commands/automation-draft.js';
@@ -68,6 +71,7 @@ import { outboundActivityCommand } from '../lib/commands/outbound-activity.js';
68
71
  import { klaviyoMigrationCommand } from '../lib/commands/klaviyo-migration.js';
69
72
  import { telephonyCommand } from '../lib/commands/telephony.js';
70
73
  import { teamsPhoneCommand } from '../lib/commands/teams-phone.js';
74
+ import { zoomPhoneCommand } from '../lib/commands/zoom-phone.js';
71
75
  import { teamsCommand } from '../lib/commands/teams.js';
72
76
  import { slackCommand } from '../lib/commands/slack.js';
73
77
  import { webflowCommand } from '../lib/commands/webflow.js';
@@ -171,6 +175,7 @@ await yargs(hideBin(process.argv))
171
175
  .command(executiveInsightsCommand)
172
176
  .command(scheduleAdherenceCommand)
173
177
  .command(experienceImpactCommand)
178
+ .command(enterpriseReportsCommand)
174
179
  .command(reportSubscriptionCommand)
175
180
  .command(reportBackfillCommand)
176
181
  .command({ command: 'staff', desc: 'Velaro staff: cross-site tools (backfill)', builder: staffCommand, handler: () => {} })
@@ -182,6 +187,8 @@ await yargs(hideBin(process.argv))
182
187
  .command(sentimentCommand)
183
188
  .command(qualityCommand)
184
189
  .command(chatIntelligenceThemesCommand)
190
+ .command(cannedLearningCommand)
191
+ .command(chatIntelligenceTopicsCommand)
185
192
  .command(focusCommand)
186
193
  .command(triageCommand)
187
194
  .command(automationDraftCommand)
@@ -196,6 +203,7 @@ await yargs(hideBin(process.argv))
196
203
  .command(klaviyoMigrationCommand)
197
204
  .command(telephonyCommand)
198
205
  .command(teamsPhoneCommand)
206
+ .command(zoomPhoneCommand)
199
207
  .command(teamsCommand)
200
208
  .command(slackCommand)
201
209
  .command(webflowCommand)
@@ -1,4 +1,4 @@
1
- import { get, getCredentials } from '../api.js';
1
+ import { get, getCredentials, messagingGet } from '../api.js';
2
2
 
3
3
  // ── helpers ──────────────────────────────────────────────────────────────────
4
4
 
@@ -86,7 +86,20 @@ async function activityHandler(argv) {
86
86
  params.set('pageSize', String(Math.min(100, argv.pageSize ?? 25)));
87
87
 
88
88
  const qs = params.toString();
89
- const data = await get(`${path}${qs ? '?' + qs : ''}`);
89
+ let data;
90
+ if (argv.site) {
91
+ // Staff: read ANOTHER site's Activity Log (customer/test site) without logging in as it.
92
+ const sp = new URLSearchParams();
93
+ if (isSkills) sp.set('slug', 'AiTool');
94
+ else if (argv.slug) sp.set('slug', argv.slug);
95
+ if (argv.severity && argv.severity !== 'All') sp.set('severity', argv.severity);
96
+ if (argv.search) sp.set('search', argv.search);
97
+ sp.set('take', String(Math.min(200, argv.pageSize ?? 25)));
98
+ const r = await messagingGet(`/SupportTools/sites/${argv.site}/integration-logs?${sp.toString()}`);
99
+ data = { items: r?.items ?? [], total: r?.count ?? 0 };
100
+ } else {
101
+ data = await get(`${path}${qs ? '?' + qs : ''}`);
102
+ }
90
103
 
91
104
  if (!data?.items?.length) {
92
105
  console.log('\nNo activity log entries.');
@@ -124,6 +137,7 @@ export const activityCommand = {
124
137
  type: 'string',
125
138
  })
126
139
  .option('search', { describe: 'Message search', type: 'string' })
140
+ .option('site', { describe: "Staff only: read this site's Activity Log instead of your own", type: 'number' })
127
141
  .option('page', { describe: 'Page number', default: 1, type: 'number' })
128
142
  .option('pageSize', { describe: 'Results per page', default: 25, type: 'number' })
129
143
  .option('entityType', { describe: '[audit-trail] Filter to one entity type, e.g. Workflow', type: 'string' })
@@ -30,6 +30,9 @@ export const avatarCommand = {
30
30
  console.log(` Enabled: ${data.enabled ? 'yes' : 'no'}`);
31
31
  console.log(` "Call me": ${data.showCallMeOption ? 'yes' : 'no'}`);
32
32
  console.log(` Callback window: ${data.callbackConfirmWindowMinutes} min`);
33
+ console.log(` Display name: ${data.displayName || '(AI)'}`);
34
+ console.log(` Badge shown: ${data.showBadge ? 'yes' : 'no'}`);
35
+ console.log(` Size: ${data.sizePx}px`);
33
36
  console.log(` Max minutes/mo: ${data.maxAvatarMinutesPerMonth ?? '(plan default)'}`);
34
37
  console.log(` Max TTS chars/mo: ${data.maxAvatarTtsChars ?? '(plan default)'}\n`);
35
38
  }),
@@ -43,7 +46,10 @@ export const avatarCommand = {
43
46
  .option('bot', { type: 'string', describe: 'Reserved for a future per-bot override (not yet implemented on this endpoint)' })
44
47
  .option('avatar-id',{ type: 'string', describe: 'Provider-specific avatar/face ID. Omit for provider default.' })
45
48
  .option('voice', { type: 'string', describe: 'ElevenLabs voice ID override' })
46
- .option('call-me', { type: 'boolean', describe: 'Show the "Call me instead" option. Omit on a re-run to leave it untouched.' }),
49
+ .option('call-me', { type: 'boolean', describe: 'Show the "Call me instead" option. Omit on a re-run to leave it untouched.' })
50
+ .option('display-name', { type: 'string', describe: 'Visitor-facing avatar name on the badge (e.g. Moshky). Empty string resets to "AI".' })
51
+ .option('show-badge', { type: 'boolean', describe: 'Show the name badge over the avatar video.' })
52
+ .option('size', { type: 'number', describe: 'Avatar panel max width in px (160-640). Default 360.' }),
47
53
  handler: runCommand(async argv => {
48
54
  if (argv.bot) {
49
55
  console.log(`\nNote: --bot is not yet supported by this endpoint — this sets the SITE-level avatar config only. Use the admin UI's per-bot override toggle for bot-specific control.\n`);
@@ -56,6 +62,9 @@ export const avatarCommand = {
56
62
  if (argv['avatar-id'] !== undefined) body.avatarId = argv['avatar-id'];
57
63
  if (argv.voice !== undefined) body.voiceId = argv.voice;
58
64
  if (argv['call-me'] !== undefined) body.showCallMeOption = argv['call-me'];
65
+ if (argv['display-name'] !== undefined) body.displayName = argv['display-name'];
66
+ if (argv['show-badge'] !== undefined) body.showBadge = argv['show-badge'];
67
+ if (argv.size !== undefined) body.sizePx = argv.size;
59
68
  const data = await request('POST', `/Avatar/superadmin/config?siteId=${argv.site}`, body);
60
69
  if (!data.success) throw new Error(data.error || 'Configure failed');
61
70
  console.log(`\n✓ AI Video Avatar enabled for site ${argv.site}`);
@@ -13,7 +13,7 @@ function fmtDate(iso) {
13
13
  // ── plan-status ───────────────────────────────────────────────────────────────
14
14
 
15
15
  async function planStatus() {
16
- const s = await request('GET', '/BillingPayment/plan-status');
16
+ const s = await request('GET', '/api/BillingPayment/plan-status');
17
17
 
18
18
  console.log(`\nPlan Status — Site ${s.siteId}\n${'─'.repeat(50)}`);
19
19
 
@@ -0,0 +1,31 @@
1
+ import { messagingGet, messagingPost } from '../api.js';
2
+ import { runCommand } from '../run.js';
3
+
4
+ const print = (data) => console.log(JSON.stringify(data, null, 2));
5
+
6
+ // velaro canned-learning <list|generate|approve|reject|rollback|edits|record-edit> (REST: messaging /CannedLearning/*)
7
+ export const cannedLearningCommand = {
8
+ command: 'canned-learning <action> [id]',
9
+ describe: 'AI canned message learning: list/generate suggestions, approve/reject/rollback (human review)',
10
+ builder: (y) => y
11
+ .positional('action', { choices: ['list', 'generate', 'approve', 'reject', 'rollback', 'edits', 'record-edit'] })
12
+ .positional('id', { type: 'number' })
13
+ .option('state', { type: 'string', default: 'pending_review', describe: 'pending_review|approved|rejected|rolled_back|all' })
14
+ .option('text', { type: 'string', describe: 'approve: edited replacement text (defaults to the AI proposal)' })
15
+ .option('notes', { type: 'string', describe: 'rollback: reason' })
16
+ .option('final-text', { type: 'string', describe: 'record-edit: the text the agent actually sent (redacted server-side)' }),
17
+ handler: runCommand(async (a) => {
18
+ const base = '/CannedLearning';
19
+ if (a.action === 'list') return print(await messagingGet(`${base}/suggestions?state=${encodeURIComponent(a.state)}`));
20
+ if (a.action === 'generate') return print(await messagingPost(`${base}/generate`, {}));
21
+ if (a.action === 'edits') return print(await messagingGet(`${base}/edit-events`));
22
+ if (a.action === 'record-edit') {
23
+ if (a.id == null || !a.finalText) throw new Error('record-edit requires a canned response id and --final-text');
24
+ return print(await messagingPost(`${base}/edit-events`, { cannedResponseId: a.id, finalText: a.finalText }));
25
+ }
26
+ if (a.id == null) throw new Error(`${a.action} requires a suggestion id`);
27
+ if (a.action === 'approve') return print(await messagingPost(`${base}/suggestions/${a.id}/approve`, { editedText: a.text ?? null }));
28
+ if (a.action === 'reject') return print(await messagingPost(`${base}/suggestions/${a.id}/reject`, {}));
29
+ return print(await messagingPost(`${base}/suggestions/${a.id}/rollback`, { notes: a.notes ?? null }));
30
+ }),
31
+ };
@@ -0,0 +1,47 @@
1
+ import { messagingGet, messagingPost, messagingPut } from '../api.js';
2
+ import { runCommand } from '../run.js';
3
+
4
+ const print = (data) => console.log(JSON.stringify(data, null, 2));
5
+ const list = (v) => (v == null ? [] : String(v).split(',').map((s) => s.trim()).filter(Boolean));
6
+
7
+ // velaro chat-topics <show|set|template|merge|rename|retire|group|group-suggestions|suggestions|accept|dismiss|
8
+ // reclassify-estimate|reclassify|analysis-estimate|analysis|proposal>
9
+ // REST: messaging /ChatIntelligenceTopics/* (site comes from the CLI session, never an argument)
10
+ export const chatIntelligenceTopicsCommand = {
11
+ command: 'chat-topics <action>',
12
+ describe: 'Chat Intelligence topics: edit the list, merge/rename/group (no AI cost), suggested topics, optional reclassify and analysis',
13
+ builder: (y) => y
14
+ .positional('action', { choices: ['show', 'set', 'template', 'merge', 'rename', 'retire', 'group', 'group-suggestions', 'suggestions', 'accept', 'dismiss', 'reclassify-estimate', 'reclassify', 'analysis-estimate', 'analysis', 'proposal'] })
15
+ .option('topics', { type: 'string', describe: 'set: comma-separated topic list' })
16
+ .option('industry', { type: 'string', describe: 'template: insurance|healthcare|retail|ecommerce|financial|saas|government|manufacturing' })
17
+ .option('from', { type: 'string', describe: 'merge/rename/retire/group: comma-separated topics' })
18
+ .option('to', { type: 'string', describe: 'merge/rename: target topic' })
19
+ .option('group', { type: 'string', describe: 'group: group name (empty clears)' })
20
+ .option('suggestion', { type: 'string', describe: 'accept/dismiss/merge a suggested topic' })
21
+ .option('scope', { type: 'string', default: 'other', describe: 'reclassify: other (default) or all' })
22
+ .option('days', { type: 'number', default: 90 })
23
+ .option('max', { type: 'number', describe: 'reclassify: cap (server hard cap 500)' })
24
+ .option('confirm', { type: 'boolean', default: false, describe: 'reclassify/analysis: required after reading the estimate' })
25
+ .option('id', { type: 'string', describe: 'proposal: proposal id' })
26
+ .option('accept', { type: 'boolean', default: true, describe: 'proposal: --no-accept rejects' }),
27
+ handler: runCommand(async (a) => {
28
+ const base = '/ChatIntelligenceTopics';
29
+ const ops = (op) => messagingPost(`${base}/mapping`, { ops: [{ op, from: list(a.from), to: a.to ?? null, group: a.group ?? null }] });
30
+ switch (a.action) {
31
+ case 'show': return print(await messagingGet(base));
32
+ case 'set': return print(await messagingPut(base, { topics: list(a.topics) }));
33
+ case 'template': return print(await messagingPost(`${base}/template`, { industry: a.industry }));
34
+ case 'merge': case 'rename': case 'retire': case 'group': return print(await ops(a.action));
35
+ case 'group-suggestions': return print(await messagingGet(`${base}/group-suggestions?days=${a.days}`));
36
+ case 'suggestions': return print(await messagingGet(`${base}/suggestions?days=${a.days}`));
37
+ case 'accept': return print(await messagingPost(`${base}/suggestions/resolve`, { suggestion: a.suggestion, action: a.to ? 'merge' : 'accept', target: a.to ?? null }));
38
+ case 'dismiss': return print(await messagingPost(`${base}/suggestions/resolve`, { suggestion: a.suggestion, action: 'dismiss' }));
39
+ case 'reclassify-estimate': return print(await messagingGet(`${base}/reclassify/estimate?scope=${a.scope}&days=${a.days}${a.max ? `&max=${a.max}` : ''}`));
40
+ case 'reclassify': return print(await messagingPost(`${base}/reclassify`, { scope: a.scope, days: a.days, max: a.max ?? null, confirm: a.confirm }));
41
+ case 'analysis-estimate': return print(await messagingGet(`${base}/analysis/estimate`));
42
+ case 'analysis': return print(a.confirm ? await messagingPost(`${base}/analysis`, { confirm: true }) : await messagingGet(`${base}/analysis`));
43
+ case 'proposal': return print(await messagingPost(`${base}/analysis/proposals`, { id: a.id, accept: a.accept }));
44
+ default: throw new Error(`Unknown action ${a.action}`);
45
+ }
46
+ }),
47
+ };
@@ -1,4 +1,4 @@
1
- import { messagingGet, messagingPatch, messagingDel } from '../api.js';
1
+ import { messagingGet, messagingPost, messagingPatch, messagingDel } from '../api.js';
2
2
  import { runCommand } from '../run.js';
3
3
 
4
4
  // ── velaro email-inbox ────────────────────────────────────────────────────────
@@ -10,6 +10,7 @@ import { runCommand } from '../run.js';
10
10
  // GET SupportTools/sites/{siteId}/email-inbox/{provider}/list
11
11
  // GET SupportTools/sites/{siteId}/email-inbox/gmail/config-status
12
12
  // PATCH SupportTools/sites/{siteId}/email-inbox/{provider}/{id}/team
13
+ // POST SupportTools/sites/{siteId}/email-inbox/outlook/{id}/resubscribe
13
14
  // DELETE SupportTools/sites/{siteId}/email-inbox/{provider}/{id}
14
15
 
15
16
  function providerOption(y) {
@@ -56,7 +57,15 @@ export const emailInboxCommand = {
56
57
  console.log(`\nGmail global config status:`);
57
58
  console.log(` OAuth configured: ${status.oauthConfigured ? 'yes' : 'no'}`);
58
59
  console.log(` DWD configured: ${status.dwdConfigured ? 'yes' : 'no'}`);
59
- console.log(` Pub/Sub configured: ${status.pubSubConfigured ? 'yes' : 'no'}\n`);
60
+ console.log(` Pub/Sub configured: ${status.pubSubConfigured ? 'yes' : 'no'}`);
61
+ if (status.clientIdPrefix) console.log(` Client ID prefix: ${status.clientIdPrefix}...`);
62
+ if (status.credentialPair) console.log(` Credential pair: ${status.credentialPair}${status.credentialPair === 'PairValid' ? '' : ' <-- ' + (status.credentialPairDetail ?? '')}`);
63
+ if (status.redirectUri) console.log(` Redirect URI: ${status.redirectUri}`);
64
+ if (status.pubSubTopic) console.log(` Pub/Sub topic: ${status.pubSubTopic}`);
65
+ for (const d of status.duplicateKeys ?? []) {
66
+ console.log(` DUPLICATE SETTING: ${d.colonKey} and ${d.underscoreKey}${d.valuesDiffer ? ' with DIFFERENT values - delete the stale one' : ' (same value)'}`);
67
+ }
68
+ console.log('');
60
69
  }),
61
70
  })
62
71
  .command({
@@ -73,6 +82,19 @@ export const emailInboxCommand = {
73
82
  : `\n✓ Mailbox #${argv.id} (${argv.provider}) reassigned to team ${argv['team-id']}.\n`);
74
83
  }),
75
84
  })
85
+ .command({
86
+ command: 'resubscribe',
87
+ describe: 'Outlook only: (re)create the Microsoft Graph inbound-mail subscription for a mailbox (fixes connected-but-receives-nothing)',
88
+ builder: y => y
89
+ .option('site', { type: 'number', demandOption: true, describe: 'Site ID' })
90
+ .option('id', { type: 'number', demandOption: true, describe: 'Mailbox integration ID (from "list")' }),
91
+ handler: runCommand(async argv => {
92
+ const r = await messagingPost(`/SupportTools/sites/${argv.site}/email-inbox/outlook/${argv.id}/resubscribe`, {});
93
+ console.log(r.created
94
+ ? `\n✓ Mailbox #${argv.id} subscribed (id ${r.graphSubscriptionId}, expires ${r.graphSubscriptionExpiresAt}).\n`
95
+ : `\n✗ Subscription NOT created. ${r.note ?? ''}\n Run: velaro activity OutlookInbox --site ${argv.site} --severity Error\n`);
96
+ }),
97
+ })
76
98
  .command({
77
99
  command: 'disconnect',
78
100
  describe: 'Disconnect and remove a mailbox connection',
@@ -84,5 +106,5 @@ export const emailInboxCommand = {
84
106
  console.log(`\n✓ Mailbox #${argv.id} (${argv.provider}) disconnected from site ${argv.site}.\n`);
85
107
  }),
86
108
  })
87
- .demandCommand(1, 'Specify a subcommand: list | status | set-team | disconnect'),
109
+ .demandCommand(1, 'Specify a subcommand: list | status | set-team | resubscribe | disconnect'),
88
110
  };
@@ -0,0 +1,44 @@
1
+ import { messagingGet, messagingPost } from '../api.js';
2
+ import { runCommand } from '../run.js';
3
+
4
+ // -- velaro enterprise-reports ----------------------------------------------------
5
+ // Backend: velaro-messaging /api/enterprise-reports/* (Enterprise Reports package: enterprise-internal, default OFF;
6
+ // every endpoint returns 403 when the site does not hold that package item). Site scope comes from auth, never an argument.
7
+
8
+ const REPORTS = {
9
+ 'program-breakdown': 'Compare websites, teams, platforms, devices or entry pages',
10
+ 'agent-scorecard': 'Per agent: outcomes, speed, AI score, reviewed score, sentiment impact',
11
+ 'qa-coverage': 'Share of resolved chats AI-scored and supervisor-reviewed',
12
+ 'coaching-queue': 'Low-scoring chats nobody has reviewed, per agent',
13
+ 'sentiment-breakdown': 'Sentiment by website, team, agent or topic',
14
+ 'topic-radar': 'Topics by volume, growth, transfer rate and handle time',
15
+ 'weekly-brief': 'Preview the Monday brief sections',
16
+ 'weekly-brief-send-test': 'Email this week\'s brief to the signed-in user',
17
+ };
18
+
19
+ export const enterpriseReportsCommand = {
20
+ command: 'enterprise-reports <report>',
21
+ describe: 'Enterprise Reports package (enterprise-internal): ' + Object.keys(REPORTS).join(' | '),
22
+ builder: (y) => y
23
+ .positional('report', { type: 'string', choices: Object.keys(REPORTS), describe: 'Which report' })
24
+ .option('from', { type: 'string', describe: 'yyyy-MM-dd (default: 30 days ago)' })
25
+ .option('to', { type: 'string', describe: 'yyyy-MM-dd, inclusive (default: today)' })
26
+ .option('group-by', { type: 'string', describe: 'program-breakdown: website|team|platform|device|entrypage; sentiment-breakdown: website|team|agent|theme' })
27
+ .option('deployment-id', { type: 'number', describe: 'Narrow to one website' })
28
+ .option('team-id', { type: 'number', describe: 'Narrow to one team' })
29
+ .option('threshold', { type: 'number', describe: 'coaching-queue: queue chats scored below this (1-100)' })
30
+ .option('per-agent', { type: 'number', describe: 'coaching-queue: chats listed per agent' })
31
+ .option('limit', { type: 'number', describe: 'topic-radar: max topics' })
32
+ .option('days', { type: 'number', describe: 'weekly-brief: days to cover' }),
33
+ handler: runCommand(async (argv) => {
34
+ if (argv.report === 'weekly-brief-send-test') {
35
+ console.log(JSON.stringify(await messagingPost('/api/enterprise-reports/weekly-brief/send-test', {}), null, 2));
36
+ return;
37
+ }
38
+ const qs = new URLSearchParams();
39
+ const map = { from: 'from', to: 'to', groupBy: 'groupBy', deploymentId: 'deploymentId', teamId: 'teamId', threshold: 'threshold', perAgent: 'perAgent', limit: 'limit', days: 'days' };
40
+ for (const [arg, key] of Object.entries(map)) if (argv[arg] != null && argv[arg] !== '') qs.set(key, String(argv[arg]));
41
+ const q = qs.toString();
42
+ console.log(JSON.stringify(await messagingGet(`/api/enterprise-reports/${argv.report}${q ? `?${q}` : ''}`), null, 2));
43
+ }),
44
+ };
@@ -24,6 +24,21 @@ async function getSite(args) {
24
24
  }
25
25
  }
26
26
 
27
+ async function enterpriseReportsPackage(args) {
28
+ const q = args.site ? `?siteId=${args.site}` : '';
29
+ const pkg = await request('GET', `Entitlements/packages/enterprise-reports${q}`);
30
+ console.log(`
31
+ ${pkg.displayName} (${pkg.packageKey}) - plan ${pkg.plan}, default OFF${pkg.siteId ? `, site ${pkg.siteId}` : ''}
32
+ `);
33
+ for (const it of pkg.items) {
34
+ const mark = pkg.siteId ? (it.enabled ? '[ON ] ' : '[off] ') : '';
35
+ console.log(`${mark}${it.ask}. ${it.title}`);
36
+ console.log(` key: ${it.featureKey}; builds on: ${it.buildsOn.join(', ')}`);
37
+ console.log(` ${it.summary}`);
38
+ }
39
+ console.log('');
40
+ }
41
+
27
42
  async function listFeatures() {
28
43
  const features = await request('GET', 'Entitlements/features');
29
44
 
@@ -175,6 +190,9 @@ export function entitlementCommand(yargs) {
175
190
  .option('all', { type: 'boolean', desc: 'Show disabled features too (default: enabled only)' })
176
191
  , a => getSite(a))
177
192
  .command('list-features', 'List all registered entitlement feature keys', () => {}, () => listFeatures())
193
+ .command('enterprise-reports', 'Show the Enterprise Reports package items (with --site, which are ON for that site)', y => y
194
+ .option('site', { type: 'number', desc: 'Site ID' })
195
+ , a => enterpriseReportsPackage(a))
178
196
  .command('set-override', 'Enable or disable a feature for a specific site (overrides plan)', y => y
179
197
  .option('site', { type: 'number', demandOption: true, desc: 'Site ID' })
180
198
  .option('key', { type: 'string', demandOption: true, desc: 'Feature key, e.g. EnableIvr' })
@@ -209,5 +227,5 @@ export function entitlementCommand(yargs) {
209
227
  .option('key', { type: 'string', demandOption: true, desc: 'Feature key, e.g. EnableIvr' })
210
228
  .option('rollback', { type: 'boolean', desc: 'Pass to roll back this flag to Subscription.cs (default: go live)' })
211
229
  , a => setCutover(a))
212
- .demandCommand(1, 'Specify a subcommand: get | list-features | set-override | remove-override | seed | seed-plans | backfill | backfill-sap-tiers | backfill-legacy-write-tiers | reconcile | comp | set-lock | list-cutover | set-cutover');
230
+ .demandCommand(1, 'Specify a subcommand: get | list-features | enterprise-reports | set-override | remove-override | seed | seed-plans | backfill | backfill-sap-tiers | backfill-legacy-write-tiers | reconcile | comp | set-lock | list-cutover | set-cutover');
213
231
  }
@@ -1,5 +1,5 @@
1
1
  import { writeFileSync } from 'node:fs';
2
- import { messagingGet, messagingPost } from '../api.js';
2
+ import { messagingGet, messagingPost, messagingRequestText } from '../api.js';
3
3
  import { runCommand } from '../run.js';
4
4
 
5
5
  // -- velaro executive-insights ---------------------------------------------------
@@ -27,7 +27,7 @@ const frictionCommand = {
27
27
  handler: runCommand(async (argv) => {
28
28
  const qs = rangeArgs(argv);
29
29
  qs.set('limit', String(argv.limit ?? 20));
30
- const r = await messagingGet(`/executive-insights/friction-radar?${qs}`);
30
+ const r = await messagingGet(`/api/executive-insights/friction-radar?${qs}`);
31
31
  console.log(`Friction radar: ${r.conversationsScanned} conversations scanned${r.truncated ? ' (truncated)' : ''}`);
32
32
  for (const t of r.topics ?? []) {
33
33
  const growth = t.isNew ? 'new' : t.growthPct == null ? 'n/a' : `${t.growthPct}%`;
@@ -41,7 +41,7 @@ const briefCommand = {
41
41
  describe: 'Executive brief: volume, sentiment, QA, Experience Impact, rising keywords, top friction',
42
42
  builder: rangeOptions,
43
43
  handler: runCommand(async (argv) => {
44
- const r = await messagingGet(`/executive-insights/brief?${rangeArgs(argv)}`);
44
+ const r = await messagingGet(`/api/executive-insights/brief?${rangeArgs(argv)}`);
45
45
  console.log(JSON.stringify(r.brief, null, 2));
46
46
  }),
47
47
  };
@@ -51,12 +51,14 @@ const exportCommand = {
51
51
  describe: 'CSV of conversations with sentiment, QA score, tag, escalations, handle time and keywords',
52
52
  builder: (y) => rangeOptions(y).option('out', { type: 'string', describe: 'Write the CSV to this file (default: stdout)' }),
53
53
  handler: runCommand(async (argv) => {
54
- const r = await messagingGet(`/executive-insights/export?${rangeArgs(argv)}`);
54
+ // The endpoint is GET api/executive-insights/export.csv and answers text/csv, not JSON.
55
+ const csv = await messagingRequestText('GET', `/api/executive-insights/export.csv?${rangeArgs(argv)}`);
55
56
  if (argv.out) {
56
- writeFileSync(argv.out, r.csv, 'utf8');
57
- console.log(`Wrote ${r.rows} rows to ${argv.out}${r.truncated ? ' (truncated at the row cap)' : ''}`);
57
+ writeFileSync(argv.out, csv, 'utf8');
58
+ const rows = Math.max(csv.split('\n').filter((l) => l.trim().length > 0).length - 1, 0);
59
+ console.log(`Wrote ${rows} rows to ${argv.out}`);
58
60
  } else {
59
- process.stdout.write(r.csv);
61
+ process.stdout.write(csv);
60
62
  }
61
63
  }),
62
64
  };
@@ -74,7 +74,12 @@ const listCommand = {
74
74
  ]);
75
75
 
76
76
  // Quota from subscription
77
- const sub = await get('/Subscription/Get').catch(() => null);
77
+ // GET /Subscription lives on the MESSAGING api (the old admin /Subscription/Get never existed, and its
78
+ // .catch(() => null) hid that, so the quota line silently never rendered).
79
+ const sub = await messagingGet('/Subscription').catch((err) => {
80
+ console.error(`\x1b[33m⚠ Could not read subscription quota: ${err.message}\x1b[0m`);
81
+ return null;
82
+ });
78
83
  const chunkCap = sub?.maxIndexedChunkTotal ?? 0;
79
84
  const usedChunks = indexes.reduce((s, i) => s + (i.documentCount ?? 0), 0);
80
85
 
@@ -169,7 +174,10 @@ const reingestCommand = {
169
174
  const creds = await getCredentials();
170
175
 
171
176
  // Check quota before allowing reingest
172
- const sub = await get('/Subscription/Get').catch(() => null);
177
+ const sub = await messagingGet('/Subscription').catch((err) => {
178
+ console.error(`\x1b[33m⚠ Could not read subscription quota (index quota pre-check skipped): ${err.message}\x1b[0m`);
179
+ return null;
180
+ });
173
181
  if (!isVelaroAdmin(creds) && sub?.maxIndexedChunkTotal > 0) {
174
182
  const indexes = await messagingGet('/AzureIndexes/List').catch(() => []);
175
183
  const usedChunks = indexes.reduce((s, i) => s + (i.documentCount ?? 0), 0);
@@ -1,4 +1,4 @@
1
- import { getCredentials, post } from '../api.js';
1
+ import { getCredentials, messagingPost } from '../api.js';
2
2
  import { runCommand } from '../run.js';
3
3
 
4
4
  export const ingestCommand = {
@@ -18,7 +18,8 @@ export const ingestCommand = {
18
18
 
19
19
  console.log(`Triggering ingestion for job ${argv['job-id']} on site ${creds.siteId}...`);
20
20
 
21
- const result = await post('/AzureIndexes/IngestJobDirect', {
21
+ // AzureIndexesController (IngestJobDirect) lives on the MESSAGING api; the admin api has no such route.
22
+ const result = await messagingPost('/AzureIndexes/IngestJobDirect', {
22
23
  siteId: creds.siteId,
23
24
  jobId: argv['job-id'],
24
25
  });
@@ -122,7 +122,8 @@ const reindexCommand = {
122
122
  }
123
123
  } else {
124
124
  console.log("Reindexing your site's KB articles\u2026");
125
- const result = await post('/KBSearchIndex/reindex', {});
125
+ // KBSearchIndexController is routed api/kb/search (the old /KBSearchIndex/reindex 404'd).
126
+ const result = await post('/api/kb/search/reindex', {});
126
127
  if (result.success === false) {
127
128
  console.error(`❌ Failed: ${result.message}`);
128
129
  } else {
@@ -1,6 +1,7 @@
1
1
  import { writeFileSync } from 'fs';
2
2
  import { messagingGet, messagingPost, messagingPut, messagingDel, getCredentials } from '../api.js';
3
3
  import { runCommand } from '../run.js';
4
+ import { applicationIdPath, buildApplySettingsBody, buildBotSettingsBody } from '../recruiting-apply.js';
4
5
 
5
6
  // -- velaro recruiting --------------------------------------------------------
6
7
  // CLI surface for the Recruiting Bot: job-feed sync (JSON/XML/RSS/Indeed/
@@ -16,6 +17,7 @@ import { runCommand } from '../run.js';
16
17
  // GET Recruiting/Jobs?search=&location=&department= -> jobListing[]
17
18
  // DELETE Recruiting/Jobs/{id} -> { success }
18
19
  // GET Recruiting/Applications -> candidateApplication[]
20
+ // GET Recruiting/Applications/AtsFailures; POST Recruiting/Applications/{id}/AtsRetry; PUT Recruiting/ApplySettings { publicApplyEnabled?, autoCreateCaseTypeId? }; GET Recruiting/Applications/{id}/Resume (binary)
19
21
  // GET Recruiting/Applications/ExportEeoc -> text/csv (Professional tier+, EnableRecruitingBotEeoc)
20
22
 
21
23
  const BOLD = '\x1b[1m';
@@ -123,6 +125,56 @@ async function applicationsExportEeocHandler(argv) {
123
125
  console.log(`\n${GRN}OK${RST} EEOC export saved to ${outPath}\n`);
124
126
  }
125
127
 
128
+ async function atsFailuresHandler() {
129
+ const rows = await messagingGet('/Recruiting/Applications/AtsFailures');
130
+ if (!Array.isArray(rows) || !rows.length) {
131
+ console.log('\n No applications with pending or failed ATS write-back.\n');
132
+ return;
133
+ }
134
+ console.log(`\n${BOLD}ATS write-back queue${RST} (${rows.length})\n`);
135
+ for (const r of rows) {
136
+ console.log(` ${DIM}#${r.id}${RST} ${r.candidateName} ${r.atsSyncStatus} ${DIM}attempts=${r.atsSyncAttempts} next=${r.atsSyncNextRetryAt || '-'} ${r.atsSyncLastError || ''}${RST}`);
137
+ }
138
+ console.log('');
139
+ }
140
+
141
+ async function atsRetryHandler(argv) {
142
+ const r = await messagingPost(applicationIdPath(argv.applicationId, 'AtsRetry'), {});
143
+ console.log(`\n${GRN}OK${RST} Application ${r.id} ATS status: ${r.atsSyncStatus} (attempts ${r.atsSyncAttempts})${r.atsSyncLastError ? ` ${RED}${r.atsSyncLastError}${RST}` : ''}\n`);
144
+ }
145
+
146
+ async function botSettingsHandler(argv) {
147
+ const keys = ['pageContext', 'revealPostedDate', 'revealDescription', 'revealApplyLink', 'publicApply', 'applyMode', 'tone', 'maxResults', 'greeting'];
148
+ const given = Object.fromEntries(keys.filter((k) => argv[k] !== undefined).map((k) => [k, argv[k]]));
149
+ // No flags = show current settings.
150
+ const r = Object.keys(given).length ? await messagingPut('/Recruiting/BotSettings', buildBotSettingsBody(given)) : await messagingGet('/Recruiting/BotSettings');
151
+ console.log(`\n${GRN}OK${RST} Recruiting bot settings:\n${JSON.stringify(r, null, 2)}\n`);
152
+ }
153
+
154
+ async function applySettingsHandler(argv) {
155
+ const r = await messagingPut('/Recruiting/ApplySettings', buildApplySettingsBody({ publicApply: argv.publicApply, caseTypeId: argv.caseTypeId }));
156
+ console.log(`\n${GRN}OK${RST} Apply settings: publicApplyEnabled=${r.publicApplyEnabled}, autoCreateCaseTypeId=${r.autoCreateCaseTypeId ?? '(none)'}\n`);
157
+ }
158
+
159
+ // Resume is a binary download; fetch raw (messagingGet JSON-parses) with the same auth/site header.
160
+ async function resumeDownloadHandler(argv) {
161
+ const creds = await getCredentials();
162
+ const res = await fetch(`${creds.messagingApiBase.replace(/\/+$/, '')}${applicationIdPath(argv.applicationId, 'Resume')}`, {
163
+ headers: {
164
+ Authorization: `Bearer ${creds.velaroToken}`,
165
+ ...(creds.siteId ? { 'vmsg-siteId': String(creds.siteId) } : {}),
166
+ },
167
+ });
168
+ if (!res.ok) {
169
+ const text = await res.text().catch(() => '');
170
+ throw new Error(`GET Resume -> ${res.status}: ${text.slice(0, 300)}`);
171
+ }
172
+ const m = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(res.headers.get('content-disposition') || '');
173
+ const outPath = argv.out || (m ? decodeURIComponent(m[1]) : `resume-${argv.applicationId}`);
174
+ writeFileSync(outPath, Buffer.from(await res.arrayBuffer()));
175
+ console.log(`\n${GRN}OK${RST} Resume saved to ${outPath}\n`);
176
+ }
177
+
126
178
  const syncCommand = {
127
179
  command: 'sync',
128
180
  describe: 'Manually trigger a Recruiting Bot job-feed sync for this site',
@@ -192,7 +244,43 @@ const applicationsCommand = {
192
244
  builder: (y2) => y2.option('out', { describe: 'Output CSV file path (default: eeoc-export-<timestamp>.csv)', type: 'string' }),
193
245
  handler: runCommand(applicationsExportEeocHandler),
194
246
  })
195
- .demandCommand(1, 'Specify an applications subcommand: list | export-eeoc'),
247
+ .command({ command: 'ats-failures', describe: 'List applications whose ATS write-back is Pending/Failed/Abandoned', handler: runCommand(atsFailuresHandler) })
248
+ .command({
249
+ command: 'ats-retry <applicationId>',
250
+ describe: 'Retry the ATS write-back for one application now (resets attempts)',
251
+ builder: (y2) => y2.positional('applicationId', { type: 'number' }),
252
+ handler: runCommand(atsRetryHandler),
253
+ })
254
+ .command({
255
+ command: 'resume <applicationId>',
256
+ describe: 'Download an applicant resume',
257
+ builder: (y2) => y2.positional('applicationId', { type: 'number' }).option('out', { describe: 'Output file path (default: original file name)', type: 'string' }),
258
+ handler: runCommand(resumeDownloadHandler),
259
+ })
260
+ .command({
261
+ command: 'apply-settings',
262
+ describe: 'Set public apply form options (apply-if-present)',
263
+ builder: (y2) => y2
264
+ .option('public-apply', { describe: 'Enable/disable the public apply endpoint for this site', type: 'boolean' })
265
+ .option('case-type-id', { describe: 'Case type to auto-create a case per application (0 clears)', type: 'number' }),
266
+ handler: runCommand(applySettingsHandler),
267
+ })
268
+ .command({
269
+ command: 'bot-settings',
270
+ describe: 'Show or set what the recruiting bot reveals and how it behaves (omit flags to show)',
271
+ builder: (y2) => y2
272
+ .option('page-context', { describe: 'Use the job from the host page (Velaro context call)', type: 'boolean' })
273
+ .option('reveal-posted-date', { describe: 'Let the bot share when a job was first listed', type: 'boolean' })
274
+ .option('reveal-description', { describe: 'Let the bot share the full job description', type: 'boolean' })
275
+ .option('reveal-apply-link', { describe: 'Let the bot share the listing link', type: 'boolean' })
276
+ .option('public-apply', { describe: 'Enable the public apply endpoint', type: 'boolean' })
277
+ .option('apply-mode', { describe: 'both | link | inproduct', type: 'string' })
278
+ .option('tone', { describe: 'friendly | professional | concise', type: 'string' })
279
+ .option('max-results', { describe: 'Most jobs per search (1-10)', type: 'number' })
280
+ .option('greeting', { describe: 'Opening greeting (500 chars max)', type: 'string' }),
281
+ handler: runCommand(botSettingsHandler),
282
+ })
283
+ .demandCommand(1, 'Specify an applications subcommand: list | export-eeoc | ats-failures | ats-retry | resume | apply-settings | bot-settings'),
196
284
  handler: () => {},
197
285
  };
198
286
 
@@ -1,4 +1,4 @@
1
- import { messagingGet as get, messagingPost as post, messagingDel as del } from '../api.js';
1
+ import { messagingGet as get, messagingPost as post, messagingRequest } from '../api.js';
2
2
  import { requireFeature } from '../subscription.js';
3
3
  import { runCommand } from '../run.js';
4
4
 
@@ -79,7 +79,9 @@ const ruleDeleteCommand = {
79
79
  builder: (y) => y.positional('id', { type: 'number', describe: 'Rule ID' }),
80
80
  handler: runCommand(async (argv) => {
81
81
  await requireFeature('enableWorkflowRules', 'Routing Rules');
82
- await del(`/Rules/${argv.id}`);
82
+ // RulesController.DeleteAsync is `[HttpDelete]` taking the rule in the BODY (DELETE /Rules {id}); there is no
83
+ // DELETE /Rules/{id} route (it 404'd). Verified live on staging 2026-10-12: DELETE /Rules {"id":N} -> 200.
84
+ await messagingRequest('DELETE', '/Rules', { id: argv.id });
83
85
  console.log(`Rule ${argv.id} deleted.`);
84
86
  }),
85
87
  };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * velaro zoom-phone - Zoom Phone (SiteVoiceConfig ProviderSlug="zoom") screen-pop config
3
+ *
4
+ * velaro zoom-phone status Show provider, saved-secret flag and Zoom account_id
5
+ * velaro zoom-phone set --account-id ... [--webhook-secret] Save the Zoom account_id (and optionally the webhook secret)
6
+ *
7
+ * Backend contract: VoiceController GET/POST voice/admin/config (velaro-messaging). Site is
8
+ * resolved server-side from the auth context. The Zoom account_id is what the app-level webhook
9
+ * (POST /ZoomPhoneWebhook) routes events by; without it every call event is dropped.
10
+ * Repeatable end-to-end proof: scripts/smoke-zoom-phone.sh.
11
+ */
12
+
13
+ import { messagingGet, messagingPost } from '../api.js';
14
+
15
+ const GRN = '\x1b[32m';
16
+ const YEL = '\x1b[33m';
17
+ const DIM = '\x1b[2m';
18
+ const BOLD = '\x1b[1m';
19
+ const RST = '\x1b[0m';
20
+
21
+ async function statusHandler() {
22
+ const c = await messagingGet('/voice/admin/config');
23
+ if (!c?.configured) {
24
+ console.log(`\n${DIM}No voice provider configured for this site.${RST}\n`);
25
+ return;
26
+ }
27
+ console.log(`\n${BOLD}Voice provider${RST}: ${c.providerSlug}`);
28
+ if (c.providerSlug !== 'zoom') {
29
+ console.log(` ${YEL}Zoom Phone is not the active provider; "set" will refuse unless you switch it in the admin UI.${RST}\n`);
30
+ return;
31
+ }
32
+ console.log(` Zoom account_id: ${c.zoomAccountId || `${YEL}(not set - call events are dropped)${RST}`}`);
33
+ console.log(` Webhook secret saved: ${c.hasWebhookSecret ? `${GRN}yes${RST}` : `${DIM}no${RST}`}\n`);
34
+ }
35
+
36
+ async function setHandler(argv) {
37
+ if (!argv.accountId && !argv.webhookSecret) throw new Error('Pass --account-id and/or --webhook-secret.');
38
+ const current = await messagingGet('/voice/admin/config');
39
+ if (current?.configured && current.providerSlug && current.providerSlug !== 'zoom') {
40
+ throw new Error(`Site already uses voice provider "${current.providerSlug}". Switch providers in the admin UI (explicit, destructive) before configuring Zoom Phone.`);
41
+ }
42
+ await messagingPost('/voice/admin/config', {
43
+ providerSlug: 'zoom',
44
+ zoomAccountId: argv.accountId ? String(argv.accountId).trim() : undefined,
45
+ webhookSecret: argv.webhookSecret || undefined,
46
+ });
47
+ console.log(`\n${GRN}✓${RST} Zoom Phone settings saved.`);
48
+ await statusHandler();
49
+ }
50
+
51
+ export const zoomPhoneCommand = {
52
+ command: 'zoom-phone <subcommand>',
53
+ describe: 'Zoom Phone screen-pop configuration (account_id + webhook secret)',
54
+ builder: y => y
55
+ .command({ command: 'status', describe: 'Show the Zoom Phone configuration for this site', builder: yy => yy, handler: statusHandler })
56
+ .command({
57
+ command: 'set',
58
+ describe: 'Save the Zoom account_id and/or webhook secret (apply-if-present)',
59
+ builder: yy => yy
60
+ .option('account-id', { describe: 'Zoom account_id from a webhook payload or Account Profile (not the account number)', type: 'string' })
61
+ .option('webhook-secret', { describe: 'Secret token from the Zoom Event Subscription', type: 'string' }),
62
+ handler: setHandler,
63
+ })
64
+ .demandCommand(1, 'Specify a zoom-phone subcommand.')
65
+ .strict(),
66
+ handler: () => {},
67
+ };
@@ -9,7 +9,7 @@ import path from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
10
 
11
11
  const dir = path.join(path.dirname(fileURLToPath(import.meta.url)), 'commands');
12
- const MESSAGING_ONLY = ['/Teams/List', '/Deployment', '/Users/List', '/Workflows/List', '/Rules', '/Ticket/knowledge-overrides', '/UserStatus/admin', '/api/ivr/config', '/WooCommerce/billing', '/WooCommerce/auth', '/diagnostics/test-magento'];
12
+ const MESSAGING_ONLY = ['/Teams/List', '/Deployment', '/Users/List', '/Workflows/List', '/Rules', '/Ticket/knowledge-overrides', '/UserStatus/admin', '/api/ivr/config', '/WooCommerce/billing', '/WooCommerce/auth', '/diagnostics/test-magento', '/voice/admin'];
13
13
 
14
14
  test('messaging-only routes are never called through the admin api helpers', () => {
15
15
  const offenders = [];
@@ -0,0 +1,53 @@
1
+ // Pure helpers for the Recruiting public-apply / ATS write-back CLI commands.
2
+ // Backend: velaro-messaging RecruitingController (Applications/AtsFailures, Applications/{id}/AtsRetry,
3
+ // ApplySettings, Applications/{id}/Resume).
4
+
5
+ export function applicationIdPath(id, suffix) {
6
+ const n = Number(id);
7
+ if (!Number.isInteger(n) || n <= 0) throw new Error('applicationId must be a positive integer.');
8
+ return `/Recruiting/Applications/${n}/${suffix}`;
9
+ }
10
+
11
+ // Apply-if-present body for PUT /Recruiting/ApplySettings. caseTypeId 0 clears the auto-case link.
12
+ export function buildApplySettingsBody({ publicApply, caseTypeId } = {}) {
13
+ const body = {};
14
+ if (publicApply !== undefined) body.publicApplyEnabled = Boolean(publicApply);
15
+ if (caseTypeId !== undefined) {
16
+ const n = Number(caseTypeId);
17
+ if (!Number.isInteger(n) || n < 0) throw new Error('caseTypeId must be an integer >= 0 (0 clears).');
18
+ body.autoCreateCaseTypeId = n;
19
+ }
20
+ if (!Object.keys(body).length) throw new Error('Specify at least one of publicApply, caseTypeId.');
21
+ return body;
22
+ }
23
+
24
+ const APPLY_MODES = ['both', 'link', 'inproduct'];
25
+ const TONES = ['friendly', 'professional', 'concise'];
26
+
27
+ // Apply-if-present body for PUT /Recruiting/BotSettings (customer controls for the recruiting bot).
28
+ export function buildBotSettingsBody(o = {}) {
29
+ const body = {};
30
+ const bools = { pageContext: 'pageContextEnabled', revealPostedDate: 'revealPostedDate', revealDescription: 'revealDescription', revealApplyLink: 'revealApplyLink', publicApply: 'publicApplyEnabled' };
31
+ for (const [k, field] of Object.entries(bools)) if (o[k] !== undefined) body[field] = Boolean(o[k]);
32
+ if (o.applyMode !== undefined) {
33
+ const m = String(o.applyMode).toLowerCase();
34
+ if (!APPLY_MODES.includes(m)) throw new Error(`applyMode must be one of: ${APPLY_MODES.join(', ')}.`);
35
+ body.applyMode = m;
36
+ }
37
+ if (o.tone !== undefined) {
38
+ const t = String(o.tone).toLowerCase();
39
+ if (!TONES.includes(t)) throw new Error(`tone must be one of: ${TONES.join(', ')}.`);
40
+ body.tone = t;
41
+ }
42
+ if (o.maxResults !== undefined) {
43
+ const n = Number(o.maxResults);
44
+ if (!Number.isInteger(n) || n < 1 || n > 10) throw new Error('maxResults must be an integer from 1 to 10.');
45
+ body.maxResults = n;
46
+ }
47
+ if (o.greeting !== undefined) {
48
+ if (String(o.greeting).length > 500) throw new Error('greeting must be 500 characters or fewer.');
49
+ body.greeting = String(o.greeting);
50
+ }
51
+ if (!Object.keys(body).length) throw new Error('Specify at least one bot setting.');
52
+ return body;
53
+ }
@@ -0,0 +1,28 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { applicationIdPath, buildApplySettingsBody, buildBotSettingsBody } from './recruiting-apply.js';
4
+
5
+ test('application paths validate id', () => {
6
+ assert.equal(applicationIdPath(12, 'AtsRetry'), '/Recruiting/Applications/12/AtsRetry');
7
+ assert.equal(applicationIdPath('5', 'Resume'), '/Recruiting/Applications/5/Resume');
8
+ assert.throws(() => applicationIdPath('x', 'Resume'));
9
+ assert.throws(() => applicationIdPath(0, 'Resume'));
10
+ });
11
+
12
+ test('apply settings body is apply-if-present', () => {
13
+ assert.deepEqual(buildApplySettingsBody({ publicApply: true }), { publicApplyEnabled: true });
14
+ assert.deepEqual(buildApplySettingsBody({ caseTypeId: 0 }), { autoCreateCaseTypeId: 0 });
15
+ assert.deepEqual(buildApplySettingsBody({ publicApply: false, caseTypeId: 3 }), { publicApplyEnabled: false, autoCreateCaseTypeId: 3 });
16
+ assert.throws(() => buildApplySettingsBody({}));
17
+ assert.throws(() => buildApplySettingsBody({ caseTypeId: -1 }));
18
+ });
19
+
20
+ test('bot settings body is apply-if-present and validated', () => {
21
+ assert.deepEqual(buildBotSettingsBody({ revealPostedDate: false }), { revealPostedDate: false });
22
+ assert.deepEqual(buildBotSettingsBody({ applyMode: 'LINK', tone: 'Concise', maxResults: 3, pageContext: true, publicApply: false }), { pageContextEnabled: true, publicApplyEnabled: false, applyMode: 'link', tone: 'concise', maxResults: 3 });
23
+ assert.throws(() => buildBotSettingsBody({}));
24
+ assert.throws(() => buildBotSettingsBody({ applyMode: 'x' }));
25
+ assert.throws(() => buildBotSettingsBody({ tone: 'angry' }));
26
+ assert.throws(() => buildBotSettingsBody({ maxResults: 11 }));
27
+ assert.throws(() => buildBotSettingsBody({ greeting: 'g'.repeat(501) }));
28
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velaro/cli",
3
- "version": "1.4.88",
3
+ "version": "1.4.90",
4
4
  "description": "Velaro Workspace v20 — command-line interface for managing bots, knowledge base ingestion, MCP API keys, search indexes, and ops health.",
5
5
  "type": "module",
6
6
  "bin": {