@velaro/cli 1.4.69 → 1.4.71
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/velaro.js +4 -0
- package/lib/commands/agent.js +43 -1
- package/lib/commands/ai-models.js +45 -1
- package/lib/commands/article.js +47 -1
- package/lib/commands/booking.js +63 -10
- package/lib/commands/contact-list.js +46 -0
- package/lib/commands/experience-impact.js +6 -0
- package/lib/commands/klaviyo-migration.js +84 -0
- package/lib/commands/report-explain.js +29 -0
- package/lib/commands/report.js +52 -0
- package/lib/commands/support.js +57 -1
- package/lib/report-explain.json +232 -0
- package/lib/siteUser.js +35 -0
- package/lib/siteUser.test.js +45 -0
- package/package.json +1 -1
package/bin/velaro.js
CHANGED
|
@@ -41,6 +41,7 @@ import { bookingCommand } from '../lib/commands/booking.js';
|
|
|
41
41
|
import { reportCommand } from '../lib/commands/report.js';
|
|
42
42
|
import { scheduleAdherenceCommand } from '../lib/commands/schedule-adherence.js';
|
|
43
43
|
import { experienceImpactCommand } from '../lib/commands/experience-impact.js';
|
|
44
|
+
import { reportExplainCommand } from '../lib/commands/report-explain.js';
|
|
44
45
|
import { conversationEfficiencyCommand } from '../lib/commands/conversation-efficiency.js';
|
|
45
46
|
import { dbCommand } from '../lib/commands/db.js';
|
|
46
47
|
import { focusCommand } from '../lib/commands/focus.js';
|
|
@@ -51,6 +52,7 @@ import { notionCommand } from '../lib/commands/notion.js';
|
|
|
51
52
|
import { callrailCommand } from '../lib/commands/callrail.js';
|
|
52
53
|
import { emailCampaignCommand } from '../lib/commands/email-campaign.js';
|
|
53
54
|
import { outboundActivityCommand } from '../lib/commands/outbound-activity.js';
|
|
55
|
+
import { klaviyoMigrationCommand } from '../lib/commands/klaviyo-migration.js';
|
|
54
56
|
import { telephonyCommand } from '../lib/commands/telephony.js';
|
|
55
57
|
import { teamsPhoneCommand } from '../lib/commands/teams-phone.js';
|
|
56
58
|
import { teamsCommand } from '../lib/commands/teams.js';
|
|
@@ -143,6 +145,7 @@ await yargs(hideBin(process.argv))
|
|
|
143
145
|
.command(conversationEfficiencyCommand)
|
|
144
146
|
.command(scheduleAdherenceCommand)
|
|
145
147
|
.command(experienceImpactCommand)
|
|
148
|
+
.command(reportExplainCommand)
|
|
146
149
|
.command(dbCommand)
|
|
147
150
|
.command(focusCommand)
|
|
148
151
|
.command(automationDraftCommand)
|
|
@@ -152,6 +155,7 @@ await yargs(hideBin(process.argv))
|
|
|
152
155
|
.command(callrailCommand)
|
|
153
156
|
.command(emailCampaignCommand)
|
|
154
157
|
.command(outboundActivityCommand)
|
|
158
|
+
.command(klaviyoMigrationCommand)
|
|
155
159
|
.command(telephonyCommand)
|
|
156
160
|
.command(teamsPhoneCommand)
|
|
157
161
|
.command(teamsCommand)
|
package/lib/commands/agent.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { get, post } from '../api.js';
|
|
1
|
+
import { get, post, messagingPost, getCredentials } from '../api.js';
|
|
2
2
|
import { runCommand } from '../run.js';
|
|
3
3
|
|
|
4
4
|
const VALID_STATUSES = ['Available', 'Away', 'Offline'];
|
|
@@ -55,6 +55,48 @@ export const agentCommand = {
|
|
|
55
55
|
console.log(`\n${rows.length} agent(s)`);
|
|
56
56
|
}),
|
|
57
57
|
})
|
|
58
|
+
.command({
|
|
59
|
+
command: 'create',
|
|
60
|
+
describe: 'Provision a new licensed agent/admin on an EXISTING site (Velaro staff only) -- ' +
|
|
61
|
+
'closes the gap where site-provision only creates brand-new sites and Users/List ' +
|
|
62
|
+
'requires an existing site admin, which a zero-agent site can never have',
|
|
63
|
+
builder: (y) => y
|
|
64
|
+
.option('site-id', { type: 'number', demandOption: true, describe: 'Target site ID (must already exist)' })
|
|
65
|
+
.option('email', { type: 'string', demandOption: true, describe: 'New agent email' })
|
|
66
|
+
.option('first-name', { type: 'string', describe: 'First name' })
|
|
67
|
+
.option('last-name', { type: 'string', describe: 'Last name' })
|
|
68
|
+
.option('roles', { type: 'array', describe: 'Roles to grant, e.g. --roles Administrator (default: Administrator). "Velaro Admin"/"Velaro Support" are always stripped server-side.' })
|
|
69
|
+
.option('team-ids', { type: 'array', describe: 'Team IDs on the target site (default: the site\'s first team)' })
|
|
70
|
+
.option('max-chats', { type: 'number', describe: 'Concurrent chat capacity (default: site ceiling, usually 4)' }),
|
|
71
|
+
handler: runCommand(async (argv) => {
|
|
72
|
+
const creds = await getCredentials();
|
|
73
|
+
if (creds.siteId !== 1032) {
|
|
74
|
+
console.error('This command is only available to Velaro staff.');
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const body = {
|
|
79
|
+
email: argv.email,
|
|
80
|
+
firstName: argv['first-name'],
|
|
81
|
+
lastName: argv['last-name'],
|
|
82
|
+
roles: argv.roles,
|
|
83
|
+
teamIds: argv['team-ids']?.map(Number),
|
|
84
|
+
maxChats: argv['max-chats'] ?? 0,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
console.log(`\nCreating agent ${argv.email} on site ${argv['site-id']}...`);
|
|
88
|
+
// Non-2xx (existing email, seat limit reached, site not found) throws via
|
|
89
|
+
// messagingRequest and is reported by runCommand's own catch -- no separate
|
|
90
|
+
// success/error branch needed here, unlike site-provision's payload-level flag.
|
|
91
|
+
const result = await messagingPost(`/SupportTools/sites/${argv['site-id']}/agents`, body);
|
|
92
|
+
|
|
93
|
+
console.log(`\x1b[32mOK: agent created on site ${argv['site-id']}\x1b[0m`);
|
|
94
|
+
console.log(` Email: ${result.user?.email ?? argv.email}`);
|
|
95
|
+
console.log(` Roles: ${(result.user?.roles ?? []).join(', ')}`);
|
|
96
|
+
if (result.temporaryPassword) console.log(` Temp password: ${result.temporaryPassword}`);
|
|
97
|
+
console.log(` ${result.message ?? ''}`);
|
|
98
|
+
}),
|
|
99
|
+
})
|
|
58
100
|
.command({
|
|
59
101
|
command: 'set-status',
|
|
60
102
|
describe: 'Set an agent\'s availability status (admin only)',
|
|
@@ -114,6 +114,29 @@ async function clearTierDefault(argv) {
|
|
|
114
114
|
console.log(`\n${GREEN}OK${RESET} Cleared tier '${argv.tier}' role '${argv.role}'. Reverts to global default.\n`);
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
// -- per-site thinking + version pin -------------------------------------------
|
|
118
|
+
|
|
119
|
+
async function setSiteThinking(argv) {
|
|
120
|
+
const data = await callResolver(argv, `site/${argv.site}/thinking`, {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
body: { effort: argv.effort, ...(argv.role ? { role: argv.role } : {}) },
|
|
123
|
+
});
|
|
124
|
+
console.log(`
|
|
125
|
+
${GREEN}OK${RESET} Site ${data.siteId}: ${data.key} = ${data.effort}`);
|
|
126
|
+
console.log(`${DIM}Thinking bills as output tokens; only active if the site holds EnableAiThinking.${RESET}
|
|
127
|
+
`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function setSitePin(argv) {
|
|
131
|
+
const data = await callResolver(argv, `site/${argv.site}`, {
|
|
132
|
+
method: 'POST',
|
|
133
|
+
body: { role: argv.role, model: argv.model, pinVersion: argv.pin === true, updatedBy: 'velaro-cli' },
|
|
134
|
+
});
|
|
135
|
+
console.log(`
|
|
136
|
+
${GREEN}OK${RESET} Site ${data.siteId} role '${data.role}' -> ${data.model} (pinVersion=${data.pinVersion})
|
|
137
|
+
`);
|
|
138
|
+
}
|
|
139
|
+
|
|
117
140
|
// -- command export -------------------------------------------------------------
|
|
118
141
|
|
|
119
142
|
export const aiModelsCommand = {
|
|
@@ -155,5 +178,26 @@ export const aiModelsCommand = {
|
|
|
155
178
|
.option('env', { describe: 'staging or prod', type: 'string' }),
|
|
156
179
|
handler: clearTierDefault,
|
|
157
180
|
})
|
|
158
|
-
.
|
|
181
|
+
.command({
|
|
182
|
+
command: 'set-thinking',
|
|
183
|
+
describe: 'Set the extended-thinking level for a site (off default; needs EnableAiThinking entitlement)',
|
|
184
|
+
builder: y => y
|
|
185
|
+
.option('site', { describe: 'Site ID', type: 'number', demandOption: true })
|
|
186
|
+
.option('effort', { describe: 'off, low, medium, or high', type: 'string', choices: ['off', 'low', 'medium', 'high'], demandOption: true })
|
|
187
|
+
.option('role', { describe: 'Scope to one role (omit for site-wide)', type: 'string' })
|
|
188
|
+
.option('env', { describe: 'staging or prod', type: 'string' }),
|
|
189
|
+
handler: setSiteThinking,
|
|
190
|
+
})
|
|
191
|
+
.command({
|
|
192
|
+
command: 'set-site-model',
|
|
193
|
+
describe: 'Set a site role override, optionally pinned to its exact version (--pin), e.g. velaro ai-models set-site-model --site 1032 --role chatbot --model claude-opus-5 --pin',
|
|
194
|
+
builder: y => y
|
|
195
|
+
.option('site', { describe: 'Site ID', type: 'number', demandOption: true })
|
|
196
|
+
.option('role', { describe: 'e.g. chatbot, ivr, skill_synthesis', type: 'string', demandOption: true })
|
|
197
|
+
.option('model', { describe: 'Model alias/ID', type: 'string', demandOption: true })
|
|
198
|
+
.option('pin', { describe: 'Keep this exact model, do not auto-upgrade', type: 'boolean', default: false })
|
|
199
|
+
.option('env', { describe: 'staging or prod', type: 'string' }),
|
|
200
|
+
handler: setSitePin,
|
|
201
|
+
})
|
|
202
|
+
.demandCommand(1, 'Specify a subcommand: resolver, effective, set-tier, clear-tier, set-thinking, or set-site-model'),
|
|
159
203
|
};
|
package/lib/commands/article.js
CHANGED
|
@@ -198,7 +198,7 @@ const pushCommand = {
|
|
|
198
198
|
|
|
199
199
|
const deleteCommand = {
|
|
200
200
|
command: 'delete <id>',
|
|
201
|
-
describe: 'Delete a KB article',
|
|
201
|
+
describe: 'Delete a KB article (restorable for 30 days)',
|
|
202
202
|
builder: (y) => y.positional('id', { type: 'number', describe: 'Article ID' }),
|
|
203
203
|
handler: runCommand(async (argv) => {
|
|
204
204
|
await adminRequest('DELETE', `/api/kb/articles?id=${argv.id}`);
|
|
@@ -206,6 +206,48 @@ const deleteCommand = {
|
|
|
206
206
|
}),
|
|
207
207
|
};
|
|
208
208
|
|
|
209
|
+
// ── recycle bin (soft delete) ────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
const deletedCommand = {
|
|
212
|
+
command: 'deleted',
|
|
213
|
+
describe: 'List recently deleted KB articles (restorable for 30 days)',
|
|
214
|
+
handler: runCommand(async () => {
|
|
215
|
+
const rows = await adminRequest('GET', '/api/kb/articles/deleted');
|
|
216
|
+
(rows || []).forEach(r => console.log(`#${r.id} ${r.name} (purges in ${r.daysUntilPurge} day(s))`));
|
|
217
|
+
if (!rows || !rows.length) console.log('Nothing in Recently deleted.');
|
|
218
|
+
}),
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const restoreCommand = {
|
|
222
|
+
command: 'restore <id>',
|
|
223
|
+
describe: 'Restore a deleted KB article (re-indexes it)',
|
|
224
|
+
builder: (y) => y.positional('id', { type: 'number', describe: 'Article ID' }),
|
|
225
|
+
handler: runCommand(async (argv) => {
|
|
226
|
+
await adminRequest('POST', `/api/kb/articles/${argv.id}/restore`);
|
|
227
|
+
console.log(`Article ${argv.id} restored.`);
|
|
228
|
+
}),
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const deletedTopicsCommand = {
|
|
232
|
+
command: 'deleted-topics',
|
|
233
|
+
describe: 'List recently deleted KB topics',
|
|
234
|
+
handler: runCommand(async () => {
|
|
235
|
+
const rows = await adminRequest('GET', '/api/kb/topics/deleted');
|
|
236
|
+
(rows || []).forEach(r => console.log(`#${r.id} ${r.name} (purges in ${r.daysUntilPurge} day(s))`));
|
|
237
|
+
if (!rows || !rows.length) console.log('Nothing in Recently deleted.');
|
|
238
|
+
}),
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const restoreTopicCommand = {
|
|
242
|
+
command: 'restore-topic <id>',
|
|
243
|
+
describe: 'Restore a deleted KB topic',
|
|
244
|
+
builder: (y) => y.positional('id', { type: 'number', describe: 'Topic ID' }),
|
|
245
|
+
handler: runCommand(async (argv) => {
|
|
246
|
+
await adminRequest('POST', `/api/kb/topics/${argv.id}/restore`);
|
|
247
|
+
console.log(`Topic ${argv.id} restored.`);
|
|
248
|
+
}),
|
|
249
|
+
};
|
|
250
|
+
|
|
209
251
|
// ── seed-views ────────────────────────────────────────────────────────────────
|
|
210
252
|
|
|
211
253
|
const seedViewsCommand = {
|
|
@@ -461,6 +503,10 @@ export const articleCommand = {
|
|
|
461
503
|
.command(pushCommand)
|
|
462
504
|
.command(previewCommand)
|
|
463
505
|
.command(deleteCommand)
|
|
506
|
+
.command(deletedCommand)
|
|
507
|
+
.command(restoreCommand)
|
|
508
|
+
.command(deletedTopicsCommand)
|
|
509
|
+
.command(restoreTopicCommand)
|
|
464
510
|
.command(seedViewsCommand)
|
|
465
511
|
.command(improveCommand)
|
|
466
512
|
.command(screenshotCommand)
|
package/lib/commands/booking.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { messagingGet, messagingPost, messagingPut, messagingDel } from '../api.js';
|
|
2
2
|
import { runCommand } from '../run.js';
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
// CLI surface for the generic booking engine (Phase 1b
|
|
6
|
-
//
|
|
4
|
+
// -- velaro booking --------------------------------------------------------------
|
|
5
|
+
// CLI surface for the generic booking engine (Phase 1b config CRUD + Phase 1c
|
|
6
|
+
// booking write path) -- see velaro-admin memory
|
|
7
|
+
// project_2026_09_06_generic_booking_engine_schema_plan.md. Backend:
|
|
7
8
|
// server/Velaro.Messaging/Controllers/{BookableResourcesController,
|
|
8
9
|
// BookableServicesController,ResourceAvailabilityRulesController}.cs, routed
|
|
9
|
-
// at /BookableResources, /BookableServices, /ResourceAvailabilityRules
|
|
10
|
+
// at /BookableResources, /BookableServices, /ResourceAvailabilityRules
|
|
11
|
+
// (Phase 1b, config); server/Velaro.Messaging/Controllers/Bookings/BookingsController.cs,
|
|
12
|
+
// routed at /Bookings (Phase 1c, staff-facing list/get/cancel of actual bookings).
|
|
10
13
|
// Mirrors the booking_* MCP tools. SiteId always derives from the caller's
|
|
11
14
|
// JWT server-side -- never accepted as a CLI argument.
|
|
12
15
|
|
|
@@ -17,9 +20,9 @@ function fmtTime(t) { return t == null ? '-' : String(t); }
|
|
|
17
20
|
|
|
18
21
|
export const bookingCommand = {
|
|
19
22
|
command: 'booking <subcommand>',
|
|
20
|
-
describe: 'Generic booking engine -- bookable resources, services,
|
|
23
|
+
describe: 'Generic booking engine -- bookable resources, services, availability rules, and bookings',
|
|
21
24
|
builder: yargs => yargs
|
|
22
|
-
//
|
|
25
|
+
// -- resource --------------------------------------------------------------
|
|
23
26
|
.command({
|
|
24
27
|
command: 'resource <action>',
|
|
25
28
|
describe: 'Manage bookable resources (staff / room / equipment / virtual)',
|
|
@@ -93,7 +96,7 @@ export const bookingCommand = {
|
|
|
93
96
|
})
|
|
94
97
|
.demandCommand(1, 'Specify a resource action: list | get | create | update | delete'),
|
|
95
98
|
})
|
|
96
|
-
//
|
|
99
|
+
// -- service -----------------------------------------------------------------
|
|
97
100
|
.command({
|
|
98
101
|
command: 'service <action>',
|
|
99
102
|
describe: 'Manage bookable services (the "thing" a customer books)',
|
|
@@ -179,7 +182,7 @@ export const bookingCommand = {
|
|
|
179
182
|
})
|
|
180
183
|
.demandCommand(1, 'Specify a service action: list | get | create | update | delete'),
|
|
181
184
|
})
|
|
182
|
-
//
|
|
185
|
+
// -- availability --------------------------------------------------------------
|
|
183
186
|
.command({
|
|
184
187
|
command: 'availability <action>',
|
|
185
188
|
describe: 'Manage weekly-recurring resource availability rules',
|
|
@@ -275,5 +278,55 @@ export const bookingCommand = {
|
|
|
275
278
|
})
|
|
276
279
|
.demandCommand(1, 'Specify an availability action: list | get | create | update | delete'),
|
|
277
280
|
})
|
|
278
|
-
|
|
279
|
-
|
|
281
|
+
// -- appointment (Phase 1c: real bookings, staff-facing) ------------------------
|
|
282
|
+
.command({
|
|
283
|
+
command: 'appointment <action>',
|
|
284
|
+
describe: 'View and cancel actual bookings made against the booking engine (staff-facing)',
|
|
285
|
+
builder: y => y
|
|
286
|
+
.command({
|
|
287
|
+
command: 'list',
|
|
288
|
+
describe: 'List bookings for this site, optionally filtered by date range or service',
|
|
289
|
+
builder: y2 => y2
|
|
290
|
+
.option('from', { type: 'string', describe: 'Only bookings starting at or after this ISO 8601 timestamp' })
|
|
291
|
+
.option('to', { type: 'string', describe: 'Only bookings starting at or before this ISO 8601 timestamp' })
|
|
292
|
+
.option('service-id', { type: 'number', describe: 'Only bookings for this bookable service id' }),
|
|
293
|
+
handler: runCommand(async argv => {
|
|
294
|
+
const params = new URLSearchParams();
|
|
295
|
+
if (argv.from != null) params.set('fromUtc', argv.from);
|
|
296
|
+
if (argv.to != null) params.set('toUtc', argv.to);
|
|
297
|
+
if (argv.serviceId != null) params.set('bookableServiceId', String(argv.serviceId));
|
|
298
|
+
const qs = params.toString();
|
|
299
|
+
const bookings = await messagingGet(`/Bookings${qs ? `?${qs}` : ''}`);
|
|
300
|
+
if (!Array.isArray(bookings) || !bookings.length) {
|
|
301
|
+
console.log('\n No bookings found.\n');
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
console.log('\nBookings\n');
|
|
305
|
+
for (const b of bookings) {
|
|
306
|
+
console.log(` id:${b.id} service:${b.bookableServiceId} resource:${b.resourceId ?? '-'} ${fmtDate(b.startUtc)}-${fmtDate(b.endUtc)} party:${b.partySize} status:${b.status} customer:${b.customerName} (${b.customerPhone})`);
|
|
307
|
+
}
|
|
308
|
+
console.log('');
|
|
309
|
+
}),
|
|
310
|
+
})
|
|
311
|
+
.command({
|
|
312
|
+
command: 'get <id>',
|
|
313
|
+
describe: 'Get a single booking by id',
|
|
314
|
+
builder: y2 => y2.positional('id', { type: 'number', describe: 'Booking id' }),
|
|
315
|
+
handler: runCommand(async argv => {
|
|
316
|
+
const booking = await messagingGet(`/Bookings/${argv.id}`);
|
|
317
|
+
console.log(JSON.stringify(booking, null, 2));
|
|
318
|
+
}),
|
|
319
|
+
})
|
|
320
|
+
.command({
|
|
321
|
+
command: 'cancel <id>',
|
|
322
|
+
describe: 'Cancel a booking (staff-initiated)',
|
|
323
|
+
builder: y2 => y2.positional('id', { type: 'number', describe: 'Booking id' }),
|
|
324
|
+
handler: runCommand(async argv => {
|
|
325
|
+
const result = await messagingPost(`/Bookings/${argv.id}/cancel`, {});
|
|
326
|
+
console.log(`\n✓ Cancelled booking id:${result.bookingId} (status: ${result.status})\n`);
|
|
327
|
+
}),
|
|
328
|
+
})
|
|
329
|
+
.demandCommand(1, 'Specify an appointment action: list | get | cancel'),
|
|
330
|
+
})
|
|
331
|
+
.demandCommand(1, 'Specify a subcommand: resource | service | availability | appointment'),
|
|
332
|
+
};
|
|
@@ -277,6 +277,52 @@ export const contactListCommand = {
|
|
|
277
277
|
describe: 'Marketing contact lists: CRUD, members, export, resync, quota, segments, CRM import',
|
|
278
278
|
builder: (y) => y
|
|
279
279
|
.command({ command: 'list', describe: 'List all contact lists for this site', handler: runCommand(listsHandler) })
|
|
280
|
+
.command({
|
|
281
|
+
command: 'deleted',
|
|
282
|
+
describe: 'List recently deleted contact lists (restorable for 30 days)',
|
|
283
|
+
handler: runCommand(async () => {
|
|
284
|
+
const rows = await request('GET', 'ContactLists/deleted');
|
|
285
|
+
(rows || []).forEach(r => console.log(`#${r.id} ${r.name} (purges in ${r.daysUntilPurge} day(s))`));
|
|
286
|
+
if (!rows || !rows.length) console.log('Nothing in Recently deleted.');
|
|
287
|
+
}),
|
|
288
|
+
})
|
|
289
|
+
.command({
|
|
290
|
+
command: 'restore <id>',
|
|
291
|
+
describe: 'Restore a deleted contact list',
|
|
292
|
+
builder: (y2) => y2.positional('id', { type: 'number' }),
|
|
293
|
+
handler: runCommand(async (argv) => { await request('POST', `ContactLists/${argv.id}/restore`); console.log(`List #${argv.id} restored.`); }),
|
|
294
|
+
})
|
|
295
|
+
.command({
|
|
296
|
+
command: 'deleted-members <listId>',
|
|
297
|
+
describe: 'List members recently removed from a list',
|
|
298
|
+
builder: (y2) => y2.positional('listId', { type: 'number' }),
|
|
299
|
+
handler: runCommand(async (argv) => {
|
|
300
|
+
const rows = await request('GET', `ContactLists/${argv.listId}/Members/deleted`);
|
|
301
|
+
(rows || []).forEach(r => console.log(`#${r.id} ${r.name} (purges in ${r.daysUntilPurge} day(s))`));
|
|
302
|
+
if (!rows || !rows.length) console.log('Nothing in Recently deleted.');
|
|
303
|
+
}),
|
|
304
|
+
})
|
|
305
|
+
.command({
|
|
306
|
+
command: 'restore-member <listId> <memberId>',
|
|
307
|
+
describe: 'Restore a member removed from a list',
|
|
308
|
+
builder: (y2) => y2.positional('listId', { type: 'number' }).positional('memberId', { type: 'number' }),
|
|
309
|
+
handler: runCommand(async (argv) => { await request('POST', `ContactLists/${argv.listId}/Members/${argv.memberId}/restore`); console.log(`Member #${argv.memberId} restored.`); }),
|
|
310
|
+
})
|
|
311
|
+
.command({
|
|
312
|
+
command: 'deleted-segments',
|
|
313
|
+
describe: 'List recently deleted contact segments',
|
|
314
|
+
handler: runCommand(async () => {
|
|
315
|
+
const rows = await request('GET', 'ContactSegments/deleted');
|
|
316
|
+
(rows || []).forEach(r => console.log(`#${r.id} ${r.name} (purges in ${r.daysUntilPurge} day(s))`));
|
|
317
|
+
if (!rows || !rows.length) console.log('Nothing in Recently deleted.');
|
|
318
|
+
}),
|
|
319
|
+
})
|
|
320
|
+
.command({
|
|
321
|
+
command: 'restore-segment <id>',
|
|
322
|
+
describe: 'Restore a deleted contact segment',
|
|
323
|
+
builder: (y2) => y2.positional('id', { type: 'number' }),
|
|
324
|
+
handler: runCommand(async (argv) => { await request('POST', `ContactSegments/${argv.id}/restore`); console.log(`Segment #${argv.id} restored.`); }),
|
|
325
|
+
})
|
|
280
326
|
.command({
|
|
281
327
|
command: 'create',
|
|
282
328
|
describe: 'Create a new contact list',
|
|
@@ -36,6 +36,12 @@ export const experienceImpactCommand = {
|
|
|
36
36
|
console.log(` ${label}: ${x.chats} chats | avg wait ${x.avgWaitSec}s | missed ${x.missed} | CSAT ${x.csatResponses ? x.csatAvg : '-'} (${x.csatResponses ?? 0})`);
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
+
// Additive fields from the rebuilt report (absent on older backends).
|
|
40
|
+
if (d.coverage) {
|
|
41
|
+
const c = d.coverage;
|
|
42
|
+
console.log(` CSAT coverage: ${(c.sources ?? []).map(x => `${x.label} ${x.responses} (avg ${x.avg ?? '-'})`).join(' | ')} | survey submissions ${c.surveySubmissions ?? 0}, rated ${c.surveyRated ?? 0}, response rate ${c.responseRatePct ?? 0}% | CSAT survey configured: ${c.csatSurveyConfigured ? 'yes' : 'no'}`);
|
|
43
|
+
}
|
|
44
|
+
for (const x of d.reasons ?? []) console.log(` Why ${x.kpi} is low/empty [${x.code}]: ${x.message}${x.action ? ' Action: ' + x.action : ''}`);
|
|
39
45
|
for (const i of d.insights ?? []) console.log(` [${i.severity}] ${i.text}`);
|
|
40
46
|
for (const a of d.agents ?? []) {
|
|
41
47
|
console.log(` [${a.agentId}] ${a.name}: index ${a.experienceIndex} adherence ${a.adherencePct}% CSAT ${a.csatAvg ?? '-'} handled ${a.handled}`);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* velaro klaviyo-migration - Klaviyo -> Velaro importer + savings calculator
|
|
3
|
+
*
|
|
4
|
+
* velaro klaviyo-migration preview Dry run: what would be imported (writes nothing)
|
|
5
|
+
* velaro klaviyo-migration apply Import lists, segments, flows (idempotent)
|
|
6
|
+
* velaro klaviyo-migration savings --profiles N --bill USD [--plan plan.professional]
|
|
7
|
+
*
|
|
8
|
+
* Uses the API key stored by the Klaviyo integration. Site scoping is server-side.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { request } from '../api.js';
|
|
12
|
+
|
|
13
|
+
const BOLD = '\x1b[1m';
|
|
14
|
+
const YEL = '\x1b[33m';
|
|
15
|
+
const RED = '\x1b[31m';
|
|
16
|
+
const DIM = '\x1b[2m';
|
|
17
|
+
const RST = '\x1b[0m';
|
|
18
|
+
|
|
19
|
+
function body(argv) {
|
|
20
|
+
const split = v => (v ? String(v).split(',').map(s => s.trim()).filter(Boolean) : undefined);
|
|
21
|
+
return {
|
|
22
|
+
listIds: split(argv.lists),
|
|
23
|
+
segmentIds: split(argv.segments),
|
|
24
|
+
flowIds: split(argv.flows),
|
|
25
|
+
includeFlows: argv['no-flows'] ? false : true,
|
|
26
|
+
maxProfilesPerGroup: argv.max,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function render(r) {
|
|
31
|
+
console.log(`\n${BOLD}${r.dryRun ? 'Klaviyo import preview (nothing written)' : 'Klaviyo import applied'}${RST}\n`);
|
|
32
|
+
for (const e of r.errors ?? []) console.log(`${RED} ! ${e}${RST}`);
|
|
33
|
+
for (const i of r.items ?? []) {
|
|
34
|
+
const n = i.kind === 'flow' ? `${i.stepCount} steps` : `${i.profileCount ?? 0} contacts`;
|
|
35
|
+
console.log(` ${i.kind.padEnd(8)} ${i.name} ${DIM}${i.action}, ${n}, skipped not-subscribed ${i.skippedNotSubscribed}${i.truncated ? ', capped' : ''}${RST}`);
|
|
36
|
+
for (const w of i.warnings ?? []) console.log(`${YEL} ${w}${RST}`);
|
|
37
|
+
}
|
|
38
|
+
if (!r.dryRun) {
|
|
39
|
+
console.log(`\n Created: ${r.listsCreated} lists, ${r.segmentsCreated} segments, ${r.flowsCreated} flows; ${r.contactsUpserted} contacts upserted.`);
|
|
40
|
+
}
|
|
41
|
+
console.log('');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const sharedOptions = yy => yy
|
|
45
|
+
.option('lists', { describe: 'Comma-separated Klaviyo list ids (default all)', type: 'string' })
|
|
46
|
+
.option('segments', { describe: 'Comma-separated Klaviyo segment ids (default all)', type: 'string' })
|
|
47
|
+
.option('flows', { describe: 'Comma-separated Klaviyo flow ids (default all)', type: 'string' })
|
|
48
|
+
.option('no-flows', { describe: 'Skip flows', type: 'boolean' })
|
|
49
|
+
.option('max', { describe: 'Max profiles per list/segment', type: 'number', default: 5000 });
|
|
50
|
+
|
|
51
|
+
export const klaviyoMigrationCommand = {
|
|
52
|
+
command: 'klaviyo-migration <subcommand>',
|
|
53
|
+
describe: 'Import lists, segments and flows from Klaviyo; compare cost',
|
|
54
|
+
builder: y => y
|
|
55
|
+
.command({
|
|
56
|
+
command: 'preview',
|
|
57
|
+
describe: 'Dry run, writes nothing',
|
|
58
|
+
builder: sharedOptions,
|
|
59
|
+
handler: async argv => render(await request('POST', '/KlaviyoMigration/Preview', body(argv))),
|
|
60
|
+
})
|
|
61
|
+
.command({
|
|
62
|
+
command: 'apply',
|
|
63
|
+
describe: 'Run the import (idempotent)',
|
|
64
|
+
builder: sharedOptions,
|
|
65
|
+
handler: async argv => render(await request('POST', '/KlaviyoMigration/Apply', body(argv))),
|
|
66
|
+
})
|
|
67
|
+
.command({
|
|
68
|
+
command: 'savings',
|
|
69
|
+
describe: 'Compare your Klaviyo bill to a Velaro plan catalog price',
|
|
70
|
+
builder: yy => yy
|
|
71
|
+
.option('profiles', { describe: 'Klaviyo profile count', type: 'number', demandOption: true })
|
|
72
|
+
.option('bill', { describe: 'Klaviyo monthly bill in USD', type: 'number', demandOption: true })
|
|
73
|
+
.option('plan', { describe: 'Velaro plan key (default plan.professional)', type: 'string' }),
|
|
74
|
+
handler: async argv => {
|
|
75
|
+
const qs = new URLSearchParams({ profiles: String(argv.profiles), klaviyoMonthlyBill: String(argv.bill) });
|
|
76
|
+
if (argv.plan) qs.set('planKey', argv.plan);
|
|
77
|
+
const r = (await request('GET', `/KlaviyoMigration/SavingsCalculator?${qs}`)).result;
|
|
78
|
+
console.log(`\nVelaro ${r.planName}: $${r.velaroMonthly}/mo vs Klaviyo $${r.klaviyoMonthly}/mo`);
|
|
79
|
+
console.log(`Difference: $${r.monthlySavings}/mo, $${r.annualSavings}/yr\n${DIM}${r.note}${RST}\n`);
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
.demandCommand(1, 'Specify a subcommand. Try: velaro klaviyo-migration --help'),
|
|
83
|
+
handler: () => {},
|
|
84
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { runCommand } from '../run.js';
|
|
5
|
+
|
|
6
|
+
// -- velaro report-explain [report] ---------------------------------------------
|
|
7
|
+
// Static how-to-interpret guidance per report (same data as the report_explain MCP tool
|
|
8
|
+
// and copilot-kb/report-interpretation-guide.md). No network call.
|
|
9
|
+
|
|
10
|
+
export const reportExplainCommand = {
|
|
11
|
+
command: 'report-explain [report]',
|
|
12
|
+
describe: 'Explain how to read a report, its data sources, setup prerequisites and why values can be zero',
|
|
13
|
+
builder: (y) => y
|
|
14
|
+
.positional('report', { type: 'string', describe: 'Report key, e.g. experience-impact (omit to list all)' })
|
|
15
|
+
.option('json', { type: 'boolean', default: false, describe: 'Raw JSON output' }),
|
|
16
|
+
handler: runCommand(async (argv) => {
|
|
17
|
+
const all = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'report-explain.json'), 'utf8'));
|
|
18
|
+
const key = (argv.report ?? '').toString().trim().toLowerCase();
|
|
19
|
+
if (!key) {
|
|
20
|
+
if (argv.json) { console.log(JSON.stringify(Object.keys(all))); return; }
|
|
21
|
+
for (const [k, r] of Object.entries(all)) console.log(`${k}: ${r.name}`);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const r = all[key];
|
|
25
|
+
if (!r) throw new Error(`Unknown report "${key}". Valid keys: ${Object.keys(all).join(', ')}`);
|
|
26
|
+
if (argv.json) { console.log(JSON.stringify(r, null, 2)); return; }
|
|
27
|
+
console.log(`\n${r.name}\n Answers: ${r.answers}\n Sources: ${r.sources}\n Setup: ${r.prerequisites}\n How to read: ${r.interpret}\n Why zero/empty: ${r.whyZero}\n What to do: ${r.fix}\n Related: ${r.related}`);
|
|
28
|
+
}),
|
|
29
|
+
};
|
package/lib/commands/report.js
CHANGED
|
@@ -448,6 +448,57 @@ const workedHoursCommand = {
|
|
|
448
448
|
},
|
|
449
449
|
};
|
|
450
450
|
|
|
451
|
+
// -- visitor-traffic command ------------------------------------------------
|
|
452
|
+
// Visitor Traffic report (velaro-messaging): daily visit trend, top countries,
|
|
453
|
+
// chat-channel breakdown. Backend: server/Velaro.Messaging/Controllers/ReportsController.cs
|
|
454
|
+
// POST reports/visitor-traffic-overview { start, end } -> { kpis, dailySeries, countryBreakdown, channelBreakdown }
|
|
455
|
+
const visitorTrafficCommand = {
|
|
456
|
+
command: 'visitor-traffic',
|
|
457
|
+
describe: 'Visitor Traffic report: daily visit trend, top countries and chat-channel breakdown for a date range',
|
|
458
|
+
builder: (y) => y
|
|
459
|
+
.option('start', { type: 'string', describe: 'Start date (ISO 8601 or YYYY-MM-DD). Default: 30 days ago' })
|
|
460
|
+
.option('end', { type: 'string', describe: 'End date (ISO 8601 or YYYY-MM-DD). Default: now' }),
|
|
461
|
+
handler: async (argv) => {
|
|
462
|
+
const end = argv.end ? new Date(argv.end) : new Date();
|
|
463
|
+
const start = argv.start ? new Date(argv.start) : new Date(end.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
464
|
+
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
|
465
|
+
console.error('--start and --end must be valid dates (ISO 8601 or YYYY-MM-DD).');
|
|
466
|
+
process.exit(1);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const data = await messagingRequest('POST', '/reports/visitor-traffic-overview', {
|
|
470
|
+
start: start.toISOString(),
|
|
471
|
+
end: end.toISOString(),
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
section('Visitor Traffic');
|
|
475
|
+
console.log(` Period: ${data?.start ?? start.toISOString().slice(0, 10)} -> ${data?.end ?? end.toISOString().slice(0, 10)}`);
|
|
476
|
+
|
|
477
|
+
const k = data?.kpis ?? {};
|
|
478
|
+
fmtRow('Total visits', fmtNum(k.totalVisits));
|
|
479
|
+
fmtRow('Avg daily visits', fmtNum(k.avgDailyVisits));
|
|
480
|
+
fmtRow('Countries reached', fmtNum(k.countriesReached));
|
|
481
|
+
fmtRow('Chats started', fmtNum(k.chatsStarted));
|
|
482
|
+
|
|
483
|
+
const countries = Array.isArray(data?.countryBreakdown) ? data.countryBreakdown : [];
|
|
484
|
+
section('Top Countries');
|
|
485
|
+
if (!countries.length) {
|
|
486
|
+
console.log(' (no data)');
|
|
487
|
+
} else {
|
|
488
|
+
for (const c of countries) console.log(` ${(c.country ?? '?').padEnd(6)} ${fmtNum(c.count)}`);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const channels = Array.isArray(data?.channelBreakdown) ? data.channelBreakdown : [];
|
|
492
|
+
section('By Channel');
|
|
493
|
+
if (!channels.length) {
|
|
494
|
+
console.log(' (no data)');
|
|
495
|
+
} else {
|
|
496
|
+
for (const c of channels) console.log(` ${(c.name ?? '?').padEnd(16)} ${fmtNum(c.count)} (${fmtNum(c.pct)}%)`);
|
|
497
|
+
}
|
|
498
|
+
console.log('');
|
|
499
|
+
},
|
|
500
|
+
};
|
|
501
|
+
|
|
451
502
|
// ── scheduled reports (Reports/Schedules) ──────────────────────────────────────
|
|
452
503
|
// Backend: server/Velaro.Admin/Controllers/ReportsController.cs
|
|
453
504
|
// GET Reports/Schedules -> scheduledReport[]
|
|
@@ -638,6 +689,7 @@ export const reportCommand = {
|
|
|
638
689
|
.command(conversationsCommand)
|
|
639
690
|
.command(callactivityCommand)
|
|
640
691
|
.command(workedHoursCommand)
|
|
692
|
+
.command(visitorTrafficCommand)
|
|
641
693
|
.command(schedulesCommand)
|
|
642
694
|
.command(exportCommand)
|
|
643
695
|
.demandCommand(1, 'Specify a report type. Run "velaro report --help" for options.'),
|
package/lib/commands/support.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// velaro support — staff SupportTools / staff-scoped actions (superadmin only)
|
|
2
2
|
import { request, messagingPost, messagingGet } from '../api.js';
|
|
3
|
+
import { ensureSiteUser } from '../siteUser.js';
|
|
3
4
|
|
|
4
5
|
async function toggleFlag(args) {
|
|
5
6
|
if (!args.site || !args.flag || args.enabled === undefined) {
|
|
@@ -91,6 +92,18 @@ async function agentLookup(args) {
|
|
|
91
92
|
console.log(JSON.stringify(result, null, 2));
|
|
92
93
|
}
|
|
93
94
|
|
|
95
|
+
async function addSiteUser(args) {
|
|
96
|
+
// Password via --password or VELARO_NEW_USER_PASSWORD env (preferred; keeps it out of shell history). Never printed.
|
|
97
|
+
const password = args.password || process.env.VELARO_NEW_USER_PASSWORD;
|
|
98
|
+
try {
|
|
99
|
+
const r = await ensureSiteUser({ get: messagingGet, post: messagingPost }, { ...args, siteId: args.site, password });
|
|
100
|
+
console.log(`${r.created ? 'Created' : 'Updated existing'} ${r.email} on site ${r.siteId} (userId ${r.userId}, roles ${r.roles.join(',')}); password set, usable immediately.`);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
console.error(`add-site-user failed: ${e.message}`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
94
107
|
async function listDeployments(args) {
|
|
95
108
|
const result = await request('GET', `SupportTools/sites/${args.site}/deployments`);
|
|
96
109
|
if (args.json) {
|
|
@@ -114,8 +127,43 @@ async function tourInsights(args) {
|
|
|
114
127
|
console.log(JSON.stringify(result, null, 2));
|
|
115
128
|
}
|
|
116
129
|
|
|
130
|
+
function windowQuery(args, extra = {}) {
|
|
131
|
+
const q = new URLSearchParams();
|
|
132
|
+
if (args.from) q.set('from', args.from);
|
|
133
|
+
if (args.to) q.set('to', args.to);
|
|
134
|
+
for (const [k, v] of Object.entries(extra)) if (v !== undefined) q.set(k, String(v));
|
|
135
|
+
const s = q.toString();
|
|
136
|
+
return s ? `?${s}` : '';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function connectionTimeline(args) {
|
|
140
|
+
// velaro-messaging SupportToolsController.AgentConnectionTimelineAsync (staff-only, read-only).
|
|
141
|
+
const result = await messagingGet(
|
|
142
|
+
`SupportTools/sites/${args.site}/agents/${args.agent}/connection-timeline${windowQuery(args, { limit: args.limit })}`);
|
|
143
|
+
console.log(JSON.stringify(result, null, 2));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function connectionEvidence(args) {
|
|
147
|
+
// velaro-messaging SupportToolsController.ConnectionEvidenceAsync (staff-only, read-only).
|
|
148
|
+
const result = await messagingGet(
|
|
149
|
+
`SupportTools/sites/${args.site}/connection-evidence${windowQuery(args)}`);
|
|
150
|
+
console.log(JSON.stringify(result, null, 2));
|
|
151
|
+
}
|
|
152
|
+
|
|
117
153
|
export function supportCommand(yargs) {
|
|
118
154
|
return yargs
|
|
155
|
+
.command('connection-timeline', 'Raw ordered agent connection events (connect/disconnect/reconnect) for one agent in a time window (Velaro staff, read-only)', y => y
|
|
156
|
+
.option('site', { type: 'number', demandOption: true, desc: 'Site ID' })
|
|
157
|
+
.option('agent', { type: 'number', demandOption: true, desc: 'WorkspaceUser.Id of the agent' })
|
|
158
|
+
.option('from', { type: 'string', desc: 'ISO start (default: 24h before --to)' })
|
|
159
|
+
.option('to', { type: 'string', desc: 'ISO end (default: now)' })
|
|
160
|
+
.option('limit', { type: 'number', default: 2000, desc: 'Max events (1-5000)' })
|
|
161
|
+
, a => connectionTimeline(a))
|
|
162
|
+
.command('connection-evidence', 'Dispute-grade connection evidence for a site: Velaro probe uptime, incidents (incl. excluded, with reason), per-agent disconnects and client-vs-server reconciliation (Velaro staff, read-only)', y => y
|
|
163
|
+
.option('site', { type: 'number', demandOption: true, desc: 'Site ID' })
|
|
164
|
+
.option('from', { type: 'string', desc: 'ISO start (default: 30 days before --to)' })
|
|
165
|
+
.option('to', { type: 'string', desc: 'ISO end (default: now)' })
|
|
166
|
+
, a => connectionEvidence(a))
|
|
119
167
|
.command('tour-insights', 'Moshky setup-tour funnel + scrubbed feedback across all sites, or one site\'s tour timeline with --site (Velaro staff, read-only)', y => y
|
|
120
168
|
.option('site', { type: 'number', desc: 'Optional site ID: show that site\'s tour events and feedback instead of the funnel' })
|
|
121
169
|
.option('days', { type: 'number', default: 30, desc: 'Look-back window in days (max 365)' })
|
|
@@ -136,6 +184,14 @@ export function supportCommand(yargs) {
|
|
|
136
184
|
.command('agent-lookup', 'Resolve a bare WorkspaceUser.Id (AgentId) to its Email/DisplayName/SiteId across all sites (superadmin only, read-only)', y => y
|
|
137
185
|
.option('id', { type: 'number', demandOption: true, desc: 'WorkspaceUser.Id / AgentId to look up' })
|
|
138
186
|
, a => agentLookup(a))
|
|
187
|
+
.command('add-site-user', 'Create or ensure a NORMAL user (agent|administrator, never staff) on ANY site with an explicit non-forcing password (staff only, idempotent)', y => y
|
|
188
|
+
.option('site', { type: 'number', demandOption: true, desc: 'Target site ID' })
|
|
189
|
+
.option('email', { type: 'string', demandOption: true, desc: 'User email' })
|
|
190
|
+
.option('password', { type: 'string', desc: 'Password (or set VELARO_NEW_USER_PASSWORD; never logged)' })
|
|
191
|
+
.option('role', { type: 'string', default: 'agent', choices: ['agent', 'administrator'], desc: 'agent or administrator' })
|
|
192
|
+
.option('firstName', { type: 'string' })
|
|
193
|
+
.option('lastName', { type: 'string' })
|
|
194
|
+
, a => addSiteUser(a))
|
|
139
195
|
.command('toggle-flag', 'Toggle a single legacy Subscription.cs feature flag on a site (superadmin only, allowlisted flags)', y => y
|
|
140
196
|
.option('site', { type: 'number', demandOption: true, desc: 'Site ID' })
|
|
141
197
|
.option('flag', { type: 'string', demandOption: true, desc: 'Flag name, e.g. EnableScraperBotIndexing (must be on SupportToolsController.AllowedFeatureFlags)' })
|
|
@@ -155,5 +211,5 @@ export function supportCommand(yargs) {
|
|
|
155
211
|
.option('channel', { type: 'string', desc: 'Conversation source, e.g. Web, TwilioSms (default Web)' })
|
|
156
212
|
.option('overrideIndexes', { type: 'string', desc: 'Comma-separated index names to override the config\'s own IndexName for this query' })
|
|
157
213
|
, a => chatQuery(a))
|
|
158
|
-
.demandCommand(1, 'Specify a subcommand: deployments | impersonate | agent-lookup | toggle-flag | scraper-create-job | chat-query | send-email | tour-insights');
|
|
214
|
+
.demandCommand(1, 'Specify a subcommand: deployments | impersonate | agent-lookup | add-site-user | toggle-flag | scraper-create-job | chat-query | send-email | tour-insights | connection-timeline | connection-evidence');
|
|
159
215
|
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
{
|
|
2
|
+
"experience-impact": {
|
|
3
|
+
"name": "Experience Impact",
|
|
4
|
+
"answers": "How staffing and adherence affect what customers feel, as a 0 to 100 Experience Index.",
|
|
5
|
+
"sources": "Work Schedules and presence, chat queue timing, missed chats, and CSAT from ticket replies and 1-5 survey ratings (questions tagged csat or star_rating).",
|
|
6
|
+
"prerequisites": "Work Schedules for agents; a survey with a 1-5 CSAT or star question attached to post-chat or a workflow, or ticket CSAT requests turned on; chat traffic in range.",
|
|
7
|
+
"interpret": "Weights: service level 30, adherence 25, CSAT 25, missed chats 20; a signal with no data drops out. CSAT needs 5+ responses to count. Response rate = rated survey submissions / all survey submissions. Actual agents below scheduled while missed chats rise = staffing gap.",
|
|
8
|
+
"whyZero": "CSAT empty: no CSAT survey configured; survey exists but nobody answered; answers came in with no 1-5 rating question; fewer than 5 responses; or the range predates the survey. If older chats were surveyed but never counted, ask Velaro support to run a CSAT backfill. Adherence empty: no Work Schedules. Sample data never mixes with real data.",
|
|
9
|
+
"fix": "Add a 1-5 star or CSAT question to the post-chat survey, enable ticket CSAT requests, widen the range, then fix the weakest component.",
|
|
10
|
+
"related": "Schedule Adherence, Service Level, Missed Chats, Surveys & NPS, Sentiment, Agent Performance"
|
|
11
|
+
},
|
|
12
|
+
"conversations": {
|
|
13
|
+
"name": "Conversations",
|
|
14
|
+
"answers": "How many conversations came in, on which channels and when.",
|
|
15
|
+
"sources": "Every conversation record on your account, grouped by channel, team, bot or agent, and time.",
|
|
16
|
+
"prerequisites": "At least one deployed channel (chat widget, SMS, email, voice, social).",
|
|
17
|
+
"interpret": "Overview shows totals and trend; Breakdown splits by channel, team and handler. Look at peaks by hour and day to plan staffing. Rising volume with flat staffing predicts longer waits.",
|
|
18
|
+
"whyZero": "No channel deployed or no traffic; the date range or team filter is too narrow; or you lack access to that team's data.",
|
|
19
|
+
"fix": "Widen the range, clear filters, confirm the deployment is live and the widget loads on your site.",
|
|
20
|
+
"related": "Service Level, Missed Chats, Visitor Traffic"
|
|
21
|
+
},
|
|
22
|
+
"missed": {
|
|
23
|
+
"name": "Missed Chats",
|
|
24
|
+
"answers": "Visitors who asked for help and got no live answer, and why.",
|
|
25
|
+
"sources": "Chat requests that ended without an agent or bot response, plus offline or unanswered conversations.",
|
|
26
|
+
"prerequisites": "Live chat deployed with agents or routing configured.",
|
|
27
|
+
"interpret": "Overview gives the missed count and rate. Breakdown shows time of day and team. Analysis groups likely causes such as nobody online, queue timeout, or visitor left. A missed spike at one hour points to a coverage gap.",
|
|
28
|
+
"whyZero": "Genuinely none, no chat traffic, or a bot answers everything so nothing reaches an agent queue.",
|
|
29
|
+
"fix": "Add agents or schedules for peak hours, review routing and after-hours behavior, enable offline message capture.",
|
|
30
|
+
"related": "Service Level, Experience Impact, Agent Availability"
|
|
31
|
+
},
|
|
32
|
+
"agent-performance": {
|
|
33
|
+
"name": "Agent Performance",
|
|
34
|
+
"answers": "How each agent handles conversations: volume, handle time, response speed and quality.",
|
|
35
|
+
"sources": "Conversations assigned to and handled by each agent, message timestamps, and survey scores where collected.",
|
|
36
|
+
"prerequisites": "Agents added to the account and handling chats.",
|
|
37
|
+
"interpret": "Compare an agent against the team over several weeks, not one day. Pair handle time with CSAT: fast and unhappy is a quality problem, slow and happy may be complex work.",
|
|
38
|
+
"whyZero": "Agent has no assigned chats in range, was not online, or the filter excludes their team. Quality scores need survey data.",
|
|
39
|
+
"fix": "Check routing assigns chats to that agent and that they set themselves available.",
|
|
40
|
+
"related": "Response Time, Agent Utilization, Surveys & NPS"
|
|
41
|
+
},
|
|
42
|
+
"response-time": {
|
|
43
|
+
"name": "Response Time",
|
|
44
|
+
"answers": "How quickly customers get a first reply and how long replies take afterwards.",
|
|
45
|
+
"sources": "Message timestamps between the customer's message and the first agent or bot reply.",
|
|
46
|
+
"prerequisites": "Chats with at least one reply.",
|
|
47
|
+
"interpret": "Look at the median and the slow tail together. A fine average with a long tail means some customers wait far too long.",
|
|
48
|
+
"whyZero": "No replies in range, or chats were only bot-handled and excluded from agent timing.",
|
|
49
|
+
"fix": "Add coverage at slow hours, turn on auto-greeting, review the routing capacity limit.",
|
|
50
|
+
"related": "Service Level, Agent Performance"
|
|
51
|
+
},
|
|
52
|
+
"service-level": {
|
|
53
|
+
"name": "Service Level",
|
|
54
|
+
"answers": "The share of chats answered within your target time.",
|
|
55
|
+
"sources": "Queue entry and first-answer timestamps compared with your configured service level threshold.",
|
|
56
|
+
"prerequisites": "A service level threshold in report settings and chats that enter an agent queue.",
|
|
57
|
+
"interpret": "A common target is 80 percent answered in 30 seconds. Check the trend by hour to find breaches.",
|
|
58
|
+
"whyZero": "No chats were queued for agents in the range, or no threshold is set.",
|
|
59
|
+
"fix": "Set the threshold, then staff to the hours that breach it.",
|
|
60
|
+
"related": "Response Time, Missed Chats, Experience Impact"
|
|
61
|
+
},
|
|
62
|
+
"sentiment": {
|
|
63
|
+
"name": "Sentiment",
|
|
64
|
+
"answers": "The overall tone of customer messages, by contact and by agent.",
|
|
65
|
+
"sources": "AI sentiment scoring of conversation messages.",
|
|
66
|
+
"prerequisites": "Sentiment reporting enabled on your plan and enough conversations to score.",
|
|
67
|
+
"interpret": "Watch the trend and the share of negative conversations. Use By Agent for coaching, By Contact to spot at-risk customers. Sentiment is inferred from text, so treat single scores as hints.",
|
|
68
|
+
"whyZero": "Feature not enabled, conversations too short to score, or scoring has not run yet for recent chats.",
|
|
69
|
+
"fix": "Confirm the plan includes it, widen the range, check again after the next processing cycle.",
|
|
70
|
+
"related": "Customer Health, Surveys & NPS, Experience Impact"
|
|
71
|
+
},
|
|
72
|
+
"health": {
|
|
73
|
+
"name": "Customer Health",
|
|
74
|
+
"answers": "Which contacts look healthy or at risk based on recent interactions.",
|
|
75
|
+
"sources": "Conversation frequency, sentiment, unresolved issues and survey results per contact.",
|
|
76
|
+
"prerequisites": "Contacts with conversation history.",
|
|
77
|
+
"interpret": "Low scores with rising contact frequency are the first to act on. Scores are relative to your own customers.",
|
|
78
|
+
"whyZero": "New account with little history, or sentiment data is not yet available.",
|
|
79
|
+
"fix": "Allow history to accumulate and enable surveys to add a direct signal.",
|
|
80
|
+
"related": "Sentiment, Surveys & NPS, Intelligence Suite"
|
|
81
|
+
},
|
|
82
|
+
"surveys": {
|
|
83
|
+
"name": "Surveys & NPS",
|
|
84
|
+
"answers": "What customers say in surveys: CSAT, NPS and custom questions, and how many respond.",
|
|
85
|
+
"sources": "Survey answers collected after chats, through workflows, standalone links or email.",
|
|
86
|
+
"prerequisites": "At least one active survey (post-chat CSAT, NPS or custom) delivered on a channel.",
|
|
87
|
+
"interpret": "Read the average with the response count and response rate. NPS is promoters minus detractors. Under about 30 responses, trends are noisy.",
|
|
88
|
+
"whyZero": "No survey enabled, survey not attached to the channel or workflow, nobody responded, or the range predates the survey. The visitor may close the chat before the survey shows.",
|
|
89
|
+
"fix": "Enable a short post-chat survey on every staffed channel, test it yourself, and keep questions to one or two.",
|
|
90
|
+
"related": "Experience Impact, Sentiment, Missed Chats"
|
|
91
|
+
},
|
|
92
|
+
"tickets": {
|
|
93
|
+
"name": "Tickets",
|
|
94
|
+
"answers": "Ticket volume, status, age and resolution speed.",
|
|
95
|
+
"sources": "Tickets created from email, forms, chats and manual entry.",
|
|
96
|
+
"prerequisites": "Ticketing or an email inbox connected.",
|
|
97
|
+
"interpret": "Watch open backlog and time to resolve. A growing backlog with steady intake means capacity is short.",
|
|
98
|
+
"whyZero": "Ticketing not enabled, no inbox connected, or no tickets in range.",
|
|
99
|
+
"fix": "Connect an inbox or enable ticket creation from forms and chat.",
|
|
100
|
+
"related": "Conversations, Agent Performance"
|
|
101
|
+
},
|
|
102
|
+
"calls": {
|
|
103
|
+
"name": "Calls",
|
|
104
|
+
"answers": "Phone call volume, duration, answered and missed calls.",
|
|
105
|
+
"sources": "Call records from your connected phone line or voice integration.",
|
|
106
|
+
"prerequisites": "A provisioned phone line or connected voice integration.",
|
|
107
|
+
"interpret": "Compare answered against missed by hour. Long durations may signal complexity or unresolved issues.",
|
|
108
|
+
"whyZero": "No phone line connected or no calls in range.",
|
|
109
|
+
"fix": "Connect a number and confirm it routes into Velaro.",
|
|
110
|
+
"related": "IVR, Voice"
|
|
111
|
+
},
|
|
112
|
+
"ivr": {
|
|
113
|
+
"name": "IVR",
|
|
114
|
+
"answers": "How callers move through your phone menus and where they drop off.",
|
|
115
|
+
"sources": "IVR call events for each menu step and outcome.",
|
|
116
|
+
"prerequisites": "An IVR configured on a phone line.",
|
|
117
|
+
"interpret": "A high drop-off at one step suggests a confusing prompt. Many transfers to an agent from one option shows demand to automate.",
|
|
118
|
+
"whyZero": "No IVR configured or no calls reached it.",
|
|
119
|
+
"fix": "Complete IVR setup, place a test call, then re-check.",
|
|
120
|
+
"related": "Calls, Voice"
|
|
121
|
+
},
|
|
122
|
+
"account-utilization": {
|
|
123
|
+
"name": "Account Utilization",
|
|
124
|
+
"answers": "How much of your plan (agents, bots, AI usage, channels) you are using.",
|
|
125
|
+
"sources": "Plan limits compared with measured usage on your account.",
|
|
126
|
+
"prerequisites": "Administrator or manager access.",
|
|
127
|
+
"interpret": "Near 100 percent means you may need a higher limit; very low usage means features you pay for are idle.",
|
|
128
|
+
"whyZero": "Feature not enabled yet or no usage in range.",
|
|
129
|
+
"fix": "Turn on unused features or request a limit increase.",
|
|
130
|
+
"related": "Agent Utilization"
|
|
131
|
+
},
|
|
132
|
+
"agent-availability": {
|
|
133
|
+
"name": "Agent Availability",
|
|
134
|
+
"answers": "When agents were online, away or offline.",
|
|
135
|
+
"sources": "Agent presence and status changes.",
|
|
136
|
+
"prerequisites": "Agents logging in to the workspace.",
|
|
137
|
+
"interpret": "Compare online time with scheduled time and chat demand to find coverage gaps.",
|
|
138
|
+
"whyZero": "Agents did not log in or the selected agents are not in range.",
|
|
139
|
+
"fix": "Confirm agents sign in and set their status to available.",
|
|
140
|
+
"related": "Schedule Adherence, Agent Utilization, Missed Chats"
|
|
141
|
+
},
|
|
142
|
+
"agent-utilization": {
|
|
143
|
+
"name": "Agent Utilization",
|
|
144
|
+
"answers": "How busy agents are compared with their capacity.",
|
|
145
|
+
"sources": "Concurrent chat load and online time per agent.",
|
|
146
|
+
"prerequisites": "Administrator or manager access; agents handling chats.",
|
|
147
|
+
"interpret": "Around 60 to 80 percent is healthy. Above that risks long waits and burnout, well below means spare capacity.",
|
|
148
|
+
"whyZero": "Agents were not online or not given chats.",
|
|
149
|
+
"fix": "Rebalance routing and capacity limits.",
|
|
150
|
+
"related": "Agent Availability, Agent Performance"
|
|
151
|
+
},
|
|
152
|
+
"visitor-traffic": {
|
|
153
|
+
"name": "Visitor Traffic",
|
|
154
|
+
"answers": "Who is visiting pages that have the Velaro widget and how many start chats.",
|
|
155
|
+
"sources": "Visitor tracking from the widget script on your site.",
|
|
156
|
+
"prerequisites": "Visitor tracking enabled and the widget installed on your pages.",
|
|
157
|
+
"interpret": "Compare visits with chats started to find pages where engagement is low.",
|
|
158
|
+
"whyZero": "Widget not installed on those pages, tracking not enabled, or an ad blocker or consent setting blocks it.",
|
|
159
|
+
"fix": "Verify the embed code on the live page and the tracking setting.",
|
|
160
|
+
"related": "Conversations"
|
|
161
|
+
},
|
|
162
|
+
"schedule-adherence": {
|
|
163
|
+
"name": "Schedule Adherence",
|
|
164
|
+
"answers": "Whether agents were online when scheduled.",
|
|
165
|
+
"sources": "Work Schedules compared with agent presence, plus approved exceptions.",
|
|
166
|
+
"prerequisites": "Work Schedules assigned to agents; the site time zone set correctly.",
|
|
167
|
+
"interpret": "Adherence is minutes available during scheduled time divided by scheduled minutes. Approved exceptions are excluded. Use Intraday for now and Timeline for the day's detail.",
|
|
168
|
+
"whyZero": "No schedules assigned, agents not scheduled in range, or time zone mismatch.",
|
|
169
|
+
"fix": "Create and assign schedules and check the time zone.",
|
|
170
|
+
"related": "Experience Impact, Agent Availability, Service Level"
|
|
171
|
+
},
|
|
172
|
+
"chat-intelligence": {
|
|
173
|
+
"name": "Chat Intelligence",
|
|
174
|
+
"answers": "What topics customers ask about, trends, and where the bot or KB falls short.",
|
|
175
|
+
"sources": "AI topic grouping of conversations and missed topics.",
|
|
176
|
+
"prerequisites": "Enough conversations (typically dozens) and the feature enabled.",
|
|
177
|
+
"interpret": "Rising themes show new demand. Missed topics are KB gaps: write or ingest content for them. Recommendations can be applied or dismissed.",
|
|
178
|
+
"whyZero": "Too little traffic, feature not enabled, or analysis has not run.",
|
|
179
|
+
"fix": "Widen the range and allow the next analysis run.",
|
|
180
|
+
"related": "Intelligence Suite, Conversation Efficiency"
|
|
181
|
+
},
|
|
182
|
+
"conversation-efficiency": {
|
|
183
|
+
"name": "Conversation Efficiency",
|
|
184
|
+
"answers": "How efficiently conversations are resolved and where time is lost.",
|
|
185
|
+
"sources": "Message counts, handle time, transfers and outcomes.",
|
|
186
|
+
"prerequisites": "Conversations with agent involvement.",
|
|
187
|
+
"interpret": "Agents shows who is above or below the norm, Correlation shows what drives long chats, Recommendations suggests actions.",
|
|
188
|
+
"whyZero": "No handled conversations in range.",
|
|
189
|
+
"fix": "Widen the range or pick a team with traffic.",
|
|
190
|
+
"related": "Agent Performance, Chat Intelligence"
|
|
191
|
+
},
|
|
192
|
+
"intelligence": {
|
|
193
|
+
"name": "Intelligence Suite",
|
|
194
|
+
"answers": "AI analysis across agents, customers, knowledge gaps, volume forecasts, revenue impact, competitors and channels.",
|
|
195
|
+
"sources": "Conversation history, surveys, and connected commerce or CRM data where present.",
|
|
196
|
+
"prerequisites": "Feature enabled on the plan and a few weeks of data. Revenue Impact needs a commerce or CRM integration.",
|
|
197
|
+
"interpret": "Treat each tab as guidance. Predictive Volume is a forecast, not a guarantee. Revenue Impact is an estimate from connected data.",
|
|
198
|
+
"whyZero": "Not enough history, missing integration, or feature not enabled.",
|
|
199
|
+
"fix": "Connect the relevant integration and allow data to accumulate.",
|
|
200
|
+
"related": "Customer Health, Chat Intelligence"
|
|
201
|
+
},
|
|
202
|
+
"operations": {
|
|
203
|
+
"name": "Operations Center",
|
|
204
|
+
"answers": "Live view of queues, agents and conversations right now.",
|
|
205
|
+
"sources": "Real-time presence and queue state.",
|
|
206
|
+
"prerequisites": "Workforce management enabled.",
|
|
207
|
+
"interpret": "Use it for moment-to-moment staffing. A rising queue with idle agents suggests a routing problem.",
|
|
208
|
+
"whyZero": "No active conversations or agents online.",
|
|
209
|
+
"fix": "Confirm agents are online and routing is active.",
|
|
210
|
+
"related": "Agent Availability, Service Level"
|
|
211
|
+
},
|
|
212
|
+
"voice": {
|
|
213
|
+
"name": "Voice",
|
|
214
|
+
"answers": "Individual call history and voice usage and billing.",
|
|
215
|
+
"sources": "Call log and voice minute usage.",
|
|
216
|
+
"prerequisites": "Voice enabled with a phone number.",
|
|
217
|
+
"interpret": "Call Log for detail, Usage for minutes against your plan.",
|
|
218
|
+
"whyZero": "No voice line or no calls.",
|
|
219
|
+
"fix": "Set up a voice number.",
|
|
220
|
+
"related": "Calls, IVR"
|
|
221
|
+
},
|
|
222
|
+
"email-subscriptions": {
|
|
223
|
+
"name": "Email Reports",
|
|
224
|
+
"answers": "Scheduled report summaries emailed to you.",
|
|
225
|
+
"sources": "The same data as the reports, summarized on your schedule.",
|
|
226
|
+
"prerequisites": "Access to the underlying report; some are manager or admin only.",
|
|
227
|
+
"interpret": "Each email links back to the full report.",
|
|
228
|
+
"whyZero": "Subscription not saved, the report is not on your plan, or it had no data for the period.",
|
|
229
|
+
"fix": "Re-save the subscription and check spam filtering for noreply@velaro.com.",
|
|
230
|
+
"related": "All reports"
|
|
231
|
+
}
|
|
232
|
+
}
|
package/lib/siteUser.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Ensure a NORMAL (agent|administrator) user with an explicit, non-forcing password on any customer site.
|
|
2
|
+
// Composes velaro-messaging's existing staff SuperAdmin endpoints (no new backend):
|
|
3
|
+
// GET SuperAdmin/users/{siteId} (find by email)
|
|
4
|
+
// POST SuperAdmin/users/{siteId} (create WorkspaceUser + Entra, SendEmail=false)
|
|
5
|
+
// POST SuperAdmin/users/{siteId}/{userId}/set-password (non-forcing, audit-logged server side)
|
|
6
|
+
// The password is only ever sent in the HTTPS body of set-password; never logged or returned.
|
|
7
|
+
const ROLE_MAP = { agent: 'Agent', administrator: 'Administrator' };
|
|
8
|
+
|
|
9
|
+
export async function ensureSiteUser({ get, post }, args) {
|
|
10
|
+
const role = String(args.role || 'agent').toLowerCase();
|
|
11
|
+
if (!ROLE_MAP[role]) throw new Error('role must be agent or administrator (staff/superadmin roles are never granted here).');
|
|
12
|
+
const siteId = Number(args.siteId);
|
|
13
|
+
if (!Number.isInteger(siteId) || siteId <= 0) throw new Error('siteId must be a positive integer.');
|
|
14
|
+
const email = String(args.email || '').trim().toLowerCase();
|
|
15
|
+
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) throw new Error('A valid email is required.');
|
|
16
|
+
if (typeof args.password !== 'string' || args.password.length < 8) throw new Error('password is required (min 8 characters).');
|
|
17
|
+
|
|
18
|
+
const listed = await get(`SuperAdmin/users/${siteId}`);
|
|
19
|
+
const users = Array.isArray(listed) ? listed : (listed?.users || []);
|
|
20
|
+
let user = users.find(u => String(u.email || u.Email || '').toLowerCase() === email);
|
|
21
|
+
let created = false;
|
|
22
|
+
if (!user) {
|
|
23
|
+
const res = await post(`SuperAdmin/users/${siteId}`, {
|
|
24
|
+
email, firstName: args.firstName || '', lastName: args.lastName || '',
|
|
25
|
+
roles: [ROLE_MAP[role]], sendEmail: false,
|
|
26
|
+
});
|
|
27
|
+
if (!res?.success) throw new Error(`Create failed: ${res?.error || res?.message || 'unknown error'}`);
|
|
28
|
+
user = { id: res.user.id ?? res.user.Id, roles: res.user.roles };
|
|
29
|
+
created = true;
|
|
30
|
+
}
|
|
31
|
+
const userId = user.id ?? user.Id;
|
|
32
|
+
const sp = await post(`SuperAdmin/users/${siteId}/${userId}/set-password`, { newPassword: args.password });
|
|
33
|
+
if (sp && sp.success === false) throw new Error(`set-password failed: ${sp.error || 'unknown error'}`);
|
|
34
|
+
return { success: true, siteId, userId, email, created, roles: user.roles || [ROLE_MAP[role]], passwordSet: true };
|
|
35
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { ensureSiteUser } from './siteUser.js';
|
|
4
|
+
|
|
5
|
+
const base = { siteId: 100160, email: 'R@Velaro.com', password: 'S3cretPassw0rd!', role: 'agent' };
|
|
6
|
+
function fakes(existing) {
|
|
7
|
+
const calls = [];
|
|
8
|
+
return {
|
|
9
|
+
calls,
|
|
10
|
+
get: async (p) => { calls.push(['GET', p]); return existing ? [{ id: 7, email: 'r@velaro.com', roles: ['Agent'] }] : []; },
|
|
11
|
+
post: async (p, b) => { calls.push(['POST', p, b]); return p.endsWith('set-password') ? { success: true } : { success: true, user: { id: 9, roles: ['Agent'] } }; },
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
test('creates user then sets password, never returns the password', async () => {
|
|
16
|
+
const f = fakes(false);
|
|
17
|
+
const r = await ensureSiteUser(f, base);
|
|
18
|
+
assert.equal(r.created, true);
|
|
19
|
+
assert.equal(r.userId, 9);
|
|
20
|
+
assert.ok(!JSON.stringify(r).includes(base.password));
|
|
21
|
+
assert.equal(f.calls[1][2].sendEmail, false);
|
|
22
|
+
assert.deepEqual(f.calls[1][2].roles, ['Agent']);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('idempotent: existing user only gets password reset', async () => {
|
|
26
|
+
const f = fakes(true);
|
|
27
|
+
const r = await ensureSiteUser(f, base);
|
|
28
|
+
assert.equal(r.created, false);
|
|
29
|
+
assert.equal(f.calls.filter(c => c[0] === 'POST').length, 1);
|
|
30
|
+
assert.ok(f.calls[1][1].endsWith('/7/set-password'));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('rejects superadmin/staff roles and weak input', async () => {
|
|
34
|
+
const f = fakes(false);
|
|
35
|
+
for (const role of ['superadmin', 'Velaro Admin', 'support']) await assert.rejects(ensureSiteUser(f, { ...base, role }));
|
|
36
|
+
await assert.rejects(ensureSiteUser(f, { ...base, password: 'short' }));
|
|
37
|
+
await assert.rejects(ensureSiteUser(f, { ...base, email: 'nope' }));
|
|
38
|
+
assert.equal(f.calls.length, 0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('errors never contain the password', async () => {
|
|
42
|
+
const f = fakes(true);
|
|
43
|
+
f.post = async () => ({ success: false, error: 'boom' });
|
|
44
|
+
await assert.rejects(ensureSiteUser(f, base), e => !e.message.includes(base.password));
|
|
45
|
+
});
|
package/package.json
CHANGED