@badgie/crm-cli 0.5.0 → 0.7.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.
@@ -1,32 +1,293 @@
1
1
  import { getAuthedClient } from '../core/supabase.js';
2
2
  import { commonListOptions, output, parseColumns, parseLimit } from '../core/format.js';
3
- import { maybeResolve } from '../core/resolve.js';
3
+ import { maybeResolve, resolveRef } from '../core/resolve.js';
4
4
  import { bulkOptions, collectIds, runBulk } from '../core/bulk.js';
5
5
  import { swapIfLegacyOrder } from '../core/compat.js';
6
- async function listTopClients(_a, opts) {
7
- const { client } = await getAuthedClient();
6
+ const ACTIVE_CLIENT_STATUSES = ['active', 'active free trial'];
7
+ const REVIEW_SELECT = `
8
+ *,
9
+ reviewed_by_member:team_members!client_monthly_reviews_reviewed_by_fkey(id, full_name, email, avatar_url),
10
+ client:clients(
11
+ id, name, email, sport, city, status, assigned_to,
12
+ plan_type, plan_student_tier, plan_student_count, plan_payment_frequency, plan_payment_amount,
13
+ assigned:team_members!clients_assigned_to_fkey(id, full_name, email, avatar_url)
14
+ )
15
+ `;
16
+ const CLIENT_CONFIG_SELECT = `
17
+ id, name, email, sport, city, status, assigned_to,
18
+ plan_type, plan_student_tier, plan_student_count, plan_payment_frequency, plan_payment_amount,
19
+ assigned:team_members!clients_assigned_to_fkey(id, full_name, email, avatar_url)
20
+ `;
21
+ const TOP_CLIENT_QUESTIONS = [
22
+ {
23
+ id: 'notes',
24
+ key: 'notes',
25
+ label: 'Notas generales',
26
+ storage: { table: 'client_monthly_reviews', column: 'notes', type: 'text' },
27
+ answerCommand: 'badgie-crm top-clients notes set --review-id <id> --notes "..."',
28
+ },
29
+ {
30
+ id: 'needs',
31
+ key: 'needs',
32
+ label: 'Necesidades identificadas',
33
+ storage: { table: 'client_monthly_reviews', column: 'needs', type: 'text[]' },
34
+ answerCommand: 'badgie-crm top-clients answer set --review-id <id> --question needs --value \'["..."]\'',
35
+ },
36
+ {
37
+ id: 'testimonials',
38
+ key: 'testimonials',
39
+ label: 'Testimonios',
40
+ storage: { table: 'client_monthly_reviews', column: 'testimonials', type: 'text[]' },
41
+ answerCommand: 'badgie-crm top-clients answer set --review-id <id> --question testimonials --value \'["..."]\'',
42
+ },
43
+ {
44
+ id: 'risk_level',
45
+ key: 'risk_level',
46
+ label: 'Riesgo',
47
+ allowedValues: ['low', 'medium', 'high'],
48
+ storage: { table: 'client_monthly_reviews', column: 'risk_level', type: 'text' },
49
+ answerCommand: 'badgie-crm top-clients answer set --review-id <id> --question risk_level --value high',
50
+ },
51
+ {
52
+ id: 'next_actions',
53
+ key: 'next_actions',
54
+ label: 'Proximas acciones',
55
+ storage: { table: 'client_monthly_reviews', column: 'next_actions', type: 'text[]' },
56
+ answerCommand: 'badgie-crm top-clients answer set --review-id <id> --question next_actions --value \'["..."]\'',
57
+ },
58
+ {
59
+ id: 'status',
60
+ key: 'status',
61
+ label: 'Estado',
62
+ allowedValues: ['pending', 'in_progress', 'completed'],
63
+ storage: { table: 'client_monthly_reviews', column: 'status', type: 'text' },
64
+ answerCommand: 'badgie-crm top-clients set-status completed <id>',
65
+ },
66
+ {
67
+ id: 'reviewed_at',
68
+ key: 'reviewed_at',
69
+ label: 'Fecha de revision',
70
+ storage: { table: 'client_monthly_reviews', column: 'reviewed_at', type: 'timestamp' },
71
+ answerCommand: 'badgie-crm top-clients answer set --review-id <id> --question reviewed_at --value 2026-04-26',
72
+ },
73
+ {
74
+ id: 'satisfaction_score',
75
+ key: 'satisfaction_score',
76
+ label: 'Satisfaccion',
77
+ allowedValues: [1, 2, 3, 4, 5],
78
+ storage: { table: 'client_monthly_reviews', column: 'satisfaction_score', type: 'integer' },
79
+ answerCommand: 'badgie-crm top-clients answer set --review-id <id> --question satisfaction_score --value 5',
80
+ },
81
+ {
82
+ id: 'contacted',
83
+ key: 'contacted',
84
+ label: 'Contactado esta semana',
85
+ allowedValues: [true, false],
86
+ storage: { table: 'client_monthly_reviews', column: 'metadata.contacted/contact_log', type: 'jsonb' },
87
+ answerCommand: 'badgie-crm top-clients answer set --review-id <id> --question contacted --value true',
88
+ },
89
+ ];
90
+ const QUESTION_ALIASES = Object.fromEntries(TOP_CLIENT_QUESTIONS.flatMap((q) => [
91
+ [q.id, q.key],
92
+ [q.key, q.key],
93
+ ]));
94
+ function currentMonthYear(opts) {
8
95
  const now = new Date();
9
96
  const month = typeof opts.month === 'string' ? Number(opts.month) : now.getMonth() + 1;
10
97
  const year = typeof opts.year === 'string' ? Number(opts.year) : now.getFullYear();
11
- // Reviews for selected month/year
98
+ assertMonthYear(month, year);
99
+ return { month, year };
100
+ }
101
+ function assertMonthYear(month, year) {
102
+ if (!Number.isInteger(month) || month < 1 || month > 12) {
103
+ throw new Error('--month must be an integer between 1 and 12');
104
+ }
105
+ if (!Number.isInteger(year) || year < 2000 || year > 2100) {
106
+ throw new Error('--year must be a YYYY integer');
107
+ }
108
+ }
109
+ function boolFromUnknown(value, label) {
110
+ if (typeof value === 'boolean')
111
+ return value;
112
+ if (typeof value !== 'string')
113
+ throw new Error(`${label} must be true|false`);
114
+ const v = value.trim().toLowerCase();
115
+ if (v === 'true' || v === '1' || v === 'yes')
116
+ return true;
117
+ if (v === 'false' || v === '0' || v === 'no')
118
+ return false;
119
+ throw new Error(`${label} must be true|false (got "${value}")`);
120
+ }
121
+ function nullableString(value) {
122
+ if (value === undefined)
123
+ return undefined;
124
+ if (typeof value !== 'string')
125
+ return undefined;
126
+ const v = value.trim();
127
+ if (v === '' || v.toLowerCase() === 'null' || v.toLowerCase() === 'none')
128
+ return null;
129
+ return v;
130
+ }
131
+ function numberOrNull(value, label) {
132
+ if (value === undefined)
133
+ return undefined;
134
+ if (typeof value !== 'string' || value.trim() === '' || value.trim().toLowerCase() === 'null') {
135
+ return null;
136
+ }
137
+ const n = Number(value);
138
+ if (!Number.isFinite(n))
139
+ throw new Error(`${label} must be a number`);
140
+ return n;
141
+ }
142
+ function parseJsonish(value) {
143
+ const trimmed = value.trim();
144
+ if (trimmed === '')
145
+ return '';
146
+ try {
147
+ return JSON.parse(trimmed);
148
+ }
149
+ catch {
150
+ return value;
151
+ }
152
+ }
153
+ function parseStringArray(value, label) {
154
+ const parsed = parseJsonish(value);
155
+ if (parsed === null)
156
+ return null;
157
+ if (Array.isArray(parsed)) {
158
+ return parsed.map((v) => String(v).trim()).filter(Boolean);
159
+ }
160
+ return value
161
+ .split(/\n|,/)
162
+ .map((v) => v.trim())
163
+ .filter(Boolean);
164
+ }
165
+ function normalizeStatus(status) {
166
+ if (status === 'pending' || status === 'in_progress' || status === 'completed')
167
+ return status;
168
+ throw new Error('status must be pending|in_progress|completed');
169
+ }
170
+ function normalizeRisk(risk) {
171
+ if (risk === 'low' || risk === 'medium' || risk === 'high')
172
+ return risk;
173
+ throw new Error('risk_level must be low|medium|high');
174
+ }
175
+ function safeMetadata(metadata) {
176
+ if (metadata && typeof metadata === 'object' && !Array.isArray(metadata)) {
177
+ return { ...metadata };
178
+ }
179
+ return {};
180
+ }
181
+ function isValidContactLogEntry(x) {
182
+ if (!x || typeof x !== 'object')
183
+ return false;
184
+ const o = x;
185
+ return typeof o.at === 'string' && (o.type === 'marked' || o.type === 'unmarked');
186
+ }
187
+ function mergeContactedMetadata(existing, contacted, reviewStatus) {
188
+ const base = safeMetadata(existing);
189
+ const prevLog = Array.isArray(base.contact_log)
190
+ ? base.contact_log.filter(isValidContactLogEntry)
191
+ : [];
192
+ return {
193
+ ...base,
194
+ contacted,
195
+ contact_log: [
196
+ ...prevLog,
197
+ {
198
+ at: new Date().toISOString(),
199
+ type: contacted ? 'marked' : 'unmarked',
200
+ review_status: reviewStatus || 'pending',
201
+ },
202
+ ],
203
+ };
204
+ }
205
+ function isContactedThisWeek(review) {
206
+ const now = new Date();
207
+ const dow = now.getDay();
208
+ const offsetToMonday = dow === 0 ? -6 : 1 - dow;
209
+ const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate() + offsetToMonday);
210
+ monday.setHours(0, 0, 0, 0);
211
+ const sunday = new Date(monday);
212
+ sunday.setDate(monday.getDate() + 6);
213
+ sunday.setHours(23, 59, 59, 999);
214
+ const metadata = safeMetadata(review.metadata);
215
+ const log = Array.isArray(metadata.contact_log)
216
+ ? metadata.contact_log.filter(isValidContactLogEntry)
217
+ : metadata.contacted === true && (review.updated_at || review.created_at)
218
+ ? [{ at: review.updated_at || review.created_at || '', type: 'marked' }]
219
+ : [];
220
+ const inWeek = log.filter((e) => {
221
+ const t = new Date(e.at).getTime();
222
+ return t >= monday.getTime() && t <= sunday.getTime();
223
+ });
224
+ inWeek.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime());
225
+ return inWeek[0]?.type === 'marked';
226
+ }
227
+ function answered(value) {
228
+ if (value === null || value === undefined)
229
+ return false;
230
+ if (Array.isArray(value))
231
+ return value.length > 0;
232
+ if (typeof value === 'string')
233
+ return value.trim().length > 0;
234
+ return true;
235
+ }
236
+ function outputOptions(opts) {
237
+ return { pretty: !!opts.pretty, columns: parseColumns(opts) };
238
+ }
239
+ async function getCurrentTeamMemberId(client) {
240
+ const { data } = await client.auth.getUser();
241
+ const email = data.user?.email;
242
+ if (!email)
243
+ return null;
244
+ const { data: member, error } = await client
245
+ .from('team_members')
246
+ .select('id')
247
+ .eq('email', email)
248
+ .maybeSingle();
249
+ if (error)
250
+ throw error;
251
+ return member?.id ?? null;
252
+ }
253
+ async function fetchReview(client, id) {
254
+ const { data, error } = await client
255
+ .from('client_monthly_reviews')
256
+ .select(REVIEW_SELECT)
257
+ .eq('id', id)
258
+ .single();
259
+ if (error)
260
+ throw error;
261
+ return data;
262
+ }
263
+ async function listTopClients(_a, opts) {
264
+ const { client } = await getAuthedClient();
265
+ const { month, year } = currentMonthYear(opts);
266
+ const kam = await maybeResolve(client, 'team_member', opts.kam);
267
+ const reviewer = await maybeResolve(client, 'team_member', opts.reviewer);
268
+ const limit = parseLimit(opts);
269
+ const hasClientSideFilters = !!kam || typeof opts.search === 'string';
12
270
  let reviewsQ = client
13
271
  .from('client_monthly_reviews')
14
- .select(`id, client_id, month, year, status, reviewed_by,
15
- reviewed_by_member:team_members!client_monthly_reviews_reviewed_by_fkey(id, full_name, email),
16
- client:clients(id, name, sport, city, assigned_to, assigned:team_members!clients_assigned_to_fkey(full_name, email))`)
272
+ .select(REVIEW_SELECT)
17
273
  .eq('month', month)
18
274
  .eq('year', year)
19
275
  .order('created_at', { ascending: false })
20
- .limit(parseLimit(opts));
21
- const kam = await maybeResolve(client, 'team_member', opts.kam);
22
- if (kam)
23
- reviewsQ = reviewsQ.eq('reviewed_by', kam);
276
+ .limit(hasClientSideFilters ? Math.max(1000, limit) : limit);
277
+ if (reviewer)
278
+ reviewsQ = reviewsQ.eq('reviewed_by', reviewer);
24
279
  if (typeof opts.status === 'string')
25
280
  reviewsQ = reviewsQ.eq('status', opts.status);
26
281
  const { data, error } = await reviewsQ;
27
282
  if (error)
28
283
  throw error;
29
- let rows = data ?? [];
284
+ let rows = (data ?? []);
285
+ if (kam) {
286
+ rows = rows.filter((r) => {
287
+ const c = r.client ?? null;
288
+ return c?.assigned_to === kam || r.reviewed_by === kam;
289
+ });
290
+ }
30
291
  if (typeof opts.search === 'string') {
31
292
  const needle = opts.search.toLowerCase();
32
293
  rows = rows.filter((r) => {
@@ -34,13 +295,168 @@ async function listTopClients(_a, opts) {
34
295
  return c?.name?.toLowerCase().includes(needle) ?? false;
35
296
  });
36
297
  }
37
- output(rows, { pretty: !!opts.pretty, columns: parseColumns(opts) });
298
+ if (hasClientSideFilters)
299
+ rows = rows.slice(0, limit);
300
+ output(rows, outputOptions(opts));
301
+ }
302
+ async function getTopClient(args, opts) {
303
+ const id = args.id;
304
+ if (!id)
305
+ throw new Error('review id is required');
306
+ const { client } = await getAuthedClient();
307
+ output(await fetchReview(client, id), { pretty: !!opts.pretty });
308
+ }
309
+ async function listQuestions(_a, opts) {
310
+ output({
311
+ source: 'client_monthly_reviews columns used by the current Top Clients UI',
312
+ questions: TOP_CLIENT_QUESTIONS,
313
+ }, { pretty: !!opts.pretty });
314
+ }
315
+ async function listAnswers(_a, opts) {
316
+ const reviewId = typeof opts.reviewId === 'string' ? opts.reviewId : null;
317
+ if (!reviewId)
318
+ throw new Error('--review-id is required');
319
+ const { client } = await getAuthedClient();
320
+ const review = await fetchReview(client, reviewId);
321
+ const rows = TOP_CLIENT_QUESTIONS.map((q) => {
322
+ let value = review[q.key];
323
+ if (q.key === 'contacted')
324
+ value = isContactedThisWeek(review);
325
+ return {
326
+ id: q.id,
327
+ key: q.key,
328
+ label: q.label,
329
+ value,
330
+ answered: answered(value),
331
+ storage: q.storage,
332
+ };
333
+ });
334
+ output(rows, outputOptions(opts));
335
+ }
336
+ async function summary(_a, opts) {
337
+ const { client } = await getAuthedClient();
338
+ const { month, year } = currentMonthYear(opts);
339
+ const [reviewsRes, clientsRes] = await Promise.all([
340
+ client
341
+ .from('client_monthly_reviews')
342
+ .select('id, client_id, status, risk_level, reviewed_by, reviewed_at, metadata, created_at, updated_at')
343
+ .eq('month', month)
344
+ .eq('year', year),
345
+ client
346
+ .from('clients')
347
+ .select('id, name, status, assigned_to')
348
+ .in('status', ACTIVE_CLIENT_STATUSES),
349
+ ]);
350
+ if (reviewsRes.error)
351
+ throw reviewsRes.error;
352
+ if (clientsRes.error)
353
+ throw clientsRes.error;
354
+ const reviews = reviewsRes.data ?? [];
355
+ const activeClients = clientsRes.data ?? [];
356
+ const totalClientIds = new Set();
357
+ activeClients.forEach((c) => totalClientIds.add(c.id));
358
+ reviews.forEach((r) => {
359
+ if (r.client_id)
360
+ totalClientIds.add(r.client_id);
361
+ });
362
+ const completed = reviews.filter((r) => r.status === 'completed').length;
363
+ const inProgress = reviews.filter((r) => r.status === 'in_progress').length;
364
+ const pending = [...totalClientIds].filter((clientId) => {
365
+ const r = reviews.find((x) => x.client_id === clientId);
366
+ return !r || (r.status !== 'completed' && r.status !== 'in_progress');
367
+ }).length;
368
+ const contacted = reviews.filter(isContactedThisWeek).length;
369
+ const total = totalClientIds.size;
370
+ const completionRate = total > 0 ? Math.round((completed / total) * 100) : 0;
371
+ const byRisk = {
372
+ low: reviews.filter((r) => r.risk_level === 'low').length,
373
+ medium: reviews.filter((r) => r.risk_level === 'medium').length,
374
+ high: reviews.filter((r) => r.risk_level === 'high').length,
375
+ unset: reviews.filter((r) => !r.risk_level).length,
376
+ };
377
+ output({
378
+ month,
379
+ year,
380
+ total_clients_in_scope: total,
381
+ active_clients: activeClients.length,
382
+ reviews_created: reviews.length,
383
+ completed,
384
+ in_progress: inProgress,
385
+ pending,
386
+ contacted_this_week: contacted,
387
+ completion_rate: completionRate,
388
+ by_risk: byRisk,
389
+ }, { pretty: !!opts.pretty });
390
+ }
391
+ async function generate(_a, opts) {
392
+ const { client } = await getAuthedClient();
393
+ const { month, year } = currentMonthYear(opts);
394
+ const dryRun = !!opts.dryRun;
395
+ const { data: clients, error: clientsError } = await client
396
+ .from('clients')
397
+ .select('id, name, status, assigned_to')
398
+ .in('status', ACTIVE_CLIENT_STATUSES)
399
+ .order('name');
400
+ if (clientsError)
401
+ throw clientsError;
402
+ const clientIds = (clients ?? []).map((c) => c.id);
403
+ let existing = [];
404
+ if (clientIds.length > 0) {
405
+ const { data, error } = await client
406
+ .from('client_monthly_reviews')
407
+ .select('id, client_id')
408
+ .eq('month', month)
409
+ .eq('year', year)
410
+ .in('client_id', clientIds);
411
+ if (error)
412
+ throw error;
413
+ existing = data ?? [];
414
+ }
415
+ const existingByClient = new Map(existing.map((r) => [r.client_id, r.id]));
416
+ const toCreate = (clients ?? []).filter((c) => !existingByClient.has(c.id));
417
+ const rows = toCreate.map((c) => ({
418
+ client_id: c.id,
419
+ month,
420
+ year,
421
+ status: 'pending',
422
+ reviewed_by: c.assigned_to ?? null,
423
+ }));
424
+ if (dryRun || rows.length === 0) {
425
+ output({
426
+ dry_run: dryRun,
427
+ month,
428
+ year,
429
+ active_clients: clients?.length ?? 0,
430
+ existing_reviews: existing.length,
431
+ would_create: rows.length,
432
+ create_preview: toCreate.slice(0, parseLimit(opts, 20)).map((c) => ({
433
+ client_id: c.id,
434
+ name: c.name,
435
+ reviewed_by: c.assigned_to,
436
+ })),
437
+ }, { pretty: !!opts.pretty });
438
+ return;
439
+ }
440
+ const { data, error } = await client
441
+ .from('client_monthly_reviews')
442
+ .insert(rows)
443
+ .select(REVIEW_SELECT);
444
+ if (error)
445
+ throw error;
446
+ output({
447
+ month,
448
+ year,
449
+ created: data?.length ?? 0,
450
+ existing_reviews: existing.length,
451
+ reviews: data ?? [],
452
+ }, { pretty: !!opts.pretty });
38
453
  }
39
454
  async function setReviewStatus(args, opts) {
40
455
  swapIfLegacyOrder(args, 'status', 'id', 'top-clients set-status');
41
456
  const { client } = await getAuthedClient();
42
457
  if (!args.status)
43
458
  throw new Error('status argument is required');
459
+ const status = normalizeStatus(args.status);
44
460
  const ids = await collectIds({
45
461
  positional: args.id,
46
462
  ids: opts.ids,
@@ -49,12 +465,15 @@ async function setReviewStatus(args, opts) {
49
465
  });
50
466
  if (ids.length === 0)
51
467
  throw new Error('No IDs provided. Pass <id>, --ids, --ids-file, or --stdin.');
468
+ const patch = { status };
469
+ if (status === 'completed')
470
+ patch.reviewed_at = new Date().toISOString();
52
471
  if (ids.length === 1 && !opts.dryRun) {
53
472
  const { data, error } = await client
54
473
  .from('client_monthly_reviews')
55
- .update({ status: args.status })
474
+ .update(patch)
56
475
  .eq('id', ids[0])
57
- .select('id, status')
476
+ .select(REVIEW_SELECT)
58
477
  .single();
59
478
  if (error)
60
479
  throw error;
@@ -64,9 +483,9 @@ async function setReviewStatus(args, opts) {
64
483
  const result = await runBulk(ids, async (id) => {
65
484
  const { data, error } = await client
66
485
  .from('client_monthly_reviews')
67
- .update({ status: args.status })
486
+ .update(patch)
68
487
  .eq('id', id)
69
- .select('id, status')
488
+ .select('id, status, reviewed_at')
70
489
  .single();
71
490
  if (error)
72
491
  throw error;
@@ -74,6 +493,230 @@ async function setReviewStatus(args, opts) {
74
493
  }, { dryRun: !!opts.dryRun, concurrency: Number(opts.concurrency ?? 5), label: 'set-status' });
75
494
  output({ updated: result.ok.length, failed: result.failed.length, errors: result.failed.slice(0, 10) }, { pretty: !!opts.pretty });
76
495
  }
496
+ async function setAnswer(_a, opts) {
497
+ const reviewId = typeof opts.reviewId === 'string' ? opts.reviewId : null;
498
+ const questionInput = typeof opts.question === 'string' ? opts.question : null;
499
+ const rawValue = typeof opts.value === 'string' ? opts.value : null;
500
+ if (!reviewId)
501
+ throw new Error('--review-id is required');
502
+ if (!questionInput)
503
+ throw new Error('--question is required');
504
+ if (rawValue === null)
505
+ throw new Error('--value is required');
506
+ const question = QUESTION_ALIASES[questionInput] ?? questionInput;
507
+ const { client } = await getAuthedClient();
508
+ const before = await fetchReview(client, reviewId);
509
+ let patch = {};
510
+ switch (question) {
511
+ case 'notes':
512
+ patch = { notes: rawValue || null };
513
+ break;
514
+ case 'needs':
515
+ case 'testimonials':
516
+ case 'next_actions':
517
+ patch = { [question]: parseStringArray(rawValue, question) };
518
+ break;
519
+ case 'risk_level':
520
+ patch = { risk_level: rawValue.trim().toLowerCase() === 'null' ? null : normalizeRisk(rawValue) };
521
+ break;
522
+ case 'status': {
523
+ const status = normalizeStatus(rawValue);
524
+ patch = { status };
525
+ if (status === 'completed')
526
+ patch.reviewed_at = new Date().toISOString();
527
+ break;
528
+ }
529
+ case 'reviewed_at': {
530
+ if (rawValue.trim().toLowerCase() === 'null') {
531
+ patch = { reviewed_at: null };
532
+ }
533
+ else {
534
+ const d = new Date(rawValue);
535
+ if (Number.isNaN(d.getTime())) {
536
+ throw new Error(`reviewed_at must be a valid date (got "${rawValue}"). Use ISO 8601, e.g. 2026-04-26 or 2026-04-26T10:00:00Z.`);
537
+ }
538
+ patch = { reviewed_at: d.toISOString() };
539
+ }
540
+ break;
541
+ }
542
+ case 'satisfaction_score': {
543
+ const n = Number(rawValue);
544
+ if (!Number.isInteger(n) || n < 1 || n > 5)
545
+ throw new Error('satisfaction_score must be an integer 1..5');
546
+ patch = { satisfaction_score: n };
547
+ break;
548
+ }
549
+ case 'metadata':
550
+ patch = { metadata: parseJsonish(rawValue) };
551
+ break;
552
+ case 'contacted': {
553
+ const contacted = boolFromUnknown(rawValue, '--value');
554
+ patch = { metadata: mergeContactedMetadata(before.metadata, contacted, before.status) };
555
+ break;
556
+ }
557
+ default:
558
+ throw new Error(`Unknown question "${questionInput}". Run \`badgie-crm top-clients questions list\`.`);
559
+ }
560
+ if (opts.dryRun) {
561
+ output({ dry_run: true, review_id: reviewId, question, patch, before }, { pretty: !!opts.pretty });
562
+ return;
563
+ }
564
+ const { data, error } = await client
565
+ .from('client_monthly_reviews')
566
+ .update(patch)
567
+ .eq('id', reviewId)
568
+ .select(REVIEW_SELECT)
569
+ .single();
570
+ if (error)
571
+ throw error;
572
+ output(data, { pretty: !!opts.pretty });
573
+ }
574
+ async function setNotes(_a, opts) {
575
+ const reviewId = typeof opts.reviewId === 'string' ? opts.reviewId : null;
576
+ if (!reviewId)
577
+ throw new Error('--review-id is required');
578
+ if (typeof opts.notes !== 'string')
579
+ throw new Error('--notes is required');
580
+ const { client } = await getAuthedClient();
581
+ const patch = { notes: opts.notes.trim() === '' ? null : opts.notes };
582
+ if (opts.dryRun) {
583
+ output({ dry_run: true, review_id: reviewId, patch }, { pretty: !!opts.pretty });
584
+ return;
585
+ }
586
+ const { data, error } = await client
587
+ .from('client_monthly_reviews')
588
+ .update(patch)
589
+ .eq('id', reviewId)
590
+ .select(REVIEW_SELECT)
591
+ .single();
592
+ if (error)
593
+ throw error;
594
+ output(data, { pretty: !!opts.pretty });
595
+ }
596
+ async function setOwner(_a, opts) {
597
+ const reviewId = typeof opts.reviewId === 'string' ? opts.reviewId : null;
598
+ if (!reviewId)
599
+ throw new Error('--review-id is required');
600
+ const { client } = await getAuthedClient();
601
+ const kam = opts.clearKam || opts.clearOwner
602
+ ? null
603
+ : await maybeResolve(client, 'team_member', opts.kam);
604
+ if (!opts.clearKam && !opts.clearOwner && !kam)
605
+ throw new Error('--kam is required (or pass --clear-kam)');
606
+ const patch = { reviewed_by: kam };
607
+ if (opts.dryRun) {
608
+ output({ dry_run: true, review_id: reviewId, patch }, { pretty: !!opts.pretty });
609
+ return;
610
+ }
611
+ const { data, error } = await client
612
+ .from('client_monthly_reviews')
613
+ .update(patch)
614
+ .eq('id', reviewId)
615
+ .select(REVIEW_SELECT)
616
+ .single();
617
+ if (error)
618
+ throw error;
619
+ output(data, { pretty: !!opts.pretty });
620
+ }
621
+ async function getConfig(_a, opts) {
622
+ const clientRef = typeof opts.clientId === 'string' ? opts.clientId : null;
623
+ if (!clientRef)
624
+ throw new Error('--client-id is required');
625
+ const { client } = await getAuthedClient();
626
+ const clientId = await resolveRef(client, 'client', clientRef);
627
+ const { data, error } = await client
628
+ .from('clients')
629
+ .select(CLIENT_CONFIG_SELECT)
630
+ .eq('id', clientId)
631
+ .single();
632
+ if (error)
633
+ throw error;
634
+ output({
635
+ client: data,
636
+ top_clients_scope: {
637
+ included_when_status_in: ACTIVE_CLIENT_STATUSES,
638
+ currently_included: ACTIVE_CLIENT_STATUSES.includes(data.status),
639
+ },
640
+ supported_config_fields: [
641
+ 'clients.status',
642
+ 'clients.assigned_to',
643
+ 'clients.plan_type',
644
+ 'clients.plan_student_tier',
645
+ 'clients.plan_student_count',
646
+ 'clients.plan_payment_frequency',
647
+ 'clients.plan_payment_amount',
648
+ ],
649
+ no_dedicated_schema_fields: [
650
+ 'review periodicity',
651
+ 'active questions per client',
652
+ 'top-client priority',
653
+ ],
654
+ note: 'The current UI derives Top Clients from active clients and stores monthly answers in client_monthly_reviews.',
655
+ }, { pretty: !!opts.pretty });
656
+ }
657
+ async function setConfig(_a, opts) {
658
+ const clientRef = typeof opts.clientId === 'string' ? opts.clientId : null;
659
+ if (!clientRef)
660
+ throw new Error('--client-id is required');
661
+ const { client } = await getAuthedClient();
662
+ const clientId = await resolveRef(client, 'client', clientRef);
663
+ const patch = {};
664
+ if (typeof opts.status === 'string')
665
+ patch.status = opts.status;
666
+ if (opts.topClient !== undefined && patch.status === undefined) {
667
+ patch.status = boolFromUnknown(opts.topClient, '--top-client') ? 'active' : 'inactive';
668
+ }
669
+ if (opts.clearKam) {
670
+ patch.assigned_to = null;
671
+ }
672
+ else if (typeof opts.kam === 'string') {
673
+ patch.assigned_to = await resolveRef(client, 'team_member', opts.kam);
674
+ }
675
+ const planType = nullableString(opts.planType);
676
+ if (planType !== undefined)
677
+ patch.plan_type = planType;
678
+ const planStudentTier = nullableString(opts.planStudentTier);
679
+ if (planStudentTier !== undefined)
680
+ patch.plan_student_tier = planStudentTier;
681
+ const planPaymentFrequency = nullableString(opts.planPaymentFrequency);
682
+ if (planPaymentFrequency !== undefined)
683
+ patch.plan_payment_frequency = planPaymentFrequency;
684
+ const planStudentCount = numberOrNull(opts.planStudentCount, '--plan-student-count');
685
+ if (planStudentCount !== undefined)
686
+ patch.plan_student_count = planStudentCount;
687
+ const planPaymentAmount = numberOrNull(opts.planPaymentAmount, '--plan-payment-amount');
688
+ if (planPaymentAmount !== undefined)
689
+ patch.plan_payment_amount = planPaymentAmount;
690
+ if (Object.keys(patch).length === 0) {
691
+ throw new Error('Pass at least one config field to update. Run `badgie-crm top-clients config get --client-id <client>`.');
692
+ }
693
+ if (opts.dryRun) {
694
+ output({ dry_run: true, client_id: clientId, patch }, { pretty: !!opts.pretty });
695
+ return;
696
+ }
697
+ const { data, error } = await client
698
+ .from('clients')
699
+ .update({ ...patch, updated_at: new Date().toISOString() })
700
+ .eq('id', clientId)
701
+ .select(CLIENT_CONFIG_SELECT)
702
+ .single();
703
+ if (error)
704
+ throw error;
705
+ if ('assigned_to' in patch) {
706
+ const actorId = await getCurrentTeamMemberId(client).catch(() => null);
707
+ await client.from('activities').insert({
708
+ client_id: clientId,
709
+ type: 'note',
710
+ title: 'Cambio de KAM',
711
+ description: `KAM actualizado desde badgie-crm CLI`,
712
+ created_by: actorId,
713
+ });
714
+ }
715
+ output(data, { pretty: !!opts.pretty });
716
+ }
717
+ const prettyOption = { flag: '--pretty', description: 'human output' };
718
+ const jsonOption = { flag: '--json', description: 'force JSON output (default)' };
719
+ const dryRunOption = { flag: '--dry-run', description: 'show what would happen without writing' };
77
720
  export const topClientsModule = {
78
721
  name: 'top-clients',
79
722
  summary: 'Monthly top-client reviews (KAM follow-up tracker)',
@@ -85,28 +728,182 @@ export const topClientsModule = {
85
728
  options: [
86
729
  { flag: '--month <n>', description: '1-12 (default current month)' },
87
730
  { flag: '--year <n>', description: 'YYYY (default current year)' },
88
- { flag: '--kam <uuid|name|email>', description: 'filter by reviewed_by (team member)' },
89
- { flag: '--status <s>', description: 'pending|completed|…' },
731
+ { flag: '--kam <uuid|name|email>', description: 'filter by client KAM (clients.assigned_to) or review owner (reviewed_by)' },
732
+ { flag: '--reviewer <uuid|name|email>', description: 'filter strictly by reviewed_by (team member)' },
733
+ { flag: '--status <s>', description: 'pending|in_progress|completed' },
90
734
  { flag: '--search <text>', description: 'filter by client name (client-side)' },
91
735
  ...commonListOptions(),
92
736
  ],
93
737
  examples: [
94
- 'badgie-crm top-clients list --month 3 --year 2026 --status pending --pretty',
95
- 'badgie-crm top-clients list --kam <uuid> --pretty',
738
+ 'badgie-crm top-clients list --month 4 --year 2026 --limit 10',
739
+ 'badgie-crm top-clients list --month 4 --year 2026 --kam Greg --status pending --pretty',
96
740
  ],
97
741
  handler: listTopClients,
98
742
  },
743
+ {
744
+ path: ['top-clients', 'get'],
745
+ summary: 'Show one monthly review with client and reviewer joined',
746
+ tags: ['read'],
747
+ args: [{ name: 'id', required: true, description: 'client_monthly_reviews.id' }],
748
+ options: [prettyOption, jsonOption],
749
+ examples: ['badgie-crm top-clients get <review-id> --pretty'],
750
+ handler: getTopClient,
751
+ },
752
+ {
753
+ path: ['top-clients', 'questions', 'list'],
754
+ summary: 'List review questions/answer fields used by the current UI',
755
+ tags: ['read'],
756
+ options: [prettyOption, jsonOption],
757
+ examples: ['badgie-crm top-clients questions list'],
758
+ handler: listQuestions,
759
+ },
760
+ {
761
+ path: ['top-clients', 'answers', 'list'],
762
+ summary: 'List stored answers for one review',
763
+ tags: ['read'],
764
+ options: [
765
+ { flag: '--review-id <id>', description: 'client_monthly_reviews.id (required)' },
766
+ ...commonListOptions(),
767
+ ],
768
+ examples: ['badgie-crm top-clients answers list --review-id <review-id> --pretty'],
769
+ handler: listAnswers,
770
+ },
771
+ {
772
+ path: ['top-clients', 'summary'],
773
+ summary: 'Monthly Top Clients summary stats',
774
+ tags: ['read'],
775
+ options: [
776
+ { flag: '--month <n>', description: '1-12 (default current month)' },
777
+ { flag: '--year <n>', description: 'YYYY (default current year)' },
778
+ prettyOption,
779
+ jsonOption,
780
+ ],
781
+ examples: ['badgie-crm top-clients summary --month 4 --year 2026'],
782
+ handler: summary,
783
+ },
784
+ {
785
+ path: ['top-clients', 'generate'],
786
+ summary: 'Create or preview monthly reviews for active Top Clients',
787
+ description: 'Top Clients scope matches the UI: clients whose status is active or active free trial. Existing reviews for the same client/month/year are not duplicated.',
788
+ tags: ['write'],
789
+ options: [
790
+ { flag: '--month <n>', description: '1-12 (default current month)' },
791
+ { flag: '--year <n>', description: 'YYYY (default current year)' },
792
+ dryRunOption,
793
+ { flag: '--limit <n>', description: 'max preview rows for --dry-run (default 20)' },
794
+ prettyOption,
795
+ jsonOption,
796
+ ],
797
+ examples: [
798
+ 'badgie-crm top-clients generate --month 4 --year 2026 --dry-run',
799
+ 'badgie-crm top-clients generate --month 4 --year 2026 --pretty',
800
+ ],
801
+ handler: generate,
802
+ },
99
803
  {
100
804
  path: ['top-clients', 'set-status'],
101
805
  summary: 'Change review status (single or bulk)',
102
806
  tags: ['write'],
103
807
  args: [
104
- { name: 'status', required: true, description: 'new status' },
808
+ { name: 'status', required: true, description: 'pending|in_progress|completed' },
105
809
  { name: 'id', required: false, description: 'client_monthly_reviews.id (or use --ids / --stdin)' },
106
810
  ],
107
- options: [...bulkOptions, { flag: '--pretty', description: 'human output' }],
811
+ options: [...bulkOptions, prettyOption, jsonOption],
812
+ examples: [
813
+ 'badgie-crm top-clients set-status completed <review-id>',
814
+ 'badgie-crm top-clients set-status in_progress --ids "<id1>,<id2>" --dry-run',
815
+ ],
108
816
  handler: setReviewStatus,
109
817
  },
818
+ {
819
+ path: ['top-clients', 'answer', 'set'],
820
+ summary: 'Set one answer field on a review',
821
+ description: 'Question can be notes, needs, testimonials, risk_level, next_actions, status, reviewed_at, satisfaction_score, metadata, or contacted. Arrays accept JSON arrays or comma/newline-separated text.',
822
+ tags: ['write'],
823
+ options: [
824
+ { flag: '--review-id <id>', description: 'client_monthly_reviews.id (required)' },
825
+ { flag: '--question <key|id>', description: 'question key from `top-clients questions list` (required)' },
826
+ { flag: '--value <text|json>', description: 'answer value (required)' },
827
+ dryRunOption,
828
+ prettyOption,
829
+ jsonOption,
830
+ ],
831
+ examples: [
832
+ 'badgie-crm top-clients answer set --review-id <id> --question risk_level --value high',
833
+ 'badgie-crm top-clients answer set --review-id <id> --question needs --value \'["Más demos", "Soporte técnico"]\'',
834
+ 'badgie-crm top-clients answer set --review-id <id> --question contacted --value true',
835
+ ],
836
+ handler: setAnswer,
837
+ },
838
+ {
839
+ path: ['top-clients', 'notes', 'set'],
840
+ summary: 'Set review notes',
841
+ tags: ['write'],
842
+ options: [
843
+ { flag: '--review-id <id>', description: 'client_monthly_reviews.id (required)' },
844
+ { flag: '--notes <text>', description: 'notes text (required, empty string clears)' },
845
+ dryRunOption,
846
+ prettyOption,
847
+ jsonOption,
848
+ ],
849
+ examples: ['badgie-crm top-clients notes set --review-id <id> --notes "Cliente estable, revisar renovacion"'],
850
+ handler: setNotes,
851
+ },
852
+ {
853
+ path: ['top-clients', 'owner', 'set'],
854
+ summary: 'Set the review owner/reviewer (reviewed_by)',
855
+ tags: ['write'],
856
+ options: [
857
+ { flag: '--review-id <id>', description: 'client_monthly_reviews.id (required)' },
858
+ { flag: '--kam <uuid|name|email>', description: 'team member assigned as reviewed_by' },
859
+ { flag: '--clear-kam', description: 'clear reviewed_by' },
860
+ { flag: '--clear-owner', description: 'alias for --clear-kam' },
861
+ dryRunOption,
862
+ prettyOption,
863
+ jsonOption,
864
+ ],
865
+ examples: ['badgie-crm top-clients owner set --review-id <id> --kam Greg --pretty'],
866
+ handler: setOwner,
867
+ },
868
+ {
869
+ path: ['top-clients', 'config', 'get'],
870
+ summary: 'Show Top Clients configuration derived from an existing client row',
871
+ description: 'The current UI has no dedicated top-client config table. Inclusion is derived from clients.status, KAM from clients.assigned_to, and answers from client_monthly_reviews.',
872
+ tags: ['read'],
873
+ options: [
874
+ { flag: '--client-id <uuid|name|email>', description: 'client reference (required)' },
875
+ prettyOption,
876
+ jsonOption,
877
+ ],
878
+ examples: ['badgie-crm top-clients config get --client-id "Club Example"'],
879
+ handler: getConfig,
880
+ },
881
+ {
882
+ path: ['top-clients', 'config', 'set'],
883
+ summary: 'Update Top Clients-related client configuration fields',
884
+ description: 'Updates only existing client columns used by the UI: status/top-client inclusion, assigned KAM, and plan fields. There are no dedicated DB columns for review periodicity, active questions, or top-client priority.',
885
+ tags: ['write'],
886
+ options: [
887
+ { flag: '--client-id <uuid|name|email>', description: 'client reference (required)' },
888
+ { flag: '--kam <uuid|name|email>', description: 'set clients.assigned_to' },
889
+ { flag: '--clear-kam', description: 'clear clients.assigned_to' },
890
+ { flag: '--status <s>', description: 'set clients.status directly' },
891
+ { flag: '--top-client <bool>', description: 'true => status active, false => status inactive (ignored if --status is passed)' },
892
+ { flag: '--plan-type <value>', description: 'set clients.plan_type (empty/null clears)' },
893
+ { flag: '--plan-student-tier <value>', description: 'set clients.plan_student_tier (empty/null clears)' },
894
+ { flag: '--plan-student-count <n>', description: 'set clients.plan_student_count (empty/null clears)' },
895
+ { flag: '--plan-payment-frequency <value>', description: 'set clients.plan_payment_frequency (empty/null clears)' },
896
+ { flag: '--plan-payment-amount <n>', description: 'set clients.plan_payment_amount (empty/null clears)' },
897
+ dryRunOption,
898
+ prettyOption,
899
+ jsonOption,
900
+ ],
901
+ examples: [
902
+ 'badgie-crm top-clients config set --client-id "Club Example" --kam Greg --dry-run',
903
+ 'badgie-crm top-clients config set --client-id <client-id> --top-client true --plan-payment-frequency monthly',
904
+ ],
905
+ handler: setConfig,
906
+ },
110
907
  ],
111
908
  };
112
909
  //# sourceMappingURL=top-clients.js.map