@velaro/cli 1.4.102 → 1.4.103
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/README.md +19 -0
- package/bin/velaro.js +12 -0
- package/lib/api.js +2 -1
- package/lib/apple-pay.js +32 -0
- package/lib/apple-pay.test.js +32 -0
- package/lib/avatar-switch.js +42 -0
- package/lib/avatar-switch.test.js +49 -0
- package/lib/build-info.js +28 -0
- package/lib/build-info.test.js +24 -0
- package/lib/commands/apple-pay.js +73 -0
- package/lib/commands/avatar.js +60 -2
- package/lib/commands/billing-automation.js +172 -0
- package/lib/commands/copilotstudio.js +14 -1
- package/lib/commands/enterprise-reports.js +1 -0
- package/lib/commands/entitlement.js +29 -1
- package/lib/commands/focus.js +1 -1
- package/lib/commands/index.js +38 -2
- package/lib/commands/logs-insights.js +342 -0
- package/lib/commands/logs.js +313 -3
- package/lib/commands/magento.js +16 -0
- package/lib/commands/mcp-key.js +23 -1
- package/lib/commands/netsuite.js +67 -2
- package/lib/commands/notifications.js +34 -1
- package/lib/commands/ops.js +23 -54
- package/lib/commands/report-backfill.js +23 -5
- package/lib/commands/report.js +77 -14
- package/lib/commands/salesforce.js +101 -0
- package/lib/commands/shopify.js +84 -0
- package/lib/commands/signature.js +79 -0
- package/lib/commands/site-builder.js +30 -0
- package/lib/commands/support.js +22 -0
- package/lib/commands/teams.js +11 -1
- package/lib/commands/workflow.js +33 -0
- package/lib/commands/zoom-phone.js +15 -0
- package/lib/enterprise-reports.test.js +1 -1
- package/lib/entitlement-report-packages.test.js +47 -0
- package/lib/logs-insights.test.js +146 -0
- package/lib/mcp-key.test.js +11 -0
- package/lib/messaging-routes.test.js +10 -1
- package/lib/oauth.js +14 -2
- package/lib/report-range.js +21 -0
- package/lib/report-range.test.js +38 -0
- package/lib/shopify-write-policy.test.js +34 -0
- package/lib/signature-policy.js +70 -0
- package/lib/signature-policy.test.js +48 -0
- package/package.json +1 -1
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { messagingGet } from '../api.js';
|
|
2
|
+
import { runCommand } from '../run.js';
|
|
3
|
+
|
|
4
|
+
// velaro site-builder connect-info
|
|
5
|
+
// Backend: velaro-messaging SiteBuilderConnectController (PR #1874)
|
|
6
|
+
// GET /integrations/site-builder/connect-info -> signed-in caller's own site id + chat snippet
|
|
7
|
+
// The same data is available from the Velaro MCP tool site_connect_info.
|
|
8
|
+
|
|
9
|
+
async function connectInfoHandler(argv) {
|
|
10
|
+
const info = await messagingGet('/integrations/site-builder/connect-info');
|
|
11
|
+
if (argv.json) { console.log(JSON.stringify(info, null, 2)); return; }
|
|
12
|
+
for (const [k, v] of Object.entries(info ?? {})) {
|
|
13
|
+
console.log(`${k}: ${typeof v === 'object' ? JSON.stringify(v) : v}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const siteBuilderCommand = {
|
|
18
|
+
command: 'site-builder <subcommand>',
|
|
19
|
+
describe: 'Website builder connection (site ID and chat snippet)',
|
|
20
|
+
builder: (y) => y
|
|
21
|
+
.command({
|
|
22
|
+
command: 'connect-info',
|
|
23
|
+
describe: 'Show your site ID and chat snippet for pasting into a site builder',
|
|
24
|
+
builder: (y2) => y2.option('json', { describe: 'Print raw JSON', type: 'boolean' }),
|
|
25
|
+
handler: runCommand(connectInfoHandler),
|
|
26
|
+
})
|
|
27
|
+
.demandCommand(1, 'Specify a subcommand: connect-info')
|
|
28
|
+
.strict(),
|
|
29
|
+
handler: () => {},
|
|
30
|
+
};
|
package/lib/commands/support.js
CHANGED
|
@@ -106,6 +106,23 @@ async function agentLookup(args) {
|
|
|
106
106
|
console.log(JSON.stringify(result, null, 2));
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
async function siteAuthMode(args) {
|
|
110
|
+
// Wraps the existing staff endpoints DatabaseTool/AuthMode/{siteId} (GET/PUT). No new backend.
|
|
111
|
+
const site = Number(args.site);
|
|
112
|
+
if (!Number.isInteger(site) || site <= 0) { console.error('site must be a positive integer.'); process.exit(1); }
|
|
113
|
+
try {
|
|
114
|
+
if (args.mode) {
|
|
115
|
+
const allowLocalLogin = args.allowLocalLogin === undefined ? true : (args.allowLocalLogin === true || args.allowLocalLogin === 'true');
|
|
116
|
+
await request('PUT', `DatabaseTool/AuthMode/${site}`, { authMode: args.mode, allowLocalLogin });
|
|
117
|
+
}
|
|
118
|
+
const r = await request('GET', `DatabaseTool/AuthMode/${site}`);
|
|
119
|
+
console.log(JSON.stringify({ siteId: r.siteId, authMode: r.authMode, allowLocalLogin: r.allowLocalLogin, hasConfig: r.hasConfig }));
|
|
120
|
+
} catch (e) {
|
|
121
|
+
console.error(`site-auth-mode failed: ${e.message}`);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
109
126
|
async function addSiteUser(args) {
|
|
110
127
|
// Password via --password or VELARO_NEW_USER_PASSWORD env (preferred; keeps it out of shell history). Never printed.
|
|
111
128
|
const password = args.password || process.env.VELARO_NEW_USER_PASSWORD;
|
|
@@ -206,6 +223,11 @@ export function supportCommand(yargs) {
|
|
|
206
223
|
.option('firstName', { type: 'string' })
|
|
207
224
|
.option('lastName', { type: 'string' })
|
|
208
225
|
, a => addSiteUser(a))
|
|
226
|
+
.command('site-auth-mode', 'Show or set a site messaging-app auth mode (V10|Dual|EntraId) and AllowLocalLogin (staff only)', y => y
|
|
227
|
+
.option('site', { type: 'number', demandOption: true, desc: 'Target site ID' })
|
|
228
|
+
.option('mode', { type: 'string', choices: ['V10', 'Dual', 'EntraId'], desc: 'Set this auth mode (omit to just show)' })
|
|
229
|
+
.option('allow-local-login', { type: 'string', desc: 'true|false (default true when setting)' })
|
|
230
|
+
, a => siteAuthMode({ ...a, allowLocalLogin: a['allow-local-login'] }))
|
|
209
231
|
.command('toggle-flag', 'Toggle a single legacy Subscription.cs feature flag on a site (superadmin only, allowlisted flags)', y => y
|
|
210
232
|
.option('site', { type: 'number', demandOption: true, desc: 'Site ID' })
|
|
211
233
|
.option('flag', { type: 'string', demandOption: true, desc: 'Flag name, e.g. EnableScraperBotIndexing (must be on SupportToolsController.AllowedFeatureFlags)' })
|
package/lib/commands/teams.js
CHANGED
|
@@ -191,13 +191,23 @@ export const teamsCommand = {
|
|
|
191
191
|
.option('enabled', { type: 'boolean', describe: 'Turn Teams Channel on/off' })
|
|
192
192
|
.option('welcome-message', { type: 'string', describe: 'Sent when a Teams user opens a new conversation. Use {{user.name}} for their name.' })
|
|
193
193
|
.option('team-id', { type: 'number', describe: 'Route new conversations to this team id' })
|
|
194
|
-
.option('clear-team-id', { type: 'boolean', describe: 'Clear the team override and use the site default team' })
|
|
194
|
+
.option('clear-team-id', { type: 'boolean', describe: 'Clear the team override and use the site default team' })
|
|
195
|
+
.option('prechat', { type: 'boolean', describe: 'Ask prechat questions before a conversation opens' })
|
|
196
|
+
.option('prechat-fields', { type: 'string', describe: 'JSON array of {key,label,required}; replaces the prechat questions (max 6)' })
|
|
197
|
+
.option('quick-reply', { type: 'array', string: true, describe: 'Quick-reply button label (repeatable, max 6); pass --quick-reply "" to clear' })
|
|
198
|
+
.option('post-chat', { type: 'boolean', describe: 'Send the post-chat message when an agent closes the conversation' })
|
|
199
|
+
.option('post-chat-message', { type: 'string', describe: 'Post-chat text ({{user.name}} = first name); empty string clears it' }),
|
|
195
200
|
async (argv) => {
|
|
196
201
|
const body = {};
|
|
197
202
|
if (argv.enabled !== undefined) body.enabled = argv.enabled;
|
|
198
203
|
if (argv.welcomeMessage !== undefined) body.welcomeMessage = argv.welcomeMessage;
|
|
199
204
|
if (argv.teamId !== undefined) body.teamId = argv.teamId;
|
|
200
205
|
if (argv.clearTeamId) body.clearTeamId = true;
|
|
206
|
+
if (argv.prechat !== undefined) body.prechatEnabled = argv.prechat;
|
|
207
|
+
if (argv.prechatFields !== undefined) body.prechatFields = JSON.parse(argv.prechatFields);
|
|
208
|
+
if (argv.quickReply !== undefined) body.quickReplies = argv.quickReply.filter((r) => r !== '');
|
|
209
|
+
if (argv.postChat !== undefined) body.postChatEnabled = argv.postChat;
|
|
210
|
+
if (argv.postChatMessage !== undefined) body.postChatMessage = argv.postChatMessage;
|
|
201
211
|
const result = Object.keys(body).length
|
|
202
212
|
? await request('PUT', '/TeamsBot/ChannelConfig', body)
|
|
203
213
|
: await request('GET', '/TeamsBot/ChannelConfig');
|
package/lib/commands/workflow.js
CHANGED
|
@@ -14,6 +14,7 @@ export const workflowCommand = {
|
|
|
14
14
|
.command(workflowCreateCommand)
|
|
15
15
|
.command(workflowEnableCommand)
|
|
16
16
|
.command(workflowDisableCommand)
|
|
17
|
+
.command(workflowRunCommand)
|
|
17
18
|
.command(workflowNodeOptionsCommand)
|
|
18
19
|
.command(workflowIntegrityScanCommand)
|
|
19
20
|
.command(workflowAiFollowupsCommand)
|
|
@@ -117,6 +118,38 @@ const workflowCreateCommand = {
|
|
|
117
118
|
|
|
118
119
|
// ── enable / disable ──────────────────────────────────────────────────────────
|
|
119
120
|
|
|
121
|
+
// ── run ────────────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
const workflowRunCommand = {
|
|
124
|
+
command: 'run <id>',
|
|
125
|
+
describe: 'Queue a published workflow now, optionally seeding it with a JSON context file',
|
|
126
|
+
builder: (y) => y
|
|
127
|
+
.positional('id', { type: 'number', describe: 'Workflow ID' })
|
|
128
|
+
.option('context', { type: 'string', describe: 'Path to a JSON file with a shallow object of context values (max 50 keys, 4KB)' }),
|
|
129
|
+
handler: runCommand(async (argv) => {
|
|
130
|
+
await requireFeature('enableAutomation', 'Workflows');
|
|
131
|
+
|
|
132
|
+
let body = {};
|
|
133
|
+
if (argv.context) {
|
|
134
|
+
let parsed;
|
|
135
|
+
try {
|
|
136
|
+
parsed = JSON.parse(fs.readFileSync(argv.context, 'utf8'));
|
|
137
|
+
} catch (e) {
|
|
138
|
+
console.error(`Could not read ${argv.context} as JSON: ${e.message}`);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
142
|
+
console.error('Context file must contain a JSON object, like {"customerId":"123"}.');
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
body = { contextOverride: parsed };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const result = await messagingPost(`/Workflows/${argv.id}/run-now`, body);
|
|
149
|
+
console.log(`Workflow ${argv.id} queued${result?.jobId ? ` (job #${result.jobId})` : ''}.`);
|
|
150
|
+
}),
|
|
151
|
+
};
|
|
152
|
+
|
|
120
153
|
const workflowEnableCommand = {
|
|
121
154
|
command: 'enable <id>',
|
|
122
155
|
describe: 'Enable a workflow',
|
|
@@ -48,11 +48,26 @@ async function setHandler(argv) {
|
|
|
48
48
|
await statusHandler();
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
async function numbersHandler() {
|
|
52
|
+
const r = await messagingGet('/ZoomPhoneCti/numbers');
|
|
53
|
+
const list = r?.phoneNumbers || [];
|
|
54
|
+
if (!list.length) {
|
|
55
|
+
console.log(`\n${YEL}Zoom returned no assigned phone numbers for this account.${RST}\n`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
console.log(`\n${BOLD}Zoom Phone numbers (assigned)${RST}`);
|
|
59
|
+
for (const n of list) {
|
|
60
|
+
console.log(` ${n.number || '(no number)'} ${DIM}${n.displayName || ''} status=${n.status || '?'} assignee=${n.assigneeType || '?'} ext=${n.assigneeExtension || '-'}${RST}`);
|
|
61
|
+
}
|
|
62
|
+
console.log('');
|
|
63
|
+
}
|
|
64
|
+
|
|
51
65
|
export const zoomPhoneCommand = {
|
|
52
66
|
command: 'zoom-phone <subcommand>',
|
|
53
67
|
describe: 'Zoom Phone screen-pop configuration (account_id + webhook secret)',
|
|
54
68
|
builder: y => y
|
|
55
69
|
.command({ command: 'status', describe: 'Show the Zoom Phone configuration for this site', builder: yy => yy, handler: statusHandler })
|
|
70
|
+
.command({ command: 'numbers', describe: 'List the Zoom Phone numbers on the connected account (which number fires call events)', builder: yy => yy, handler: numbersHandler })
|
|
56
71
|
.command({
|
|
57
72
|
command: 'set',
|
|
58
73
|
describe: 'Save the Zoom account_id and/or webhook secret (apply-if-present)',
|
|
@@ -5,7 +5,7 @@ import { enterpriseReportsCommand } from './commands/enterprise-reports.js';
|
|
|
5
5
|
import { yearInReviewBody, lastTwelveWholeMonthsKeys } from './year-in-review-range.js';
|
|
6
6
|
|
|
7
7
|
test('describe lists the five novel reports and the promise dismiss action', () => {
|
|
8
|
-
for (const r of ['repeat-contact', 'demand-coverage', 'bot-containment', 'effort-index', 'promise-ledger', 'promise-dismiss']) {
|
|
8
|
+
for (const r of ['repeat-contact', 'demand-coverage', 'bot-containment', 'effort-index', 'promise-ledger', 'promise-dismiss', 'new-agent-ramp']) {
|
|
9
9
|
assert.ok(enterpriseReportsCommand.describe.includes(r), `${r} missing from describe`);
|
|
10
10
|
}
|
|
11
11
|
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// `velaro entitlement report-packages` must stay wired to GET Entitlements/report-packages and render the five category packages.
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import { formatReportPackages } from './commands/entitlement.js';
|
|
6
|
+
|
|
7
|
+
const sample = (siteId, enabled) => ({
|
|
8
|
+
siteId,
|
|
9
|
+
defaultOff: true,
|
|
10
|
+
plan: 'plan.enterprise-internal',
|
|
11
|
+
top: { slug: 'enterprise-reports', name: 'Enterprise Reports', tagline: 'Includes all five category packages.', enabled, reportCount: 2 },
|
|
12
|
+
categories: [
|
|
13
|
+
{
|
|
14
|
+
slug: 'reports-conversations-channels', name: 'Conversations & Channels', category: 'Conversations & Channels', tagline: 'Know what is coming in.', audience: 'Teams',
|
|
15
|
+
enabled, enabledCount: enabled ? 2 : 0, reportCount: 2,
|
|
16
|
+
reports: [
|
|
17
|
+
{ id: 'conversations', name: 'Conversations', packageSlug: 'report-conversations', packageKey: 'EnableReportConversations', enabled },
|
|
18
|
+
{ id: 'missed', name: 'Missed Chats', packageSlug: 'report-missed', packageKey: 'EnableReportMissed', enabled },
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('without a site the output lists the package and every report with no on/off marks', () => {
|
|
25
|
+
const out = formatReportPackages(sample(null, false));
|
|
26
|
+
assert.match(out, /Enterprise Reports \(enterprise-reports\) - plan plan\.enterprise-internal, default OFF/);
|
|
27
|
+
assert.match(out, /Conversations & Channels \(reports-conversations-channels\) 2 reports/);
|
|
28
|
+
assert.match(out, /Missed Chats \(missed\) - package report-missed, key EnableReportMissed/);
|
|
29
|
+
assert.doesNotMatch(out, /\[ON \]|\[off\]/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('with a site each report and category shows its on/off state', () => {
|
|
33
|
+
const on = formatReportPackages(sample(1032, true));
|
|
34
|
+
assert.match(on, /site 1032 \[ALL ON\]/);
|
|
35
|
+
assert.match(on, /\[ON \] 2\/2 reports/);
|
|
36
|
+
assert.match(on, /\[ON \] Conversations/);
|
|
37
|
+
const off = formatReportPackages(sample(40001, false));
|
|
38
|
+
assert.match(off, /\[off\] 0\/2 reports/);
|
|
39
|
+
assert.match(off, /\[off\] Missed Chats/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('the command calls the admin route and is registered in the usage line', () => {
|
|
43
|
+
const src = fs.readFileSync(new URL('./commands/entitlement.js', import.meta.url), 'utf8');
|
|
44
|
+
assert.ok(src.includes("`Entitlements/report-packages${q}`"));
|
|
45
|
+
assert.ok(src.includes(".command('report-packages'"));
|
|
46
|
+
assert.ok(src.includes('enterprise-reports | report-packages'));
|
|
47
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
parseWindowMs, parseWhen, qs, commonParams, windowParams, parseExpiry, ptTime, trendText,
|
|
5
|
+
sparkline, clean, formatGroups, formatRegression, formatTrace, formatAnomalies, formatSites,
|
|
6
|
+
formatMutes, formatSensitive, logsInsightsCommand, INSIGHTS_BASE, INSIGHTS_META,
|
|
7
|
+
scoped, anomalyLevel, CALL_TIMEOUT_MS, BRIEF_TIMEOUT_MS, MAX_WINDOW_MS,
|
|
8
|
+
} from './commands/logs-insights.js';
|
|
9
|
+
|
|
10
|
+
const NOW = new Date('2026-10-12T12:00:00Z');
|
|
11
|
+
const strip = s => s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
12
|
+
|
|
13
|
+
test('parseWindowMs handles m/h/d, falls back only when missing, throws on junk', () => {
|
|
14
|
+
assert.equal(parseWindowMs('30m'), 30 * 60_000);
|
|
15
|
+
assert.equal(parseWindowMs('6h'), 6 * 3_600_000);
|
|
16
|
+
assert.equal(parseWindowMs('2d'), 2 * 86_400_000);
|
|
17
|
+
assert.equal(parseWindowMs(undefined, 7), 7);
|
|
18
|
+
assert.equal(parseWindowMs('', 7), 7);
|
|
19
|
+
assert.throws(() => parseWindowMs('6hrs'), /Invalid duration/);
|
|
20
|
+
assert.throws(() => parseWindowMs('soon', 5), /Invalid duration/);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('parseWhen: relative means "ago", ISO passes through, junk is null', () => {
|
|
24
|
+
assert.equal(parseWhen('2h', NOW).toISOString(), '2026-10-12T10:00:00.000Z');
|
|
25
|
+
assert.equal(parseWhen('2026-10-12T09:00:00Z', NOW).toISOString(), '2026-10-12T09:00:00.000Z');
|
|
26
|
+
assert.equal(parseWhen('banana', NOW), null);
|
|
27
|
+
assert.equal(parseWhen('', NOW), null);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('parseExpiry: never/undefined -> undefined, duration is from now, junk throws', () => {
|
|
31
|
+
assert.equal(parseExpiry('never', NOW), undefined);
|
|
32
|
+
assert.equal(parseExpiry(undefined, NOW), undefined);
|
|
33
|
+
assert.equal(parseExpiry('7d', NOW), '2026-10-19T12:00:00.000Z');
|
|
34
|
+
assert.equal(parseExpiry('2026-12-01T00:00:00Z', NOW), '2026-12-01T00:00:00.000Z');
|
|
35
|
+
assert.throws(() => parseExpiry('someday', NOW), /--expires/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('qs skips empties and encodes', () => {
|
|
39
|
+
assert.equal(qs({ a: 'x y', b: '', c: undefined, d: 0, e: null }), '?a=x+y&d=0');
|
|
40
|
+
assert.equal(qs({}), '');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('commonParams uppercases level and maps --site to siteId', () => {
|
|
44
|
+
assert.deepEqual(commonParams({ level: 'error', platform: 'v20', environment: 'Staging', site: 5 }),
|
|
45
|
+
{ level: 'ERROR', platform: 'v20', environment: 'Staging', siteId: 5 });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('windowParams spans --last ending now', () => {
|
|
49
|
+
const w = windowParams({ last: '6h' }, '24h', NOW);
|
|
50
|
+
assert.equal(w.from, '2026-10-12T06:00:00.000Z');
|
|
51
|
+
assert.equal(w.to, '2026-10-12T12:00:00.000Z');
|
|
52
|
+
assert.equal(windowParams({}, '24h', NOW).from, '2026-10-11T12:00:00.000Z');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('routes target the messaging insights base', () => {
|
|
56
|
+
assert.equal(INSIGHTS_BASE, 'superadmin/logs/insights');
|
|
57
|
+
assert.equal(INSIGHTS_META, 'superadmin/logs/meta/insights');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('trendText uses words, handles null', () => {
|
|
61
|
+
assert.equal(trendText(25), 'up 25%');
|
|
62
|
+
assert.equal(trendText(-12.34), 'down 12.3%');
|
|
63
|
+
assert.equal(trendText(0.3), 'flat');
|
|
64
|
+
assert.equal(trendText(null), 'n/a');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('sparkline scales and tolerates empty / zeros', () => {
|
|
68
|
+
assert.equal(sparkline([]), '');
|
|
69
|
+
assert.equal(sparkline([0, 0]).length, 2);
|
|
70
|
+
const s = sparkline([0, 10]);
|
|
71
|
+
assert.equal(s[0], '▁');
|
|
72
|
+
assert.equal(s[1], '█');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('clean strips control characters (terminal injection)', () => {
|
|
76
|
+
assert.equal(clean('a\x1b[31mb\x07c'), 'a[31mbc');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('ptTime renders Pacific', () => {
|
|
80
|
+
assert.match(ptTime('2026-10-12T20:00:00Z'), /1:00.*PT$/);
|
|
81
|
+
assert.equal(ptTime(null), '-');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('formatGroups shows servedFrom, truncation, new/muted flags and never emits raw escapes from messages', () => {
|
|
85
|
+
const out = strip(formatGroups({
|
|
86
|
+
servedFrom: 'Staging', truncated: true, scanned: 100,
|
|
87
|
+
groups: [{ fingerprint: 'fp', sampleMessage: 'boom \x1b[2J gone', logger: 'A.B', count: 3, sparkline: [1, 2, 3], isNew: true, muted: false, sitesCount: 2, firstSeen: '2026-10-12T10:00:00Z', lastSeen: '2026-10-12T11:00:00Z' }],
|
|
88
|
+
}));
|
|
89
|
+
assert.match(out, /Showing Staging logs/);
|
|
90
|
+
assert.match(out, /Results capped after scanning 100/);
|
|
91
|
+
assert.match(out, /\[new\]/);
|
|
92
|
+
assert.doesNotMatch(out, /\x1b/);
|
|
93
|
+
assert.equal(strip(formatGroups({ groups: [] })), 'No error groups.');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('other formatters render and survive empty responses', () => {
|
|
97
|
+
assert.match(strip(formatRegression({ before: { total: 5, errors: 1 }, after: { total: 9, errors: 4 }, newGroups: [], increased: [{ before: 1, after: 5, ratio: 5, sampleMessage: 'x' }, { before: 0, after: 2, ratio: null, sampleMessage: 'y' }], gone: [] })), /from zero/);
|
|
98
|
+
assert.match(strip(formatTrace({ events: [{ deltaMs: 12, lane: 'v20', level: 'ERROR', message: 'bad' }], platformsSeen: ['v20'], durationMs: 12, errorCount: 1 })), /\+ {4}12ms v20 +ERROR bad/);
|
|
99
|
+
assert.match(strip(formatAnomalies({ buckets: [{ hourUtc: '2026-10-12T20:00:00Z', count: 9, z: 7, isAnomaly: true, lowConfidence: true }], anomalies: [{}] })), /ANOMALY z=7.0.*low confidence/);
|
|
100
|
+
assert.equal(formatAnomalies({ buckets: [] }), 'No hourly data.');
|
|
101
|
+
assert.match(strip(formatSites({ sites: [{ siteId: 9, total: 10, errors: 5, errorRate: 0.5, trendPct: 20, topGroups: [{ count: 2, sampleMessage: 'm' }] }], unattributed: 4 })), /50\.0%.*up 20%[\s\S]*4 events had no site id/);
|
|
102
|
+
assert.equal(formatSites({ sites: [] }), 'No site-attributed events.');
|
|
103
|
+
assert.match(strip(formatMutes({ mutes: [{ fingerprint: 'f', reason: 'r', expiresAt: null }] })), /never expires/);
|
|
104
|
+
assert.equal(formatMutes({ mutes: [] }), 'No active mutes.');
|
|
105
|
+
assert.match(strip(formatSensitive({ findings: [{ type: 'email', count: 2, sampleRedacted: '[REDACTED:email]', logger: 'L' }], truncated: true })), /email[\s\S]*Results capped/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('command tree exposes every insights subcommand', () => {
|
|
109
|
+
const seen = [];
|
|
110
|
+
const fake = { command(c) { seen.push(c.command.split(' ')[0]); return fake; }, demandCommand() { return fake; } };
|
|
111
|
+
logsInsightsCommand.builder(fake);
|
|
112
|
+
assert.deepEqual(seen, ['groups', 'new', 'regression', 'trace', 'anomalies', 'sites', 'mutes', 'mute', 'unmute', 'sensitive', 'brief']);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('scoped drops the filters an endpoint does not accept', () => {
|
|
116
|
+
const argv = { level: 'warn', platform: 'v10', environment: 'Staging', site: 9 };
|
|
117
|
+
assert.deepEqual(scoped(argv), { level: 'WARN', platform: 'v10', environment: 'Staging', siteId: 9 });
|
|
118
|
+
assert.equal(scoped(argv, { site: false }).siteId, undefined);
|
|
119
|
+
const r = scoped(argv, { site: false, level: false });
|
|
120
|
+
assert.equal('siteId' in r, false);
|
|
121
|
+
assert.equal('level' in r, false);
|
|
122
|
+
assert.equal(r.environment, 'Staging');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('anomalyLevel allows only ERROR or WARN', () => {
|
|
126
|
+
assert.equal(anomalyLevel(undefined), 'ERROR');
|
|
127
|
+
assert.equal(anomalyLevel('warn'), 'WARN');
|
|
128
|
+
assert.throws(() => anomalyLevel('info'), /ERROR or WARN/);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('timeouts and window cap constants', () => {
|
|
132
|
+
assert.equal(CALL_TIMEOUT_MS, 90_000);
|
|
133
|
+
assert.equal(BRIEF_TIMEOUT_MS, 120_000);
|
|
134
|
+
assert.equal(MAX_WINDOW_MS, 24 * 3_600_000);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('help text no longer advertises a 7d window for from/to commands', () => {
|
|
138
|
+
const opts = {};
|
|
139
|
+
const y = { option(k, o) { opts[k] = o; return y; }, positional() { return y; }, command() { return y; }, demandCommand() { return y; } };
|
|
140
|
+
const tree = [];
|
|
141
|
+
logsInsightsCommand.builder({ command(c) { tree.push(c); return this; }, demandCommand() { return this; } });
|
|
142
|
+
const groups = tree.find(c => c.command === 'groups');
|
|
143
|
+
groups.builder(y);
|
|
144
|
+
assert.doesNotMatch(opts.last.describe, /7d/);
|
|
145
|
+
assert.match(opts.last.describe, /24h/);
|
|
146
|
+
});
|
package/lib/mcp-key.test.js
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { normalizeMcpKeysResponse } from './commands/mcp-key.js';
|
|
4
|
+
import { describeExchangeFailure } from './oauth.js';
|
|
5
|
+
|
|
6
|
+
test('CLI login surfaces the plan-gate message instead of a raw 403 body', () => {
|
|
7
|
+
const body = JSON.stringify({ error: 'cli_not_entitled', message: 'Professional plan and above.' });
|
|
8
|
+
assert.equal(describeExchangeFailure(403, body), 'Professional plan and above.');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('CLI login keeps the generic message for other failures', () => {
|
|
12
|
+
assert.match(describeExchangeFailure(500, 'boom'), /token exchange failed \(500\): boom/);
|
|
13
|
+
assert.match(describeExchangeFailure(403, 'not json'), /token exchange failed \(403\)/);
|
|
14
|
+
});
|
|
4
15
|
|
|
5
16
|
test('MCP key list reads the current API response shape', () => {
|
|
6
17
|
const keys = [{ id: 1, label: 'Staff', keyPrefix: 'vel_live_example' }];
|
|
@@ -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', '/avatar/groups', '/avatar/workflows', '/avatar/group-overrides', '/Recruiting'];
|
|
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', '/Recruiting', '/superadmin/logs/insights', '/ApplePayConfig'];
|
|
13
13
|
|
|
14
14
|
test('messaging-only routes are never called through the admin api helpers', () => {
|
|
15
15
|
const offenders = [];
|
|
@@ -26,3 +26,12 @@ test('messaging-only routes are never called through the admin api helpers', ()
|
|
|
26
26
|
}
|
|
27
27
|
assert.deepEqual(offenders, [], `messaging-only routes called via the admin api:\n${offenders.join('\n')}`);
|
|
28
28
|
});
|
|
29
|
+
|
|
30
|
+
// The logger-insights routes (superadmin/logs/insights/*, meta/insights) exist only on the messaging
|
|
31
|
+
// api. logs-insights.js must reach them through messagingRequest and never the admin helpers.
|
|
32
|
+
test('logs-insights.js uses the messaging helper only', () => {
|
|
33
|
+
const src = fs.readFileSync(path.join(dir, 'logs-insights.js'), 'utf8');
|
|
34
|
+
assert.match(src, /messagingRequest/);
|
|
35
|
+
assert.doesNotMatch(src, /import\s*\{[^}]*\b(get|post|del)\b[^}]*\}\s*from '\.\.\/api\.js'/);
|
|
36
|
+
assert.match(src, /superadmin\/logs\/insights/);
|
|
37
|
+
});
|
package/lib/oauth.js
CHANGED
|
@@ -100,16 +100,28 @@ export async function pollForToken(deviceCode, intervalSeconds) {
|
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/** Turns a failed token-exchange response into a user-facing message (plan gate gets its own wording). */
|
|
104
|
+
export function describeExchangeFailure(status, body) {
|
|
105
|
+
if (status === 403) {
|
|
106
|
+
try {
|
|
107
|
+
const j = JSON.parse(body);
|
|
108
|
+
if (j?.error === 'cli_not_entitled') return j.message || 'The Velaro CLI is included with the Professional plan and above.';
|
|
109
|
+
} catch { /* not JSON -- fall through to the generic message */ }
|
|
110
|
+
}
|
|
111
|
+
return `Velaro token exchange failed (${status}): ${body}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
103
114
|
export async function exchangeForVelaroToken(entraAccessToken, apiBase) {
|
|
104
115
|
const res = await fetch(`${apiBase}/auth/entra/token`, {
|
|
105
116
|
method: 'POST',
|
|
106
|
-
|
|
117
|
+
// X-Velaro-Client lets the server enforce the plan gate: the CLI is Professional+ only.
|
|
118
|
+
headers: { Authorization: `Bearer ${entraAccessToken}`, 'X-Velaro-Client': 'cli' },
|
|
107
119
|
signal: AbortSignal.timeout(FETCH_TIMEOUT),
|
|
108
120
|
});
|
|
109
121
|
|
|
110
122
|
if (!res.ok) {
|
|
111
123
|
const body = await res.text().catch(() => res.status.toString());
|
|
112
|
-
throw new Error(
|
|
124
|
+
throw new Error(describeExchangeFailure(res.status, body));
|
|
113
125
|
}
|
|
114
126
|
|
|
115
127
|
return res.json(); // { token: { token, expires }, profile: { siteId, userName, ... } } -- camelCase, matches ASP.NET Core's default JSON casing
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The server treats every report End as EXCLUSIVE (velaro-messaging PR #1457): rows with
|
|
2
|
+
// timestamp < End. A date-only end (YYYY-MM-DD) means "through the end of that day" to a human,
|
|
3
|
+
// so send the start of the NEXT day; otherwise the whole last day is silently dropped. A full ISO
|
|
4
|
+
// datetime is an exact instant and is sent unchanged. `parsed` is the already-validated Date.
|
|
5
|
+
const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
6
|
+
|
|
7
|
+
export function toExclusiveEnd(raw, parsed) {
|
|
8
|
+
return DATE_ONLY_RE.test(String(raw).trim())
|
|
9
|
+
? new Date(parsed.getTime() + 24 * 60 * 60 * 1000)
|
|
10
|
+
: parsed;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Value to send for a report `end` query/body param. A plain YYYY-MM-DD becomes the start of the
|
|
14
|
+
// next day (UTC ISO instant) so the whole last day is included; anything else (full datetime,
|
|
15
|
+
// missing, unparseable) is returned unchanged so the server keeps its own validation.
|
|
16
|
+
export function exclusiveEndParam(raw) {
|
|
17
|
+
if (raw == null || raw === '') return raw;
|
|
18
|
+
const parsed = new Date(raw);
|
|
19
|
+
if (Number.isNaN(parsed.getTime())) return raw;
|
|
20
|
+
return DATE_ONLY_RE.test(String(raw).trim()) ? toExclusiveEnd(raw, parsed).toISOString() : raw;
|
|
21
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { toExclusiveEnd, exclusiveEndParam } from './report-range.js';
|
|
4
|
+
|
|
5
|
+
test('exclusiveEndParam: date-only -> next-day ISO instant', () => {
|
|
6
|
+
assert.equal(exclusiveEndParam('2026-09-25'), '2026-09-26T00:00:00.000Z');
|
|
7
|
+
});
|
|
8
|
+
test('exclusiveEndParam: datetime, empty and invalid pass through unchanged', () => {
|
|
9
|
+
assert.equal(exclusiveEndParam('2026-09-25T17:30:00Z'), '2026-09-25T17:30:00Z');
|
|
10
|
+
assert.equal(exclusiveEndParam(undefined), undefined);
|
|
11
|
+
assert.equal(exclusiveEndParam('garbage'), 'garbage');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('date-only end includes the whole last day (start of next day, exclusive)', () => {
|
|
15
|
+
const end = toExclusiveEnd('2026-09-25', new Date('2026-09-25'));
|
|
16
|
+
assert.equal(end.toISOString(), '2026-09-26T00:00:00.000Z');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// Call-site wiring guard: CLI report commands must route end through the helper, and
|
|
20
|
+
// servicelevel/conversations must use the startDate/endDate names + route the server binds
|
|
21
|
+
// (they were silently ignored before).
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
const reportSrc = readFileSync(new URL('./commands/report.js', import.meta.url), 'utf8');
|
|
24
|
+
|
|
25
|
+
test('report.js never sends a raw argv.end for callcenter/servicelevel/conversations', () => {
|
|
26
|
+
assert.equal(reportSrc.match(/`end(Date)?=\$\{encodeURIComponent\(argv\.end\)\}`/g), null);
|
|
27
|
+
assert.ok((reportSrc.match(/exclusiveEndParam\(argv\.end\)/g) || []).length >= 4);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('servicelevel and conversations use the server-bound startDate/endDate and route', () => {
|
|
31
|
+
assert.match(reportSrc, /`endDate=\$\{encodeURIComponent\(exclusiveEndParam\(argv\.end\)\)\}`/);
|
|
32
|
+
assert.match(reportSrc, /fetchReport\(`\/ConversationsReport\$\{qs\}`\)/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('full ISO datetime end is passed through unchanged', () => {
|
|
36
|
+
const parsed = new Date('2026-09-25T17:30:00Z');
|
|
37
|
+
assert.equal(toExclusiveEnd('2026-09-25T17:30:00Z', parsed).toISOString(), '2026-09-25T17:30:00.000Z');
|
|
38
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { buildWritePolicyBody, parseCap } from './commands/shopify.js';
|
|
7
|
+
|
|
8
|
+
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
9
|
+
|
|
10
|
+
test('parseCap handles numbers, default and invalid', () => {
|
|
11
|
+
assert.equal(parseCap(undefined, 'x'), undefined);
|
|
12
|
+
assert.equal(parseCap('default', 'x'), null);
|
|
13
|
+
assert.equal(parseCap('12.5', 'x'), 12.5);
|
|
14
|
+
assert.throws(() => parseCap('abc', 'x'));
|
|
15
|
+
assert.throws(() => parseCap('-1', 'x'));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('buildWritePolicyBody is partial and maps default to null', () => {
|
|
19
|
+
assert.deepEqual(buildWritePolicyBody({ enableShopifyWrite: false }), { enableShopifyWrite: false });
|
|
20
|
+
assert.deepEqual(buildWritePolicyBody({ maxRefundAmount: '100', maxDiscountPercent: 'default' }), { maxRefundAmount: 100, maxDiscountPercent: null });
|
|
21
|
+
assert.throws(() => buildWritePolicyBody({}));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('CLI and MCP expose the write-policy surface and it is registered', () => {
|
|
25
|
+
const cli = fs.readFileSync(path.join(root, 'cli/bin/velaro.js'), 'utf8');
|
|
26
|
+
assert.match(cli, /\.command\(shopifyCommand\)/);
|
|
27
|
+
const mcp = fs.readFileSync(path.join(root, 'mcp/server.js'), 'utf8');
|
|
28
|
+
for (const t of ['shopify_get_write_policy', 'shopify_set_write_policy']) {
|
|
29
|
+
assert.match(mcp, new RegExp(`name: '${t}'`));
|
|
30
|
+
assert.match(mcp, new RegExp(`case '${t}'`));
|
|
31
|
+
}
|
|
32
|
+
assert.match(mcp, /messagingPut\('\/Shopify\/WritePolicy'/);
|
|
33
|
+
assert.match(mcp, /messagingGet\('\/Shopify\/WritePolicy'/);
|
|
34
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Pure helpers for `velaro signature policy` (and mirrored in mcp/server.js signature_policy_set).
|
|
2
|
+
// The messaging endpoint PUT Signatures/policy REPLACES the row for a team, and silently turns an unknown mode
|
|
3
|
+
// into "follow the company-wide value", so we validate here and merge onto the current row before sending.
|
|
4
|
+
|
|
5
|
+
const MODES = {
|
|
6
|
+
'locked': 'admin_locked',
|
|
7
|
+
'admin-locked': 'admin_locked',
|
|
8
|
+
'admin_locked': 'admin_locked',
|
|
9
|
+
'company-default': 'company_default',
|
|
10
|
+
'company_default': 'company_default',
|
|
11
|
+
'personal-only': 'personal_only',
|
|
12
|
+
'personal_only': 'personal_only',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Returns undefined when not supplied, null for "follow the company-wide setting", else the API value.
|
|
16
|
+
export function parseMode(value) {
|
|
17
|
+
if (value === undefined) return undefined;
|
|
18
|
+
const v = String(value).trim().toLowerCase();
|
|
19
|
+
if (v === 'default' || v === 'inherit' || v === '') return null;
|
|
20
|
+
const mode = MODES[v];
|
|
21
|
+
if (!mode) throw new Error('mode must be locked, company-default, personal-only or default');
|
|
22
|
+
return mode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function parseTeamId(value) {
|
|
26
|
+
if (value === undefined || value === null || value === '') return 0;
|
|
27
|
+
const n = Number(value);
|
|
28
|
+
if (!Number.isInteger(n) || n < 0) throw new Error('teamId must be 0 (company-wide) or a team id');
|
|
29
|
+
return n;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// current: the matching row from GET Signatures/policy (or undefined). opts: only the fields being changed.
|
|
33
|
+
// A field left out keeps its current value; null clears it back to "follow the company-wide setting".
|
|
34
|
+
export function buildPolicyBody(current, opts = {}) {
|
|
35
|
+
const cur = current ?? {};
|
|
36
|
+
const body = {
|
|
37
|
+
teamId: parseTeamId(opts.teamId ?? cur.teamId),
|
|
38
|
+
mode: cur.mode ?? null,
|
|
39
|
+
corporateSignature: cur.corporateSignature || null,
|
|
40
|
+
disclaimerEnabled: cur.disclaimerEnabled ?? null,
|
|
41
|
+
disclaimerText: cur.disclaimerText || null,
|
|
42
|
+
};
|
|
43
|
+
const mode = parseMode(opts.mode);
|
|
44
|
+
if (mode !== undefined) body.mode = mode;
|
|
45
|
+
if (opts.corporateSignature !== undefined) body.corporateSignature = opts.corporateSignature || null;
|
|
46
|
+
if (opts.disclaimerEnabled !== undefined) {
|
|
47
|
+
if (opts.disclaimerEnabled !== null && typeof opts.disclaimerEnabled !== 'boolean') {
|
|
48
|
+
throw new Error('disclaimerEnabled must be true, false or null');
|
|
49
|
+
}
|
|
50
|
+
body.disclaimerEnabled = opts.disclaimerEnabled;
|
|
51
|
+
}
|
|
52
|
+
if (opts.disclaimerText !== undefined) body.disclaimerText = opts.disclaimerText || null;
|
|
53
|
+
if (body.corporateSignature && body.corporateSignature.length > 4000) throw new Error('corporateSignature is limited to 4000 characters');
|
|
54
|
+
if (body.disclaimerText && body.disclaimerText.length > 2000) throw new Error('disclaimerText is limited to 2000 characters');
|
|
55
|
+
return body;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function findPolicy(policies, teamId) {
|
|
59
|
+
const id = parseTeamId(teamId);
|
|
60
|
+
return (Array.isArray(policies) ? policies : []).find((p) => p.teamId === id);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function describeMode(mode) {
|
|
64
|
+
switch (mode) {
|
|
65
|
+
case 'admin_locked': return 'locked (company signature only)';
|
|
66
|
+
case 'company_default': return 'company default (personal signatures allowed)';
|
|
67
|
+
case 'personal_only': return 'personal only';
|
|
68
|
+
default: return 'follows company-wide setting (personal only if none set)';
|
|
69
|
+
}
|
|
70
|
+
}
|