@velaro/cli 1.4.91 → 1.4.92
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/lib/avatar-override.js +56 -0
- package/lib/avatar-override.test.js +43 -0
- package/lib/commands/avatar.js +51 -2
- package/lib/commands/migrate.js +44 -6
- package/lib/commands/recruiting.js +67 -3
- package/lib/messaging-routes.test.js +1 -1
- package/lib/recruiting-apply.js +38 -0
- package/lib/recruiting-apply.test.js +28 -1
- package/package.json +1 -1
|
@@ -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
|
+
});
|
package/lib/commands/avatar.js
CHANGED
|
@@ -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
|
-
.
|
|
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
|
};
|
package/lib/commands/migrate.js
CHANGED
|
@@ -9,7 +9,8 @@ export const migrateCommand = {
|
|
|
9
9
|
.command(migrateExportCommand)
|
|
10
10
|
.command(migrateLivefluenceCommand)
|
|
11
11
|
.command(migrateUrlRulesCommand)
|
|
12
|
-
.
|
|
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
|
|
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(`
|
|
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
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, buildRenewBody, jobLifecyclePath } from '../recruiting-apply.js';
|
|
4
|
+
import { 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,6 +18,7 @@ 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)
|
|
@@ -112,6 +113,42 @@ async function jobsLifecycleHandler() {
|
|
|
112
113
|
console.log(` Default listing days: ${r.defaultListingDays}\n`);
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
async function jobsPayHandler(argv) {
|
|
117
|
+
const r = await messagingPut(jobPayPath(argv.jobId), buildJobPayBody({ min: argv.min, max: argv.max, currency: argv.currency, period: argv.period, clear: argv.clear }));
|
|
118
|
+
console.log(`\n${GRN}OK${RST} Job listing ${r.id} pay: ${r.display || '(not stated)'} (source: ${r.salarySource || 'none'}).\n`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function printHosted(r) {
|
|
122
|
+
console.log(`\n${BOLD}Job Board hosted pages${RST}`);
|
|
123
|
+
if (!r.entitled) {
|
|
124
|
+
console.log(` Package: not included (the hosted pages, sitemap and feeds are off; admin calls return 403)\n`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
console.log(` Package: ${GRN}included${RST}`);
|
|
128
|
+
console.log(` Published: ${r.enabled ? `${GRN}yes${RST}` : 'no (every hosted link answers 404)'}`);
|
|
129
|
+
console.log(` Open listings:${' '}${r.liveListings ?? 0}`);
|
|
130
|
+
if (r.boardUrl) {
|
|
131
|
+
console.log(` Board link: ${r.boardUrl}`);
|
|
132
|
+
console.log(` Sitemap: ${r.sitemapUrl}`);
|
|
133
|
+
console.log(` Indeed feed: ${r.indeedFeedUrl}`);
|
|
134
|
+
console.log(` Google feed: ${r.googleFeedUrl}`);
|
|
135
|
+
console.log(` ${DIM}The feed URLs contain a secret key; treat them like a password.${RST}`);
|
|
136
|
+
}
|
|
137
|
+
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`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function hostedHandler(argv) {
|
|
141
|
+
const body = buildHostedBody({ enabled: argv.enabled, regenerateLink: argv.regenerateLink });
|
|
142
|
+
const r = Object.keys(body).length ? await messagingPut('/Recruiting/Hosted', body) : await messagingGet('/Recruiting/Hosted');
|
|
143
|
+
printHosted(r);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function hostedRotateKeyHandler() {
|
|
147
|
+
const r = await messagingPost('/Recruiting/Hosted/FeedKey/Rotate', {});
|
|
148
|
+
console.log(`\n${GRN}OK${RST} Feed key rotated; the previous Indeed and Google feed URLs no longer work.`);
|
|
149
|
+
printHosted(r);
|
|
150
|
+
}
|
|
151
|
+
|
|
115
152
|
async function applicationsListHandler() {
|
|
116
153
|
const apps = await messagingGet('/Recruiting/Applications');
|
|
117
154
|
if (!Array.isArray(apps) || !apps.length) {
|
|
@@ -260,16 +297,42 @@ const jobsCommand = {
|
|
|
260
297
|
describe: 'Show whether this site has the Job Board lifecycle package and its default listing days',
|
|
261
298
|
handler: runCommand(jobsLifecycleHandler),
|
|
262
299
|
})
|
|
300
|
+
.command({
|
|
301
|
+
command: 'pay <jobId>',
|
|
302
|
+
describe: 'Set or clear the pay range shown on the hosted page and in its structured data. Needs the Job Board hosted pages package',
|
|
303
|
+
builder: (y2) => y2
|
|
304
|
+
.positional('jobId', { describe: 'Job listing ID', type: 'number' })
|
|
305
|
+
.option('min', { describe: 'Minimum (or only) amount', type: 'number' })
|
|
306
|
+
.option('max', { describe: 'Maximum amount (optional)', type: 'number' })
|
|
307
|
+
.option('currency', { describe: '3-letter ISO currency (default USD)', type: 'string' })
|
|
308
|
+
.option('period', { describe: 'Hour | Day | Week | Month | Year', type: 'string' })
|
|
309
|
+
.option('clear', { describe: 'Remove the stated pay', type: 'boolean' }),
|
|
310
|
+
handler: runCommand(jobsPayHandler),
|
|
311
|
+
})
|
|
263
312
|
.command({
|
|
264
313
|
command: 'delete <jobId>',
|
|
265
314
|
describe: 'Delete a synced job listing',
|
|
266
315
|
builder: (y2) => y2.positional('jobId', { describe: 'Job listing ID', type: 'number' }),
|
|
267
316
|
handler: runCommand(jobsDeleteHandler),
|
|
268
317
|
})
|
|
269
|
-
.demandCommand(1, 'Specify a jobs subcommand: list | close | renew | lifecycle | delete'),
|
|
318
|
+
.demandCommand(1, 'Specify a jobs subcommand: list | close | renew | lifecycle | pay | delete'),
|
|
270
319
|
handler: () => {},
|
|
271
320
|
};
|
|
272
321
|
|
|
322
|
+
const hostedCommand = {
|
|
323
|
+
command: 'hosted',
|
|
324
|
+
describe: 'Hosted public job pages: show or change the board settings (needs the Job Board hosted pages package)',
|
|
325
|
+
builder: (y) => y
|
|
326
|
+
.option('enabled', { describe: 'Publish (true) or unpublish (false) the hosted pages, sitemap and feeds', type: 'boolean' })
|
|
327
|
+
.option('regenerate-link', { describe: 'Issue a NEW opaque board link; every old hosted URL answers 404 (administrators)', type: 'boolean' })
|
|
328
|
+
.command({
|
|
329
|
+
command: 'rotate-key',
|
|
330
|
+
describe: 'Rotate the outbound feed key; the old Indeed and Google feed URLs stop working (administrators)',
|
|
331
|
+
handler: runCommand(hostedRotateKeyHandler),
|
|
332
|
+
}),
|
|
333
|
+
handler: runCommand(hostedHandler),
|
|
334
|
+
};
|
|
335
|
+
|
|
273
336
|
const applicationsCommand = {
|
|
274
337
|
command: 'applications <subcommand>',
|
|
275
338
|
describe: 'List candidate applications or export EEOC voluntary self-ID data',
|
|
@@ -495,10 +558,11 @@ export const recruitingCommand = {
|
|
|
495
558
|
.command(syncCommand)
|
|
496
559
|
.command(configCommand)
|
|
497
560
|
.command(jobsCommand)
|
|
561
|
+
.command(hostedCommand)
|
|
498
562
|
.command(applicationsCommand)
|
|
499
563
|
.command(sourcesCommand)
|
|
500
564
|
.command(pipelineCommand)
|
|
501
|
-
.demandCommand(1, 'Specify a recruiting subcommand: sync | config | jobs | applications | sources | pipeline')
|
|
565
|
+
.demandCommand(1, 'Specify a recruiting subcommand: sync | config | jobs | hosted | applications | sources | pipeline')
|
|
502
566
|
.strict(),
|
|
503
567
|
handler: () => {},
|
|
504
568
|
};
|
|
@@ -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 = [];
|
package/lib/recruiting-apply.js
CHANGED
|
@@ -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 {};
|
|
@@ -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 { 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,30 @@ 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
|
+
});
|
package/package.json
CHANGED