@velaro/cli 1.4.92 → 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.
- package/lib/commands/agent.js +13 -1
- package/lib/commands/recruiting.js +160 -7
- package/lib/recruiting-apply.js +66 -0
- package/lib/recruiting-apply.test.js +38 -1
- package/package.json +1 -1
package/lib/commands/agent.js
CHANGED
|
@@ -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,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, buildHostedBody, buildJobPayBody, buildRenewBody, jobLifecyclePath, jobPayPath } 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/
|
|
@@ -22,6 +22,9 @@ import { applicationIdPath, buildApplySettingsBody, buildBotSettingsBody, buildH
|
|
|
22
22
|
// GET Recruiting/Applications -> candidateApplication[]
|
|
23
23
|
// GET Recruiting/Applications/AtsFailures; POST Recruiting/Applications/{id}/AtsRetry; PUT Recruiting/ApplySettings { publicApplyEnabled?, autoCreateCaseTypeId?, defaultListingDays? }; GET Recruiting/Applications/{id}/Resume (binary)
|
|
24
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)
|
|
25
28
|
|
|
26
29
|
const BOLD = '\x1b[1m';
|
|
27
30
|
const DIM = '\x1b[2m';
|
|
@@ -470,10 +473,100 @@ async function sourcesAddListingHandler(argv) {
|
|
|
470
473
|
}
|
|
471
474
|
|
|
472
475
|
async function pipelineMoveHandler(argv) {
|
|
473
|
-
const app = await messagingPut(
|
|
476
|
+
const app = await messagingPut(applicationIdPath(argv.applicationId, 'Stage'), buildStageBody(argv.stage, argv.note));
|
|
474
477
|
console.log(`\n${GRN}OK${RST} Application ${app.id} moved to stage "${app.pipelineStage}".\n`);
|
|
475
478
|
}
|
|
476
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
|
+
|
|
477
570
|
const sourcesCommand = {
|
|
478
571
|
command: 'sources <subcommand>',
|
|
479
572
|
describe: 'Manage named job posting sources for a multi-employer job board (EnableJobBoardMultiEmployer)',
|
|
@@ -541,16 +634,74 @@ const pipelineCommand = {
|
|
|
541
634
|
builder: (y) => y
|
|
542
635
|
.command({
|
|
543
636
|
command: 'move <applicationId> <stage>',
|
|
544
|
-
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)',
|
|
545
638
|
builder: (y2) => y2
|
|
546
639
|
.positional('applicationId', { type: 'number' })
|
|
547
|
-
.positional('stage', { type: 'string' })
|
|
640
|
+
.positional('stage', { type: 'string' })
|
|
641
|
+
.option('note', { describe: 'Note recorded in the stage history', type: 'string' }),
|
|
548
642
|
handler: runCommand(pipelineMoveHandler),
|
|
549
643
|
})
|
|
550
|
-
.
|
|
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'),
|
|
654
|
+
handler: () => {},
|
|
655
|
+
};
|
|
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'),
|
|
551
687
|
handler: () => {},
|
|
552
688
|
};
|
|
553
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
|
+
|
|
554
705
|
export const recruitingCommand = {
|
|
555
706
|
command: 'recruiting <subcommand>',
|
|
556
707
|
describe: 'Recruiting Bot: sync job feeds, manage listings, applications, config, and multi-employer job board sources',
|
|
@@ -562,7 +713,9 @@ export const recruitingCommand = {
|
|
|
562
713
|
.command(applicationsCommand)
|
|
563
714
|
.command(sourcesCommand)
|
|
564
715
|
.command(pipelineCommand)
|
|
565
|
-
.
|
|
716
|
+
.command(applicationCommand)
|
|
717
|
+
.command(retentionCommand)
|
|
718
|
+
.demandCommand(1, 'Specify a recruiting subcommand: sync | config | jobs | hosted | applications | application | sources | pipeline | retention')
|
|
566
719
|
.strict(),
|
|
567
720
|
handler: () => {},
|
|
568
721
|
};
|
package/lib/recruiting-apply.js
CHANGED
|
@@ -111,3 +111,69 @@ export function buildBotSettingsBody(o = {}) {
|
|
|
111
111
|
if (!Object.keys(body).length) throw new Error('Specify at least one bot setting.');
|
|
112
112
|
return body;
|
|
113
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, buildHostedBody, buildJobPayBody, buildRenewBody, jobLifecyclePath, jobPayPath } 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');
|
|
@@ -76,3 +76,40 @@ test('hosted body is apply-if-present; no flags means read-only', () => {
|
|
|
76
76
|
assert.deepEqual(buildHostedBody({ enabled: false }), { enabled: false });
|
|
77
77
|
assert.deepEqual(buildHostedBody({ regenerateLink: true }), { regenerateSlug: true });
|
|
78
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