@velaro/cli 1.4.18 → 1.4.19
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/contact-list.js +297 -0
- package/lib/commands/contacts.js +232 -0
- package/lib/commands/report.js +184 -1
- package/package.json +1 -1
package/bin/velaro.js
CHANGED
|
@@ -81,6 +81,8 @@ import { surveyCommand } from '../lib/commands/survey.js';
|
|
|
81
81
|
import { bundleCommand } from '../lib/commands/bundle.js';
|
|
82
82
|
import { featureDiscoveryCommand } from '../lib/commands/feature-discovery.js';
|
|
83
83
|
import { campaignsCommand } from '../lib/commands/campaigns.js';
|
|
84
|
+
import { contactsCommand } from '../lib/commands/contacts.js';
|
|
85
|
+
import { contactListCommand } from '../lib/commands/contact-list.js';
|
|
84
86
|
import { startUpdateCheck } from '../lib/update-check.js';
|
|
85
87
|
import { printBannerIfFirstRun } from '../lib/banner.js';
|
|
86
88
|
|
|
@@ -171,6 +173,8 @@ await yargs(hideBin(process.argv))
|
|
|
171
173
|
.command(bundleCommand)
|
|
172
174
|
.command({ command: 'feature-discovery', desc: 'View and act on feature-discovery suggestions for this site', builder: featureDiscoveryCommand, handler: () => {} })
|
|
173
175
|
.command(campaignsCommand)
|
|
176
|
+
.command(contactsCommand)
|
|
177
|
+
.command(contactListCommand)
|
|
174
178
|
.demandCommand(1, 'Specify a command. Run "velaro --help" for options.')
|
|
175
179
|
.strict()
|
|
176
180
|
.help()
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { request, getCredentials } from '../api.js';
|
|
2
|
+
import { runCommand } from '../run.js';
|
|
3
|
+
|
|
4
|
+
// -- velaro contact-list -------------------------------------------------------
|
|
5
|
+
// CLI surface for Velaro's marketing Contact Lists (server/Velaro.Admin/Controllers/
|
|
6
|
+
// ContactListController.cs). siteId is applied server-side from the authenticated
|
|
7
|
+
// session/service key, never a CLI arg.
|
|
8
|
+
//
|
|
9
|
+
// Backend contract (core CRUD/read surface -- see file-level comment in the
|
|
10
|
+
// controller for why StartImport/GetImportStatus/ImportSuppression/AcceptTerms/
|
|
11
|
+
// ResetTerms/NotifyContactMerge/PurgeInactiveContacts are intentionally NOT here):
|
|
12
|
+
// GET ContactLists -> ContactListController.GetLists
|
|
13
|
+
// POST ContactLists { name, description?, color? } -> ContactListController.CreateList
|
|
14
|
+
// PUT ContactLists/{id} { name?, description?, color? } -> ContactListController.UpdateList
|
|
15
|
+
// DELETE ContactLists/{id} -> ContactListController.DeleteList
|
|
16
|
+
// GET ContactLists/{id}/Members?page=&pageSize=&search=&sortBy=&sortDir= -> ContactListController.GetMembers
|
|
17
|
+
// POST ContactLists/{id}/Members { email, name?, phone?, tags? } -> ContactListController.AddMember
|
|
18
|
+
// DELETE ContactLists/{id}/Members/{memberId} -> ContactListController.RemoveMember
|
|
19
|
+
// GET ContactLists/{id}/Export -> ContactListController.ExportList (CSV)
|
|
20
|
+
// POST ContactLists/{id}/Resync -> ContactListController.ResyncList
|
|
21
|
+
// PUT ContactLists/{id}/AutoResync { enabled } -> ContactListController.SetAutoResync
|
|
22
|
+
// GET Campaigns/Quota -> ContactListController.GetQuota
|
|
23
|
+
// GET ContactSegments/{id}/Members -> ContactListController.GetSegmentMembers
|
|
24
|
+
// POST ContactLists/crm-preview { filters } -> ContactListController.CrmPreview
|
|
25
|
+
// POST ContactLists/import-from-crm { filters, newListName? | listId? } -> ContactListController.ImportFromCrm
|
|
26
|
+
|
|
27
|
+
function fmtRow(label, value) {
|
|
28
|
+
console.log(` ${label.padEnd(24)}${value ?? '—'}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// GET ContactLists -> ContactListController.GetLists
|
|
32
|
+
async function listsHandler() {
|
|
33
|
+
const lists = await request('GET', 'ContactLists');
|
|
34
|
+
if (!lists.length) {
|
|
35
|
+
console.log('\nNo contact lists yet.\n');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
console.log(`\nContact lists (${lists.length}):\n`);
|
|
39
|
+
for (const l of lists) {
|
|
40
|
+
console.log(` #${l.id} ${(l.name || '').padEnd(30)} ${l.memberCount} member(s) ${l.syncSource ? `[${l.syncSource}${l.autoResyncEnabled ? ', auto-resync' : ''}]` : ''}`);
|
|
41
|
+
}
|
|
42
|
+
console.log('');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function createListHandler(argv) {
|
|
46
|
+
const result = await request('POST', 'ContactLists', { name: argv.name, description: argv.description, color: argv.color });
|
|
47
|
+
console.log(`\n✅ Created list "${argv.name}" (id ${result.id}).\n`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function updateListHandler(argv) {
|
|
51
|
+
await request('PUT', `ContactLists/${argv.id}`, { name: argv.name, description: argv.description, color: argv.color });
|
|
52
|
+
console.log(`\n✅ List #${argv.id} updated.\n`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function deleteListHandler(argv) {
|
|
56
|
+
await request('DELETE', `ContactLists/${argv.id}`);
|
|
57
|
+
console.log(`\n✅ List #${argv.id} deleted.\n`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function membersHandler(argv) {
|
|
61
|
+
const params = new URLSearchParams();
|
|
62
|
+
params.set('page', String(argv.page ?? 1));
|
|
63
|
+
params.set('pageSize', String(argv.pageSize ?? 50));
|
|
64
|
+
if (argv.search) params.set('search', argv.search);
|
|
65
|
+
if (argv.sortBy) params.set('sortBy', argv.sortBy);
|
|
66
|
+
if (argv.sortDir) params.set('sortDir', argv.sortDir);
|
|
67
|
+
|
|
68
|
+
const result = await request('GET', `ContactLists/${argv.id}/Members?${params.toString()}`);
|
|
69
|
+
const items = result?.items ?? [];
|
|
70
|
+
if (!items.length) {
|
|
71
|
+
console.log('\nNo members found.\n');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
console.log(`\n${result.total} total member(s) (page ${result.page}, showing ${items.length})\n`);
|
|
75
|
+
for (const m of items) {
|
|
76
|
+
console.log(` #${m.id} ${(m.name || '').padEnd(24)} ${(m.email || '').padEnd(32)} ${m.phone || ''}`);
|
|
77
|
+
}
|
|
78
|
+
console.log('');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// POST ContactLists/{listId}/Members -> ContactListController.AddMember
|
|
82
|
+
async function addMemberHandler(argv) {
|
|
83
|
+
const result = await request('POST', `ContactLists/${argv.id}/Members`, {
|
|
84
|
+
email: argv.email, name: argv.name, phone: argv.phone, tags: argv.tags,
|
|
85
|
+
});
|
|
86
|
+
console.log(result.isNew ? `\n✅ Added ${argv.email} to list #${argv.id}.\n` : `\n${argv.email} was already a member of list #${argv.id}.\n`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// DELETE ContactLists/{listId}/Members/{memberId} -> ContactListController.RemoveMember
|
|
90
|
+
async function removeMemberHandler(argv) {
|
|
91
|
+
await request('DELETE', `ContactLists/${argv.id}/Members/${argv.memberId}`);
|
|
92
|
+
console.log(`\n✅ Member #${argv.memberId} removed from list #${argv.id}.\n`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// GET ContactLists/{listId}/Export -> ContactListController.ExportList
|
|
96
|
+
async function exportListHandler(argv) {
|
|
97
|
+
const { writeFileSync } = await import('fs');
|
|
98
|
+
const creds = await getCredentials();
|
|
99
|
+
if (!creds.adminApiBase) throw new Error('No adminApiBase configured for this environment. Run: velaro login');
|
|
100
|
+
const res = await fetch(`${creds.adminApiBase.replace(/\/+$/, '')}/ContactLists/${argv.id}/Export`, {
|
|
101
|
+
headers: { Authorization: `Bearer ${creds.velaroToken}` },
|
|
102
|
+
});
|
|
103
|
+
if (!res.ok) throw new Error(`GET ContactLists/${argv.id}/Export -> ${res.status}`);
|
|
104
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
105
|
+
const outPath = argv.out || `contact-list-${argv.id}-export.csv`;
|
|
106
|
+
writeFileSync(outPath, buf);
|
|
107
|
+
console.log(`\n✅ List #${argv.id} exported to ${outPath}\n`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function resyncListHandler(argv) {
|
|
111
|
+
const result = await request('POST', `ContactLists/${argv.id}/Resync`, {});
|
|
112
|
+
if (result.syncError) {
|
|
113
|
+
console.log(`\nResync failed for list #${argv.id}: ${result.syncError}\n`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
console.log(`\n✅ List #${argv.id} resynced. Status: ${result.syncStatus}, members: ${result.memberCount}, last synced: ${result.lastSyncedAt}\n`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function autoResyncHandler(argv) {
|
|
120
|
+
await request('PUT', `ContactLists/${argv.id}/AutoResync`, { enabled: argv.enabled });
|
|
121
|
+
console.log(`\n✅ Auto-resync ${argv.enabled ? 'enabled' : 'disabled'} for list #${argv.id}.\n`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// GET Campaigns/Quota -> ContactListController.GetQuota
|
|
125
|
+
async function quotaHandler() {
|
|
126
|
+
const q = await request('GET', 'Campaigns/Quota');
|
|
127
|
+
console.log('\nCampaign / Contact Quota\n');
|
|
128
|
+
fmtRow('Outbound campaigns', q.enableOutboundCampaigns ? 'enabled' : 'disabled');
|
|
129
|
+
fmtRow('SMS', q.enableSms ? 'enabled' : 'disabled');
|
|
130
|
+
fmtRow('Max contacts', q.maxContacts);
|
|
131
|
+
fmtRow('Contact count', q.contactCount);
|
|
132
|
+
fmtRow('Max emails/month', q.maxEmailsPerMonth);
|
|
133
|
+
fmtRow('Emails sent (mo)', q.currentMonthEmailCount);
|
|
134
|
+
fmtRow('Remaining emails', q.remainingEmails ?? 'unlimited');
|
|
135
|
+
fmtRow('Verified sender domain', q.hasVerifiedDomain ? (q.verifiedDomains ?? []).join(', ') : 'none');
|
|
136
|
+
fmtRow('Terms accepted', q.termsAccepted ? `yes (${q.termsAcceptedAt})` : 'no');
|
|
137
|
+
console.log('');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// GET ContactSegments/{id}/Members -> ContactListController.GetSegmentMembers
|
|
141
|
+
async function segmentMembersHandler(argv) {
|
|
142
|
+
const params = new URLSearchParams();
|
|
143
|
+
params.set('page', String(argv.page ?? 1));
|
|
144
|
+
params.set('pageSize', String(argv.pageSize ?? 50));
|
|
145
|
+
if (argv.search) params.set('search', argv.search);
|
|
146
|
+
const result = await request('GET', `ContactSegments/${argv.id}/Members?${params.toString()}`);
|
|
147
|
+
const items = result?.items ?? [];
|
|
148
|
+
if (!items.length) {
|
|
149
|
+
console.log('\nNo members found for this segment.\n');
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
console.log(`\n${result.total} total member(s) in segment #${argv.id} (showing ${items.length})\n`);
|
|
153
|
+
for (const m of items) console.log(` ${m.name || m.fullName || ''} <${m.email}>`);
|
|
154
|
+
console.log('');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function buildCrmFilters(argv) {
|
|
158
|
+
return {
|
|
159
|
+
source: argv.source ?? 'velaro',
|
|
160
|
+
requireEmail: argv.requireEmail ?? true,
|
|
161
|
+
requirePhone: argv.requirePhone ?? false,
|
|
162
|
+
company: argv.company,
|
|
163
|
+
country: argv.country,
|
|
164
|
+
createdAfter: argv.createdAfter,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// POST ContactLists/crm-preview -> ContactListController.CrmPreview
|
|
169
|
+
async function crmPreviewHandler(argv) {
|
|
170
|
+
const result = await request('POST', 'ContactLists/crm-preview', buildCrmFilters(argv));
|
|
171
|
+
if (result.stub) { console.log(`\n${result.message}\n`); return; }
|
|
172
|
+
console.log(`\n${result.count} contact(s) match this filter. Sample:\n`);
|
|
173
|
+
for (const p of result.preview ?? []) console.log(` ${p.name} <${p.email}> ${p.company ?? ''}`);
|
|
174
|
+
console.log('');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// POST ContactLists/import-from-crm -> ContactListController.ImportFromCrm
|
|
178
|
+
async function importFromCrmHandler(argv) {
|
|
179
|
+
const body = {
|
|
180
|
+
filters: buildCrmFilters(argv),
|
|
181
|
+
newListName: argv.newListName,
|
|
182
|
+
listId: argv.listId,
|
|
183
|
+
};
|
|
184
|
+
const result = await request('POST', 'ContactLists/import-from-crm', body);
|
|
185
|
+
if (result.stub) { console.log(`\n${result.message}\n`); return; }
|
|
186
|
+
console.log(`\n✅ List "${result.listName}" (id ${result.listId}): ${result.added} added, ${result.skipped} skipped.\n`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const crmFilterOptions = (y) => y
|
|
190
|
+
.option('source', { type: 'string', default: 'velaro', describe: 'CRM source key (only "velaro" is live today; others return a stub)' })
|
|
191
|
+
.option('requireEmail', { type: 'boolean', default: true, describe: 'Only include contacts with an email address' })
|
|
192
|
+
.option('requirePhone', { type: 'boolean', default: false, describe: 'Only include contacts with a phone number' })
|
|
193
|
+
.option('company', { type: 'string', describe: 'Filter by company name (contains)' })
|
|
194
|
+
.option('country', { type: 'string', describe: 'Filter by exact country' })
|
|
195
|
+
.option('createdAfter', { type: 'string', describe: 'Only include contacts created after this ISO date' });
|
|
196
|
+
|
|
197
|
+
export const contactListCommand = {
|
|
198
|
+
command: 'contact-list <subcommand>',
|
|
199
|
+
describe: 'Marketing contact lists: CRUD, members, export, resync, quota, segments, CRM import',
|
|
200
|
+
builder: (y) => y
|
|
201
|
+
.command({ command: 'list', describe: 'List all contact lists for this site', handler: runCommand(listsHandler) })
|
|
202
|
+
.command({
|
|
203
|
+
command: 'create',
|
|
204
|
+
describe: 'Create a new contact list',
|
|
205
|
+
builder: (y2) => y2
|
|
206
|
+
.option('name', { type: 'string', demandOption: true })
|
|
207
|
+
.option('description', { type: 'string' })
|
|
208
|
+
.option('color', { type: 'string', describe: 'Hex color, e.g. #3B82F6' }),
|
|
209
|
+
handler: runCommand(createListHandler),
|
|
210
|
+
})
|
|
211
|
+
.command({
|
|
212
|
+
command: 'update <id>',
|
|
213
|
+
describe: 'Update a contact list',
|
|
214
|
+
builder: (y2) => y2.positional('id', { type: 'number' })
|
|
215
|
+
.option('name', { type: 'string' })
|
|
216
|
+
.option('description', { type: 'string' })
|
|
217
|
+
.option('color', { type: 'string' }),
|
|
218
|
+
handler: runCommand(updateListHandler),
|
|
219
|
+
})
|
|
220
|
+
.command({
|
|
221
|
+
command: 'delete <id>',
|
|
222
|
+
describe: 'Delete a contact list',
|
|
223
|
+
builder: (y2) => y2.positional('id', { type: 'number' }),
|
|
224
|
+
handler: runCommand(deleteListHandler),
|
|
225
|
+
})
|
|
226
|
+
.command({
|
|
227
|
+
command: 'members <id>',
|
|
228
|
+
describe: 'Paginated list of members in a contact list',
|
|
229
|
+
builder: (y2) => y2.positional('id', { type: 'number' })
|
|
230
|
+
.option('page', { type: 'number', default: 1 })
|
|
231
|
+
.option('pageSize', { type: 'number', default: 50 })
|
|
232
|
+
.option('search', { type: 'string' })
|
|
233
|
+
.option('sortBy', { type: 'string', choices: ['email', 'name', 'addedAt'] })
|
|
234
|
+
.option('sortDir', { type: 'string', choices: ['asc', 'desc'] }),
|
|
235
|
+
handler: runCommand(membersHandler),
|
|
236
|
+
})
|
|
237
|
+
.command({
|
|
238
|
+
command: 'add-member <id>',
|
|
239
|
+
describe: 'Add a member to a contact list',
|
|
240
|
+
builder: (y2) => y2.positional('id', { type: 'number' })
|
|
241
|
+
.option('email', { type: 'string', demandOption: true })
|
|
242
|
+
.option('name', { type: 'string' })
|
|
243
|
+
.option('phone', { type: 'string' })
|
|
244
|
+
.option('tags', { type: 'string' }),
|
|
245
|
+
handler: runCommand(addMemberHandler),
|
|
246
|
+
})
|
|
247
|
+
.command({
|
|
248
|
+
command: 'remove-member <id> <memberId>',
|
|
249
|
+
describe: 'Remove a member from a contact list',
|
|
250
|
+
builder: (y2) => y2.positional('id', { type: 'number' }).positional('memberId', { type: 'number' }),
|
|
251
|
+
handler: runCommand(removeMemberHandler),
|
|
252
|
+
})
|
|
253
|
+
.command({
|
|
254
|
+
command: 'export <id>',
|
|
255
|
+
describe: 'Export a contact list to CSV',
|
|
256
|
+
builder: (y2) => y2.positional('id', { type: 'number' }).option('out', { type: 'string', describe: 'Output file path' }),
|
|
257
|
+
handler: runCommand(exportListHandler),
|
|
258
|
+
})
|
|
259
|
+
.command({
|
|
260
|
+
command: 'resync <id>',
|
|
261
|
+
describe: 'Re-synchronize a CRM-sourced list, or recompute member count for a manual/CSV list',
|
|
262
|
+
builder: (y2) => y2.positional('id', { type: 'number' }),
|
|
263
|
+
handler: runCommand(resyncListHandler),
|
|
264
|
+
})
|
|
265
|
+
.command({
|
|
266
|
+
command: 'auto-resync <id>',
|
|
267
|
+
describe: 'Enable/disable nightly auto-resync (crm:velaro-sourced lists only)',
|
|
268
|
+
builder: (y2) => y2.positional('id', { type: 'number' }).option('enabled', { type: 'boolean', demandOption: true }),
|
|
269
|
+
handler: runCommand(autoResyncHandler),
|
|
270
|
+
})
|
|
271
|
+
.command({ command: 'quota', describe: 'Show campaign/contact quota and usage for this site', handler: runCommand(quotaHandler) })
|
|
272
|
+
.command({
|
|
273
|
+
command: 'segment-members <id>',
|
|
274
|
+
describe: 'Paginated list of contacts matching a segment',
|
|
275
|
+
builder: (y2) => y2.positional('id', { type: 'number' })
|
|
276
|
+
.option('page', { type: 'number', default: 1 })
|
|
277
|
+
.option('pageSize', { type: 'number', default: 50 })
|
|
278
|
+
.option('search', { type: 'string' }),
|
|
279
|
+
handler: runCommand(segmentMembersHandler),
|
|
280
|
+
})
|
|
281
|
+
.command({
|
|
282
|
+
command: 'crm-preview',
|
|
283
|
+
describe: 'Preview how many contacts would be imported into a list, without saving',
|
|
284
|
+
builder: crmFilterOptions,
|
|
285
|
+
handler: runCommand(crmPreviewHandler),
|
|
286
|
+
})
|
|
287
|
+
.command({
|
|
288
|
+
command: 'import-from-crm',
|
|
289
|
+
describe: 'Import contacts into a new or existing list from a CRM source (Phase 1: "velaro" only)',
|
|
290
|
+
builder: (y2) => crmFilterOptions(y2)
|
|
291
|
+
.option('newListName', { type: 'string', describe: 'Create a new list with this name' })
|
|
292
|
+
.option('listId', { type: 'number', describe: 'Import into an existing list instead of creating one' }),
|
|
293
|
+
handler: runCommand(importFromCrmHandler),
|
|
294
|
+
})
|
|
295
|
+
.demandCommand(1, 'Specify a contact-list subcommand: list | create | update | delete | members | add-member | remove-member | export | resync | auto-resync | quota | segment-members | crm-preview | import-from-crm'),
|
|
296
|
+
handler: () => {},
|
|
297
|
+
};
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { request } from '../api.js';
|
|
2
|
+
import { runCommand } from '../run.js';
|
|
3
|
+
|
|
4
|
+
// -- velaro contacts ----------------------------------------------------------
|
|
5
|
+
// CLI surface for Velaro's native Contacts CRM (server/Velaro.Admin/Controllers/
|
|
6
|
+
// ContactsController.cs). siteId is applied server-side from the authenticated
|
|
7
|
+
// session/service key, never a CLI arg.
|
|
8
|
+
//
|
|
9
|
+
// Backend contract:
|
|
10
|
+
// GET Contacts?search=&tag=&source=&page=&pageSize= -> ContactsController.GetContacts
|
|
11
|
+
// GET Contacts/{id} -> ContactsController.GetContact
|
|
12
|
+
// POST Contacts/{id}/Notes { noteText } -> ContactsController.AddNote
|
|
13
|
+
// GET Contacts/{id}/Timeline -> ContactsController.GetTimeline
|
|
14
|
+
// GET Contacts/{id}/ImportedHistory -> ContactsController.GetImportedHistory
|
|
15
|
+
// POST Contacts/import-from-crm { filters } -> ContactsController.ImportFromCrm
|
|
16
|
+
// POST Contacts/crm-preview { filters } -> ContactsController.CrmPreview
|
|
17
|
+
// POST Contacts/{id}/Tags { tagId } -> ContactsController.AddTag
|
|
18
|
+
// GET Contacts/Tags -> ContactsController.GetTags
|
|
19
|
+
|
|
20
|
+
function fmtRow(label, value) {
|
|
21
|
+
console.log(` ${label.padEnd(20)}${value ?? '—'}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// GET Contacts -> ContactsController.GetContacts
|
|
25
|
+
async function searchHandler(argv) {
|
|
26
|
+
const params = new URLSearchParams();
|
|
27
|
+
if (argv.search) params.set('search', argv.search);
|
|
28
|
+
if (argv.tag) params.set('tag', argv.tag);
|
|
29
|
+
if (argv.source) params.set('source', argv.source);
|
|
30
|
+
params.set('page', String(argv.page ?? 1));
|
|
31
|
+
params.set('pageSize', String(argv.pageSize ?? 50));
|
|
32
|
+
|
|
33
|
+
const result = await request('GET', `Contacts?${params.toString()}`);
|
|
34
|
+
const items = result?.items ?? [];
|
|
35
|
+
if (!items.length) {
|
|
36
|
+
console.log('\nNo contacts found.\n');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
console.log(`\n${result.total} total contact(s) (page ${result.page}, showing ${items.length})\n`);
|
|
40
|
+
for (const c of items) {
|
|
41
|
+
const tags = (c.tags ?? []).map(t => t.name).join(', ');
|
|
42
|
+
console.log(` #${c.id} ${(c.fullName || '(no name)').padEnd(28)} ${(c.email || '').padEnd(30)} ${c.company || ''}${tags ? ` [${tags}]` : ''}`);
|
|
43
|
+
}
|
|
44
|
+
console.log('');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// GET Contacts/{id} -> ContactsController.GetContact
|
|
48
|
+
async function getHandler(argv) {
|
|
49
|
+
const c = await request('GET', `Contacts/${argv.id}`);
|
|
50
|
+
console.log(`\nContact #${c.id}: ${c.fullName}`);
|
|
51
|
+
fmtRow('Email', c.email);
|
|
52
|
+
fmtRow('Phone', c.phone || c.mobile);
|
|
53
|
+
fmtRow('Company', c.company);
|
|
54
|
+
fmtRow('Title', c.jobTitle);
|
|
55
|
+
fmtRow('Location', [c.city, c.state, c.country].filter(Boolean).join(', '));
|
|
56
|
+
fmtRow('Created', c.dateCreated);
|
|
57
|
+
fmtRow('Last activity', c.lastActivity);
|
|
58
|
+
if (c.tags?.length) fmtRow('Tags', c.tags.map(t => t.name).join(', '));
|
|
59
|
+
if (c.notes?.length) {
|
|
60
|
+
console.log(`\n Notes (${c.notes.length}):`);
|
|
61
|
+
for (const n of c.notes) console.log(` [${n.createdAt}] ${n.noteText}`);
|
|
62
|
+
}
|
|
63
|
+
console.log('');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// POST Contacts/{id}/Notes -> ContactsController.AddNote
|
|
67
|
+
async function addNoteHandler(argv) {
|
|
68
|
+
const note = await request('POST', `Contacts/${argv.id}/Notes`, { noteText: argv.text });
|
|
69
|
+
console.log(`\n✅ Note added to contact #${argv.id} (note #${note.id}).\n`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// GET Contacts/{id}/Timeline -> ContactsController.GetTimeline
|
|
73
|
+
async function timelineHandler(argv) {
|
|
74
|
+
const events = await request('GET', `Contacts/${argv.id}/Timeline`);
|
|
75
|
+
const list = Array.isArray(events) ? events : (events?.items ?? []);
|
|
76
|
+
if (!list.length) {
|
|
77
|
+
console.log('\nNo timeline activity for this contact.\n');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
console.log(`\nTimeline for contact #${argv.id} (${list.length} events)\n`);
|
|
81
|
+
for (const e of list) {
|
|
82
|
+
console.log(` [${e.timestamp ?? e.date ?? '—'}] ${e.type ?? e.eventType ?? 'event'}: ${e.summary ?? e.description ?? ''}`);
|
|
83
|
+
}
|
|
84
|
+
console.log('');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// GET Contacts/{id}/ImportedHistory -> ContactsController.GetImportedHistory
|
|
88
|
+
async function importedHistoryHandler(argv) {
|
|
89
|
+
const tickets = await request('GET', `Contacts/${argv.id}/ImportedHistory`);
|
|
90
|
+
if (!tickets.length) {
|
|
91
|
+
console.log('\nNo imported (backfilled) ticket history for this contact.\n');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
console.log(`\nImported ticket history for contact #${argv.id} (${tickets.length})\n`);
|
|
95
|
+
for (const t of tickets) {
|
|
96
|
+
console.log(` [${t.providerSlug}] #${t.externalTicketId} ${t.status?.padEnd(10) ?? ''} ${t.subject ?? ''} (${t.createdAtSource})`);
|
|
97
|
+
}
|
|
98
|
+
console.log('');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function buildCrmFilters(argv) {
|
|
102
|
+
return {
|
|
103
|
+
source: argv.source ?? 'velaro',
|
|
104
|
+
requireEmail: argv.requireEmail ?? true,
|
|
105
|
+
requirePhone: argv.requirePhone ?? false,
|
|
106
|
+
company: argv.company,
|
|
107
|
+
country: argv.country,
|
|
108
|
+
createdAfter: argv.createdAfter,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// POST Contacts/crm-preview -> ContactsController.CrmPreview
|
|
113
|
+
async function crmPreviewHandler(argv) {
|
|
114
|
+
const result = await request('POST', 'Contacts/crm-preview', buildCrmFilters(argv));
|
|
115
|
+
if (result.stub) {
|
|
116
|
+
console.log(`\n${result.message}\n`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
console.log(`\n${result.count} contact(s) match this filter. Sample:\n`);
|
|
120
|
+
for (const p of result.preview ?? []) console.log(` ${p.name} <${p.email}> ${p.company ?? ''}`);
|
|
121
|
+
console.log('');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// POST Contacts/import-from-crm -> ContactsController.ImportFromCrm
|
|
125
|
+
async function importFromCrmHandler(argv) {
|
|
126
|
+
// ContactCrmImportRequest wraps the filters in a `Filters` property -- unlike crm-preview,
|
|
127
|
+
// which binds ContactCrmImportFilters flat. Sending the filters unwrapped here silently no-ops
|
|
128
|
+
// (request?.Filters is null server-side, which reads as "unknown CRM source").
|
|
129
|
+
const result = await request('POST', 'Contacts/import-from-crm', { filters: buildCrmFilters(argv) });
|
|
130
|
+
if (result.stub) {
|
|
131
|
+
console.log(`\n${result.message}\n`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
console.log(`\n✅ Imported ${result.added} new contact(s), skipped ${result.skipped} existing (of ${result.total} matched).\n`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// GET Contacts/Tags -> ContactsController.GetTags
|
|
138
|
+
async function tagsListHandler() {
|
|
139
|
+
const tags = await request('GET', 'Contacts/Tags');
|
|
140
|
+
if (!tags.length) {
|
|
141
|
+
console.log('\nNo contact tags defined for this site.\n');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
console.log(`\nContact tags (${tags.length}):\n`);
|
|
145
|
+
for (const t of tags) console.log(` #${t.id} ${t.name} (${t.color})`);
|
|
146
|
+
console.log('');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// POST Contacts/{id}/Tags -> ContactsController.AddTag
|
|
150
|
+
async function addTagHandler(argv) {
|
|
151
|
+
const result = await request('POST', `Contacts/${argv.id}/Tags`, { tagId: argv.tagId });
|
|
152
|
+
console.log(result.success ? `\n✅ Tag ${argv.tagId} added to contact #${argv.id}.\n` : '\nFailed to add tag.\n');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const tagsCommand = {
|
|
156
|
+
command: 'tags <subcommand>',
|
|
157
|
+
describe: 'Manage contact tags',
|
|
158
|
+
builder: (y) => y
|
|
159
|
+
.command({ command: 'list', describe: 'List all contact tags for this site', handler: runCommand(tagsListHandler) })
|
|
160
|
+
.command({
|
|
161
|
+
command: 'add <id> <tagId>',
|
|
162
|
+
describe: 'Add an existing tag to a contact',
|
|
163
|
+
builder: (y2) => y2.positional('id', { type: 'number' }).positional('tagId', { type: 'number' }),
|
|
164
|
+
handler: runCommand(addTagHandler),
|
|
165
|
+
})
|
|
166
|
+
.demandCommand(1, 'Specify a tags subcommand: list | add'),
|
|
167
|
+
handler: () => {},
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const crmFilterOptions = (y) => y
|
|
171
|
+
.option('source', { type: 'string', default: 'velaro', describe: 'CRM source key (only "velaro" is live today; others return a stub)' })
|
|
172
|
+
.option('requireEmail', { type: 'boolean', default: true, describe: 'Only include contacts with an email address' })
|
|
173
|
+
.option('requirePhone', { type: 'boolean', default: false, describe: 'Only include contacts with a phone number' })
|
|
174
|
+
.option('company', { type: 'string', describe: 'Filter by company name (contains)' })
|
|
175
|
+
.option('country', { type: 'string', describe: 'Filter by exact country' })
|
|
176
|
+
.option('createdAfter', { type: 'string', describe: 'Only include contacts created after this ISO date' });
|
|
177
|
+
|
|
178
|
+
export const contactsCommand = {
|
|
179
|
+
command: 'contacts <subcommand>',
|
|
180
|
+
describe: 'Velaro native Contacts CRM: search, profile, notes, timeline, tags, CRM import',
|
|
181
|
+
builder: (y) => y
|
|
182
|
+
.command({
|
|
183
|
+
command: 'search',
|
|
184
|
+
describe: 'Paginated contact search',
|
|
185
|
+
builder: (y2) => y2
|
|
186
|
+
.option('search', { type: 'string', describe: 'Full-text search across name/email/company' })
|
|
187
|
+
.option('tag', { type: 'string', describe: 'Filter by tag name' })
|
|
188
|
+
.option('source', { type: 'string', describe: '"chat" (conversation-sourced, default) or "crm"' })
|
|
189
|
+
.option('page', { type: 'number', default: 1 })
|
|
190
|
+
.option('pageSize', { type: 'number', default: 50 }),
|
|
191
|
+
handler: runCommand(searchHandler),
|
|
192
|
+
})
|
|
193
|
+
.command({
|
|
194
|
+
command: 'get <id>',
|
|
195
|
+
describe: 'Show a contact profile (with tags + recent notes)',
|
|
196
|
+
builder: (y2) => y2.positional('id', { type: 'number' }),
|
|
197
|
+
handler: runCommand(getHandler),
|
|
198
|
+
})
|
|
199
|
+
.command({
|
|
200
|
+
command: 'add-note <id>',
|
|
201
|
+
describe: 'Add a note to a contact',
|
|
202
|
+
builder: (y2) => y2.positional('id', { type: 'number' }).option('text', { type: 'string', demandOption: true, describe: 'Note text' }),
|
|
203
|
+
handler: runCommand(addNoteHandler),
|
|
204
|
+
})
|
|
205
|
+
.command({
|
|
206
|
+
command: 'timeline <id>',
|
|
207
|
+
describe: 'Merged chronological activity feed for a contact (notes, campaign emails, broadcasts)',
|
|
208
|
+
builder: (y2) => y2.positional('id', { type: 'number' }),
|
|
209
|
+
handler: runCommand(timelineHandler),
|
|
210
|
+
})
|
|
211
|
+
.command({
|
|
212
|
+
command: 'imported-history <id>',
|
|
213
|
+
describe: 'Read-only backfilled ticket history for a contact (e.g. from a Zendesk import)',
|
|
214
|
+
builder: (y2) => y2.positional('id', { type: 'number' }),
|
|
215
|
+
handler: runCommand(importedHistoryHandler),
|
|
216
|
+
})
|
|
217
|
+
.command({
|
|
218
|
+
command: 'crm-preview',
|
|
219
|
+
describe: 'Preview how many contacts would be imported by a CRM import filter, without saving',
|
|
220
|
+
builder: crmFilterOptions,
|
|
221
|
+
handler: runCommand(crmPreviewHandler),
|
|
222
|
+
})
|
|
223
|
+
.command({
|
|
224
|
+
command: 'import-from-crm',
|
|
225
|
+
describe: 'Import contacts from a CRM source (Phase 1: "velaro" only). Deduplicates by email.',
|
|
226
|
+
builder: crmFilterOptions,
|
|
227
|
+
handler: runCommand(importFromCrmHandler),
|
|
228
|
+
})
|
|
229
|
+
.command(tagsCommand)
|
|
230
|
+
.demandCommand(1, 'Specify a contacts subcommand: search | get | add-note | timeline | imported-history | crm-preview | import-from-crm | tags'),
|
|
231
|
+
handler: () => {},
|
|
232
|
+
};
|
package/lib/commands/report.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { writeFileSync } from 'fs';
|
|
1
2
|
import { getCredentials, request, messagingRequest } from '../api.js';
|
|
3
|
+
import { runCommand } from '../run.js';
|
|
2
4
|
|
|
3
5
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
4
6
|
|
|
@@ -446,17 +448,198 @@ const workedHoursCommand = {
|
|
|
446
448
|
},
|
|
447
449
|
};
|
|
448
450
|
|
|
451
|
+
// ── scheduled reports (Reports/Schedules) ──────────────────────────────────────
|
|
452
|
+
// Backend: server/Velaro.Admin/Controllers/ReportsController.cs
|
|
453
|
+
// GET Reports/Schedules -> scheduledReport[]
|
|
454
|
+
// PUT Reports/Schedules { reportType, frequency?, recipientEmails?, isEnabled } -> { saved }
|
|
455
|
+
|
|
456
|
+
// GET Reports/Schedules -> ReportsController.GetSchedules
|
|
457
|
+
async function schedulesListHandler() {
|
|
458
|
+
const rows = await request('GET', 'Reports/Schedules');
|
|
459
|
+
if (!rows.length) {
|
|
460
|
+
console.log('\nNo scheduled reports configured for this site.\n');
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
section('Scheduled Reports');
|
|
464
|
+
for (const s of rows) {
|
|
465
|
+
console.log(` ${(s.reportType ?? '').padEnd(24)} ${s.isEnabled ? 'enabled ' : 'disabled'} ${(s.frequency ?? '').padEnd(12)} -> ${s.recipientEmails || '(no recipients)'}`);
|
|
466
|
+
}
|
|
467
|
+
console.log('');
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// PUT Reports/Schedules -> ReportsController.UpsertSchedule
|
|
471
|
+
async function schedulesUpsertHandler(argv) {
|
|
472
|
+
const result = await request('PUT', 'Reports/Schedules', {
|
|
473
|
+
reportType: argv.type,
|
|
474
|
+
frequency: argv.frequency,
|
|
475
|
+
recipientEmails: argv.recipients,
|
|
476
|
+
isEnabled: argv.enabled,
|
|
477
|
+
});
|
|
478
|
+
console.log(result.saved ? `\n✅ Schedule saved for report type "${argv.type}".\n` : '\nFailed to save schedule.\n');
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const schedulesCommand = {
|
|
482
|
+
command: 'schedules <subcommand>',
|
|
483
|
+
describe: 'Manage recurring report email schedules (Reports/Schedules)',
|
|
484
|
+
builder: (y) => y
|
|
485
|
+
.command({
|
|
486
|
+
command: 'list',
|
|
487
|
+
describe: 'List scheduled reports for this site',
|
|
488
|
+
handler: runCommand(schedulesListHandler),
|
|
489
|
+
})
|
|
490
|
+
.command({
|
|
491
|
+
command: 'upsert',
|
|
492
|
+
describe: 'Create or update the schedule for a report type',
|
|
493
|
+
builder: (y2) => y2
|
|
494
|
+
.option('type', { type: 'string', demandOption: true, describe: 'Report type, e.g. account-overview | agent-utilization | compliance' })
|
|
495
|
+
.option('frequency', { type: 'string', choices: ['daily', 'weekly', 'bi-monthly', 'monthly'], describe: 'How often to send (default: monthly on create)' })
|
|
496
|
+
.option('recipients', { type: 'string', describe: 'Comma-separated recipient email addresses' })
|
|
497
|
+
.option('enabled', { type: 'boolean', demandOption: true, describe: 'Enable or disable this schedule' }),
|
|
498
|
+
handler: runCommand(schedulesUpsertHandler),
|
|
499
|
+
})
|
|
500
|
+
.demandCommand(1, 'Specify a schedules subcommand: list | upsert'),
|
|
501
|
+
handler: () => {},
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
// ── async report exports (Reports/ExportAsync, /ExportStatus, /MyExports) ─────
|
|
505
|
+
// Backend: server/Velaro.Admin/Controllers/ReportsController.cs
|
|
506
|
+
// POST Reports/ExportAsync { startDate, endDate, reportType?, format?, recipientEmail? } -> { exportId, status }
|
|
507
|
+
// GET Reports/ExportStatus/{id} -> exportStatus
|
|
508
|
+
// GET Reports/MyExports -> exportStatus[] (last 30 days)
|
|
509
|
+
// POST Reports/Compliance { startDate?, endDate? } -> xlsx (activity audit log)
|
|
510
|
+
// POST Reports/AgentUtilization/Export { startDate?, endDate? } -> xlsx
|
|
511
|
+
|
|
512
|
+
// POST Reports/ExportAsync -> ReportsController.StartAsyncExport
|
|
513
|
+
async function exportStartHandler(argv) {
|
|
514
|
+
const result = await request('POST', 'Reports/ExportAsync', {
|
|
515
|
+
startDate: argv.start,
|
|
516
|
+
endDate: argv.end,
|
|
517
|
+
reportType: argv.type,
|
|
518
|
+
format: argv.format,
|
|
519
|
+
recipientEmail: argv.recipient,
|
|
520
|
+
});
|
|
521
|
+
console.log(`\nExport queued (id ${result.exportId}). ${result.message}\n`);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// GET Reports/ExportStatus/{id} -> ReportsController.GetExportStatus
|
|
525
|
+
async function exportStatusHandler(argv) {
|
|
526
|
+
const s = await request('GET', `Reports/ExportStatus/${argv.id}`);
|
|
527
|
+
section(`Export #${s.id}`);
|
|
528
|
+
fmtRow('Status', s.status);
|
|
529
|
+
fmtRow('Report type', s.reportType);
|
|
530
|
+
fmtRow('Format', s.format);
|
|
531
|
+
fmtRow('Range', `${s.startDate} -> ${s.endDate}`);
|
|
532
|
+
fmtRow('Download URL', s.downloadUrl || '(not ready)');
|
|
533
|
+
if (s.errorMessage) fmtRow('Error', s.errorMessage);
|
|
534
|
+
console.log('');
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// GET Reports/MyExports -> ReportsController.GetMyExports
|
|
538
|
+
async function myExportsHandler() {
|
|
539
|
+
const rows = await request('GET', 'Reports/MyExports');
|
|
540
|
+
if (!rows.length) {
|
|
541
|
+
console.log('\nNo report exports in the last 30 days.\n');
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
section('Recent Report Exports (last 30 days)');
|
|
545
|
+
for (const e of rows) {
|
|
546
|
+
console.log(` #${e.id} ${(e.status ?? '').padEnd(10)} ${(e.reportType ?? '').padEnd(20)} ${e.startDate} -> ${e.endDate}${e.isExpired ? ' (expired)' : ''}`);
|
|
547
|
+
}
|
|
548
|
+
console.log('');
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function downloadXlsxViaPost(path, body, outPath) {
|
|
552
|
+
const creds = await getCredentials();
|
|
553
|
+
if (!creds.adminApiBase) throw new Error('No adminApiBase configured for this environment. Run: velaro login');
|
|
554
|
+
const res = await fetch(`${creds.adminApiBase.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`, {
|
|
555
|
+
method: 'POST',
|
|
556
|
+
headers: { Authorization: `Bearer ${creds.velaroToken}`, 'Content-Type': 'application/json' },
|
|
557
|
+
body: JSON.stringify(body ?? {}),
|
|
558
|
+
});
|
|
559
|
+
if (!res.ok) {
|
|
560
|
+
const text = await res.text();
|
|
561
|
+
throw new Error(`POST /${path} -> ${res.status}: ${text.slice(0, 300)}`);
|
|
562
|
+
}
|
|
563
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
564
|
+
writeFileSync(outPath, buf);
|
|
565
|
+
return outPath;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// POST Reports/Compliance -> ReportsController.GenerateComplianceReport
|
|
569
|
+
async function complianceReportHandler(argv) {
|
|
570
|
+
const outPath = argv.out || `compliance-report-${Date.now()}.xlsx`;
|
|
571
|
+
await downloadXlsxViaPost('Reports/Compliance', { startDate: argv.start, endDate: argv.end }, outPath);
|
|
572
|
+
console.log(`\n✅ Compliance report saved to ${outPath}\n`);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// POST Reports/AgentUtilization/Export -> ReportsController.ExportAgentUtilization
|
|
576
|
+
async function agentUtilizationExportHandler(argv) {
|
|
577
|
+
const outPath = argv.out || `agent-utilization-${Date.now()}.xlsx`;
|
|
578
|
+
await downloadXlsxViaPost('Reports/AgentUtilization/Export', { startDate: argv.start, endDate: argv.end }, outPath);
|
|
579
|
+
console.log(`\n✅ Agent utilization report saved to ${outPath}\n`);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const exportCommand = {
|
|
583
|
+
command: 'export <subcommand>',
|
|
584
|
+
describe: 'Generate and track large/async report exports (Reports/ExportAsync, Compliance, AgentUtilization)',
|
|
585
|
+
builder: (y) => y
|
|
586
|
+
.command({
|
|
587
|
+
command: 'start',
|
|
588
|
+
describe: 'Queue an async report export (emailed download link when ready)',
|
|
589
|
+
builder: (y2) => y2
|
|
590
|
+
.option('start', { type: 'string', demandOption: true, describe: 'Start date (YYYY-MM-DD)' })
|
|
591
|
+
.option('end', { type: 'string', demandOption: true, describe: 'End date (YYYY-MM-DD)' })
|
|
592
|
+
.option('type', { type: 'string', describe: 'account-overview | agent-utilization | compliance (default account-overview)' })
|
|
593
|
+
.option('format', { type: 'string', choices: ['xlsx', 'csv'], describe: 'Output format (default xlsx)' })
|
|
594
|
+
.option('recipient', { type: 'string', describe: 'Override recipient email (default: the authenticated user)' }),
|
|
595
|
+
handler: runCommand(exportStartHandler),
|
|
596
|
+
})
|
|
597
|
+
.command({
|
|
598
|
+
command: 'status <id>',
|
|
599
|
+
describe: 'Check the status of a queued export',
|
|
600
|
+
builder: (y2) => y2.positional('id', { type: 'number' }),
|
|
601
|
+
handler: runCommand(exportStatusHandler),
|
|
602
|
+
})
|
|
603
|
+
.command({
|
|
604
|
+
command: 'list',
|
|
605
|
+
describe: 'List recent report exports for this site (last 30 days)',
|
|
606
|
+
handler: runCommand(myExportsHandler),
|
|
607
|
+
})
|
|
608
|
+
.command({
|
|
609
|
+
command: 'compliance',
|
|
610
|
+
describe: 'Download the activity-audit-log compliance report as xlsx (synchronous, <=366 days)',
|
|
611
|
+
builder: (y2) => y2
|
|
612
|
+
.option('start', { type: 'string', describe: 'Start date (YYYY-MM-DD), default 30 days ago' })
|
|
613
|
+
.option('end', { type: 'string', describe: 'End date (YYYY-MM-DD), default now' })
|
|
614
|
+
.option('out', { type: 'string', describe: 'Output file path (default compliance-report-<timestamp>.xlsx)' }),
|
|
615
|
+
handler: runCommand(complianceReportHandler),
|
|
616
|
+
})
|
|
617
|
+
.command({
|
|
618
|
+
command: 'agent-utilization',
|
|
619
|
+
describe: 'Download agent utilization as xlsx (synchronous, <=366 days). For the JSON table view use "velaro report callcenter" instead.',
|
|
620
|
+
builder: (y2) => y2
|
|
621
|
+
.option('start', { type: 'string', describe: 'Start date (YYYY-MM-DD), default 30 days ago' })
|
|
622
|
+
.option('end', { type: 'string', describe: 'End date (YYYY-MM-DD), default now' })
|
|
623
|
+
.option('out', { type: 'string', describe: 'Output file path (default agent-utilization-<timestamp>.xlsx)' }),
|
|
624
|
+
handler: runCommand(agentUtilizationExportHandler),
|
|
625
|
+
})
|
|
626
|
+
.demandCommand(1, 'Specify an export subcommand: start | status | list | compliance | agent-utilization'),
|
|
627
|
+
handler: () => {},
|
|
628
|
+
};
|
|
629
|
+
|
|
449
630
|
// ── top-level report command ──────────────────────────────────────────────────
|
|
450
631
|
|
|
451
632
|
export const reportCommand = {
|
|
452
633
|
command: 'report',
|
|
453
|
-
describe: 'Analytics reports: call center, service level, conversations, call activity',
|
|
634
|
+
describe: 'Analytics reports: call center, service level, conversations, call activity, schedules, exports',
|
|
454
635
|
builder: (y) => y
|
|
455
636
|
.command(callcenterCommand)
|
|
456
637
|
.command(servicelevelCommand)
|
|
457
638
|
.command(conversationsCommand)
|
|
458
639
|
.command(callactivityCommand)
|
|
459
640
|
.command(workedHoursCommand)
|
|
641
|
+
.command(schedulesCommand)
|
|
642
|
+
.command(exportCommand)
|
|
460
643
|
.demandCommand(1, 'Specify a report type. Run "velaro report --help" for options.'),
|
|
461
644
|
handler: () => {},
|
|
462
645
|
};
|
package/package.json
CHANGED