@velaro/cli 1.4.17 → 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 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
+ };
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velaro/cli",
3
- "version": "1.4.17",
3
+ "version": "1.4.19",
4
4
  "description": "Velaro Workspace v20 — command-line interface for managing bots, knowledge base ingestion, MCP API keys, search indexes, and ops health.",
5
5
  "type": "module",
6
6
  "bin": {