checkbox-cli 3.0.0 → 3.1.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/dist/checkbox.js +667 -99
  2. package/package.json +1 -1
package/dist/checkbox.js CHANGED
@@ -16,7 +16,7 @@ import { join } from 'path';
16
16
  // ── Config ──────────────────────────────────────────────────────────
17
17
  const CONFIG_DIR = join(homedir(), '.checkbox');
18
18
  const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
19
- const VERSION = '3.0.0';
19
+ const VERSION = '3.1.0';
20
20
  function loadConfig() {
21
21
  try {
22
22
  if (!existsSync(CONFIG_FILE))
@@ -56,7 +56,7 @@ function resolveConfig() {
56
56
  // ── API ─────────────────────────────────────────────────────────────
57
57
  function extractError(obj) {
58
58
  if (!obj)
59
- return 'Unknown error';
59
+ return '';
60
60
  if (typeof obj === 'string')
61
61
  return obj;
62
62
  if (typeof obj.error === 'string')
@@ -78,46 +78,78 @@ function api(cfg) {
78
78
  headers,
79
79
  body: JSON.stringify(body),
80
80
  });
81
- const json = await res.json().catch(() => null);
82
- if (!res.ok)
83
- throw new Error(extractError(json) || `Request failed (${res.status})`);
84
- if (json && json.success === false)
81
+ const text = await res.text().catch(() => '');
82
+ let json = null;
83
+ try {
84
+ json = JSON.parse(text);
85
+ }
86
+ catch { }
87
+ if (!res.ok) {
88
+ throw new Error(extractError(json) || text || `Request failed (${res.status})`);
89
+ }
90
+ if (json && json.success === false) {
85
91
  throw new Error(extractError(json) || 'Request failed');
92
+ }
86
93
  return json;
87
94
  }
88
95
  async function get(path) {
89
96
  const res = await fetch(`${cfg.apiUrl}${path}`, { headers });
90
- const json = await res.json().catch(() => null);
91
- if (!res.ok)
92
- throw new Error(extractError(json) || `Request failed (${res.status})`);
93
- if (json && json.success === false)
97
+ const text = await res.text().catch(() => '');
98
+ let json = null;
99
+ try {
100
+ json = JSON.parse(text);
101
+ }
102
+ catch { }
103
+ if (!res.ok) {
104
+ throw new Error(extractError(json) || text || `Request failed (${res.status})`);
105
+ }
106
+ if (json && json.success === false) {
94
107
  throw new Error(extractError(json) || 'Request failed');
108
+ }
95
109
  return json;
96
110
  }
97
111
  return { post, get };
98
112
  }
99
113
  // ── Data Helpers ────────────────────────────────────────────────────
100
114
  async function listEntities(post, orgId, entityType, filters) {
101
- const res = await post('/api/v2/query', {
102
- type: 'list_entities',
103
- organization_id: orgId,
104
- payload: { entity_type: entityType, organization_id: orgId, ...filters },
105
- });
106
- return Array.isArray(res.data) ? res.data : [];
115
+ try {
116
+ const res = await post('/api/v2/query', {
117
+ type: 'list_entities',
118
+ organization_id: orgId,
119
+ payload: { entity_type: entityType, organization_id: orgId, ...filters },
120
+ });
121
+ return Array.isArray(res.data) ? res.data : [];
122
+ }
123
+ catch (err) {
124
+ p.log.error(err.message || `Could not load ${entityType}`);
125
+ return [];
126
+ }
107
127
  }
108
128
  async function searchEntities(post, orgId, search, limit = 20) {
109
- const res = await post('/api/v2/introspection/entities', {
110
- organization_id: orgId,
111
- search,
112
- limit,
113
- });
114
- return res.data?.entities || [];
129
+ try {
130
+ const res = await post('/api/v2/introspection/entities', {
131
+ organization_id: orgId,
132
+ search,
133
+ limit,
134
+ });
135
+ return res.data?.entities || [];
136
+ }
137
+ catch (err) {
138
+ p.log.error(err.message || 'Search failed');
139
+ return [];
140
+ }
115
141
  }
116
142
  async function getWorkspaceNodes(post, orgId) {
117
- const res = await post('/api/v2/introspection/workspace', {
118
- organization_id: orgId,
119
- });
120
- return res.data?.nodes || [];
143
+ try {
144
+ const res = await post('/api/v2/introspection/workspace', {
145
+ organization_id: orgId,
146
+ });
147
+ return res.data?.nodes || [];
148
+ }
149
+ catch (err) {
150
+ p.log.error(err.message || 'Could not load workspace');
151
+ return [];
152
+ }
121
153
  }
122
154
  // ── Formatters ──────────────────────────────────────────────────────
123
155
  function truncate(s, max) {
@@ -448,40 +480,149 @@ async function mTasks(cfg, post) {
448
480
  if (!frequency)
449
481
  return mTasks(cfg, post);
450
482
  const payload = { name, frequency };
483
+ // Frequency configuration (day of week, day of month, etc.)
484
+ if (frequency === 'weekly') {
485
+ const dow = await sel('Which day of the week?', [
486
+ { value: 'monday', label: 'Monday' }, { value: 'tuesday', label: 'Tuesday' },
487
+ { value: 'wednesday', label: 'Wednesday' }, { value: 'thursday', label: 'Thursday' },
488
+ { value: 'friday', label: 'Friday' }, { value: 'saturday', label: 'Saturday' },
489
+ { value: 'sunday', label: 'Sunday' },
490
+ ]);
491
+ if (dow)
492
+ payload.frequency_config = { day_of_week: dow };
493
+ }
494
+ else if (frequency === 'monthly') {
495
+ const dom = await txt('Which day of the month? (1-31)', false, '1');
496
+ if (dom)
497
+ payload.frequency_config = { day_of_month: parseInt(dom, 10) };
498
+ }
499
+ else if (frequency === 'yearly') {
500
+ const month = await txt('Month (1-12)', false, '1');
501
+ const day = await txt('Day (1-31)', false, '1');
502
+ if (month && day)
503
+ payload.frequency_config = { month: parseInt(month, 10), day: parseInt(day, 10) };
504
+ }
451
505
  const addPlan = await yn('Add to a plan?');
452
506
  if (addPlan) {
453
507
  const plan = await pickEntity(post, cfg.orgId, 'plan', 'plans');
454
- if (plan)
508
+ if (plan) {
455
509
  payload.plan_id = plan.id;
510
+ // Optionally link to a specific requirement
511
+ const linkReq = await yn('Link to a specific requirement?');
512
+ if (linkReq) {
513
+ const req = await pickEntity(post, cfg.orgId, 'requirement', 'requirements');
514
+ if (req)
515
+ payload.requirement_id = req.id;
516
+ }
517
+ }
456
518
  }
457
- const more = await yn('Add more details? (description, time, reminders, etc.)');
458
- if (more) {
459
- const desc = await txt('Description');
460
- if (desc)
461
- payload.description = desc;
462
- const priv = await yn('Private task?');
463
- if (priv)
464
- payload.is_private = true;
465
- const time = await txt('Scheduled time (HH:MM)', false, '09:00');
466
- if (time)
467
- payload.time = time;
468
- const reminder = await sel('Reminder', [
469
- { value: 'none', label: 'No reminder' },
470
- { value: 'notification', label: 'Push notification' },
471
- { value: 'email', label: 'Email' },
472
- ]);
473
- if (reminder && reminder !== 'none') {
474
- payload.reminder_mode = reminder;
475
- const mins = await txt('Minutes before', false, '30');
476
- if (mins)
477
- payload.reminder_minutes = parseInt(mins, 10);
519
+ // Description
520
+ const desc = await txt('Description (blank to skip)');
521
+ if (desc)
522
+ payload.description = desc;
523
+ // Time
524
+ const time = await txt('Scheduled time (HH:MM, blank to skip)', false, '09:00');
525
+ if (time)
526
+ payload.time = time;
527
+ // Assignment
528
+ const assign = await yn('Assign to a specific person?');
529
+ if (assign) {
530
+ const assignId = await txt('User ID to assign to', true);
531
+ if (assignId) {
532
+ payload.assigned_to = assignId;
533
+ const assignName = await txt('Their display name (optional)');
534
+ if (assignName)
535
+ payload.assigned_to_name = assignName;
536
+ }
537
+ }
538
+ // Privacy
539
+ const priv = await yn('Private task?');
540
+ if (priv)
541
+ payload.is_private = true;
542
+ // Reminder
543
+ const reminder = await sel('Reminder', [
544
+ { value: 'none', label: 'No reminder' },
545
+ { value: 'notification', label: 'Push notification' },
546
+ { value: 'email', label: 'Email' },
547
+ ]);
548
+ if (reminder && reminder !== 'none') {
549
+ payload.reminder_mode = reminder;
550
+ const mins = await txt('Minutes before', false, '30');
551
+ if (mins)
552
+ payload.reminder_minutes = parseInt(mins, 10);
553
+ }
554
+ // Conditional question
555
+ const cond = await txt('Conditional question (blank to skip)', false, 'e.g. Is the equipment available?');
556
+ if (cond)
557
+ payload.conditional_question = cond;
558
+ // Approval requirement
559
+ const approval = await yn('Requires approval before completion?');
560
+ if (approval)
561
+ payload.approval_required = true;
562
+ // Proof / Validation requirements (THE MOST IMPORTANT FEATURE)
563
+ const addProof = await yn('Require proof to complete this task? (photos, documents, location, etc.)');
564
+ if (addProof) {
565
+ const validationTypes = [];
566
+ let addMoreProof = true;
567
+ while (addMoreProof) {
568
+ const proofType = await sel('What type of proof?', [
569
+ { value: 'photo', label: 'Photo', hint: 'require a photo to be taken' },
570
+ { value: 'document', label: 'Document', hint: 'require a document upload' },
571
+ { value: 'geolocation', label: 'Location check-in', hint: 'require being at a specific location' },
572
+ { value: 'checklist', label: 'Checklist', hint: 'require completing a checklist' },
573
+ { value: 'table', label: 'Table record', hint: 'require filling out a data table' },
574
+ ]);
575
+ if (!proofType)
576
+ break;
577
+ const vt = { type: proofType };
578
+ // Pick the specific asset for this proof type
579
+ if (proofType === 'photo') {
580
+ const ph = await pickEntity(post, cfg.orgId, 'photo', 'photo assets');
581
+ if (ph)
582
+ vt.config_id = ph.id;
583
+ }
584
+ else if (proofType === 'document') {
585
+ const doc = await pickEntity(post, cfg.orgId, 'document', 'document folders');
586
+ if (doc) {
587
+ vt.config_id = doc.id;
588
+ const targetMode = await sel('Where should uploads go?', [
589
+ { value: 'locked', label: 'Always to this folder' },
590
+ { value: 'user_choice', label: 'Let user choose folder' },
591
+ ]);
592
+ if (targetMode) {
593
+ vt.target_mode = targetMode;
594
+ if (targetMode === 'locked')
595
+ vt.target_folder_id = doc.id;
596
+ }
597
+ }
598
+ }
599
+ else if (proofType === 'geolocation') {
600
+ const area = await pickEntity(post, cfg.orgId, 'geolocation_area', 'location areas');
601
+ if (area)
602
+ vt.config_id = area.id;
603
+ }
604
+ else if (proofType === 'checklist') {
605
+ const cl = await pickEntity(post, cfg.orgId, 'checklist', 'checklists');
606
+ if (cl)
607
+ vt.config_id = cl.id;
608
+ }
609
+ else if (proofType === 'table') {
610
+ const tbl = await pickEntity(post, cfg.orgId, 'table', 'tables');
611
+ if (tbl)
612
+ vt.config_id = tbl.id;
613
+ }
614
+ validationTypes.push(vt);
615
+ addMoreProof = !!(await yn('Add another proof requirement?'));
478
616
  }
479
- const approval = await yn('Requires approval?');
480
- if (approval)
481
- payload.approval_required = true;
482
- const cond = await txt('Conditional question (blank to skip)', false, 'e.g. Is the equipment available?');
483
- if (cond)
484
- payload.conditional_question = cond;
617
+ if (validationTypes.length > 0)
618
+ payload.validation_types = validationTypes;
619
+ }
620
+ // Share with specific users
621
+ const share = await yn('Share with specific users?');
622
+ if (share) {
623
+ const ids = await txt('User IDs (comma-separated)', true);
624
+ if (ids)
625
+ payload.shared_with_ids = ids.split(',').map(s => s.trim()).filter(Boolean);
485
626
  }
486
627
  await cmd(post, cfg.orgId, 'create_task', payload, 'Creating task...');
487
628
  return mTasks(cfg, post);
@@ -490,24 +631,126 @@ async function mTasks(cfg, post) {
490
631
  const t = await pickEntity(post, cfg.orgId, 'task', 'tasks');
491
632
  if (!t)
492
633
  return mTasks(cfg, post);
493
- p.log.info(`Editing: ${pc.cyan(t.name)} ${pc.dim('(leave blank to skip)')}`);
494
- const updates = {};
495
- const n = await txt('New name');
496
- if (n === null)
497
- return mTasks(cfg, post);
498
- if (n)
499
- updates.name = n;
500
- const f = await sel('New frequency', [
501
- { value: '__skip', label: pc.dim('Keep current') },
502
- { value: 'daily', label: 'Daily' }, { value: 'weekly', label: 'Weekly' },
503
- { value: 'monthly', label: 'Monthly' }, { value: 'yearly', label: 'Yearly' },
504
- { value: 'one-time', label: 'One time' },
634
+ p.log.info(`Editing: ${pc.cyan(t.name)}`);
635
+ const field = await sel('What would you like to change?', [
636
+ { value: 'name', label: 'Name' },
637
+ { value: 'frequency', label: 'Frequency' },
638
+ { value: 'description', label: 'Description' },
639
+ { value: 'time', label: 'Scheduled time' },
640
+ { value: 'assigned_to', label: 'Assignment' },
641
+ { value: 'is_private', label: 'Privacy' },
642
+ { value: 'reminder', label: 'Reminder settings' },
643
+ { value: 'conditional_question', label: 'Conditional question' },
644
+ { value: 'plan', label: 'Plan / requirement link' },
645
+ { value: '__back', label: pc.dim('\u2190 Back') },
505
646
  ]);
506
- if (f && f !== '__skip')
507
- updates.frequency = f;
508
- const d = await txt('New description');
509
- if (d)
510
- updates.description = d;
647
+ if (!field || field === '__back')
648
+ return mTasks(cfg, post);
649
+ const updates = {};
650
+ switch (field) {
651
+ case 'name': {
652
+ const n = await txt('New name', true);
653
+ if (n)
654
+ updates.name = n;
655
+ break;
656
+ }
657
+ case 'frequency': {
658
+ const f = await sel('New frequency', [
659
+ { value: 'daily', label: 'Daily' }, { value: 'weekly', label: 'Weekly' },
660
+ { value: 'monthly', label: 'Monthly' }, { value: 'yearly', label: 'Yearly' },
661
+ { value: 'one-time', label: 'One time' },
662
+ ]);
663
+ if (f) {
664
+ updates.frequency = f;
665
+ if (f === 'weekly') {
666
+ const dow = await sel('Which day?', [
667
+ { value: 'monday', label: 'Monday' }, { value: 'tuesday', label: 'Tuesday' },
668
+ { value: 'wednesday', label: 'Wednesday' }, { value: 'thursday', label: 'Thursday' },
669
+ { value: 'friday', label: 'Friday' }, { value: 'saturday', label: 'Saturday' },
670
+ { value: 'sunday', label: 'Sunday' },
671
+ ]);
672
+ if (dow)
673
+ updates.frequency_config = { day_of_week: dow };
674
+ }
675
+ else if (f === 'monthly') {
676
+ const dom = await txt('Which day of the month? (1-31)', false, '1');
677
+ if (dom)
678
+ updates.frequency_config = { day_of_month: parseInt(dom, 10) };
679
+ }
680
+ }
681
+ break;
682
+ }
683
+ case 'description': {
684
+ const d = await txt('New description', true);
685
+ if (d)
686
+ updates.description = d;
687
+ break;
688
+ }
689
+ case 'time': {
690
+ const time = await txt('New scheduled time (HH:MM)', false, '09:00');
691
+ if (time)
692
+ updates.time = time;
693
+ break;
694
+ }
695
+ case 'assigned_to': {
696
+ const aid = await txt('User ID to assign to (blank to unassign)');
697
+ updates.assigned_to = aid || null;
698
+ if (aid) {
699
+ const aname = await txt('Their display name (optional)');
700
+ if (aname)
701
+ updates.assigned_to_name = aname;
702
+ }
703
+ break;
704
+ }
705
+ case 'is_private': {
706
+ const priv = await yn('Make this task private?');
707
+ updates.is_private = !!priv;
708
+ break;
709
+ }
710
+ case 'reminder': {
711
+ const mode = await sel('Reminder type', [
712
+ { value: 'none', label: 'No reminder' },
713
+ { value: 'notification', label: 'Push notification' },
714
+ { value: 'email', label: 'Email' },
715
+ ]);
716
+ if (mode === 'none') {
717
+ updates.reminder_mode = null;
718
+ updates.reminder_minutes = null;
719
+ }
720
+ else if (mode) {
721
+ updates.reminder_mode = mode;
722
+ const mins = await txt('Minutes before', false, '30');
723
+ if (mins)
724
+ updates.reminder_minutes = parseInt(mins, 10);
725
+ }
726
+ break;
727
+ }
728
+ case 'conditional_question': {
729
+ const q = await txt('Conditional question (blank to remove)');
730
+ updates.conditional_question = q || null;
731
+ break;
732
+ }
733
+ case 'plan': {
734
+ const plan = await pickEntity(post, cfg.orgId, 'plan', 'plans');
735
+ if (plan) {
736
+ updates.plan_id = plan.id;
737
+ const linkReq = await yn('Link to a requirement?');
738
+ if (linkReq) {
739
+ const req = await pickEntity(post, cfg.orgId, 'requirement', 'requirements');
740
+ if (req)
741
+ updates.requirement_id = req.id;
742
+ }
743
+ }
744
+ else {
745
+ const unlink = await yn('Remove from current plan?');
746
+ if (unlink) {
747
+ updates.plan_id = null;
748
+ updates.requirement_id = null;
749
+ }
750
+ }
751
+ break;
752
+ }
753
+ }
511
754
  if (Object.keys(updates).length > 0) {
512
755
  await cmd(post, cfg.orgId, 'update_task', { task_id: t.id, updates }, 'Updating...');
513
756
  }
@@ -977,14 +1220,67 @@ async function mTables(cfg, post) {
977
1220
  if (!fn)
978
1221
  break;
979
1222
  const ft = await sel('Column type', [
980
- { value: 'text', label: 'Text' }, { value: 'number', label: 'Number' },
981
- { value: 'date', label: 'Date' }, { value: 'select', label: 'Dropdown' },
982
- { value: 'checkbox', label: 'Checkbox' }, { value: 'file', label: 'File' },
1223
+ { value: 'text', label: 'Text' },
1224
+ { value: 'number', label: 'Number' },
1225
+ { value: 'date', label: 'Date' },
1226
+ { value: 'select', label: 'Dropdown', hint: 'pick from a list of values' },
1227
+ { value: 'multi_select', label: 'Multi-select dropdown', hint: 'pick multiple values' },
1228
+ { value: 'checkbox', label: 'Checkbox', hint: 'yes/no toggle' },
1229
+ { value: 'file', label: 'File upload' },
1230
+ { value: 'url', label: 'URL / link' },
1231
+ { value: 'email', label: 'Email address' },
1232
+ { value: 'phone', label: 'Phone number' },
1233
+ { value: 'currency', label: 'Currency / money' },
1234
+ { value: 'percent', label: 'Percentage' },
1235
+ { value: 'rating', label: 'Rating (1-5)' },
1236
+ { value: 'relation', label: 'Link to another table' },
983
1237
  ]);
984
1238
  if (!ft)
985
1239
  break;
1240
+ const col = { name: fn, type: ft };
986
1241
  const req = await yn('Required?');
987
- fields.push({ name: fn, type: ft, ...(req ? { required: true } : {}) });
1242
+ if (req)
1243
+ col.required = true;
1244
+ // Dropdown values
1245
+ if (ft === 'select' || ft === 'multi_select') {
1246
+ const optSource = await sel('Where do the options come from?', [
1247
+ { value: 'manual', label: 'I\'ll type them now' },
1248
+ { value: 'list', label: 'Link to an existing table/list' },
1249
+ ]);
1250
+ if (optSource === 'manual') {
1251
+ const optStr = await txt('Options (comma-separated)', true, 'e.g. Pass, Fail, N/A');
1252
+ if (optStr)
1253
+ col.options = optStr.split(',').map(s => s.trim()).filter(Boolean);
1254
+ }
1255
+ else if (optSource === 'list') {
1256
+ const srcTable = await pickEntity(post, cfg.orgId, 'table', 'tables');
1257
+ if (srcTable) {
1258
+ col.source_table_id = srcTable.id;
1259
+ const srcField = await txt('Which column to use as the option label?', false, 'name');
1260
+ if (srcField)
1261
+ col.source_field = srcField;
1262
+ }
1263
+ }
1264
+ const defaultVal = await txt('Default value (blank for none)');
1265
+ if (defaultVal)
1266
+ col.default = defaultVal;
1267
+ }
1268
+ // Number/currency constraints
1269
+ if (ft === 'number' || ft === 'currency' || ft === 'percent') {
1270
+ const min = await txt('Minimum value (blank for no limit)');
1271
+ if (min)
1272
+ col.min = parseFloat(min);
1273
+ const max = await txt('Maximum value (blank for no limit)');
1274
+ if (max)
1275
+ col.max = parseFloat(max);
1276
+ }
1277
+ // Relation target
1278
+ if (ft === 'relation') {
1279
+ const relTable = await pickEntity(post, cfg.orgId, 'table', 'tables');
1280
+ if (relTable)
1281
+ col.relation_table_id = relTable.id;
1282
+ }
1283
+ fields.push(col);
988
1284
  addMore = !!(await yn('Add another column?'));
989
1285
  }
990
1286
  if (fields.length > 0)
@@ -1478,20 +1774,37 @@ async function mAutomation(cfg, post) {
1478
1774
  return mAutomation(cfg, post);
1479
1775
  }
1480
1776
  case 'val-create': {
1481
- const task = await pickEntity(post, cfg.orgId, 'task', 'tasks');
1482
- if (!task)
1777
+ // Choose target: task or requirement
1778
+ const target = await sel('Apply this rule to', [
1779
+ { value: 'task', label: 'A specific task' },
1780
+ { value: 'requirement', label: 'A requirement', hint: 'applies to all tasks under it' },
1781
+ ]);
1782
+ if (!target)
1483
1783
  return mAutomation(cfg, post);
1784
+ const rulePayload = {};
1785
+ if (target === 'task') {
1786
+ const task = await pickEntity(post, cfg.orgId, 'task', 'tasks');
1787
+ if (!task)
1788
+ return mAutomation(cfg, post);
1789
+ rulePayload.task_id = task.id;
1790
+ }
1791
+ else {
1792
+ const req = await pickEntity(post, cfg.orgId, 'requirement', 'requirements');
1793
+ if (!req)
1794
+ return mAutomation(cfg, post);
1795
+ rulePayload.requirement_id = req.id;
1796
+ }
1484
1797
  const name = await txt('Rule name', true, 'e.g. Require photo proof');
1485
1798
  if (!name)
1486
1799
  return mAutomation(cfg, post);
1487
1800
  const ruleType = await sel('Rule type', [
1488
- { value: 'proof_required', label: 'Proof required', hint: 'photo, document, location, etc.' },
1489
- { value: 'field_constraint', label: 'Field constraint', hint: 'validate a data field' },
1801
+ { value: 'proof_required', label: 'Proof required', hint: 'photo, document, location, checklist, or table' },
1802
+ { value: 'field_constraint', label: 'Field constraint', hint: 'validate a specific field value' },
1803
+ { value: 'cross_field', label: 'Cross-field validation', hint: 'compare two fields' },
1804
+ { value: 'aggregate', label: 'Aggregate check', hint: 'count/sum/avg of historical data' },
1490
1805
  { value: 'approval_required', label: 'Approval required', hint: 'needs manager sign-off' },
1491
1806
  { value: 'time_window', label: 'Time window', hint: 'must be done within a time range' },
1492
- { value: 'record_exists', label: 'Record exists', hint: 'verify data was entered' },
1493
- { value: 'cross_field', label: 'Cross-field validation' },
1494
- { value: 'aggregate', label: 'Aggregate check' },
1807
+ { value: 'record_exists', label: 'Record exists', hint: 'verify a table record was created' },
1495
1808
  ]);
1496
1809
  if (!ruleType)
1497
1810
  return mAutomation(cfg, post);
@@ -1499,21 +1812,201 @@ async function mAutomation(cfg, post) {
1499
1812
  if (ruleType === 'proof_required') {
1500
1813
  const proofType = await sel('What kind of proof?', [
1501
1814
  { value: 'photo', label: 'Photo' }, { value: 'document', label: 'Document' },
1502
- { value: 'geolocation', label: 'Location' }, { value: 'checklist', label: 'Checklist' },
1815
+ { value: 'geolocation', label: 'Location check-in' }, { value: 'checklist', label: 'Checklist' },
1816
+ { value: 'table', label: 'Table record' },
1817
+ ]);
1818
+ if (proofType) {
1819
+ condition.validation_type = proofType;
1820
+ // Let them pick the specific asset
1821
+ if (proofType === 'photo') {
1822
+ const ph = await pickEntity(post, cfg.orgId, 'photo', 'photo assets');
1823
+ if (ph)
1824
+ condition.config_id = ph.id;
1825
+ }
1826
+ else if (proofType === 'document') {
1827
+ const doc = await pickEntity(post, cfg.orgId, 'document', 'document folders');
1828
+ if (doc) {
1829
+ condition.config_id = doc.id;
1830
+ const tMode = await sel('Upload destination', [
1831
+ { value: 'locked', label: 'Always to this folder' },
1832
+ { value: 'user_choice', label: 'Let user choose' },
1833
+ ]);
1834
+ if (tMode) {
1835
+ condition.target_mode = tMode;
1836
+ if (tMode === 'locked')
1837
+ condition.target_folder_id = doc.id;
1838
+ }
1839
+ }
1840
+ }
1841
+ else if (proofType === 'geolocation') {
1842
+ const area = await pickEntity(post, cfg.orgId, 'geolocation_area', 'location areas');
1843
+ if (area)
1844
+ condition.config_id = area.id;
1845
+ }
1846
+ else if (proofType === 'checklist') {
1847
+ const cl = await pickEntity(post, cfg.orgId, 'checklist', 'checklists');
1848
+ if (cl)
1849
+ condition.config_id = cl.id;
1850
+ }
1851
+ else if (proofType === 'table') {
1852
+ const tbl = await pickEntity(post, cfg.orgId, 'table', 'tables');
1853
+ if (tbl)
1854
+ condition.config_id = tbl.id;
1855
+ }
1856
+ }
1857
+ }
1858
+ else if (ruleType === 'field_constraint') {
1859
+ const field = await txt('Field name', true, 'e.g. status, temperature, score');
1860
+ if (field)
1861
+ condition.field = field;
1862
+ const op = await sel('Condition', [
1863
+ { value: 'eq', label: 'Equals' }, { value: 'neq', label: 'Not equals' },
1864
+ { value: 'gt', label: 'Greater than' }, { value: 'gte', label: 'Greater than or equal' },
1865
+ { value: 'lt', label: 'Less than' }, { value: 'lte', label: 'Less than or equal' },
1866
+ { value: 'in', label: 'Is one of (list)' }, { value: 'not_in', label: 'Is not one of (list)' },
1867
+ { value: 'is_null', label: 'Is empty' }, { value: 'is_not_null', label: 'Is not empty' },
1868
+ { value: 'matches', label: 'Matches pattern' },
1869
+ ]);
1870
+ if (op)
1871
+ condition.operator = op;
1872
+ if (op && !['is_null', 'is_not_null'].includes(op)) {
1873
+ if (op === 'in' || op === 'not_in') {
1874
+ const vals = await txt('Values (comma-separated)', true);
1875
+ if (vals)
1876
+ condition.value = vals.split(',').map(s => s.trim());
1877
+ }
1878
+ else {
1879
+ const val = await txt('Value', true);
1880
+ if (val)
1881
+ condition.value = isNaN(Number(val)) ? val : Number(val);
1882
+ }
1883
+ }
1884
+ }
1885
+ else if (ruleType === 'cross_field') {
1886
+ const f1 = await txt('First field', true);
1887
+ if (f1)
1888
+ condition.field = f1;
1889
+ const cop = await sel('Must be', [
1890
+ { value: 'eq', label: 'Equal to' }, { value: 'neq', label: 'Not equal to' },
1891
+ { value: 'gt', label: 'Greater than' }, { value: 'gte', label: 'Greater or equal to' },
1892
+ { value: 'lt', label: 'Less than' }, { value: 'lte', label: 'Less or equal to' },
1893
+ ]);
1894
+ if (cop)
1895
+ condition.compare_operator = cop;
1896
+ const f2 = await txt('Second field', true);
1897
+ if (f2)
1898
+ condition.compare_field = f2;
1899
+ }
1900
+ else if (ruleType === 'aggregate') {
1901
+ const aggFn = await sel('Aggregate function', [
1902
+ { value: 'count', label: 'Count' }, { value: 'sum', label: 'Sum' },
1903
+ { value: 'avg', label: 'Average' }, { value: 'min', label: 'Minimum' },
1904
+ { value: 'max', label: 'Maximum' },
1905
+ ]);
1906
+ if (aggFn)
1907
+ condition.aggregate_function = aggFn;
1908
+ condition.aggregate_table = 'task_history';
1909
+ if (aggFn !== 'count') {
1910
+ const aggField = await txt('Field to aggregate', true);
1911
+ if (aggField)
1912
+ condition.aggregate_field = aggField;
1913
+ }
1914
+ const aggOp = await sel('Result must be', [
1915
+ { value: 'eq', label: 'Equal to' }, { value: 'gt', label: 'Greater than' },
1916
+ { value: 'gte', label: 'At least' }, { value: 'lt', label: 'Less than' },
1917
+ { value: 'lte', label: 'At most' },
1503
1918
  ]);
1504
- if (proofType)
1505
- condition.proof_type = proofType;
1919
+ if (aggOp)
1920
+ condition.aggregate_operator = aggOp;
1921
+ const aggVal = await txt('Target value', true, 'e.g. 5');
1922
+ if (aggVal)
1923
+ condition.aggregate_value = parseFloat(aggVal);
1924
+ }
1925
+ else if (ruleType === 'approval_required') {
1926
+ const approverType = await sel('Who must approve?', [
1927
+ { value: 'role', label: 'Anyone with a specific role' },
1928
+ { value: 'user', label: 'A specific person' },
1929
+ ]);
1930
+ if (approverType === 'role') {
1931
+ const role = await sel('Required role', [
1932
+ { value: 'admin', label: 'Admin' }, { value: 'super_admin', label: 'Super Admin' },
1933
+ ]);
1934
+ if (role)
1935
+ condition.approval_approver_role = role;
1936
+ }
1937
+ else if (approverType === 'user') {
1938
+ const uid = await txt('Approver user ID', true);
1939
+ if (uid)
1940
+ condition.approval_approver_id = uid;
1941
+ }
1506
1942
  }
1507
1943
  else if (ruleType === 'time_window') {
1508
- const start = await txt('Start time (HH:MM)', false, '08:00');
1509
- const end = await txt('End time (HH:MM)', false, '17:00');
1944
+ const wType = await sel('Window type', [
1945
+ { value: 'daily', label: 'Daily time window' },
1946
+ { value: 'custom', label: 'Custom date/time range' },
1947
+ ]);
1948
+ if (wType)
1949
+ condition.window_type = wType;
1950
+ const start = await txt('Start time (HH:MM)', true, '08:00');
1510
1951
  if (start)
1511
1952
  condition.start_time = start;
1953
+ const end = await txt('End time (HH:MM)', true, '17:00');
1512
1954
  if (end)
1513
1955
  condition.end_time = end;
1956
+ const tz = await txt('Timezone (blank for UTC)', false, 'America/New_York');
1957
+ if (tz)
1958
+ condition.timezone = tz;
1959
+ }
1960
+ else if (ruleType === 'record_exists') {
1961
+ const tbl = await pickEntity(post, cfg.orgId, 'table', 'tables');
1962
+ if (tbl) {
1963
+ condition.table = tbl.id;
1964
+ const addFilters = await yn('Add filters? (only match records with specific values)');
1965
+ if (addFilters) {
1966
+ const filters = [];
1967
+ let addF = true;
1968
+ while (addF) {
1969
+ const ff = await txt('Field name', true);
1970
+ if (!ff)
1971
+ break;
1972
+ const fop = await sel('Condition', [
1973
+ { value: 'eq', label: 'Equals' }, { value: 'neq', label: 'Not equals' },
1974
+ { value: 'gt', label: 'Greater than' }, { value: 'lt', label: 'Less than' },
1975
+ { value: 'is_not_null', label: 'Has a value' },
1976
+ ]);
1977
+ if (!fop)
1978
+ break;
1979
+ if (fop === 'is_not_null') {
1980
+ filters.push({ field: ff, operator: fop, value: null });
1981
+ }
1982
+ else {
1983
+ const fv = await txt('Value', true);
1984
+ if (fv)
1985
+ filters.push({ field: ff, operator: fop, value: isNaN(Number(fv)) ? fv : Number(fv) });
1986
+ }
1987
+ addF = !!(await yn('Add another filter?'));
1988
+ }
1989
+ if (filters.length > 0)
1990
+ condition.filters = filters;
1991
+ }
1992
+ }
1993
+ }
1994
+ // Grouping options
1995
+ const addGroup = await yn('Add to a rule group? (for AND/OR logic with other rules)');
1996
+ if (addGroup) {
1997
+ const gid = await txt('Group ID (create a new UUID or use an existing group ID)', true);
1998
+ if (gid) {
1999
+ rulePayload.group_id = gid;
2000
+ const gop = await sel('Group logic', [
2001
+ { value: 'AND', label: 'AND — all rules in the group must pass' },
2002
+ { value: 'OR', label: 'OR — any rule in the group can pass' },
2003
+ ]);
2004
+ if (gop)
2005
+ rulePayload.group_operator = gop;
2006
+ }
1514
2007
  }
1515
2008
  await cmd(post, cfg.orgId, 'create_validation_rule', {
1516
- task_id: task.id, name, rule_type: ruleType, condition,
2009
+ ...rulePayload, name, rule_type: ruleType, condition,
1517
2010
  }, 'Creating validation rule...');
1518
2011
  return mAutomation(cfg, post);
1519
2012
  }
@@ -1589,25 +2082,100 @@ async function mAutomation(cfg, post) {
1589
2082
  if (!name)
1590
2083
  return mAutomation(cfg, post);
1591
2084
  const eventType = await sel('What triggers this?', [
1592
- { value: 'record_created', label: 'New record added to a table' },
1593
- { value: 'record_updated', label: 'Record updated in a table' },
1594
2085
  { value: 'task_completed', label: 'Another task completed' },
1595
- { value: 'task_missed', label: 'A task was missed' },
1596
- { value: 'approval_resolved', label: 'An approval was resolved' },
2086
+ { value: 'task_uncompleted', label: 'A task was reopened' },
2087
+ { value: 'task_created', label: 'A new task was created' },
2088
+ { value: 'task_updated', label: 'A task was updated' },
2089
+ { value: 'validation_submitted', label: 'Proof was submitted' },
2090
+ { value: 'approval_requested', label: 'An approval was requested' },
2091
+ { value: 'approval_approved', label: 'An approval was granted' },
2092
+ { value: 'approval_rejected', label: 'An approval was rejected' },
2093
+ { value: 'table_record_created', label: 'New record added to a table' },
2094
+ { value: 'conditional_answer', label: 'A conditional question was answered' },
2095
+ { value: 'document_created', label: 'A document was uploaded' },
2096
+ { value: 'checklist_created', label: 'A checklist was created' },
1597
2097
  ]);
1598
2098
  if (!eventType)
1599
2099
  return mAutomation(cfg, post);
1600
- const sourceType = await sel('Source type', [
1601
- { value: 'table', label: 'Table' }, { value: 'task', label: 'Task' },
2100
+ const sourceType = await sel('Source type (what entity fires the event)', [
2101
+ { value: 'task', label: 'Task' },
2102
+ { value: 'table', label: 'Table' },
2103
+ { value: 'document', label: 'Document' },
2104
+ { value: 'checklist', label: 'Checklist' },
2105
+ { value: 'geolocation_area', label: 'Location area' },
1602
2106
  { value: 'approval', label: 'Approval' },
2107
+ { value: 'plan', label: 'Plan' },
1603
2108
  ]);
1604
2109
  if (!sourceType)
1605
2110
  return mAutomation(cfg, post);
1606
- const payload = { task_id: task.id, name, event_type: eventType, source_entity_type: sourceType };
1607
- const source = await pickEntity(post, cfg.orgId, sourceType, `${sourceType}s`);
1608
- if (source)
1609
- payload.source_entity_id = source.id;
1610
- await cmd(post, cfg.orgId, 'create_trigger_rule', payload, 'Creating trigger rule...');
2111
+ const trigPayload = { task_id: task.id, name, event_type: eventType, source_entity_type: sourceType };
2112
+ const pickSource = await yn(`Pick a specific ${sourceType}? (or trigger on any)`);
2113
+ if (pickSource) {
2114
+ const source = await pickEntity(post, cfg.orgId, sourceType, `${sourceType}s`);
2115
+ if (source)
2116
+ trigPayload.source_entity_id = source.id;
2117
+ }
2118
+ // Conditions
2119
+ const addConds = await yn('Add conditions? (only trigger when specific criteria are met)');
2120
+ if (addConds) {
2121
+ const conditions = [];
2122
+ let addC = true;
2123
+ while (addC) {
2124
+ const cf = await txt('Field to check', true, 'e.g. status, frequency, name');
2125
+ if (!cf)
2126
+ break;
2127
+ const cop = await sel('Condition', [
2128
+ { value: 'eq', label: 'Equals' }, { value: 'neq', label: 'Not equals' },
2129
+ { value: 'gt', label: 'Greater than' }, { value: 'lt', label: 'Less than' },
2130
+ { value: 'in', label: 'Is one of' }, { value: 'is_not_null', label: 'Has a value' },
2131
+ ]);
2132
+ if (!cop)
2133
+ break;
2134
+ const csrc = await sel('Check against', [
2135
+ { value: 'payload', label: 'Event data (payload)' },
2136
+ { value: 'entity', label: 'Entity state' },
2137
+ ]);
2138
+ let cv = null;
2139
+ if (cop !== 'is_not_null') {
2140
+ if (cop === 'in') {
2141
+ const cvStr = await txt('Values (comma-separated)', true);
2142
+ if (cvStr)
2143
+ cv = cvStr.split(',').map(s => s.trim());
2144
+ }
2145
+ else {
2146
+ const cvTxt = await txt('Value', true);
2147
+ if (cvTxt)
2148
+ cv = isNaN(Number(cvTxt)) ? cvTxt : Number(cvTxt);
2149
+ }
2150
+ }
2151
+ conditions.push({ field: cf, operator: cop, value: cv, ...(csrc ? { source: csrc } : {}) });
2152
+ addC = !!(await yn('Add another condition?'));
2153
+ }
2154
+ if (conditions.length > 0) {
2155
+ trigPayload.conditions = conditions;
2156
+ if (conditions.length > 1) {
2157
+ const logic = await sel('Condition logic', [
2158
+ { value: 'AND', label: 'ALL conditions must be true' },
2159
+ { value: 'OR', label: 'ANY condition can be true' },
2160
+ ]);
2161
+ if (logic)
2162
+ trigPayload.condition_operator = logic;
2163
+ }
2164
+ }
2165
+ }
2166
+ // Advanced options
2167
+ const advanced = await yn('Configure advanced options? (frequency, message, debounce)');
2168
+ if (advanced) {
2169
+ const respect = await yn('Respect task frequency? (only activate on scheduled days)');
2170
+ trigPayload.respect_frequency = !!respect;
2171
+ const msg = await txt('Activation message (shown when triggered)');
2172
+ if (msg)
2173
+ trigPayload.activation_message = msg;
2174
+ const debounce = await txt('Debounce seconds (prevent re-triggering too fast)', false, '60');
2175
+ if (debounce)
2176
+ trigPayload.debounce_seconds = parseInt(debounce, 10);
2177
+ }
2178
+ await cmd(post, cfg.orgId, 'create_trigger_rule', trigPayload, 'Creating trigger rule...');
1611
2179
  return mAutomation(cfg, post);
1612
2180
  }
1613
2181
  case 'trig-edit': {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "checkbox-cli",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Beautiful, interactive CLI for Checkbox compliance management. Setup wizard, guided workflows, and full workspace control from your terminal.",
5
5
  "type": "module",
6
6
  "main": "dist/checkbox.js",