@startanaicompany/crm 2.16.0 → 2.18.0

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.
Files changed (2) hide show
  1. package/index.js +581 -3
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -603,7 +603,8 @@ leadsCmd
603
603
  client.get(`/leads/${leadId}/score-history`),
604
604
  ]);
605
605
  const lead = leadRes.data.data;
606
- const history = histRes.data.data || [];
606
+ // Bug 00d0c981: score-history returns { history: [], total: N } not a bare array
607
+ const history = histRes.data.data?.history || histRes.data.data || [];
607
608
  const label = lead.score >= 80 ? 'Hot' : lead.score >= 60 ? 'Qualified' : lead.score >= 40 ? 'Developing' : lead.score >= 20 ? 'Warm' : 'Cold';
608
609
  console.log(`\nLead: ${lead.name} <${lead.email}>`);
609
610
  console.log(`Current Score: ${lead.score ?? 'N/A'} (${label})`);
@@ -801,8 +802,8 @@ leadsCmd
801
802
  const client = getClient(globalOpts);
802
803
  try {
803
804
  if (opts.to) {
804
- // SET mode: PATCH /leads/:id with pipeline_stage
805
- const res = await client.patch(`/leads/${leadId}`, { pipeline_stage: opts.to });
805
+ // Bug 6d850c25: use PATCH /leads/:id/stage with { stage } not /leads/:id with { pipeline_stage }
806
+ const res = await client.patch(`/leads/${leadId}/stage`, { stage: opts.to });
806
807
  const d = res.data.data;
807
808
  console.log(`Stage set to: ${d.pipeline_stage}`);
808
809
  printJSON(res.data);
@@ -1211,6 +1212,121 @@ leadsCmd
1211
1212
  } catch (err) { handleError(err); }
1212
1213
  });
1213
1214
 
1215
+ // Sprint 54 T1: leads merge — merge secondary lead into primary
1216
+ leadsCmd
1217
+ .command('merge <primary-id> <secondary-id>')
1218
+ .description('Merge secondary lead into primary lead (Sprint 54 T1). Use --dry-run to preview without committing.')
1219
+ .option('--dry-run', 'Preview what would be merged without committing changes', false)
1220
+ .action(async (primaryId, secondaryId, opts) => {
1221
+ const globalOpts = program.opts();
1222
+ const client = getClient(globalOpts);
1223
+ try {
1224
+ if (opts.dryRun) {
1225
+ // Dry-run: fetch both leads and show diff without calling merge
1226
+ const [primaryRes, secondaryRes] = await Promise.all([
1227
+ client.get(`/leads/${primaryId}`),
1228
+ client.get(`/leads/${secondaryId}`),
1229
+ ]);
1230
+ const primary = primaryRes.data.data;
1231
+ const secondary = secondaryRes.data.data;
1232
+ console.log('\n[DRY RUN] Merge preview:');
1233
+ console.log(` Primary : [${primary.id}] ${primary.name} <${primary.email}> — ${primary.status}`);
1234
+ console.log(` Secondary: [${secondary.id}] ${secondary.name} <${secondary.email}> — ${secondary.status}`);
1235
+ console.log('\n What would happen:');
1236
+ console.log(` - Notes, communications, and audit history from secondary copied to primary`);
1237
+ console.log(` - Secondary lead soft-deleted (status → merged)`);
1238
+ console.log(` - Primary lead retained with merged data`);
1239
+ if (secondary.tags && secondary.tags.length > 0) {
1240
+ console.log(` - Tags from secondary that will be merged: ${secondary.tags.join(', ')}`);
1241
+ }
1242
+ console.log('\n Run without --dry-run to commit the merge.');
1243
+ } else {
1244
+ // Bug 58e6d000: server treats :id as the source (soft-deleted) and merge_into as target (kept)
1245
+ // So we POST to /leads/:secondaryId/merge with { merge_into: primaryId }
1246
+ const res = await client.post(`/leads/${secondaryId}/merge`, { merge_into: primaryId });
1247
+ console.log(`Merged lead ${secondaryId} into ${primaryId}.`);
1248
+ printJSON(res.data);
1249
+ }
1250
+ } catch (err) {
1251
+ handleError(err);
1252
+ }
1253
+ });
1254
+
1255
+ // Sprint 54 T1: leads bulk-update --ids — ID-based bulk status update (distinct from filter-based bulk-update)
1256
+ leadsCmd
1257
+ .command('bulk-update-ids')
1258
+ .description('Bulk update status for specific leads by ID list (Sprint 54 T1). Up to 100 leads per call.')
1259
+ .requiredOption('--ids <ids>', 'Comma-separated lead IDs to update')
1260
+ .requiredOption('--status <status>', 'New status: new|contacted|qualified|unresponsive|converted|lost')
1261
+ .option('--close-reason <reason>', 'Close reason (required when --status lost)')
1262
+ .action(async (opts) => {
1263
+ const globalOpts = program.opts();
1264
+ const client = getClient(globalOpts);
1265
+ const ids = opts.ids.split(',').map(s => s.trim()).filter(Boolean);
1266
+ if (ids.length === 0) { console.error('Error: --ids requires at least one ID'); process.exit(1); }
1267
+ const updates = { status: opts.status };
1268
+ if (opts.closeReason) updates.close_reason = opts.closeReason;
1269
+ try {
1270
+ const res = await client.patch('/leads/bulk', { ids, updates });
1271
+ const d = res.data.data || {};
1272
+ console.log(`Updated: ${d.updated !== undefined ? d.updated : ids.length} lead(s).`);
1273
+ if (d.failed && d.failed.length > 0) {
1274
+ console.log(`Failed IDs: ${d.failed.join(', ')}`);
1275
+ }
1276
+ printJSON(res.data);
1277
+ } catch (err) {
1278
+ handleError(err);
1279
+ }
1280
+ });
1281
+
1282
+ // Sprint 54 T1: leads bulk-tag — add a tag to multiple leads by ID
1283
+ leadsCmd
1284
+ .command('bulk-tag')
1285
+ .description('Add a tag to multiple leads by ID list (Sprint 54 T1). Up to 100 leads per call.')
1286
+ .requiredOption('--tag <tag>', 'Tag to add to each lead')
1287
+ .requiredOption('--ids <ids>', 'Comma-separated lead IDs')
1288
+ .action(async (opts) => {
1289
+ const globalOpts = program.opts();
1290
+ const client = getClient(globalOpts);
1291
+ const ids = opts.ids.split(',').map(s => s.trim()).filter(Boolean);
1292
+ if (ids.length === 0) { console.error('Error: --ids requires at least one ID'); process.exit(1); }
1293
+ try {
1294
+ const res = await client.patch('/leads/bulk', { ids, updates: { tags: [opts.tag] } });
1295
+ const d = res.data.data || {};
1296
+ console.log(`Tagged ${d.updated !== undefined ? d.updated : ids.length} lead(s) with "${opts.tag}".`);
1297
+ if (d.failed && d.failed.length > 0) {
1298
+ console.log(`Failed IDs: ${d.failed.join(', ')}`);
1299
+ }
1300
+ printJSON(res.data);
1301
+ } catch (err) {
1302
+ handleError(err);
1303
+ }
1304
+ });
1305
+
1306
+ // Sprint 54 T1: leads bulk-assign — reassign multiple leads to an agent by ID
1307
+ leadsCmd
1308
+ .command('bulk-assign')
1309
+ .description('Bulk reassign leads to an agent by ID list (Sprint 54 T1). Up to 100 leads per call.')
1310
+ .requiredOption('--to <agent>', 'Agent/user to assign leads to')
1311
+ .requiredOption('--ids <ids>', 'Comma-separated lead IDs')
1312
+ .action(async (opts) => {
1313
+ const globalOpts = program.opts();
1314
+ const client = getClient(globalOpts);
1315
+ const ids = opts.ids.split(',').map(s => s.trim()).filter(Boolean);
1316
+ if (ids.length === 0) { console.error('Error: --ids requires at least one ID'); process.exit(1); }
1317
+ try {
1318
+ const res = await client.patch('/leads/bulk', { ids, updates: { assigned_to: opts.to } });
1319
+ const d = res.data.data || {};
1320
+ console.log(`Assigned ${d.updated !== undefined ? d.updated : ids.length} lead(s) to "${opts.to}".`);
1321
+ if (d.failed && d.failed.length > 0) {
1322
+ console.log(`Failed IDs: ${d.failed.join(', ')}`);
1323
+ }
1324
+ printJSON(res.data);
1325
+ } catch (err) {
1326
+ handleError(err);
1327
+ }
1328
+ });
1329
+
1214
1330
  // Sprint 52 T2: leads escalate + leads unassign
1215
1331
  leadsCmd
1216
1332
  .command('escalate <lead-id>')
@@ -3571,6 +3687,468 @@ sequencesCmd
3571
3687
  } catch (err) { handleError(err); }
3572
3688
  });
3573
3689
 
3690
+ // Sprint 54 T3: sequences stats <sequenceId>
3691
+ sequencesCmd
3692
+ .command('stats <sequenceId>')
3693
+ .description('Get enrollment stats for a sequence (Sprint 54 T3)')
3694
+ .action(async (sequenceId) => {
3695
+ const globalOpts = program.opts();
3696
+ const client = getClient(globalOpts);
3697
+ try {
3698
+ const res = await client.get(`/sequences/${sequenceId}/stats`);
3699
+ const d = res.data.data || {};
3700
+ console.log(`\nSequence: ${d.sequence_name} [${d.sequence_id}]`);
3701
+ console.log(`Active: ${d.active ? 'yes' : 'no'} Steps: ${d.step_count}`);
3702
+ console.log(`Total enrolled: ${d.total_enrolled}`);
3703
+ if (d.by_status) {
3704
+ console.log(` active: ${d.by_status.active}`);
3705
+ console.log(` completed: ${d.by_status.completed}`);
3706
+ console.log(` unsubscribed: ${d.by_status.unsubscribed}`);
3707
+ console.log(` paused: ${d.by_status.paused}`);
3708
+ console.log(` failed: ${d.by_status.failed}`);
3709
+ }
3710
+ if (d.first_enrollment) console.log(`First enrollment: ${new Date(d.first_enrollment).toLocaleString()}`);
3711
+ if (d.last_enrollment) console.log(`Last enrollment: ${new Date(d.last_enrollment).toLocaleString()}`);
3712
+ } catch (err) { handleError(err); }
3713
+ });
3714
+
3715
+ // Sprint 54 T3: leads sequences <lead-id>
3716
+ leadsCmd
3717
+ .command('sequences <lead-id>')
3718
+ .description('List sequences a lead is enrolled in (Sprint 54 T3)')
3719
+ .action(async (leadId) => {
3720
+ const globalOpts = program.opts();
3721
+ const client = getClient(globalOpts);
3722
+ try {
3723
+ const res = await client.get(`/leads/${leadId}/sequences`);
3724
+ const enrollments = res.data.data || res.data || [];
3725
+ if (enrollments.length === 0) { console.log('Lead is not enrolled in any sequences.'); return; }
3726
+ enrollments.forEach(e => {
3727
+ const step = e.current_step || 'N/A';
3728
+ const next = e.next_send_at ? new Date(e.next_send_at).toLocaleString() : 'N/A';
3729
+ console.log(`[${e.sequence_id}] ${e.sequence_name} — status: ${e.status}, step: ${step}, next: ${next}`);
3730
+ });
3731
+ } catch (err) { handleError(err); }
3732
+ });
3733
+
3734
+ // ============================================================
3735
+ // FORMS COMMANDS — Sprint 54 T2
3736
+ // ============================================================
3737
+ const formsCmd = program.command('forms').description('Manage lead capture forms (Sprint 54 T2)');
3738
+
3739
+ formsCmd
3740
+ .command('list')
3741
+ .description('List all forms in the workspace')
3742
+ .action(async () => {
3743
+ const globalOpts = program.opts();
3744
+ const client = getClient(globalOpts);
3745
+ try {
3746
+ const res = await client.get('/apikey/forms');
3747
+ const forms = res.data.data || res.data || [];
3748
+ if (forms.length === 0) { console.log('No forms found.'); return; }
3749
+ forms.forEach(f => {
3750
+ console.log(`[${f.id}] ${f.name} — submissions: ${f.submission_count || 0} — created: ${new Date(f.created_at).toLocaleDateString()}`);
3751
+ });
3752
+ } catch (err) { handleError(err); }
3753
+ });
3754
+
3755
+ formsCmd
3756
+ .command('get <form-id>')
3757
+ .description('Get form details by ID')
3758
+ .action(async (formId) => {
3759
+ const globalOpts = program.opts();
3760
+ const client = getClient(globalOpts);
3761
+ try {
3762
+ const res = await client.get(`/apikey/forms/${formId}`);
3763
+ printJSON(res.data);
3764
+ } catch (err) { handleError(err); }
3765
+ });
3766
+
3767
+ formsCmd
3768
+ .command('create')
3769
+ .description('Create a new lead capture form')
3770
+ .requiredOption('--name <name>', 'Form name')
3771
+ .option('--success-message <msg>', 'Message shown after submission', 'Thank you! We will be in touch.')
3772
+ .option('--redirect-url <url>', 'Redirect URL after submission')
3773
+ .option('--fields <json>', 'JSON array of field definitions (e.g. \'[{"key":"email","label":"Email","type":"email","required":true}]\')')
3774
+ .action(async (opts) => {
3775
+ const globalOpts = program.opts();
3776
+ const client = getClient(globalOpts);
3777
+ let fields = [];
3778
+ if (opts.fields) {
3779
+ try { fields = JSON.parse(opts.fields); } catch (e) { console.error('Error: --fields must be valid JSON array'); process.exit(1); }
3780
+ }
3781
+ try {
3782
+ const body = { name: opts.name, fields, success_message: opts.successMessage };
3783
+ if (opts.redirectUrl) body.redirect_url = opts.redirectUrl;
3784
+ const res = await client.post('/apikey/forms', body);
3785
+ console.log(`Form created: ${res.data.data?.id || res.data?.id}`);
3786
+ printJSON(res.data);
3787
+ } catch (err) { handleError(err); }
3788
+ });
3789
+
3790
+ formsCmd
3791
+ .command('submissions <form-id>')
3792
+ .description('List submissions for a form')
3793
+ .option('--limit <n>', 'Max results', '50')
3794
+ .option('--offset <n>', 'Pagination offset', '0')
3795
+ .action(async (formId, opts) => {
3796
+ const globalOpts = program.opts();
3797
+ const client = getClient(globalOpts);
3798
+ try {
3799
+ const res = await client.get(`/apikey/forms/${formId}/submissions`, { params: { limit: opts.limit, offset: opts.offset } });
3800
+ const d = res.data.data || {};
3801
+ const subs = d.data || [];
3802
+ if (subs.length === 0) { console.log('No submissions found.'); return; }
3803
+ console.log(`Total: ${d.total || subs.length}`);
3804
+ subs.forEach(s => {
3805
+ const when = s.submitted_at ? new Date(s.submitted_at).toLocaleString() : 'N/A';
3806
+ console.log(`[${s.id}] ${s.lead_name || 'Unknown'} <${s.lead_email || ''}> — ${when}`);
3807
+ });
3808
+ } catch (err) { handleError(err); }
3809
+ });
3810
+
3811
+ formsCmd
3812
+ .command('delete <form-id>')
3813
+ .description('Delete a form by ID')
3814
+ .action(async (formId) => {
3815
+ const globalOpts = program.opts();
3816
+ const client = getClient(globalOpts);
3817
+ try {
3818
+ await client.delete(`/apikey/forms/${formId}`);
3819
+ console.log(`Form ${formId} deleted.`);
3820
+ } catch (err) { handleError(err); }
3821
+ });
3822
+
3823
+ // ============================================================
3824
+ // AUDIT COMMANDS — Sprint 55 T1 (admin JWT via stagesAdminLogin)
3825
+ // ============================================================
3826
+ const auditCmd = program.command('audit').description('View workspace audit log (Sprint 55 T1) — requires admin login');
3827
+
3828
+ auditCmd
3829
+ .command('list')
3830
+ .description('List audit log entries with optional filters')
3831
+ .option('--lead-id <id>', 'Filter by lead ID')
3832
+ .option('--author-type <type>', 'Filter by actor type: api_key|human-admin|system')
3833
+ .option('--from <date>', 'From date (YYYY-MM-DD)')
3834
+ .option('--to <date>', 'To date (YYYY-MM-DD)')
3835
+ .option('--limit <n>', 'Max results (max 200)', '50')
3836
+ .option('--offset <n>', 'Pagination offset', '0')
3837
+ .option('--workspace <slug>', 'Workspace slug')
3838
+ .option('--email <email>', 'Admin email')
3839
+ .option('--password <password>', 'Admin password')
3840
+ .action(async (opts) => {
3841
+ const globalOpts = program.opts();
3842
+ const apiUrl = resolveApiUrl(globalOpts.url);
3843
+ if (!apiUrl) { console.error('Error: API URL not configured.'); process.exit(1); }
3844
+ const cfg = loadConfig();
3845
+ try {
3846
+ const base = apiUrl.replace(/\/$/, '');
3847
+ const token = cfg.token || await stagesAdminLogin(base, cfg, opts);
3848
+ const params = { limit: opts.limit, offset: opts.offset };
3849
+ if (opts.leadId) params.lead_id = opts.leadId;
3850
+ if (opts.authorType) params.author_type = opts.authorType;
3851
+ if (opts.from) params.from = opts.from;
3852
+ if (opts.to) params.to = opts.to;
3853
+ const res = await axios.get(`${base}/api/v1/admin/audit-log`, {
3854
+ params, headers: { Authorization: `Bearer ${token}` }
3855
+ });
3856
+ const d = res.data.data || {};
3857
+ const items = d.items || [];
3858
+ console.log(`Total: ${d.total || items.length}`);
3859
+ items.forEach(e => {
3860
+ const when = new Date(e.created_at).toLocaleString();
3861
+ console.log(`[${e.id}] ${when} | ${e.actor_type} ${e.actor_name || 'system'} | ${e.action}: ${e.old_value || '—'} → ${e.new_value || '—'} | lead: ${e.lead_name || e.lead_id || 'N/A'}`);
3862
+ });
3863
+ } catch (err) { handleError(err); }
3864
+ });
3865
+
3866
+ auditCmd
3867
+ .command('get <entry-id>')
3868
+ .description('Get a single audit log entry by ID')
3869
+ .option('--workspace <slug>', 'Workspace slug')
3870
+ .option('--email <email>', 'Admin email')
3871
+ .option('--password <password>', 'Admin password')
3872
+ .action(async (entryId, opts) => {
3873
+ const globalOpts = program.opts();
3874
+ const apiUrl = resolveApiUrl(globalOpts.url);
3875
+ if (!apiUrl) { console.error('Error: API URL not configured.'); process.exit(1); }
3876
+ const cfg = loadConfig();
3877
+ try {
3878
+ const base = apiUrl.replace(/\/$/, '');
3879
+ const token = cfg.token || await stagesAdminLogin(base, cfg, opts);
3880
+ const res = await axios.get(`${base}/api/v1/admin/audit-log/${entryId}`, {
3881
+ headers: { Authorization: `Bearer ${token}` }
3882
+ });
3883
+ printJSON(res.data);
3884
+ } catch (err) { handleError(err); }
3885
+ });
3886
+
3887
+ // ============================================================
3888
+ // TAGS COMMANDS — Sprint 55 T1 (API key auth)
3889
+ // ============================================================
3890
+ const tagsCmd = program.command('tags').description('Explore lead tags (Sprint 55 T1)');
3891
+
3892
+ tagsCmd
3893
+ .command('list')
3894
+ .description('List all unique tags in use with lead counts')
3895
+ .option('--limit <n>', 'Max tags to return', '100')
3896
+ .action(async (opts) => {
3897
+ const globalOpts = program.opts();
3898
+ const client = getClient(globalOpts);
3899
+ try {
3900
+ const res = await client.get('/tags', { params: { limit: opts.limit } });
3901
+ const tags = res.data.data || res.data || [];
3902
+ if (tags.length === 0) { console.log('No tags in use.'); return; }
3903
+ tags.forEach(t => console.log(`${t.tag} (${t.count} lead${t.count !== 1 ? 's' : ''})`));
3904
+ } catch (err) { handleError(err); }
3905
+ });
3906
+
3907
+ tagsCmd
3908
+ .command('leads <tag-name>')
3909
+ .description('List leads with a specific tag')
3910
+ .option('--limit <n>', 'Max results', '50')
3911
+ .action(async (tagName, opts) => {
3912
+ const globalOpts = program.opts();
3913
+ const client = getClient(globalOpts);
3914
+ try {
3915
+ const res = await client.get('/leads', { params: { tag: tagName, per_page: parseInt(opts.limit) } });
3916
+ const leads = res.data.data || [];
3917
+ if (leads.length === 0) { console.log(`No leads found with tag "${tagName}".`); return; }
3918
+ console.log(`Leads tagged "${tagName}" (${leads.length}):`);
3919
+ leads.forEach(l => console.log(`[${l.id}] ${l.name} <${l.email}> — ${l.status} (${l.assigned_to || 'unassigned'})`));
3920
+ } catch (err) { handleError(err); }
3921
+ });
3922
+
3923
+ // ============================================================
3924
+ // SLA COMMANDS — Sprint 55 T2 (admin JWT via stagesAdminLogin)
3925
+ // ============================================================
3926
+ const slaCmd = program.command('sla').description('Manage SLA escalation rules and view breaches (Sprint 55 T2)');
3927
+
3928
+ slaCmd
3929
+ .command('list')
3930
+ .description('List all SLA escalation rules')
3931
+ .option('--workspace <slug>', 'Workspace slug')
3932
+ .option('--email <email>', 'Admin email')
3933
+ .option('--password <password>', 'Admin password')
3934
+ .action(async (opts) => {
3935
+ const globalOpts = program.opts();
3936
+ const apiUrl = resolveApiUrl(globalOpts.url);
3937
+ if (!apiUrl) { console.error('Error: API URL not configured.'); process.exit(1); }
3938
+ const cfg = loadConfig();
3939
+ try {
3940
+ const base = apiUrl.replace(/\/$/, '');
3941
+ const token = cfg.token || await stagesAdminLogin(base, cfg, opts);
3942
+ const res = await axios.get(`${base}/api/v1/admin/sla-escalation-rules`, {
3943
+ headers: { Authorization: `Bearer ${token}` }
3944
+ });
3945
+ const rules = res.data.data || res.data || [];
3946
+ if (rules.length === 0) { console.log('No SLA rules configured.'); return; }
3947
+ rules.forEach(r => {
3948
+ const hours = (r.escalate_after_breach_minutes / 60).toFixed(1);
3949
+ const stage = r.stage_name_label ? `stage: ${r.stage_name_label}` : 'all stages';
3950
+ const active = r.is_active ? '✓' : '✗';
3951
+ console.log(`[${r.id}] ${active} "${r.name}" — ${hours}h breach → escalate to ${r.escalate_to} (${stage})`);
3952
+ });
3953
+ } catch (err) { handleError(err); }
3954
+ });
3955
+
3956
+ slaCmd
3957
+ .command('create')
3958
+ .description('Create a new SLA escalation rule')
3959
+ .requiredOption('--name <name>', 'Rule name')
3960
+ .requiredOption('--hours <n>', 'Hours after breach before escalating')
3961
+ .requiredOption('--escalate-to <agent>', 'Agent/email to escalate to')
3962
+ .option('--stage-id <id>', 'Pipeline stage UUID to scope this rule (omit for all stages)')
3963
+ .option('--workspace <slug>', 'Workspace slug')
3964
+ .option('--email <email>', 'Admin email')
3965
+ .option('--password <password>', 'Admin password')
3966
+ .action(async (opts) => {
3967
+ const globalOpts = program.opts();
3968
+ const apiUrl = resolveApiUrl(globalOpts.url);
3969
+ if (!apiUrl) { console.error('Error: API URL not configured.'); process.exit(1); }
3970
+ const cfg = loadConfig();
3971
+ const minutes = Math.round(parseFloat(opts.hours) * 60);
3972
+ if (isNaN(minutes) || minutes < 1) { console.error('Error: --hours must be a positive number'); process.exit(1); }
3973
+ try {
3974
+ const base = apiUrl.replace(/\/$/, '');
3975
+ const token = cfg.token || await stagesAdminLogin(base, cfg, opts);
3976
+ const body = { name: opts.name, escalate_after_breach_minutes: minutes, escalate_to: opts.escalateTo };
3977
+ if (opts.stageId) body.stage_id = opts.stageId;
3978
+ const res = await axios.post(`${base}/api/v1/admin/sla-escalation-rules`, body, {
3979
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }
3980
+ });
3981
+ console.log(`SLA rule created: ${res.data.data?.id || res.data?.id}`);
3982
+ printJSON(res.data);
3983
+ } catch (err) { handleError(err); }
3984
+ });
3985
+
3986
+ slaCmd
3987
+ .command('update <id>')
3988
+ .description('Update an SLA escalation rule')
3989
+ .option('--name <name>', 'New rule name')
3990
+ .option('--hours <n>', 'New escalation threshold in hours')
3991
+ .option('--escalate-to <agent>', 'New escalation target')
3992
+ .option('--active <bool>', 'Enable/disable rule (true|false)')
3993
+ .option('--workspace <slug>', 'Workspace slug')
3994
+ .option('--email <email>', 'Admin email')
3995
+ .option('--password <password>', 'Admin password')
3996
+ .action(async (id, opts) => {
3997
+ const globalOpts = program.opts();
3998
+ const apiUrl = resolveApiUrl(globalOpts.url);
3999
+ if (!apiUrl) { console.error('Error: API URL not configured.'); process.exit(1); }
4000
+ const cfg = loadConfig();
4001
+ const body = {};
4002
+ if (opts.name) body.name = opts.name;
4003
+ if (opts.hours !== undefined) {
4004
+ const minutes = Math.round(parseFloat(opts.hours) * 60);
4005
+ if (isNaN(minutes) || minutes < 1) { console.error('Error: --hours must be a positive number'); process.exit(1); }
4006
+ body.escalate_after_breach_minutes = minutes;
4007
+ }
4008
+ if (opts.escalateTo) body.escalate_to = opts.escalateTo;
4009
+ if (opts.active !== undefined) body.is_active = opts.active === 'true';
4010
+ if (Object.keys(body).length === 0) { console.error('Error: provide at least one field to update'); process.exit(1); }
4011
+ try {
4012
+ const base = apiUrl.replace(/\/$/, '');
4013
+ const token = cfg.token || await stagesAdminLogin(base, cfg, opts);
4014
+ const res = await axios.patch(`${base}/api/v1/admin/sla-escalation-rules/${id}`, body, {
4015
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }
4016
+ });
4017
+ console.log(`SLA rule ${id} updated.`);
4018
+ printJSON(res.data);
4019
+ } catch (err) { handleError(err); }
4020
+ });
4021
+
4022
+ slaCmd
4023
+ .command('delete <id>')
4024
+ .description('Delete an SLA escalation rule')
4025
+ .option('--workspace <slug>', 'Workspace slug')
4026
+ .option('--email <email>', 'Admin email')
4027
+ .option('--password <password>', 'Admin password')
4028
+ .action(async (id, opts) => {
4029
+ const globalOpts = program.opts();
4030
+ const apiUrl = resolveApiUrl(globalOpts.url);
4031
+ if (!apiUrl) { console.error('Error: API URL not configured.'); process.exit(1); }
4032
+ const cfg = loadConfig();
4033
+ try {
4034
+ const base = apiUrl.replace(/\/$/, '');
4035
+ const token = cfg.token || await stagesAdminLogin(base, cfg, opts);
4036
+ await axios.delete(`${base}/api/v1/admin/sla-escalation-rules/${id}`, {
4037
+ headers: { Authorization: `Bearer ${token}` }
4038
+ });
4039
+ console.log(`SLA rule ${id} deleted.`);
4040
+ } catch (err) { handleError(err); }
4041
+ });
4042
+
4043
+ slaCmd
4044
+ .command('breaches')
4045
+ .description('List leads with active SLA breaches')
4046
+ .option('--status <status>', 'open (default) or resolved', 'open')
4047
+ .option('--limit <n>', 'Max results', '50')
4048
+ .option('--workspace <slug>', 'Workspace slug')
4049
+ .option('--email <email>', 'Admin email')
4050
+ .option('--password <password>', 'Admin password')
4051
+ .action(async (opts) => {
4052
+ const globalOpts = program.opts();
4053
+ const apiUrl = resolveApiUrl(globalOpts.url);
4054
+ if (!apiUrl) { console.error('Error: API URL not configured.'); process.exit(1); }
4055
+ const cfg = loadConfig();
4056
+ try {
4057
+ const base = apiUrl.replace(/\/$/, '');
4058
+ const token = cfg.token || await stagesAdminLogin(base, cfg, opts);
4059
+ const res = await axios.get(`${base}/api/v1/admin/sla-breaches`, {
4060
+ params: { status: opts.status, limit: opts.limit },
4061
+ headers: { Authorization: `Bearer ${token}` }
4062
+ });
4063
+ const d = res.data.data || {};
4064
+ const items = d.items || [];
4065
+ if (items.length === 0) { console.log(`No ${opts.status} SLA breaches.`); return; }
4066
+ console.log(`${opts.status.toUpperCase()} SLA breaches (${d.total || items.length}):`);
4067
+ items.forEach(l => {
4068
+ const breached = l.breached_at ? new Date(l.breached_at).toLocaleString() : 'N/A';
4069
+ console.log(`[${l.id}] ${l.name} <${l.email}> — ${l.status} | stage: ${l.pipeline_stage || 'N/A'} | breached: ${breached} | assigned: ${l.assigned_to || 'none'}`);
4070
+ });
4071
+ } catch (err) { handleError(err); }
4072
+ });
4073
+
4074
+ // ============================================================
4075
+ // ANALYTICS COMMANDS — Sprint 55 T3 (admin JWT via stagesAdminLogin)
4076
+ // (analyticsCmd already declared above — appending pipeline + funnel subcommands)
4077
+ // ============================================================
4078
+
4079
+ analyticsCmd
4080
+ .command('pipeline')
4081
+ .description('Stage-by-stage pipeline breakdown: lead count, avg deal value, avg time-in-stage, conversion rate')
4082
+ .option('--stage <name>', 'Filter output to a single stage name')
4083
+ .option('--workspace <slug>', 'Workspace slug')
4084
+ .option('--email <email>', 'Admin email')
4085
+ .option('--password <password>', 'Admin password')
4086
+ .action(async (opts) => {
4087
+ const globalOpts = program.opts();
4088
+ const apiUrl = resolveApiUrl(globalOpts.url);
4089
+ if (!apiUrl) { console.error('Error: API URL not configured.'); process.exit(1); }
4090
+ const cfg = loadConfig();
4091
+ try {
4092
+ const base = apiUrl.replace(/\/$/, '');
4093
+ const token = cfg.token || await stagesAdminLogin(base, cfg, opts);
4094
+ const res = await axios.get(`${base}/api/v1/admin/analytics/funnel`, {
4095
+ headers: { Authorization: `Bearer ${token}` }
4096
+ });
4097
+ let stages = (res.data.data?.stages || []);
4098
+ if (opts.stage) {
4099
+ stages = stages.filter(s => s.stage_name.toLowerCase() === opts.stage.toLowerCase());
4100
+ if (stages.length === 0) { console.log(`Stage "${opts.stage}" not found.`); return; }
4101
+ }
4102
+ console.log('\nPipeline Breakdown:');
4103
+ console.log('─'.repeat(90));
4104
+ stages.forEach(s => {
4105
+ const deal = s.avg_deal_value != null ? `$${s.avg_deal_value}` : 'N/A';
4106
+ const time = s.avg_time_in_stage_hours != null ? `${s.avg_time_in_stage_hours}h` : 'N/A';
4107
+ const conv = s.conversion_rate_to_next_stage != null ? `${s.conversion_rate_to_next_stage}%` : 'N/A';
4108
+ console.log(`${String(s.stage_name).padEnd(20)} leads: ${String(s.lead_count).padStart(4)} | avg deal: ${String(deal).padStart(10)} | avg time: ${String(time).padStart(8)} | conv: ${conv}`);
4109
+ });
4110
+ } catch (err) { handleError(err); }
4111
+ });
4112
+
4113
+ analyticsCmd
4114
+ .command('funnel')
4115
+ .description('Full top-to-bottom conversion funnel with drop-off rates')
4116
+ .option('--from <date>', 'From date (YYYY-MM-DD, default: 30 days ago)')
4117
+ .option('--to <date>', 'To date (YYYY-MM-DD, default: today)')
4118
+ .option('--workspace <slug>', 'Workspace slug')
4119
+ .option('--email <email>', 'Admin email')
4120
+ .option('--password <password>', 'Admin password')
4121
+ .action(async (opts) => {
4122
+ const globalOpts = program.opts();
4123
+ const apiUrl = resolveApiUrl(globalOpts.url);
4124
+ if (!apiUrl) { console.error('Error: API URL not configured.'); process.exit(1); }
4125
+ const cfg = loadConfig();
4126
+ try {
4127
+ const base = apiUrl.replace(/\/$/, '');
4128
+ const token = cfg.token || await stagesAdminLogin(base, cfg, opts);
4129
+ const params = {};
4130
+ if (opts.from) params.from = opts.from;
4131
+ if (opts.to) params.to = opts.to;
4132
+ const res = await axios.get(`${base}/api/v1/admin/analytics/funnel`, {
4133
+ params, headers: { Authorization: `Bearer ${token}` }
4134
+ });
4135
+ const d = res.data.data || {};
4136
+ const stages = d.stages || [];
4137
+ const from = d.from ? new Date(d.from).toLocaleDateString() : '30d ago';
4138
+ const to = d.to ? new Date(d.to).toLocaleDateString() : 'today';
4139
+ console.log(`\nConversion Funnel (${from} → ${to}):`);
4140
+ console.log('─'.repeat(85));
4141
+ stages.forEach((s, i) => {
4142
+ const entered = s.leads_entered_window || 0;
4143
+ const exited = s.leads_exited_window || 0;
4144
+ const dropOff = s.drop_off_rate != null ? `${s.drop_off_rate}% drop-off` : 'N/A';
4145
+ const conv = s.conversion_rate_to_next_stage != null ? `${s.conversion_rate_to_next_stage}%` : 'N/A';
4146
+ const bar = '█'.repeat(Math.min(Math.round((entered / Math.max(...stages.map(x => x.leads_entered_window || 0), 1)) * 20), 20));
4147
+ console.log(`${String(s.stage_name).padEnd(20)} ${bar.padEnd(20)} entered: ${String(entered).padStart(4)} | exited: ${String(exited).padStart(4)} | conv: ${String(conv).padStart(5)} | ${dropOff}`);
4148
+ });
4149
+ } catch (err) { handleError(err); }
4150
+ });
4151
+
3574
4152
  // ============================================================
3575
4153
  // GDPR COMMANDS — Sprint 50 T2
3576
4154
  // ============================================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startanaicompany/crm",
3
- "version": "2.16.0",
3
+ "version": "2.18.0",
4
4
  "description": "AI-first CRM CLI — manage leads and API keys from the terminal",
5
5
  "main": "index.js",
6
6
  "bin": {