@startanaicompany/crm 2.10.0 → 2.11.1
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/index.js +703 -6
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -212,6 +212,36 @@ keysCmd
|
|
|
212
212
|
}
|
|
213
213
|
});
|
|
214
214
|
|
|
215
|
+
// S20-1: set per-key rate limit (requires admin JWT auth)
|
|
216
|
+
keysCmd
|
|
217
|
+
.command('set-limit <key-id> <limit>')
|
|
218
|
+
.description('Set rate limit (req/min) for an API key — requires admin credentials')
|
|
219
|
+
.option('--email <email>', 'Admin email (or set SAAC_CRM_ADMIN_EMAIL env)')
|
|
220
|
+
.option('--password <password>', 'Admin password (or set SAAC_CRM_ADMIN_PASSWORD env)')
|
|
221
|
+
.action(async (keyId, limitStr, opts) => {
|
|
222
|
+
const globalOpts = program.opts();
|
|
223
|
+
const apiUrl = resolveApiUrl(globalOpts.url);
|
|
224
|
+
if (!apiUrl) { console.error('Error: API URL not configured.'); process.exit(1); }
|
|
225
|
+
const limit = parseInt(limitStr, 10);
|
|
226
|
+
if (isNaN(limit) || limit < 1 || limit > 10000) {
|
|
227
|
+
console.error('Error: limit must be an integer between 1 and 10000');
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
const cfg = loadConfig();
|
|
231
|
+
const email = opts.email || process.env.SAAC_CRM_ADMIN_EMAIL || cfg.admin_email;
|
|
232
|
+
const password = opts.password || process.env.SAAC_CRM_ADMIN_PASSWORD || await promptSecret('Admin password: ');
|
|
233
|
+
try {
|
|
234
|
+
const base = apiUrl.replace(/\/$/, '');
|
|
235
|
+
const loginRes = await axios.post(`${base}/api/v1/admin/login`, { email, password });
|
|
236
|
+
const token = loginRes.data.data?.token;
|
|
237
|
+
const res = await axios.patch(`${base}/api/v1/admin/keys/${keyId}`, { rate_limit: limit }, {
|
|
238
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }
|
|
239
|
+
});
|
|
240
|
+
console.log(`Rate limit updated to ${limit} req/min.`);
|
|
241
|
+
printJSON(res.data.data);
|
|
242
|
+
} catch (err) { handleError(err); }
|
|
243
|
+
});
|
|
244
|
+
|
|
215
245
|
// ============================================================
|
|
216
246
|
// REGISTER — first-time workspace + admin key setup
|
|
217
247
|
// NOTE: For agent self-service (zero-auth), use: saac_crm keys create --name <name>
|
|
@@ -578,6 +608,10 @@ leadsCmd
|
|
|
578
608
|
.option('--status <status>', 'New status')
|
|
579
609
|
.option('--source <source>', 'New source')
|
|
580
610
|
.option('--deal-value <amount>', 'Deal value (numeric)')
|
|
611
|
+
.option('--close-probability <n>', 'Close probability 0-100')
|
|
612
|
+
.option('--close-reason <reason>', 'Close reason (price|timing|competition|no_budget|no_decision|fit|other)')
|
|
613
|
+
.option('--expected-close-date <date>', 'Expected close date (YYYY-MM-DD)')
|
|
614
|
+
.option('--score <n>', 'Lead score 0-100 (calls PATCH /leads/:id/score)')
|
|
581
615
|
.option('--notes <notes>', 'New notes')
|
|
582
616
|
.option('--assigned-to <assignedTo>', 'New assigned-to')
|
|
583
617
|
.option('--tag <tag>', 'Replace ALL tags (repeatable). Use --add-tag / --remove-tag for surgical edits.', (v, prev) => prev.concat([v]), [])
|
|
@@ -585,9 +619,11 @@ leadsCmd
|
|
|
585
619
|
.option('--remove-tag <tag>', 'Remove a single tag (repeatable)', (v, prev) => prev.concat([v]), [])
|
|
586
620
|
.option('--external-id <externalId>', 'New external ID')
|
|
587
621
|
.option('--version <version>', 'Optimistic lock version')
|
|
622
|
+
.option('--from-agent-name <name>', 'Agent name for attribution (falls back to config defaultAgentName)')
|
|
588
623
|
.action(async (id, opts) => {
|
|
589
624
|
const globalOpts = program.opts();
|
|
590
|
-
const
|
|
625
|
+
const agentName = resolveAgentName(opts.fromAgentName);
|
|
626
|
+
const client = getClient(globalOpts, agentName);
|
|
591
627
|
try {
|
|
592
628
|
// Surgical tag ops: call PATCH /leads/:id/tags first if add/remove specified
|
|
593
629
|
if (opts.addTag.length > 0 || opts.removeTag.length > 0) {
|
|
@@ -598,14 +634,19 @@ leadsCmd
|
|
|
598
634
|
// If no other fields to update, return tag result
|
|
599
635
|
const hasOtherFields = opts.name || opts.email || opts.phone !== undefined ||
|
|
600
636
|
opts.company !== undefined || opts.status || opts.source ||
|
|
601
|
-
opts.dealValue !== undefined || opts.
|
|
602
|
-
opts.
|
|
603
|
-
opts.
|
|
637
|
+
opts.dealValue !== undefined || opts.closeProbability !== undefined ||
|
|
638
|
+
opts.closeReason !== undefined || opts.expectedCloseDate !== undefined ||
|
|
639
|
+
opts.notes !== undefined || opts.assignedTo !== undefined ||
|
|
640
|
+
opts.tag.length > 0 || opts.externalId !== undefined || opts.score !== undefined;
|
|
604
641
|
if (!hasOtherFields) {
|
|
605
642
|
printJSON(tagRes.data);
|
|
606
643
|
return;
|
|
607
644
|
}
|
|
608
645
|
}
|
|
646
|
+
// Score update: call separate PATCH /leads/:id/score endpoint
|
|
647
|
+
if (opts.score !== undefined) {
|
|
648
|
+
await client.patch(`/leads/${id}/score`, { score: parseInt(opts.score) });
|
|
649
|
+
}
|
|
609
650
|
const body = {
|
|
610
651
|
...(opts.name && { name: opts.name }),
|
|
611
652
|
...(opts.email && { email: opts.email }),
|
|
@@ -614,14 +655,22 @@ leadsCmd
|
|
|
614
655
|
...(opts.status && { status: opts.status }),
|
|
615
656
|
...(opts.source && { source: opts.source }),
|
|
616
657
|
...(opts.dealValue !== undefined && { deal_value: parseFloat(opts.dealValue) }),
|
|
658
|
+
...(opts.closeProbability !== undefined && { close_probability: parseInt(opts.closeProbability) }),
|
|
659
|
+
...(opts.closeReason !== undefined && { close_reason: opts.closeReason }),
|
|
660
|
+
...(opts.expectedCloseDate !== undefined && { expected_close_date: opts.expectedCloseDate }),
|
|
617
661
|
...(opts.notes !== undefined && { notes: opts.notes }),
|
|
618
662
|
...(opts.assignedTo !== undefined && { assigned_to: opts.assignedTo }),
|
|
619
663
|
...(opts.tag.length > 0 && { tags: opts.tag }),
|
|
620
664
|
...(opts.externalId !== undefined && { external_id: opts.externalId }),
|
|
621
665
|
...(opts.version !== undefined && { version: parseInt(opts.version) })
|
|
622
666
|
};
|
|
623
|
-
|
|
624
|
-
|
|
667
|
+
// Only call PUT if there are fields beyond score/tags to update
|
|
668
|
+
if (Object.keys(body).length > 0) {
|
|
669
|
+
const res = await client.put(`/leads/${id}`, body);
|
|
670
|
+
printJSON(res.data);
|
|
671
|
+
} else if (opts.score !== undefined) {
|
|
672
|
+
console.log(JSON.stringify({ success: true, data: { scored: true, score: parseInt(opts.score) } }, null, 2));
|
|
673
|
+
}
|
|
625
674
|
} catch (err) {
|
|
626
675
|
handleError(err);
|
|
627
676
|
}
|
|
@@ -642,6 +691,21 @@ leadsCmd
|
|
|
642
691
|
}
|
|
643
692
|
});
|
|
644
693
|
|
|
694
|
+
leadsCmd
|
|
695
|
+
.command('assign <id> <agent>')
|
|
696
|
+
.description('Assign a lead to an agent (fires lead.assigned webhook) — S18-2')
|
|
697
|
+
.action(async (id, agent) => {
|
|
698
|
+
const globalOpts = program.opts();
|
|
699
|
+
const client = getClient(globalOpts);
|
|
700
|
+
try {
|
|
701
|
+
const res = await client.patch(`/leads/${id}/assign`, { assigned_to: agent });
|
|
702
|
+
console.log(`Lead assigned to: ${res.data.data.assigned_to}`);
|
|
703
|
+
printJSON(res.data);
|
|
704
|
+
} catch (err) {
|
|
705
|
+
handleError(err);
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
|
|
645
709
|
leadsCmd
|
|
646
710
|
.command('history <id>')
|
|
647
711
|
.description('Show status change history for a lead')
|
|
@@ -656,6 +720,146 @@ leadsCmd
|
|
|
656
720
|
}
|
|
657
721
|
});
|
|
658
722
|
|
|
723
|
+
leadsCmd
|
|
724
|
+
.command('bulk-update')
|
|
725
|
+
.description('Bulk update lead status using filter criteria (S17-1). Up to 200 leads per call.')
|
|
726
|
+
.option('--filter-status <status>', 'Only update leads with this current status')
|
|
727
|
+
.option('--filter-source <source>', 'Only update leads with this source')
|
|
728
|
+
.option('--filter-assigned-to <agent>', 'Only update leads assigned to this agent')
|
|
729
|
+
.requiredOption('--status <status>', 'New status to set (new|contacted|qualified|unresponsive|converted|lost)')
|
|
730
|
+
.option('--close-reason <reason>', 'Close reason (required when --status lost): price|timing|competition|no_budget|no_decision|fit|other')
|
|
731
|
+
.option('--dry-run', 'Preview count without applying changes', false)
|
|
732
|
+
.action(async (opts) => {
|
|
733
|
+
const globalOpts = program.opts();
|
|
734
|
+
const client = getClient(globalOpts);
|
|
735
|
+
const filter = {};
|
|
736
|
+
if (opts.filterStatus) filter.status = opts.filterStatus;
|
|
737
|
+
if (opts.filterSource) filter.source = opts.filterSource;
|
|
738
|
+
if (opts.filterAssignedTo) filter.assigned_to = opts.filterAssignedTo;
|
|
739
|
+
const update = { status: opts.status };
|
|
740
|
+
if (opts.closeReason) update.close_reason = opts.closeReason;
|
|
741
|
+
try {
|
|
742
|
+
const res = await client.patch('/leads/bulk', { filter, update, dry_run: opts.dryRun });
|
|
743
|
+
if (opts.dryRun) {
|
|
744
|
+
console.log(`Dry run: ${res.data.data.would_update} lead(s) would be updated.`);
|
|
745
|
+
} else {
|
|
746
|
+
console.log(`Updated: ${res.data.data.updated} lead(s).`);
|
|
747
|
+
if (res.data.data.failed && res.data.data.failed.length > 0) {
|
|
748
|
+
console.log(`Failed: ${res.data.data.failed.length}`);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
printJSON(res.data);
|
|
752
|
+
} catch (err) {
|
|
753
|
+
handleError(err);
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
// leads search <term> — S19-1
|
|
758
|
+
leadsCmd
|
|
759
|
+
.command('search <term>')
|
|
760
|
+
.description('Search leads by name, email, company, or notes')
|
|
761
|
+
.action(async (term) => {
|
|
762
|
+
const globalOpts = program.opts();
|
|
763
|
+
const client = getClient(globalOpts);
|
|
764
|
+
try {
|
|
765
|
+
const res = await client.get('/leads', { params: { q: term, per_page: 50 } });
|
|
766
|
+
const leads = res.data.data || [];
|
|
767
|
+
if (leads.length === 0) {
|
|
768
|
+
console.log('No leads found matching: ' + term);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
const rows = leads.map(l => ({
|
|
772
|
+
id: l.id,
|
|
773
|
+
name: l.name,
|
|
774
|
+
email: l.email,
|
|
775
|
+
company: l.company || '—',
|
|
776
|
+
status: l.status
|
|
777
|
+
}));
|
|
778
|
+
console.table(rows);
|
|
779
|
+
} catch (err) {
|
|
780
|
+
handleError(err);
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
// leads dedupe <id> — S19-3
|
|
785
|
+
leadsCmd
|
|
786
|
+
.command('dedupe <id>')
|
|
787
|
+
.description('Find duplicate leads for a given lead ID')
|
|
788
|
+
.action(async (id) => {
|
|
789
|
+
const globalOpts = program.opts();
|
|
790
|
+
const client = getClient(globalOpts);
|
|
791
|
+
try {
|
|
792
|
+
const leadRes = await client.get(`/leads/${id}`);
|
|
793
|
+
const lead = leadRes.data.data;
|
|
794
|
+
const body = {};
|
|
795
|
+
if (lead.email) body.email = lead.email;
|
|
796
|
+
if (lead.name) body.name = lead.name;
|
|
797
|
+
if (lead.company) body.company = lead.company;
|
|
798
|
+
const dedupeRes = await client.post('/leads/dedupe/check', body);
|
|
799
|
+
const duplicates = (dedupeRes.data.data && dedupeRes.data.data.duplicates) || [];
|
|
800
|
+
// Filter out the lead itself
|
|
801
|
+
const matches = duplicates.filter(d => d.id !== id);
|
|
802
|
+
if (matches.length === 0) {
|
|
803
|
+
console.log('No duplicate leads found for: ' + lead.name);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
console.log(`Potential duplicates for "${lead.name}" (${id}):`);
|
|
807
|
+
const rows = matches.map(d => ({
|
|
808
|
+
id: d.id,
|
|
809
|
+
name: d.name,
|
|
810
|
+
email: d.email,
|
|
811
|
+
company: d.company || '—',
|
|
812
|
+
status: d.status,
|
|
813
|
+
score: d.score
|
|
814
|
+
}));
|
|
815
|
+
console.table(rows);
|
|
816
|
+
} catch (err) {
|
|
817
|
+
handleError(err);
|
|
818
|
+
}
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
// S21-1: leads tag / leads untag
|
|
823
|
+
leadsCmd
|
|
824
|
+
.command('tag <lead-id> <tag>')
|
|
825
|
+
.description('Add a tag to a lead')
|
|
826
|
+
.action(async (leadId, tag, opts) => {
|
|
827
|
+
const globalOpts = program.opts();
|
|
828
|
+
const client = getClient(globalOpts);
|
|
829
|
+
try {
|
|
830
|
+
const res = await client.patch(`/leads/${leadId}/tags`, { add: [tag] });
|
|
831
|
+
printJSON(res.data);
|
|
832
|
+
} catch (err) { handleError(err); }
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
leadsCmd
|
|
836
|
+
.command('untag <lead-id> <tag>')
|
|
837
|
+
.description('Remove a tag from a lead')
|
|
838
|
+
.action(async (leadId, tag, opts) => {
|
|
839
|
+
const globalOpts = program.opts();
|
|
840
|
+
const client = getClient(globalOpts);
|
|
841
|
+
try {
|
|
842
|
+
const res = await client.patch(`/leads/${leadId}/tags`, { remove: [tag] });
|
|
843
|
+
printJSON(res.data);
|
|
844
|
+
} catch (err) { handleError(err); }
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
leadsCmd
|
|
848
|
+
.command('audit <id>')
|
|
849
|
+
.description('Show audit trail for a lead')
|
|
850
|
+
.action(async (id) => {
|
|
851
|
+
const globalOpts = program.opts();
|
|
852
|
+
const client = getClient(globalOpts);
|
|
853
|
+
try {
|
|
854
|
+
const res = await client.get(`/leads/${id}/audit`);
|
|
855
|
+
const events = res.data.data || [];
|
|
856
|
+
if (events.length === 0) { console.log('No audit events found.'); return; }
|
|
857
|
+
for (const e of events) {
|
|
858
|
+
console.log(`[${new Date(e.changed_at).toLocaleString()}] ${e.field}: ${e.old_value ?? '—'} → ${e.new_value ?? '—'} (by ${e.changed_by})`);
|
|
859
|
+
}
|
|
860
|
+
} catch (err) { handleError(err); }
|
|
861
|
+
});
|
|
862
|
+
|
|
659
863
|
// ============================================================
|
|
660
864
|
// USERS COMMANDS (requires admin scope key)
|
|
661
865
|
// ============================================================
|
|
@@ -2493,4 +2697,497 @@ const supportCmd = program.command('support').description('Submit and manage bug
|
|
|
2493
2697
|
});
|
|
2494
2698
|
});
|
|
2495
2699
|
|
|
2700
|
+
// ============================================================
|
|
2701
|
+
// EMAIL SYNC CONFIG COMMANDS (Sprint 16)
|
|
2702
|
+
// ============================================================
|
|
2703
|
+
|
|
2704
|
+
const emailCmd = program.command('email-config').description('Persistent workspace email sync settings (Sprint 16)');
|
|
2705
|
+
|
|
2706
|
+
emailCmd
|
|
2707
|
+
.command('status')
|
|
2708
|
+
.description('Show current workspace email sync configuration and defaults')
|
|
2709
|
+
.action(async () => {
|
|
2710
|
+
const globalOpts = program.opts();
|
|
2711
|
+
const client = getClient(globalOpts);
|
|
2712
|
+
try {
|
|
2713
|
+
const res = await client.get('/emails/sync/status');
|
|
2714
|
+
const d = res.data.data;
|
|
2715
|
+
console.log('\n=== Email Sync Config ===');
|
|
2716
|
+
console.log(`Gmail configured: ${d.gmail_configured}`);
|
|
2717
|
+
console.log(`Workspace: ${d.workspace_slug}`);
|
|
2718
|
+
console.log(`auto_create_inbound_leads default: ${d.auto_create_inbound_leads}`);
|
|
2719
|
+
} catch (err) {
|
|
2720
|
+
handleError(err);
|
|
2721
|
+
}
|
|
2722
|
+
});
|
|
2723
|
+
|
|
2724
|
+
emailCmd
|
|
2725
|
+
.command('set')
|
|
2726
|
+
.description('Set persistent workspace default for auto_create_inbound_leads')
|
|
2727
|
+
.requiredOption('--auto-create-leads <bool>', 'Set workspace default: true or false')
|
|
2728
|
+
.action(async (opts) => {
|
|
2729
|
+
const globalOpts = program.opts();
|
|
2730
|
+
const client = getClient(globalOpts);
|
|
2731
|
+
const value = opts.autoCreateLeads === 'true' || opts.autoCreateLeads === true;
|
|
2732
|
+
try {
|
|
2733
|
+
const res = await client.patch('/emails/sync/config', { auto_create_inbound_leads: value });
|
|
2734
|
+
console.log(`Workspace default auto_create_inbound_leads set to: ${res.data.data.auto_create_inbound_leads}`);
|
|
2735
|
+
} catch (err) {
|
|
2736
|
+
handleError(err);
|
|
2737
|
+
}
|
|
2738
|
+
});
|
|
2739
|
+
|
|
2740
|
+
// ============================================================
|
|
2741
|
+
// ANALYTICS COMMANDS (S17-2)
|
|
2742
|
+
// ============================================================
|
|
2743
|
+
|
|
2744
|
+
const analyticsCmd = program.command('analytics').description('CRM analytics and pipeline metrics (S17-2)');
|
|
2745
|
+
|
|
2746
|
+
analyticsCmd
|
|
2747
|
+
.command('summary')
|
|
2748
|
+
.description('Show aggregated lead analytics including pipeline velocity, loss reasons, and conversion rates')
|
|
2749
|
+
.option('--assigned-to <agent>', 'Scope metrics to a specific agent')
|
|
2750
|
+
.action(async (opts) => {
|
|
2751
|
+
const globalOpts = program.opts();
|
|
2752
|
+
const client = getClient(globalOpts);
|
|
2753
|
+
const params = {};
|
|
2754
|
+
if (opts.assignedTo) params.assigned_to = opts.assignedTo;
|
|
2755
|
+
try {
|
|
2756
|
+
const res = await client.get('/analytics/summary', { params });
|
|
2757
|
+
const d = res.data.data;
|
|
2758
|
+
console.log('\n=== Analytics Summary ===');
|
|
2759
|
+
console.log(`Total leads: ${d.total_leads}`);
|
|
2760
|
+
if (d.scoped_to) console.log(`Scoped to agent: ${d.scoped_to}`);
|
|
2761
|
+
console.log('\nBy Status:');
|
|
2762
|
+
Object.entries(d.by_status || {}).forEach(([k, v]) => console.log(` ${k}: ${v}`));
|
|
2763
|
+
console.log('\nBy Stage:');
|
|
2764
|
+
Object.entries(d.by_stage || {}).forEach(([k, v]) => console.log(` ${k}: ${v}`));
|
|
2765
|
+
if (d.avg_close_probability != null) console.log(`\nAvg close probability: ${d.avg_close_probability}%`);
|
|
2766
|
+
console.log(`Stale leads (30d no activity): ${d.stale_leads_count}`);
|
|
2767
|
+
if (Object.keys(d.loss_reason_distribution || {}).length > 0) {
|
|
2768
|
+
console.log('\nLoss Reasons:');
|
|
2769
|
+
Object.entries(d.loss_reason_distribution).forEach(([k, v]) => console.log(` ${k}: ${v}`));
|
|
2770
|
+
}
|
|
2771
|
+
if (Object.keys(d.pipeline_velocity || {}).length > 0) {
|
|
2772
|
+
console.log('\nPipeline Velocity (avg days per stage):');
|
|
2773
|
+
Object.entries(d.pipeline_velocity).forEach(([k, v]) => console.log(` ${k}: ${v}d`));
|
|
2774
|
+
}
|
|
2775
|
+
if (Object.keys(d.stage_conversion_rates || {}).length > 0) {
|
|
2776
|
+
console.log('\nStage Conversion Rates:');
|
|
2777
|
+
Object.entries(d.stage_conversion_rates).forEach(([k, v]) => console.log(` ${k}: ${v}%`));
|
|
2778
|
+
}
|
|
2779
|
+
} catch (err) {
|
|
2780
|
+
handleError(err);
|
|
2781
|
+
}
|
|
2782
|
+
});
|
|
2783
|
+
|
|
2784
|
+
// ============================================================
|
|
2785
|
+
// WEBHOOKS COMMANDS (S17-3)
|
|
2786
|
+
// ============================================================
|
|
2787
|
+
|
|
2788
|
+
const webhooksCmd = program.command('webhooks').description('Manage webhook endpoints (S17-3)');
|
|
2789
|
+
|
|
2790
|
+
webhooksCmd
|
|
2791
|
+
.command('list')
|
|
2792
|
+
.description('List registered webhook endpoints')
|
|
2793
|
+
.action(async () => {
|
|
2794
|
+
const globalOpts = program.opts();
|
|
2795
|
+
const client = getClient(globalOpts);
|
|
2796
|
+
try {
|
|
2797
|
+
const res = await client.get('/webhooks');
|
|
2798
|
+
printJSON(res.data);
|
|
2799
|
+
} catch (err) {
|
|
2800
|
+
handleError(err);
|
|
2801
|
+
}
|
|
2802
|
+
});
|
|
2803
|
+
|
|
2804
|
+
webhooksCmd
|
|
2805
|
+
.command('events')
|
|
2806
|
+
.description('List valid webhook event types')
|
|
2807
|
+
.action(async () => {
|
|
2808
|
+
const globalOpts = program.opts();
|
|
2809
|
+
const client = getClient(globalOpts);
|
|
2810
|
+
try {
|
|
2811
|
+
const res = await client.get('/webhooks/events');
|
|
2812
|
+
printJSON(res.data);
|
|
2813
|
+
} catch (err) {
|
|
2814
|
+
handleError(err);
|
|
2815
|
+
}
|
|
2816
|
+
});
|
|
2817
|
+
|
|
2818
|
+
webhooksCmd
|
|
2819
|
+
.command('create')
|
|
2820
|
+
.description('Register a new webhook endpoint')
|
|
2821
|
+
.requiredOption('--url <url>', 'Webhook destination URL')
|
|
2822
|
+
.requiredOption('--events <events>', 'Comma-separated event types (e.g. lead.created,lead.status_changed)')
|
|
2823
|
+
.option('--secret <secret>', 'Signing secret for HMAC-SHA256 verification (generated if omitted)')
|
|
2824
|
+
.action(async (opts) => {
|
|
2825
|
+
const globalOpts = program.opts();
|
|
2826
|
+
const client = getClient(globalOpts);
|
|
2827
|
+
const events = opts.events.split(',').map(e => e.trim()).filter(Boolean);
|
|
2828
|
+
const body = { url: opts.url, events };
|
|
2829
|
+
if (opts.secret) body.secret = opts.secret;
|
|
2830
|
+
try {
|
|
2831
|
+
const res = await client.post('/webhooks', body);
|
|
2832
|
+
console.log('Webhook registered.');
|
|
2833
|
+
printJSON(res.data);
|
|
2834
|
+
} catch (err) {
|
|
2835
|
+
handleError(err);
|
|
2836
|
+
}
|
|
2837
|
+
});
|
|
2838
|
+
|
|
2839
|
+
webhooksCmd
|
|
2840
|
+
.command('delete <id>')
|
|
2841
|
+
.description('Remove a webhook endpoint by ID')
|
|
2842
|
+
.action(async (id) => {
|
|
2843
|
+
const globalOpts = program.opts();
|
|
2844
|
+
const client = getClient(globalOpts);
|
|
2845
|
+
try {
|
|
2846
|
+
const res = await client.delete(`/webhooks/${id}`);
|
|
2847
|
+
console.log('Webhook deleted.');
|
|
2848
|
+
printJSON(res.data);
|
|
2849
|
+
} catch (err) {
|
|
2850
|
+
handleError(err);
|
|
2851
|
+
}
|
|
2852
|
+
});
|
|
2853
|
+
|
|
2854
|
+
// ── stages ──────────────────────────────────────────────────────────
|
|
2855
|
+
const stagesCmd = program.command('stages').description('Manage pipeline stages (admin)');
|
|
2856
|
+
|
|
2857
|
+
// Helper: login via workspace-scoped POST /auth/login and return JWT token
|
|
2858
|
+
async function stagesAdminLogin(base, cfg, opts) {
|
|
2859
|
+
const workspace = opts.workspace || process.env.SAAC_CRM_WORKSPACE || cfg.workspace;
|
|
2860
|
+
const email = opts.email || process.env.SAAC_CRM_ADMIN_EMAIL || cfg.admin_email;
|
|
2861
|
+
const password = opts.password || process.env.SAAC_CRM_ADMIN_PASSWORD || await promptSecret('Admin password: ');
|
|
2862
|
+
if (!workspace) { console.error('Error: workspace required. Use --workspace or run: saac_crm config set --workspace <slug>'); process.exit(1); }
|
|
2863
|
+
if (!email) { console.error('Error: email required. Use --email or SAAC_CRM_ADMIN_EMAIL'); process.exit(1); }
|
|
2864
|
+
const loginRes = await axios.post(`${base}/api/v1/auth/login`, { workspace, email, password });
|
|
2865
|
+
return loginRes.data.data?.token;
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
stagesCmd
|
|
2869
|
+
.command('list')
|
|
2870
|
+
.description('List all pipeline stages for workspace')
|
|
2871
|
+
.option('--workspace <slug>', 'Workspace slug')
|
|
2872
|
+
.option('--email <email>', 'Admin email')
|
|
2873
|
+
.option('--password <password>', 'Admin password')
|
|
2874
|
+
.action(async (opts) => {
|
|
2875
|
+
const globalOpts = program.opts();
|
|
2876
|
+
const apiUrl = resolveApiUrl(globalOpts.url);
|
|
2877
|
+
if (!apiUrl) { console.error('Error: API URL not configured.'); process.exit(1); }
|
|
2878
|
+
const cfg = loadConfig();
|
|
2879
|
+
try {
|
|
2880
|
+
const base = apiUrl.replace(/\/$/, '');
|
|
2881
|
+
const token = await stagesAdminLogin(base, cfg, opts);
|
|
2882
|
+
const res = await axios.get(`${base}/api/v1/admin/stages`, {
|
|
2883
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }
|
|
2884
|
+
});
|
|
2885
|
+
printJSON(res.data);
|
|
2886
|
+
} catch (err) { handleError(err); }
|
|
2887
|
+
});
|
|
2888
|
+
|
|
2889
|
+
stagesCmd
|
|
2890
|
+
.command('create <name>')
|
|
2891
|
+
.description('Create a new pipeline stage')
|
|
2892
|
+
.option('--order <n>', 'Sort order (integer)', '999')
|
|
2893
|
+
.option('--workspace <slug>', 'Workspace slug')
|
|
2894
|
+
.option('--email <email>', 'Admin email')
|
|
2895
|
+
.option('--password <password>', 'Admin password')
|
|
2896
|
+
.action(async (name, opts) => {
|
|
2897
|
+
const globalOpts = program.opts();
|
|
2898
|
+
const apiUrl = resolveApiUrl(globalOpts.url);
|
|
2899
|
+
if (!apiUrl) { console.error('Error: API URL not configured.'); process.exit(1); }
|
|
2900
|
+
const cfg = loadConfig();
|
|
2901
|
+
try {
|
|
2902
|
+
const base = apiUrl.replace(/\/$/, '');
|
|
2903
|
+
const token = await stagesAdminLogin(base, cfg, opts);
|
|
2904
|
+
const res = await axios.post(`${base}/api/v1/admin/stages`, { name, order: parseInt(opts.order, 10) }, {
|
|
2905
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }
|
|
2906
|
+
});
|
|
2907
|
+
printJSON(res.data);
|
|
2908
|
+
} catch (err) { handleError(err); }
|
|
2909
|
+
});
|
|
2910
|
+
|
|
2911
|
+
stagesCmd
|
|
2912
|
+
.command('update <stage-id>')
|
|
2913
|
+
.description('Rename or reorder a pipeline stage')
|
|
2914
|
+
.option('--name <name>', 'New stage name')
|
|
2915
|
+
.option('--order <n>', 'New sort order (integer)')
|
|
2916
|
+
.option('--workspace <slug>', 'Workspace slug')
|
|
2917
|
+
.option('--email <email>', 'Admin email')
|
|
2918
|
+
.option('--password <password>', 'Admin password')
|
|
2919
|
+
.action(async (stageId, opts) => {
|
|
2920
|
+
const globalOpts = program.opts();
|
|
2921
|
+
const apiUrl = resolveApiUrl(globalOpts.url);
|
|
2922
|
+
if (!apiUrl) { console.error('Error: API URL not configured.'); process.exit(1); }
|
|
2923
|
+
const cfg = loadConfig();
|
|
2924
|
+
const body = {};
|
|
2925
|
+
if (opts.name) body.name = opts.name;
|
|
2926
|
+
if (opts.order !== undefined) body.order = parseInt(opts.order, 10);
|
|
2927
|
+
if (Object.keys(body).length === 0) { console.error('Provide --name and/or --order'); process.exit(1); }
|
|
2928
|
+
try {
|
|
2929
|
+
const base = apiUrl.replace(/\/$/, '');
|
|
2930
|
+
const token = await stagesAdminLogin(base, cfg, opts);
|
|
2931
|
+
const res = await axios.patch(`${base}/api/v1/admin/stages/${stageId}`, body, {
|
|
2932
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }
|
|
2933
|
+
});
|
|
2934
|
+
printJSON(res.data);
|
|
2935
|
+
} catch (err) { handleError(err); }
|
|
2936
|
+
});
|
|
2937
|
+
|
|
2938
|
+
stagesCmd
|
|
2939
|
+
.command('delete <stage-id>')
|
|
2940
|
+
.description('Delete a pipeline stage (fails if leads are in that stage)')
|
|
2941
|
+
.option('--workspace <slug>', 'Workspace slug')
|
|
2942
|
+
.option('--email <email>', 'Admin email')
|
|
2943
|
+
.option('--password <password>', 'Admin password')
|
|
2944
|
+
.action(async (stageId, opts) => {
|
|
2945
|
+
const globalOpts = program.opts();
|
|
2946
|
+
const apiUrl = resolveApiUrl(globalOpts.url);
|
|
2947
|
+
if (!apiUrl) { console.error('Error: API URL not configured.'); process.exit(1); }
|
|
2948
|
+
const cfg = loadConfig();
|
|
2949
|
+
try {
|
|
2950
|
+
const base = apiUrl.replace(/\/$/, '');
|
|
2951
|
+
const token = await stagesAdminLogin(base, cfg, opts);
|
|
2952
|
+
const res = await axios.delete(`${base}/api/v1/admin/stages/${stageId}`, {
|
|
2953
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }
|
|
2954
|
+
});
|
|
2955
|
+
printJSON(res.data);
|
|
2956
|
+
} catch (err) { handleError(err); }
|
|
2957
|
+
});
|
|
2958
|
+
|
|
2959
|
+
// ============================================================
|
|
2960
|
+
// Sprint 49 T1-B: companies command group
|
|
2961
|
+
// ============================================================
|
|
2962
|
+
const companiesCmd = program.command('companies').description('Manage companies/accounts (S49-T1-B)');
|
|
2963
|
+
|
|
2964
|
+
companiesCmd
|
|
2965
|
+
.command('list')
|
|
2966
|
+
.description('List companies')
|
|
2967
|
+
.option('--q <query>', 'Search by name or domain')
|
|
2968
|
+
.option('--limit <n>', 'Max results', '20')
|
|
2969
|
+
.option('--offset <n>', 'Offset for pagination', '0')
|
|
2970
|
+
.action(async (opts) => {
|
|
2971
|
+
const globalOpts = program.opts();
|
|
2972
|
+
const client = getClient(globalOpts);
|
|
2973
|
+
try {
|
|
2974
|
+
const params = {};
|
|
2975
|
+
if (opts.q) params.q = opts.q;
|
|
2976
|
+
if (opts.limit) params.limit = opts.limit;
|
|
2977
|
+
if (opts.offset) params.offset = opts.offset;
|
|
2978
|
+
const res = await client.get('/companies', { params });
|
|
2979
|
+
printJSON(res.data);
|
|
2980
|
+
} catch (err) { handleError(err); }
|
|
2981
|
+
});
|
|
2982
|
+
|
|
2983
|
+
companiesCmd
|
|
2984
|
+
.command('get <id>')
|
|
2985
|
+
.description('Get a company by ID (includes linked leads)')
|
|
2986
|
+
.action(async (id) => {
|
|
2987
|
+
const globalOpts = program.opts();
|
|
2988
|
+
const client = getClient(globalOpts);
|
|
2989
|
+
try {
|
|
2990
|
+
const res = await client.get(`/companies/${id}`);
|
|
2991
|
+
printJSON(res.data);
|
|
2992
|
+
} catch (err) { handleError(err); }
|
|
2993
|
+
});
|
|
2994
|
+
|
|
2995
|
+
companiesCmd
|
|
2996
|
+
.command('create')
|
|
2997
|
+
.description('Create a new company')
|
|
2998
|
+
.requiredOption('--name <name>', 'Company name')
|
|
2999
|
+
.option('--domain <domain>', 'Company domain (e.g. acme.com)')
|
|
3000
|
+
.option('--industry <industry>', 'Industry')
|
|
3001
|
+
.option('--employee-count <n>', 'Number of employees')
|
|
3002
|
+
.option('--website <url>', 'Website URL')
|
|
3003
|
+
.option('--from-agent-name <name>', 'Agent name for attribution')
|
|
3004
|
+
.action(async (opts) => {
|
|
3005
|
+
const globalOpts = program.opts();
|
|
3006
|
+
const client = getClient(globalOpts, resolveAgentName(opts.fromAgentName));
|
|
3007
|
+
try {
|
|
3008
|
+
const body = {
|
|
3009
|
+
name: opts.name,
|
|
3010
|
+
...(opts.domain && { domain: opts.domain }),
|
|
3011
|
+
...(opts.industry && { industry: opts.industry }),
|
|
3012
|
+
...(opts.employeeCount !== undefined && { size: String(parseInt(opts.employeeCount)) }),
|
|
3013
|
+
...(opts.website && { website: opts.website }),
|
|
3014
|
+
};
|
|
3015
|
+
const res = await client.post('/companies', body);
|
|
3016
|
+
printJSON(res.data);
|
|
3017
|
+
} catch (err) { handleError(err); }
|
|
3018
|
+
});
|
|
3019
|
+
|
|
3020
|
+
companiesCmd
|
|
3021
|
+
.command('update <id>')
|
|
3022
|
+
.description('Update a company by ID')
|
|
3023
|
+
.option('--name <name>', 'New name')
|
|
3024
|
+
.option('--domain <domain>', 'New domain')
|
|
3025
|
+
.option('--industry <industry>', 'New industry')
|
|
3026
|
+
.option('--employee-count <n>', 'New employee count')
|
|
3027
|
+
.option('--website <url>', 'New website URL')
|
|
3028
|
+
.option('--from-agent-name <name>', 'Agent name for attribution')
|
|
3029
|
+
.action(async (id, opts) => {
|
|
3030
|
+
const globalOpts = program.opts();
|
|
3031
|
+
const client = getClient(globalOpts, resolveAgentName(opts.fromAgentName));
|
|
3032
|
+
try {
|
|
3033
|
+
const body = {
|
|
3034
|
+
...(opts.name && { name: opts.name }),
|
|
3035
|
+
...(opts.domain !== undefined && { domain: opts.domain }),
|
|
3036
|
+
...(opts.industry !== undefined && { industry: opts.industry }),
|
|
3037
|
+
...(opts.employeeCount !== undefined && { size: String(parseInt(opts.employeeCount)) }),
|
|
3038
|
+
...(opts.website !== undefined && { website: opts.website }),
|
|
3039
|
+
};
|
|
3040
|
+
const res = await client.put(`/companies/${id}`, body);
|
|
3041
|
+
printJSON(res.data);
|
|
3042
|
+
} catch (err) { handleError(err); }
|
|
3043
|
+
});
|
|
3044
|
+
|
|
3045
|
+
companiesCmd
|
|
3046
|
+
.command('delete <id>')
|
|
3047
|
+
.description('Delete a company (soft delete, unlinks leads)')
|
|
3048
|
+
.action(async (id) => {
|
|
3049
|
+
const globalOpts = program.opts();
|
|
3050
|
+
const client = getClient(globalOpts);
|
|
3051
|
+
try {
|
|
3052
|
+
const res = await client.delete(`/companies/${id}`);
|
|
3053
|
+
printJSON(res.data);
|
|
3054
|
+
} catch (err) { handleError(err); }
|
|
3055
|
+
});
|
|
3056
|
+
|
|
3057
|
+
companiesCmd
|
|
3058
|
+
.command('leads <id>')
|
|
3059
|
+
.description('List leads linked to a company')
|
|
3060
|
+
.action(async (id) => {
|
|
3061
|
+
const globalOpts = program.opts();
|
|
3062
|
+
const client = getClient(globalOpts);
|
|
3063
|
+
try {
|
|
3064
|
+
const res = await client.get(`/companies/${id}`);
|
|
3065
|
+
// Return only the leads array from the company response
|
|
3066
|
+
const data = res.data?.data;
|
|
3067
|
+
printJSON({ success: true, data: data?.leads || [] });
|
|
3068
|
+
} catch (err) { handleError(err); }
|
|
3069
|
+
});
|
|
3070
|
+
|
|
3071
|
+
// ============================================================
|
|
3072
|
+
// Sprint 49 T1-B: sequences command group (enrollment only — definitions are admin-only)
|
|
3073
|
+
// ============================================================
|
|
3074
|
+
const sequencesCmd = program.command('sequences').description('Manage sequence enrollments (S49-T1-B). Sequence definitions are admin-only.');
|
|
3075
|
+
|
|
3076
|
+
sequencesCmd
|
|
3077
|
+
.command('list')
|
|
3078
|
+
.description('List all sequences (read-only)')
|
|
3079
|
+
.action(async () => {
|
|
3080
|
+
const globalOpts = program.opts();
|
|
3081
|
+
const client = getClient(globalOpts);
|
|
3082
|
+
try {
|
|
3083
|
+
const res = await client.get('/sequences');
|
|
3084
|
+
printJSON(res.data);
|
|
3085
|
+
} catch (err) { handleError(err); }
|
|
3086
|
+
});
|
|
3087
|
+
|
|
3088
|
+
sequencesCmd
|
|
3089
|
+
.command('get <id>')
|
|
3090
|
+
.description('Get a sequence with its steps')
|
|
3091
|
+
.action(async (id) => {
|
|
3092
|
+
const globalOpts = program.opts();
|
|
3093
|
+
const client = getClient(globalOpts);
|
|
3094
|
+
try {
|
|
3095
|
+
const res = await client.get(`/sequences/${id}`);
|
|
3096
|
+
printJSON(res.data);
|
|
3097
|
+
} catch (err) { handleError(err); }
|
|
3098
|
+
});
|
|
3099
|
+
|
|
3100
|
+
sequencesCmd
|
|
3101
|
+
.command('enroll <sequenceId>')
|
|
3102
|
+
.description('Enroll one or more leads into a sequence')
|
|
3103
|
+
.requiredOption('--lead-ids <ids>', 'Comma-separated lead UUIDs')
|
|
3104
|
+
.option('--from-agent-name <name>', 'Agent name for attribution')
|
|
3105
|
+
.action(async (sequenceId, opts) => {
|
|
3106
|
+
const globalOpts = program.opts();
|
|
3107
|
+
const client = getClient(globalOpts, resolveAgentName(opts.fromAgentName));
|
|
3108
|
+
try {
|
|
3109
|
+
const lead_ids = opts.leadIds.split(',').map(s => s.trim()).filter(Boolean);
|
|
3110
|
+
const res = await client.post(`/sequences/${sequenceId}/enroll`, { lead_ids });
|
|
3111
|
+
printJSON(res.data);
|
|
3112
|
+
} catch (err) { handleError(err); }
|
|
3113
|
+
});
|
|
3114
|
+
|
|
3115
|
+
sequencesCmd
|
|
3116
|
+
.command('unenroll <sequenceId> <leadId>')
|
|
3117
|
+
.description('Unenroll a single lead from a sequence')
|
|
3118
|
+
.action(async (sequenceId, leadId) => {
|
|
3119
|
+
const globalOpts = program.opts();
|
|
3120
|
+
const client = getClient(globalOpts);
|
|
3121
|
+
try {
|
|
3122
|
+
const res = await client.delete(`/sequences/${sequenceId}/enroll/${leadId}`);
|
|
3123
|
+
printJSON(res.data);
|
|
3124
|
+
} catch (err) { handleError(err); }
|
|
3125
|
+
});
|
|
3126
|
+
|
|
3127
|
+
sequencesCmd
|
|
3128
|
+
.command('enrollments <sequenceId>')
|
|
3129
|
+
.description('List enrollments for a sequence')
|
|
3130
|
+
.option('--status <status>', 'Filter by status (active|paused|completed|unenrolled)')
|
|
3131
|
+
.option('--q <query>', 'Search by lead name or email')
|
|
3132
|
+
.option('--limit <n>', 'Max results', '50')
|
|
3133
|
+
.option('--offset <n>', 'Offset for pagination', '0')
|
|
3134
|
+
.action(async (sequenceId, opts) => {
|
|
3135
|
+
const globalOpts = program.opts();
|
|
3136
|
+
const client = getClient(globalOpts);
|
|
3137
|
+
try {
|
|
3138
|
+
const params = { limit: opts.limit, offset: opts.offset };
|
|
3139
|
+
if (opts.status) params.status = opts.status;
|
|
3140
|
+
if (opts.q) params.q = opts.q;
|
|
3141
|
+
const res = await client.get(`/sequences/${sequenceId}/enrollments`, { params });
|
|
3142
|
+
printJSON(res.data);
|
|
3143
|
+
} catch (err) { handleError(err); }
|
|
3144
|
+
});
|
|
3145
|
+
|
|
3146
|
+
sequencesCmd
|
|
3147
|
+
.command('bulk-enroll <sequenceId>')
|
|
3148
|
+
.description('Bulk enroll leads into a sequence (≤50 sync, >50 returns job ID)')
|
|
3149
|
+
.option('--lead-ids <ids>', 'Comma-separated lead UUIDs (max 500)')
|
|
3150
|
+
.option('--segment-id <id>', 'Enroll all leads in a segment')
|
|
3151
|
+
.option('--search-status <status>', 'Enroll leads matching status filter')
|
|
3152
|
+
.option('--from-agent-name <name>', 'Agent name for attribution')
|
|
3153
|
+
.action(async (sequenceId, opts) => {
|
|
3154
|
+
const globalOpts = program.opts();
|
|
3155
|
+
const client = getClient(globalOpts, resolveAgentName(opts.fromAgentName));
|
|
3156
|
+
try {
|
|
3157
|
+
const body = {};
|
|
3158
|
+
if (opts.leadIds) body.lead_ids = opts.leadIds.split(',').map(s => s.trim()).filter(Boolean);
|
|
3159
|
+
else if (opts.segmentId) body.segment_id = opts.segmentId;
|
|
3160
|
+
else if (opts.searchStatus) body.search = { status: opts.searchStatus };
|
|
3161
|
+
else { console.error('One of --lead-ids, --segment-id, or --search-status required'); process.exit(1); }
|
|
3162
|
+
const res = await client.post(`/sequences/${sequenceId}/enroll/bulk`, body);
|
|
3163
|
+
printJSON(res.data);
|
|
3164
|
+
} catch (err) { handleError(err); }
|
|
3165
|
+
});
|
|
3166
|
+
|
|
3167
|
+
sequencesCmd
|
|
3168
|
+
.command('bulk-unenroll <sequenceId>')
|
|
3169
|
+
.description('Bulk unenroll leads from a sequence')
|
|
3170
|
+
.requiredOption('--lead-ids <ids>', 'Comma-separated lead UUIDs')
|
|
3171
|
+
.action(async (sequenceId, opts) => {
|
|
3172
|
+
const globalOpts = program.opts();
|
|
3173
|
+
const client = getClient(globalOpts);
|
|
3174
|
+
try {
|
|
3175
|
+
const lead_ids = opts.leadIds.split(',').map(s => s.trim()).filter(Boolean);
|
|
3176
|
+
const res = await client.post(`/sequences/${sequenceId}/unenroll/bulk`, { lead_ids });
|
|
3177
|
+
printJSON(res.data);
|
|
3178
|
+
} catch (err) { handleError(err); }
|
|
3179
|
+
});
|
|
3180
|
+
|
|
3181
|
+
sequencesCmd
|
|
3182
|
+
.command('job <jobId>')
|
|
3183
|
+
.description('Poll status of an async bulk-enroll job')
|
|
3184
|
+
.action(async (jobId) => {
|
|
3185
|
+
const globalOpts = program.opts();
|
|
3186
|
+
const client = getClient(globalOpts);
|
|
3187
|
+
try {
|
|
3188
|
+
const res = await client.get(`/jobs/${jobId}`);
|
|
3189
|
+
printJSON(res.data);
|
|
3190
|
+
} catch (err) { handleError(err); }
|
|
3191
|
+
});
|
|
3192
|
+
|
|
2496
3193
|
program.parse(process.argv);
|