@velaro/cli 1.4.91 → 1.4.93

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,56 @@
1
+ // Pure helpers for `velaro avatar group-override | workflow-override | effective | reset`.
2
+ // Per-group / per-workflow avatar + backdrop overrides live in velaro-messaging (VelaroAvatarController);
3
+ // effective = perWorkflow ?? perGroup ?? perSite. Kept dependency-free so it is unit-testable.
4
+
5
+ export const BACKDROP_MODES = ['none', 'color', 'gradient', 'image'];
6
+
7
+ const HEX = /^#[0-9a-fA-F]{3,8}$/;
8
+ const GRADIENT = /^#[0-9a-fA-F]{3,8},#[0-9a-fA-F]{3,8}$/;
9
+ const HTTPS = /^https:\/\/[^\s"'()<>\\]+$/i;
10
+
11
+ /** Returns an error string or null. Mirrors AvatarExperienceSettings.ValidateBackdrop on the server. */
12
+ export function validateBackdrop(mode, value) {
13
+ if (mode === undefined || mode === '') return null;
14
+ if (!BACKDROP_MODES.includes(mode)) return `--backdrop must be one of ${BACKDROP_MODES.join(', ')}`;
15
+ const v = (value ?? '').trim();
16
+ if (mode === 'none') return null;
17
+ if (mode === 'color') return HEX.test(v) ? null : '--backdrop-value must be a hex color like #1e293b';
18
+ if (mode === 'gradient') return GRADIENT.test(v) ? null : '--backdrop-value must be two hex colors like #1e293b,#7c3aed';
19
+ return v.length <= 500 && HTTPS.test(v) ? null : '--backdrop-value must be an https image URL';
20
+ }
21
+
22
+ /**
23
+ * Builds the PUT body. Merge by default: only flags the caller passed are sent, so changing one field never wipes the
24
+ * others; an empty string clears that one field back to inherit. `replace` replaces the whole override.
25
+ */
26
+ export function overrideBody(argv) {
27
+ const body = { merge: !argv.replace };
28
+ const map = {
29
+ provider: 'providerSlug',
30
+ avatar: 'avatarId',
31
+ personality: 'personalityPresetSlug',
32
+ voice: 'voiceId',
33
+ 'display-name': 'displayName',
34
+ backdrop: 'backgroundMode',
35
+ 'backdrop-value': 'backgroundValue',
36
+ };
37
+ for (const [flag, key] of Object.entries(map)) {
38
+ if (argv[flag] !== undefined) body[key] = String(argv[flag]);
39
+ }
40
+ const err = validateBackdrop(body.backgroundMode, body.backgroundValue);
41
+ if (err) throw new Error(err);
42
+ if (body.backgroundMode === undefined && body.backgroundValue !== undefined && body.backgroundValue !== '')
43
+ throw new Error('--backdrop-value needs --backdrop (none | color | gradient | image)');
44
+ if (Object.keys(body).length === 1) throw new Error('Pass at least one of --avatar --personality --voice --display-name --backdrop --backdrop-value --provider');
45
+ return body;
46
+ }
47
+
48
+ /** `group` or `workflow` target path segment from the flags; exactly one is required. */
49
+ export function scopePath(argv) {
50
+ const hasGroup = argv.group !== undefined;
51
+ const hasWorkflow = argv.workflow !== undefined;
52
+ if (hasGroup === hasWorkflow) throw new Error('Pass exactly one of --group <teamId> or --workflow <workflowId>');
53
+ const id = Number(hasGroup ? argv.group : argv.workflow);
54
+ if (!Number.isInteger(id) || id <= 0) throw new Error('The id must be a positive whole number');
55
+ return hasGroup ? `/avatar/groups/${id}` : `/avatar/workflows/${id}`;
56
+ }
@@ -0,0 +1,43 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { overrideBody, scopePath, validateBackdrop } from './avatar-override.js';
4
+
5
+ const TIMKEN = 'https://admin.velaro.com/avatar-backdrops/timken-hr-office.png';
6
+
7
+ test('validateBackdrop accepts https images, hex colors and two-color gradients only', () => {
8
+ assert.equal(validateBackdrop('image', TIMKEN), null);
9
+ assert.match(validateBackdrop('image', 'http://admin.velaro.com/a.png'), /https/);
10
+ assert.match(validateBackdrop('image', 'javascript:alert(1)'), /https/);
11
+ assert.match(validateBackdrop('image', 'https://admin.velaro.com/a.png");background:url(//evil'), /https/);
12
+ assert.equal(validateBackdrop('color', '#1e293b'), null);
13
+ assert.match(validateBackdrop('color', 'red'), /hex/);
14
+ assert.equal(validateBackdrop('gradient', '#111,#222'), null);
15
+ assert.match(validateBackdrop('gradient', '#111'), /two hex/);
16
+ assert.equal(validateBackdrop('none', ''), null);
17
+ assert.match(validateBackdrop('sparkles', 'x'), /one of/);
18
+ });
19
+
20
+ test('overrideBody merges by default and only sends the flags that were passed', () => {
21
+ assert.deepEqual(overrideBody({ avatar: 'priya', backdrop: 'image', 'backdrop-value': TIMKEN }), {
22
+ merge: true,
23
+ avatarId: 'priya',
24
+ backgroundMode: 'image',
25
+ backgroundValue: TIMKEN,
26
+ });
27
+ assert.deepEqual(overrideBody({ 'display-name': '' }), { merge: true, displayName: '' }); // clears just that field
28
+ assert.equal(overrideBody({ avatar: 'maya', replace: true }).merge, false);
29
+ });
30
+
31
+ test('overrideBody refuses an unsafe backdrop, a value with no mode, and an empty change', () => {
32
+ assert.throws(() => overrideBody({ backdrop: 'image', 'backdrop-value': 'http://admin.velaro.com/a.png' }), /https/);
33
+ assert.throws(() => overrideBody({ 'backdrop-value': '#fff' }), /needs --backdrop/);
34
+ assert.throws(() => overrideBody({}), /at least one/);
35
+ });
36
+
37
+ test('scopePath needs exactly one of --group / --workflow and a positive id', () => {
38
+ assert.equal(scopePath({ workflow: 12 }), '/avatar/workflows/12');
39
+ assert.equal(scopePath({ group: 7 }), '/avatar/groups/7');
40
+ assert.throws(() => scopePath({}), /exactly one/);
41
+ assert.throws(() => scopePath({ group: 1, workflow: 2 }), /exactly one/);
42
+ assert.throws(() => scopePath({ group: 0 }), /positive/);
43
+ });
@@ -1,4 +1,4 @@
1
- import { get, post, messagingGet, messagingPost, getCredentials } from '../api.js';
1
+ import { get, post, messagingGet, messagingPost, messagingPut, getCredentials } from '../api.js';
2
2
  import { runCommand } from '../run.js';
3
3
 
4
4
  const VALID_STATUSES = ['Available', 'Away', 'Offline'];
@@ -97,6 +97,18 @@ export const agentCommand = {
97
97
  console.log(` ${result.message ?? ''}`);
98
98
  }),
99
99
  })
100
+ .command({
101
+ command: 'set-roles',
102
+ describe: 'Set roles (Administrator/Manager/Agent) of an EXISTING user on a site (Velaro staff only) -- makes E2E/QA test logins admin-capable',
103
+ builder: (y) => y
104
+ .option('site-id', { type: 'number', demandOption: true, describe: 'Target site ID' })
105
+ .option('email', { type: 'string', demandOption: true, describe: 'Existing user email' })
106
+ .option('roles', { type: 'array', demandOption: true, describe: 'e.g. --roles Administrator' }),
107
+ handler: runCommand(async (argv) => {
108
+ const r = await messagingPut(`/SupportTools/sites/${argv['site-id']}/agents/roles`, { email: argv.email, roles: argv.roles.map(String) });
109
+ console.log(`OK: ${r.email} roles ${r.before || '(none)'} -> ${r.roles.join(', ')}`);
110
+ }),
111
+ })
100
112
  .command({
101
113
  command: 'set-status',
102
114
  describe: 'Set an agent\'s availability status (admin only)',
@@ -1,4 +1,5 @@
1
- import { request } from '../api.js';
1
+ import { request, messagingGet, messagingPut, messagingDel } from '../api.js';
2
+ import { overrideBody, scopePath } from '../avatar-override.js';
2
3
  import { runCommand } from '../run.js';
3
4
 
4
5
  // ── velaro avatar ─────────────────────────────────────────────────────────────
@@ -104,5 +105,53 @@ export const avatarCommand = {
104
105
  console.log(` Max TTS chars/mo: ${data.maxAvatarTtsChars}\n`);
105
106
  }),
106
107
  })
107
- .demandCommand(1, 'Specify a subcommand: get | enable | disable | set'),
108
+ .command({
109
+ command: 'override',
110
+ describe: 'Set the person / personality / voice / name / backdrop for ONE group or workflow of YOUR site (perWorkflow ?? perGroup ?? perSite)',
111
+ builder: y => y
112
+ .option('group', { type: 'number', describe: 'Group (team) id' })
113
+ .option('workflow', { type: 'number', describe: 'Workflow id (wins over its group)' })
114
+ .option('avatar', { type: 'string', describe: 'Library avatar slug (see the avatar library), e.g. priya. Empty string clears.' })
115
+ .option('personality', { type: 'string', describe: 'Personality preset slug. Empty string clears.' })
116
+ .option('voice', { type: 'string', describe: 'ElevenLabs voice id. Empty string clears.' })
117
+ .option('display-name', { type: 'string', describe: 'Visitor-facing avatar name. Empty string clears.' })
118
+ .option('backdrop', { type: 'string', choices: ['none', 'color', 'gradient', 'image', ''], describe: 'Backdrop type. none = explicitly no backdrop; empty string = inherit.' })
119
+ .option('backdrop-value', { type: 'string', describe: 'Hex color, "#a,#b" gradient, or https image URL' })
120
+ .option('provider', { type: 'string', describe: 'Provider slug (a library avatar implies velaro)' })
121
+ .option('replace', { type: 'boolean', describe: 'Replace the whole override instead of changing only the flags passed' }),
122
+ handler: runCommand(async argv => {
123
+ const base = scopePath(argv);
124
+ const data = await messagingPut(`${base}/override`, overrideBody(argv));
125
+ if (!data?.success) throw new Error(data?.error || 'Override failed');
126
+ const eff = await messagingGet(`${base}/effective`);
127
+ console.log(`
128
+ ✓ Override saved. Effective now: avatar=${eff.avatarId ?? '(default)'} backdrop=${eff.backgroundMode}${eff.backgroundValue ? ' ' + eff.backgroundValue : ''} name=${eff.displayName ?? '(AI)'} layer=${eff.layer}
129
+ `);
130
+ }),
131
+ })
132
+ .command({
133
+ command: 'effective',
134
+ describe: 'Show what a group or workflow of your site gets (override fields layered over the site default)',
135
+ builder: y => y
136
+ .option('group', { type: 'number', describe: 'Group (team) id' })
137
+ .option('workflow', { type: 'number', describe: 'Workflow id' }),
138
+ handler: runCommand(async argv => {
139
+ const eff = await messagingGet(`${scopePath(argv)}/effective`);
140
+ console.log(JSON.stringify(eff, null, 2));
141
+ }),
142
+ })
143
+ .command({
144
+ command: 'reset',
145
+ describe: 'Remove a group or workflow override (back to the next layer / site default)',
146
+ builder: y => y
147
+ .option('group', { type: 'number', describe: 'Group (team) id' })
148
+ .option('workflow', { type: 'number', describe: 'Workflow id' }),
149
+ handler: runCommand(async argv => {
150
+ const data = await messagingDel(`${scopePath(argv)}/override`);
151
+ console.log(`
152
+ ✓ Reset (${data?.removed ?? 0} override removed)
153
+ `);
154
+ }),
155
+ })
156
+ .demandCommand(1, 'Specify a subcommand: get | enable | disable | set | override | effective | reset'),
108
157
  };
@@ -9,7 +9,8 @@ export const migrateCommand = {
9
9
  .command(migrateExportCommand)
10
10
  .command(migrateLivefluenceCommand)
11
11
  .command(migrateUrlRulesCommand)
12
- .demandCommand(1, 'Specify a subcommand: export, livefluence, url-rules'),
12
+ .command(migrateTeamsCommand)
13
+ .demandCommand(1, 'Specify a subcommand: export, livefluence, url-rules, teams'),
13
14
  handler: () => {},
14
15
  };
15
16
 
@@ -21,9 +22,13 @@ const migrateExportCommand = {
21
22
  builder: (y) =>
22
23
  y
23
24
  .positional('v10SiteId', { type: 'number', describe: 'V10 site ID to export from' })
24
- .option('v20-site-id', { type: 'number', alias: 's', describe: 'V20 site ID for auto-matching teams/AI configs' }),
25
+ .option('v20-site-id', { type: 'number', alias: 's', describe: 'V20 site ID for auto-matching teams/AI configs' })
26
+ .option('include-non-routing', { type: 'boolean', default: false, describe: 'Also include v10 rules with IsRouting=false (group-assignment rules)' }),
25
27
  handler: runCommand(async (argv) => {
26
- const qs = argv['v20-site-id'] ? `?v20SiteId=${argv['v20-site-id']}` : '';
28
+ const params = [];
29
+ if (argv['v20-site-id']) params.push(`v20SiteId=${argv['v20-site-id']}`);
30
+ if (argv['include-non-routing']) params.push('includeNonRouting=true');
31
+ const qs = params.length ? `?${params.join('&')}` : '';
27
32
  const data = await get(`/DatabaseTool/V10Export/${argv.v10SiteId}${qs}`);
28
33
 
29
34
  console.log(`\n${'─'.repeat(60)}`);
@@ -78,7 +83,9 @@ const migrateLivefluenceCommand = {
78
83
  .option('v10-site-id', { type: 'number', alias: 'v', describe: 'V10 (Livefluence) site ID', demandOption: true })
79
84
  .option('v20-site-id', { type: 'number', alias: 's', describe: 'V20 destination site ID', demandOption: true })
80
85
  .option('dry-run', { type: 'boolean', default: false, describe: 'Preview without creating' })
81
- .option('enable', { type: 'boolean', default: false, describe: 'Enable created workflows immediately (default: disabled)' })
86
+ .option('enable', { type: 'boolean', default: false, describe: 'Enable created workflows immediately (default: disabled; workflows with unconverted v10 terms always stay disabled)' })
87
+ .option('include-non-routing', { type: 'boolean', default: false, describe: 'Also migrate v10 rules with IsRouting=false (group-assignment rules)' })
88
+ .option('route-bot-groups-to-team', { type: 'boolean', default: false, describe: 'Route to the v20 team named after the v10 group even if the group had a v10 bot' })
82
89
  .option('team-map', { type: 'string', describe: 'JSON: [{"v10GroupId":1,"v20TeamId":5},...] override auto-resolution for teams' })
83
90
  .option('ai-config-map',{ type: 'string', describe: 'JSON: [{"v10GroupId":2,"v20AiConfigId":3},...] override auto-resolution for AI configs' }),
84
91
  handler: runCommand(async (argv) => {
@@ -90,6 +97,8 @@ const migrateLivefluenceCommand = {
90
97
  v20SiteId,
91
98
  dryRun: argv['dry-run'],
92
99
  enable: argv.enable,
100
+ includeNonRouting: argv['include-non-routing'],
101
+ routeBotGroupsToTeam: argv['route-bot-groups-to-team'],
93
102
  teamMappings: argv['team-map'] ? JSON.parse(argv['team-map']) : [],
94
103
  aiConfigMappings: argv['ai-config-map'] ? JSON.parse(argv['ai-config-map']) : [],
95
104
  };
@@ -111,6 +120,8 @@ const migrateLivefluenceCommand = {
111
120
  if (item.v2AiConfigId) console.log(` → AI Config id=${item.v2AiConfigId}`);
112
121
  if (item.v2TeamId) console.log(` → Team id=${item.v2TeamId}`);
113
122
  if (item.v10Expression) console.log(` V10 expression: ${item.v10Expression}`);
123
+ if (item.conditionGroups !== undefined) console.log(` v20 condition groups (OR): ${item.conditionGroups}`);
124
+ for (const u of item.unconverted ?? []) console.log(` ⚠ not converted: ${u}`);
114
125
  console.log('');
115
126
  }
116
127
  }
@@ -127,8 +138,7 @@ const migrateLivefluenceCommand = {
127
138
  console.log(` ${result.message}`);
128
139
  if (!argv['dry-run'] && result.created > 0) {
129
140
  console.log(`\n ⚠ All workflows created DISABLED — review conditions in the canvas then enable.`);
130
- console.log(` Note: V10 routing expressions were NOT auto-converted.`);
131
- console.log(` Add conditions manually in the Workflows canvas (Phase 3 trigger node).`);
141
+ console.log(` Country and URL terms are converted; terms marked 'not converted' need manual canvas conditions.`);
132
142
  }
133
143
  console.log(`${'─'.repeat(60)}\n`);
134
144
  }),
@@ -157,3 +167,31 @@ const migrateUrlRulesCommand = {
157
167
  console.log(`\n ${result.dryRun ? 'DRY RUN: ' : ''}created ${result.created}, skipped ${result.skipped}`);
158
168
  }),
159
169
  };
170
+
171
+ // -- copy v10 groups to v20 teams (v10 read-only) ------------------------------
172
+
173
+ const migrateTeamsCommand = {
174
+ command: 'teams',
175
+ describe: 'Copy v10 (Livefluence) groups to v20 teams on a site (v10 is read-only; idempotent by name)',
176
+ builder: (y) =>
177
+ y
178
+ .option('v10-site-id', { type: 'number', alias: 'v', describe: 'V10 (Livefluence) site ID', demandOption: true })
179
+ .option('v20-site-id', { type: 'number', alias: 's', describe: 'V20 destination site ID', demandOption: true })
180
+ .option('groups', { type: 'string', describe: 'Comma-separated group names to copy (default: every non-deleted v10 group). "Default Group" maps to the site default team.' })
181
+ .option('dry-run', { type: 'boolean', default: false, describe: 'Preview without creating' }),
182
+ handler: runCommand(async (argv) => {
183
+ const body = {
184
+ v10SiteId: argv['v10-site-id'],
185
+ v20SiteId: argv['v20-site-id'],
186
+ dryRun: argv['dry-run'],
187
+ groupNames: argv.groups ? argv.groups.split(',').map(g => g.trim()).filter(Boolean) : [],
188
+ };
189
+ const result = await post('/DatabaseTool/V10CreateTeams', body);
190
+ console.log(`\n ${result.dryRun ? 'DRY RUN - ' : ''}v10 site ${body.v10SiteId} -> v20 site ${body.v20SiteId}\n`);
191
+ for (const t of result.teams ?? []) {
192
+ const members = t.v10Members !== undefined ? ` (v10 members: ${t.v10Members})` : '';
193
+ console.log(` ${t.status.padEnd(24)} ${t.name}${t.teamId ? ` [team id=${t.teamId}]` : ''}${members}`);
194
+ }
195
+ console.log('\n Members are not copied; assign v20 agents to the new teams.\n');
196
+ }),
197
+ };
@@ -1,7 +1,7 @@
1
- import { writeFileSync } from 'fs';
1
+ import { readFileSync, 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, buildRenewBody, jobLifecyclePath } from '../recruiting-apply.js';
4
+ import { buildAssignBody, buildNoteBody, buildPipelineBody, buildRetentionBody, buildStageBody, applicationIdPath, buildApplySettingsBody, buildBotSettingsBody, buildHostedBody, buildJobPayBody, buildRenewBody, jobLifecyclePath, jobPayPath } from '../recruiting-apply.js';
5
5
 
6
6
  // -- velaro recruiting --------------------------------------------------------
7
7
  // CLI surface for the Recruiting Bot: job-feed sync (JSON/XML/RSS/Indeed/
@@ -18,9 +18,13 @@ import { applicationIdPath, buildApplySettingsBody, buildBotSettingsBody, buildR
18
18
  // DELETE Recruiting/Jobs/{id} -> { success }
19
19
  // GET Recruiting/Jobs?includeAll=true -> every status (needs the Job Board lifecycle package)
20
20
  // POST Recruiting/Jobs/{id}/close | /renew { days? } ; GET Recruiting/Lifecycle -> { entitled, defaultListingDays } (403 without the package)
21
+ // GET/PUT Recruiting/Hosted { enabled?, regenerateSlug? } ; POST Recruiting/Hosted/FeedKey/Rotate ; PUT Recruiting/Jobs/{id}/Pay -> hosted job pages (package job-board-hosted, 403 without it)
21
22
  // GET Recruiting/Applications -> candidateApplication[]
22
23
  // GET Recruiting/Applications/AtsFailures; POST Recruiting/Applications/{id}/AtsRetry; PUT Recruiting/ApplySettings { publicApplyEnabled?, autoCreateCaseTypeId?, defaultListingDays? }; GET Recruiting/Applications/{id}/Resume (binary)
23
24
  // GET Recruiting/Applications/ExportEeoc -> text/csv (Professional tier+, EnableRecruitingBotEeoc)
25
+ // GET/PUT Recruiting/Pipeline { stages:[{key,label,color?,applicantVisible?,applicantLabel?}] } ; PUT Recruiting/Applications/{id}/Stage { stage, note? }
26
+ // GET Recruiting/Applications/{id}/History ; POST .../Notes { note } ; PUT .../Assign { assignedTo }
27
+ // GET/PUT Recruiting/Retention ; POST Recruiting/Retention/Run?dryRun= -> recruiter pipeline and retention (package job-board-pipeline; 403 job_board_pipeline_not_entitled without it)
24
28
 
25
29
  const BOLD = '\x1b[1m';
26
30
  const DIM = '\x1b[2m';
@@ -112,6 +116,42 @@ async function jobsLifecycleHandler() {
112
116
  console.log(` Default listing days: ${r.defaultListingDays}\n`);
113
117
  }
114
118
 
119
+ async function jobsPayHandler(argv) {
120
+ const r = await messagingPut(jobPayPath(argv.jobId), buildJobPayBody({ min: argv.min, max: argv.max, currency: argv.currency, period: argv.period, clear: argv.clear }));
121
+ console.log(`\n${GRN}OK${RST} Job listing ${r.id} pay: ${r.display || '(not stated)'} (source: ${r.salarySource || 'none'}).\n`);
122
+ }
123
+
124
+ function printHosted(r) {
125
+ console.log(`\n${BOLD}Job Board hosted pages${RST}`);
126
+ if (!r.entitled) {
127
+ console.log(` Package: not included (the hosted pages, sitemap and feeds are off; admin calls return 403)\n`);
128
+ return;
129
+ }
130
+ console.log(` Package: ${GRN}included${RST}`);
131
+ console.log(` Published: ${r.enabled ? `${GRN}yes${RST}` : 'no (every hosted link answers 404)'}`);
132
+ console.log(` Open listings:${' '}${r.liveListings ?? 0}`);
133
+ if (r.boardUrl) {
134
+ console.log(` Board link: ${r.boardUrl}`);
135
+ console.log(` Sitemap: ${r.sitemapUrl}`);
136
+ console.log(` Indeed feed: ${r.indeedFeedUrl}`);
137
+ console.log(` Google feed: ${r.googleFeedUrl}`);
138
+ console.log(` ${DIM}The feed URLs contain a secret key; treat them like a password.${RST}`);
139
+ }
140
+ console.log(` Apply form: ${r.publicApplyEnabled ? 'on' : 'off (candidates are sent to each listing\'s apply link)'}${r.publicApplyEnabled && r.eeocSelfIdAvailable ? ', with voluntary EEO self-ID' : ''}\n`);
141
+ }
142
+
143
+ async function hostedHandler(argv) {
144
+ const body = buildHostedBody({ enabled: argv.enabled, regenerateLink: argv.regenerateLink });
145
+ const r = Object.keys(body).length ? await messagingPut('/Recruiting/Hosted', body) : await messagingGet('/Recruiting/Hosted');
146
+ printHosted(r);
147
+ }
148
+
149
+ async function hostedRotateKeyHandler() {
150
+ const r = await messagingPost('/Recruiting/Hosted/FeedKey/Rotate', {});
151
+ console.log(`\n${GRN}OK${RST} Feed key rotated; the previous Indeed and Google feed URLs no longer work.`);
152
+ printHosted(r);
153
+ }
154
+
115
155
  async function applicationsListHandler() {
116
156
  const apps = await messagingGet('/Recruiting/Applications');
117
157
  if (!Array.isArray(apps) || !apps.length) {
@@ -260,16 +300,42 @@ const jobsCommand = {
260
300
  describe: 'Show whether this site has the Job Board lifecycle package and its default listing days',
261
301
  handler: runCommand(jobsLifecycleHandler),
262
302
  })
303
+ .command({
304
+ command: 'pay <jobId>',
305
+ describe: 'Set or clear the pay range shown on the hosted page and in its structured data. Needs the Job Board hosted pages package',
306
+ builder: (y2) => y2
307
+ .positional('jobId', { describe: 'Job listing ID', type: 'number' })
308
+ .option('min', { describe: 'Minimum (or only) amount', type: 'number' })
309
+ .option('max', { describe: 'Maximum amount (optional)', type: 'number' })
310
+ .option('currency', { describe: '3-letter ISO currency (default USD)', type: 'string' })
311
+ .option('period', { describe: 'Hour | Day | Week | Month | Year', type: 'string' })
312
+ .option('clear', { describe: 'Remove the stated pay', type: 'boolean' }),
313
+ handler: runCommand(jobsPayHandler),
314
+ })
263
315
  .command({
264
316
  command: 'delete <jobId>',
265
317
  describe: 'Delete a synced job listing',
266
318
  builder: (y2) => y2.positional('jobId', { describe: 'Job listing ID', type: 'number' }),
267
319
  handler: runCommand(jobsDeleteHandler),
268
320
  })
269
- .demandCommand(1, 'Specify a jobs subcommand: list | close | renew | lifecycle | delete'),
321
+ .demandCommand(1, 'Specify a jobs subcommand: list | close | renew | lifecycle | pay | delete'),
270
322
  handler: () => {},
271
323
  };
272
324
 
325
+ const hostedCommand = {
326
+ command: 'hosted',
327
+ describe: 'Hosted public job pages: show or change the board settings (needs the Job Board hosted pages package)',
328
+ builder: (y) => y
329
+ .option('enabled', { describe: 'Publish (true) or unpublish (false) the hosted pages, sitemap and feeds', type: 'boolean' })
330
+ .option('regenerate-link', { describe: 'Issue a NEW opaque board link; every old hosted URL answers 404 (administrators)', type: 'boolean' })
331
+ .command({
332
+ command: 'rotate-key',
333
+ describe: 'Rotate the outbound feed key; the old Indeed and Google feed URLs stop working (administrators)',
334
+ handler: runCommand(hostedRotateKeyHandler),
335
+ }),
336
+ handler: runCommand(hostedHandler),
337
+ };
338
+
273
339
  const applicationsCommand = {
274
340
  command: 'applications <subcommand>',
275
341
  describe: 'List candidate applications or export EEOC voluntary self-ID data',
@@ -407,10 +473,100 @@ async function sourcesAddListingHandler(argv) {
407
473
  }
408
474
 
409
475
  async function pipelineMoveHandler(argv) {
410
- const app = await messagingPut(`/Recruiting/Applications/${argv.applicationId}/Stage`, { stage: argv.stage });
476
+ const app = await messagingPut(applicationIdPath(argv.applicationId, 'Stage'), buildStageBody(argv.stage, argv.note));
411
477
  console.log(`\n${GRN}OK${RST} Application ${app.id} moved to stage "${app.pipelineStage}".\n`);
412
478
  }
413
479
 
480
+ function printPipeline(r) {
481
+ console.log(`\n${BOLD}Job Board pipeline${RST}`);
482
+ console.log(` Package: ${r.entitled ? `${GRN}included${RST}` : 'not included (default stages; history, notes, assignment, stage editing and retention are off)'}`);
483
+ console.log(` Source: ${r.source === 'caseType' ? `case type ${r.caseTypeId}` : 'default list'}`);
484
+ for (const s of r.stages ?? []) {
485
+ const seen = s.applicantVisible ? `applicants see "${s.applicantLabel || s.label}"` : 'applicants see "In review"';
486
+ console.log(` ${s.key.padEnd(16)} ${String(s.label).padEnd(20)} ${DIM}${seen}${s.color ? `, ${s.color}` : ''}${RST}`);
487
+ }
488
+ console.log('');
489
+ }
490
+
491
+ async function pipelineGetHandler() {
492
+ printPipeline(await messagingGet('/Recruiting/Pipeline'));
493
+ }
494
+
495
+ async function pipelineSetHandler(argv) {
496
+ let raw = argv.stagesJson;
497
+ if (!raw && argv.file) raw = readFileSync(argv.file, 'utf8');
498
+ if (!raw) throw new Error('Pass --stages-json \'[{"key":"Applied","label":"Applied"}]\' or --file stages.json.');
499
+ const r = await messagingPut('/Recruiting/Pipeline', buildPipelineBody(raw));
500
+ console.log(`\n${GRN}OK${RST} Pipeline saved.`);
501
+ printPipeline(r);
502
+ }
503
+
504
+ // The API returns UTC without a zone designator; show Pacific Time like every other user-facing timestamp.
505
+ function pacific(raw) {
506
+ if (!raw) return '';
507
+ const d = new Date(/(Z|[+-]\d{2}:?\d{2})$/i.test(raw) ? raw : `${raw}Z`);
508
+ return Number.isNaN(d.getTime()) ? String(raw) : `${d.toLocaleString('en-US', { timeZone: 'America/Los_Angeles', dateStyle: 'medium', timeStyle: 'short' })} PT`;
509
+ }
510
+
511
+ async function applicationHistoryHandler(argv) {
512
+ const r = await messagingGet(applicationIdPath(argv.applicationId, 'History'));
513
+ const events = r.events ?? [];
514
+ if (!events.length) {
515
+ console.log('\n No history yet.\n');
516
+ return;
517
+ }
518
+ console.log(`\n${BOLD}Application ${argv.applicationId} history${RST} (newest first, Pacific Time)\n`);
519
+ for (const e of events) {
520
+ // An Assign event carries the new recruiter in note (empty = unassigned); toStage is just the stage at the time.
521
+ const what = e.kind === 'Note' ? 'note' : e.kind === 'Assign' ? (e.note ? `assigned to ${e.note}` : 'unassigned') : `${e.fromStage ?? '-'} -> ${e.toStage ?? '?'}`;
522
+ console.log(` ${pacific(e.at)} ${String(e.actorType).padEnd(8)} ${e.actorId ?? ''} ${what}${e.note && e.kind !== 'Assign' ? ` "${e.note}"` : ''}`);
523
+ }
524
+ console.log('');
525
+ }
526
+
527
+ async function applicationNoteHandler(argv) {
528
+ await messagingPost(applicationIdPath(argv.applicationId, 'Notes'), buildNoteBody(argv.text));
529
+ console.log(`\n${GRN}OK${RST} Note added to application ${argv.applicationId}.\n`);
530
+ }
531
+
532
+ async function applicationAssignHandler(argv) {
533
+ const app = await messagingPut(applicationIdPath(argv.applicationId, 'Assign'), buildAssignBody(argv.who));
534
+ console.log(`\n${GRN}OK${RST} Application ${app.id} ${app.assignedTo ? `assigned to ${app.assignedTo}` : 'unassigned'}.\n`);
535
+ }
536
+
537
+ function printRetention(r) {
538
+ console.log(`\n${BOLD}Applicant record retention${RST}`);
539
+ console.log(` Automatic retention: ${r.enabled ? `${GRN}on${RST}` : 'off'}${r.enabled && r.dryRun ? ' (count only, nothing is deleted)' : ''}`);
540
+ console.log(` Legal hold: ${r.legalHold ? `${RED}ON, all deletion stopped${RST}` : 'off'}`);
541
+ console.log(` Federal contractor: ${r.isFederalContractor ? 'yes' : 'no'}`);
542
+ console.log(` Retention days: ${r.retentionDays} (legal minimum ${r.floorDays}, applied ${r.effectiveDays})`);
543
+ if (r.lastRunAt) console.log(` Last run: ${pacific(r.lastRunAt)}, ${r.lastRunDeleted} deleted, ${r.lastRunWouldDelete} would have been deleted`);
544
+ console.log(` ${DIM}Records are never deleted before 1 year (2 years for federal contractors). Counts only.${RST}\n`);
545
+ }
546
+
547
+ async function retentionHandler(argv) {
548
+ const action = argv.action ?? 'get';
549
+ if (action === 'get') return printRetention(await messagingGet('/Recruiting/Retention'));
550
+ if (action === 'set') {
551
+ // Arming real deletion, or lifting a legal hold, is destructive: refuse unless --yes.
552
+ if ((argv.dryRun === false || argv.legalHold === false) && !argv.yes) {
553
+ throw new Error('This can start real deletion of applicant records or lift a legal hold. Preview first with `velaro recruiting retention run`, then repeat with --yes.');
554
+ }
555
+ const r = await messagingPut('/Recruiting/Retention', buildRetentionBody({ enabled: argv.enabled, dryRun: argv.dryRun, days: argv.days, federalContractor: argv.federalContractor, legalHold: argv.legalHold }));
556
+ console.log(`\n${GRN}OK${RST} Retention settings saved.`);
557
+ return printRetention(r);
558
+ }
559
+ // run: previews by default (nothing deleted); --execute --yes performs a real run.
560
+ if (argv.execute && !argv.yes) throw new Error('--execute permanently deletes applicant records past retention. Preview first (omit --execute), then repeat with --execute --yes.');
561
+ const r = await messagingPost(`/Recruiting/Retention/Run?dryRun=${argv.execute ? 'false' : 'true'}`, {});
562
+ console.log(`\n${BOLD}Retention run${RST} ${r.dryRun ? '(preview, nothing deleted)' : ''}`);
563
+ console.log(` Due: ${r.due}`);
564
+ console.log(` Deleted: ${r.deleted}`);
565
+ console.log(` Resumes deleted: ${r.resumesDeleted}`);
566
+ console.log(` Held by legal hold: ${r.skippedLegalHold}`);
567
+ console.log(` Legal minimum: ${r.floorDays} days\n`);
568
+ }
569
+
414
570
  const sourcesCommand = {
415
571
  command: 'sources <subcommand>',
416
572
  describe: 'Manage named job posting sources for a multi-employer job board (EnableJobBoardMultiEmployer)',
@@ -478,16 +634,74 @@ const pipelineCommand = {
478
634
  builder: (y) => y
479
635
  .command({
480
636
  command: 'move <applicationId> <stage>',
481
- describe: 'Move a candidate application to a new pipeline stage (Applied|Screened|Interview|Offer|Hired|Rejected)',
637
+ describe: 'Move a candidate application to a new pipeline stage (default stages: Applied|Screened|Interview|Offer|Hired|Rejected)',
482
638
  builder: (y2) => y2
483
639
  .positional('applicationId', { type: 'number' })
484
- .positional('stage', { type: 'string' }),
640
+ .positional('stage', { type: 'string' })
641
+ .option('note', { describe: 'Note recorded in the stage history', type: 'string' }),
485
642
  handler: runCommand(pipelineMoveHandler),
486
643
  })
487
- .demandCommand(1, 'Specify a pipeline subcommand: move'),
644
+ .command({ command: 'get', describe: 'Show the pipeline stages (the default list when the Job board pipeline package is not included)', handler: runCommand(pipelineGetHandler) })
645
+ .command({
646
+ command: 'set',
647
+ describe: 'Replace the pipeline stages (needs the Job board pipeline package)',
648
+ builder: (y2) => y2
649
+ .option('stages-json', { describe: 'JSON array: [{"key","label","color"?,"applicantVisible"?,"applicantLabel"?}]', type: 'string' })
650
+ .option('file', { describe: 'Read the stages JSON from a file', type: 'string' }),
651
+ handler: runCommand(pipelineSetHandler),
652
+ })
653
+ .demandCommand(1, 'Specify a pipeline subcommand: get | set | move'),
488
654
  handler: () => {},
489
655
  };
490
656
 
657
+ // `recruiting application ...` (singular): per-application stage, history, notes and assignment.
658
+ const applicationCommand = {
659
+ command: 'application <subcommand>',
660
+ describe: 'One application: move stage, show history, add a note, assign a recruiter',
661
+ builder: (y) => y
662
+ .command({
663
+ command: 'stage <applicationId> <stage>',
664
+ describe: 'Move an application to a stage, with an optional note',
665
+ builder: (y2) => y2.positional('applicationId', { type: 'number' }).positional('stage', { type: 'string' }).option('note', { describe: 'Note recorded in the stage history', type: 'string' }),
666
+ handler: runCommand(pipelineMoveHandler),
667
+ })
668
+ .command({
669
+ command: 'history <applicationId>',
670
+ describe: 'Show the stage, note and assignment history, newest first (needs the Job board pipeline package)',
671
+ builder: (y2) => y2.positional('applicationId', { type: 'number' }),
672
+ handler: runCommand(applicationHistoryHandler),
673
+ })
674
+ .command({
675
+ command: 'note <applicationId> <text>',
676
+ describe: 'Add an internal note (never shown to the applicant; needs the Job board pipeline package)',
677
+ builder: (y2) => y2.positional('applicationId', { type: 'number' }).positional('text', { type: 'string' }),
678
+ handler: runCommand(applicationNoteHandler),
679
+ })
680
+ .command({
681
+ command: 'assign <applicationId> <who>',
682
+ describe: 'Assign a recruiter (name or email; "none" clears; needs the Job board pipeline package)',
683
+ builder: (y2) => y2.positional('applicationId', { type: 'number' }).positional('who', { type: 'string' }),
684
+ handler: runCommand(applicationAssignHandler),
685
+ })
686
+ .demandCommand(1, 'Specify an application subcommand: stage | history | note | assign'),
687
+ handler: () => {},
688
+ };
689
+
690
+ const retentionCommand = {
691
+ command: 'retention [action]',
692
+ describe: 'Applicant record retention: show (default), set, or run a preview (needs the Job board pipeline package; set and run need an Administrator)',
693
+ builder: (y) => y
694
+ .positional('action', { describe: 'get | set | run', type: 'string', choices: ['get', 'set', 'run'], default: 'get' })
695
+ .option('enabled', { describe: 'set: turn automatic retention on or off', type: 'boolean' })
696
+ .option('dry-run', { describe: 'set: count only, never delete', type: 'boolean' })
697
+ .option('days', { describe: 'set: days to keep records (below the legal minimum the minimum applies)', type: 'number' })
698
+ .option('federal-contractor', { describe: 'set: federal contractor (2 year minimum)', type: 'boolean' })
699
+ .option('legal-hold', { describe: 'set: stop all deletion', type: 'boolean' })
700
+ .option('execute', { describe: 'run: really delete due records (default is a preview that deletes nothing)', type: 'boolean' })
701
+ .option('yes', { describe: 'Confirm a deleting run, or set --dry-run=false / --legal-hold=false', type: 'boolean' }),
702
+ handler: runCommand(retentionHandler),
703
+ };
704
+
491
705
  export const recruitingCommand = {
492
706
  command: 'recruiting <subcommand>',
493
707
  describe: 'Recruiting Bot: sync job feeds, manage listings, applications, config, and multi-employer job board sources',
@@ -495,10 +709,13 @@ export const recruitingCommand = {
495
709
  .command(syncCommand)
496
710
  .command(configCommand)
497
711
  .command(jobsCommand)
712
+ .command(hostedCommand)
498
713
  .command(applicationsCommand)
499
714
  .command(sourcesCommand)
500
715
  .command(pipelineCommand)
501
- .demandCommand(1, 'Specify a recruiting subcommand: sync | config | jobs | applications | sources | pipeline')
716
+ .command(applicationCommand)
717
+ .command(retentionCommand)
718
+ .demandCommand(1, 'Specify a recruiting subcommand: sync | config | jobs | hosted | applications | application | sources | pipeline | retention')
502
719
  .strict(),
503
720
  handler: () => {},
504
721
  };
@@ -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', '/voice/admin', '/api/enterprise-reports'];
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', '/api/enterprise-reports', '/avatar/groups', '/avatar/workflows', '/avatar/group-overrides'];
13
13
 
14
14
  test('messaging-only routes are never called through the admin api helpers', () => {
15
15
  const offenders = [];
@@ -16,6 +16,44 @@ export function jobLifecyclePath(id, action) {
16
16
  return `/Recruiting/Jobs/${n}/${action}`;
17
17
  }
18
18
 
19
+ // Job Board hosted pages (package job-board-hosted): PUT Recruiting/Jobs/{id}/Pay.
20
+ export function jobPayPath(id) {
21
+ const n = Number(id);
22
+ if (!Number.isInteger(n) || n <= 0) throw new Error('jobId must be a positive integer.');
23
+ return `/Recruiting/Jobs/${n}/Pay`;
24
+ }
25
+
26
+ const PAY_PERIODS = ['Hour', 'Day', 'Week', 'Month', 'Year'];
27
+
28
+ // Body for PUT Recruiting/Jobs/{id}/Pay. { clear: true } removes the stated pay; otherwise min + period are required
29
+ // (max optional, currency defaults to USD). A manual value is never overwritten by a feed sync.
30
+ export function buildJobPayBody({ min, max, currency, period, clear } = {}) {
31
+ if (clear) return { clear: true };
32
+ const lo = Number(min);
33
+ if (min === undefined || !Number.isFinite(lo) || lo <= 0) throw new Error('min must be a number greater than 0 (or pass clear).');
34
+ const body = { salaryMin: lo };
35
+ if (max !== undefined) {
36
+ const hi = Number(max);
37
+ if (!Number.isFinite(hi) || hi < lo) throw new Error('max must be a number greater than or equal to min.');
38
+ body.salaryMax = hi;
39
+ }
40
+ const cur = String(currency ?? 'USD').trim().toUpperCase();
41
+ if (!/^[A-Z]{3}$/.test(cur)) throw new Error('currency must be a 3-letter ISO code such as USD.');
42
+ body.salaryCurrency = cur;
43
+ const per = PAY_PERIODS.find((p) => p.toLowerCase() === String(period ?? '').trim().toLowerCase());
44
+ if (!per) throw new Error(`period must be one of: ${PAY_PERIODS.join(', ')}.`);
45
+ body.salaryPeriod = per;
46
+ return body;
47
+ }
48
+
49
+ // Apply-if-present body for PUT Recruiting/Hosted: { enabled?, regenerateSlug? }. Omit both to only read the settings.
50
+ export function buildHostedBody({ enabled, regenerateLink } = {}) {
51
+ const body = {};
52
+ if (enabled !== undefined) body.enabled = Boolean(enabled);
53
+ if (regenerateLink) body.regenerateSlug = true;
54
+ return body;
55
+ }
56
+
19
57
  // Body for POST Recruiting/Jobs/{id}/renew. Omit days to use the site default (RecruitingConfig.DefaultListingDays).
20
58
  export function buildRenewBody(days) {
21
59
  if (days === undefined) return {};
@@ -73,3 +111,69 @@ export function buildBotSettingsBody(o = {}) {
73
111
  if (!Object.keys(body).length) throw new Error('Specify at least one bot setting.');
74
112
  return body;
75
113
  }
114
+
115
+ // ── Job Board Stage 4: recruiter pipeline, history, notes, assignment, retention (package job-board-pipeline) ──
116
+
117
+ // Body for PUT Recruiting/Applications/{id}/Stage. note is optional (recorded in the stage history).
118
+ export function buildStageBody(stage, note) {
119
+ const s = String(stage ?? '').trim();
120
+ if (!s) throw new Error('stage is required.');
121
+ const body = { stage: s };
122
+ if (note !== undefined && String(note).trim()) {
123
+ if (String(note).length > 2000) throw new Error('note must be 2000 characters or fewer.');
124
+ body.note = String(note).trim();
125
+ }
126
+ return body;
127
+ }
128
+
129
+ // Body for POST Recruiting/Applications/{id}/Notes.
130
+ export function buildNoteBody(note) {
131
+ const t = String(note ?? '').trim();
132
+ if (!t) throw new Error('note is required.');
133
+ if (t.length > 2000) throw new Error('note must be 2000 characters or fewer.');
134
+ return { note: t };
135
+ }
136
+
137
+ // Body for PUT Recruiting/Applications/{id}/Assign. "none" or "" clears the assignment.
138
+ export function buildAssignBody(who) {
139
+ const t = String(who ?? '').trim();
140
+ if (t.length > 200) throw new Error('assignedTo must be 200 characters or fewer.');
141
+ return { assignedTo: !t || t.toLowerCase() === 'none' ? null : t };
142
+ }
143
+
144
+ // Body for PUT Recruiting/Pipeline from a JSON array of stages: [{ key, label, color?, applicantVisible?, applicantLabel? }].
145
+ // The server also validates (1-12 stages, unique keys; removing a stage that holds applications is refused with a 400).
146
+ export function buildPipelineBody(stagesJson) {
147
+ let stages;
148
+ try {
149
+ stages = typeof stagesJson === 'string' ? JSON.parse(stagesJson) : stagesJson;
150
+ } catch (e) {
151
+ throw new Error(`stages must be valid JSON: ${e.message}`);
152
+ }
153
+ if (!Array.isArray(stages) || stages.length < 1 || stages.length > 12) throw new Error('stages must be a JSON array of 1 to 12 stages.');
154
+ const keys = new Set();
155
+ for (const s of stages) {
156
+ if (!s || typeof s !== 'object' || typeof s.key !== 'string' || !s.key.trim() || s.key.length > 64) throw new Error('every stage needs a key (up to 64 characters).');
157
+ if (typeof s.label !== 'string' || !s.label.trim()) throw new Error(`stage ${s.key} needs a label.`);
158
+ if (keys.has(s.key)) throw new Error(`duplicate stage key: ${s.key}.`);
159
+ keys.add(s.key);
160
+ }
161
+ return { stages };
162
+ }
163
+
164
+ // Partial body for PUT Recruiting/Retention (apply-if-present). A day count below the legal floor is accepted by the server,
165
+ // which then applies the floor (365 days, or 730 for federal contractors; 29 CFR 1602.14).
166
+ export function buildRetentionBody({ enabled, dryRun, days, federalContractor, legalHold } = {}) {
167
+ const body = {};
168
+ if (enabled !== undefined) body.enabled = Boolean(enabled);
169
+ if (dryRun !== undefined) body.dryRun = Boolean(dryRun);
170
+ if (federalContractor !== undefined) body.isFederalContractor = Boolean(federalContractor);
171
+ if (legalHold !== undefined) body.legalHold = Boolean(legalHold);
172
+ if (days !== undefined) {
173
+ const n = Number(days);
174
+ if (!Number.isInteger(n) || n < 1 || n > 36500) throw new Error('days must be a whole number from 1 to 36500.');
175
+ body.retentionDays = n;
176
+ }
177
+ if (!Object.keys(body).length) throw new Error('Specify at least one retention setting.');
178
+ return body;
179
+ }
@@ -1,6 +1,6 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { applicationIdPath, buildApplySettingsBody, buildBotSettingsBody, buildRenewBody, jobLifecyclePath } from './recruiting-apply.js';
3
+ import { buildAssignBody, buildNoteBody, buildPipelineBody, buildRetentionBody, buildStageBody, applicationIdPath, buildApplySettingsBody, buildBotSettingsBody, buildHostedBody, buildJobPayBody, buildRenewBody, jobLifecyclePath, jobPayPath } from './recruiting-apply.js';
4
4
 
5
5
  test('job lifecycle paths validate id and action', () => {
6
6
  assert.equal(jobLifecyclePath(7, 'close'), '/Recruiting/Jobs/7/close');
@@ -49,3 +49,67 @@ test('bot settings body is apply-if-present and validated', () => {
49
49
  assert.throws(() => buildBotSettingsBody({ maxResults: 11 }));
50
50
  assert.throws(() => buildBotSettingsBody({ greeting: 'g'.repeat(501) }));
51
51
  });
52
+
53
+ // -- Job Board hosted pages (Stage 2) -----------------------------------------
54
+
55
+ test('job pay path validates the id', () => {
56
+ assert.equal(jobPayPath(7), '/Recruiting/Jobs/7/Pay');
57
+ assert.throws(() => jobPayPath('x'));
58
+ assert.throws(() => jobPayPath(0));
59
+ });
60
+
61
+ test('job pay body: range, currency default and period are validated like the server', () => {
62
+ assert.deepEqual(buildJobPayBody({ min: 50000, max: 70000, period: 'year' }), { salaryMin: 50000, salaryMax: 70000, salaryCurrency: 'USD', salaryPeriod: 'Year' });
63
+ assert.deepEqual(buildJobPayBody({ min: '18.5', currency: 'cad', period: 'HOUR' }), { salaryMin: 18.5, salaryCurrency: 'CAD', salaryPeriod: 'Hour' });
64
+ assert.deepEqual(buildJobPayBody({ clear: true }), { clear: true });
65
+ assert.throws(() => buildJobPayBody({ period: 'Year' }));
66
+ assert.throws(() => buildJobPayBody({ min: 0, period: 'Year' }));
67
+ assert.throws(() => buildJobPayBody({ min: 10, max: 5, period: 'Year' }));
68
+ assert.throws(() => buildJobPayBody({ min: 10, currency: 'US', period: 'Year' }));
69
+ assert.throws(() => buildJobPayBody({ min: 10, period: 'Fortnight' }));
70
+ assert.throws(() => buildJobPayBody({ min: 10 }));
71
+ });
72
+
73
+ test('hosted body is apply-if-present; no flags means read-only', () => {
74
+ assert.deepEqual(buildHostedBody({}), {});
75
+ assert.deepEqual(buildHostedBody({ enabled: true }), { enabled: true });
76
+ assert.deepEqual(buildHostedBody({ enabled: false }), { enabled: false });
77
+ assert.deepEqual(buildHostedBody({ regenerateLink: true }), { regenerateSlug: true });
78
+ });
79
+
80
+ // -- Job Board pipeline (Stage 4) ---------------------------------------------
81
+
82
+ test('stage body carries an optional trimmed note', () => {
83
+ assert.deepEqual(buildStageBody('Interview'), { stage: 'Interview' });
84
+ assert.deepEqual(buildStageBody(' Offer ', ' good fit '), { stage: 'Offer', note: 'good fit' });
85
+ assert.deepEqual(buildStageBody('Offer', ' '), { stage: 'Offer' });
86
+ assert.throws(() => buildStageBody(''));
87
+ assert.throws(() => buildStageBody('Offer', 'x'.repeat(2001)));
88
+ });
89
+
90
+ test('note and assign bodies validate', () => {
91
+ assert.deepEqual(buildNoteBody(' hello '), { note: 'hello' });
92
+ assert.throws(() => buildNoteBody(' '));
93
+ assert.deepEqual(buildAssignBody('pat@acme.com'), { assignedTo: 'pat@acme.com' });
94
+ assert.deepEqual(buildAssignBody('none'), { assignedTo: null });
95
+ assert.deepEqual(buildAssignBody(''), { assignedTo: null });
96
+ assert.throws(() => buildAssignBody('x'.repeat(201)));
97
+ });
98
+
99
+ test('pipeline body validates the stage list client side', () => {
100
+ const ok = [{ key: 'Applied', label: 'Applied' }, { key: 'Hired', label: 'Hired', applicantVisible: true }];
101
+ assert.deepEqual(buildPipelineBody(JSON.stringify(ok)), { stages: ok });
102
+ assert.throws(() => buildPipelineBody('{'), /valid JSON/);
103
+ assert.throws(() => buildPipelineBody('[]'));
104
+ assert.throws(() => buildPipelineBody(JSON.stringify([{ key: 'A', label: 'A' }, { key: 'A', label: 'B' }])), /duplicate/);
105
+ assert.throws(() => buildPipelineBody(JSON.stringify([{ key: 'A' }])), /label/);
106
+ assert.throws(() => buildPipelineBody(JSON.stringify(Array.from({ length: 13 }, (_, i) => ({ key: `K${i}`, label: 'x' })))));
107
+ });
108
+
109
+ test('retention body is apply-if-present and range-checked', () => {
110
+ assert.deepEqual(buildRetentionBody({ legalHold: true }), { legalHold: true });
111
+ assert.deepEqual(buildRetentionBody({ enabled: false, dryRun: true, days: '400', federalContractor: true }), { enabled: false, dryRun: true, retentionDays: 400, isFederalContractor: true });
112
+ assert.throws(() => buildRetentionBody({}));
113
+ assert.throws(() => buildRetentionBody({ days: 0 }));
114
+ assert.throws(() => buildRetentionBody({ days: 1.5 }));
115
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velaro/cli",
3
- "version": "1.4.91",
3
+ "version": "1.4.93",
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": {