@goxtechnologies/connectwise-psa-mcp 1.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 (147) hide show
  1. package/data/connectwise_api.db +0 -0
  2. package/data/manage.json +298179 -0
  3. package/dist/index.d.ts +10 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +116 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/operations/analytics-extended.d.ts +6 -0
  8. package/dist/operations/analytics-extended.d.ts.map +1 -0
  9. package/dist/operations/analytics-extended.js +825 -0
  10. package/dist/operations/analytics-extended.js.map +1 -0
  11. package/dist/operations/analytics-msp-assets.d.ts +3 -0
  12. package/dist/operations/analytics-msp-assets.d.ts.map +1 -0
  13. package/dist/operations/analytics-msp-assets.js +180 -0
  14. package/dist/operations/analytics-msp-assets.js.map +1 -0
  15. package/dist/operations/analytics-msp-clients.d.ts +3 -0
  16. package/dist/operations/analytics-msp-clients.d.ts.map +1 -0
  17. package/dist/operations/analytics-msp-clients.js +198 -0
  18. package/dist/operations/analytics-msp-clients.js.map +1 -0
  19. package/dist/operations/analytics-msp-comms.d.ts +3 -0
  20. package/dist/operations/analytics-msp-comms.d.ts.map +1 -0
  21. package/dist/operations/analytics-msp-comms.js +127 -0
  22. package/dist/operations/analytics-msp-comms.js.map +1 -0
  23. package/dist/operations/analytics-msp-contracts.d.ts +3 -0
  24. package/dist/operations/analytics-msp-contracts.d.ts.map +1 -0
  25. package/dist/operations/analytics-msp-contracts.js +91 -0
  26. package/dist/operations/analytics-msp-contracts.js.map +1 -0
  27. package/dist/operations/analytics-msp-financial.d.ts +3 -0
  28. package/dist/operations/analytics-msp-financial.d.ts.map +1 -0
  29. package/dist/operations/analytics-msp-financial.js +300 -0
  30. package/dist/operations/analytics-msp-financial.js.map +1 -0
  31. package/dist/operations/analytics-msp-procurement.d.ts +3 -0
  32. package/dist/operations/analytics-msp-procurement.d.ts.map +1 -0
  33. package/dist/operations/analytics-msp-procurement.js +78 -0
  34. package/dist/operations/analytics-msp-procurement.js.map +1 -0
  35. package/dist/operations/analytics-msp-projects.d.ts +3 -0
  36. package/dist/operations/analytics-msp-projects.d.ts.map +1 -0
  37. package/dist/operations/analytics-msp-projects.js +190 -0
  38. package/dist/operations/analytics-msp-projects.js.map +1 -0
  39. package/dist/operations/analytics-msp-sales.d.ts +3 -0
  40. package/dist/operations/analytics-msp-sales.d.ts.map +1 -0
  41. package/dist/operations/analytics-msp-sales.js +99 -0
  42. package/dist/operations/analytics-msp-sales.js.map +1 -0
  43. package/dist/operations/analytics-msp-schedule.d.ts +3 -0
  44. package/dist/operations/analytics-msp-schedule.d.ts.map +1 -0
  45. package/dist/operations/analytics-msp-schedule.js +339 -0
  46. package/dist/operations/analytics-msp-schedule.js.map +1 -0
  47. package/dist/operations/analytics-msp-team.d.ts +3 -0
  48. package/dist/operations/analytics-msp-team.d.ts.map +1 -0
  49. package/dist/operations/analytics-msp-team.js +195 -0
  50. package/dist/operations/analytics-msp-team.js.map +1 -0
  51. package/dist/operations/analytics-msp-tickets.d.ts +3 -0
  52. package/dist/operations/analytics-msp-tickets.d.ts.map +1 -0
  53. package/dist/operations/analytics-msp-tickets.js +578 -0
  54. package/dist/operations/analytics-msp-tickets.js.map +1 -0
  55. package/dist/operations/analytics-msp-time.d.ts +3 -0
  56. package/dist/operations/analytics-msp-time.d.ts.map +1 -0
  57. package/dist/operations/analytics-msp-time.js +485 -0
  58. package/dist/operations/analytics-msp-time.js.map +1 -0
  59. package/dist/operations/analytics-msp-utils.d.ts +49 -0
  60. package/dist/operations/analytics-msp-utils.d.ts.map +1 -0
  61. package/dist/operations/analytics-msp-utils.js +157 -0
  62. package/dist/operations/analytics-msp-utils.js.map +1 -0
  63. package/dist/operations/analytics.d.ts +9 -0
  64. package/dist/operations/analytics.d.ts.map +1 -0
  65. package/dist/operations/analytics.js +742 -0
  66. package/dist/operations/analytics.js.map +1 -0
  67. package/dist/operations/executor.d.ts +10 -0
  68. package/dist/operations/executor.d.ts.map +1 -0
  69. package/dist/operations/executor.js +243 -0
  70. package/dist/operations/executor.js.map +1 -0
  71. package/dist/operations/registry.d.ts +16 -0
  72. package/dist/operations/registry.d.ts.map +1 -0
  73. package/dist/operations/registry.js +847 -0
  74. package/dist/operations/registry.js.map +1 -0
  75. package/dist/services/api-database.d.ts +38 -0
  76. package/dist/services/api-database.d.ts.map +1 -0
  77. package/dist/services/api-database.js +191 -0
  78. package/dist/services/api-database.js.map +1 -0
  79. package/dist/services/cache.d.ts +12 -0
  80. package/dist/services/cache.d.ts.map +1 -0
  81. package/dist/services/cache.js +32 -0
  82. package/dist/services/cache.js.map +1 -0
  83. package/dist/services/connectwise-api.d.ts +43 -0
  84. package/dist/services/connectwise-api.d.ts.map +1 -0
  85. package/dist/services/connectwise-api.js +198 -0
  86. package/dist/services/connectwise-api.js.map +1 -0
  87. package/dist/services/db-builder.d.ts +11 -0
  88. package/dist/services/db-builder.d.ts.map +1 -0
  89. package/dist/services/db-builder.js +237 -0
  90. package/dist/services/db-builder.js.map +1 -0
  91. package/dist/services/fast-memory.d.ts +39 -0
  92. package/dist/services/fast-memory.d.ts.map +1 -0
  93. package/dist/services/fast-memory.js +147 -0
  94. package/dist/services/fast-memory.js.map +1 -0
  95. package/dist/services/load-env.d.ts +15 -0
  96. package/dist/services/load-env.d.ts.map +1 -0
  97. package/dist/services/load-env.js +59 -0
  98. package/dist/services/load-env.js.map +1 -0
  99. package/dist/tools/batch.d.ts +9 -0
  100. package/dist/tools/batch.d.ts.map +1 -0
  101. package/dist/tools/batch.js +159 -0
  102. package/dist/tools/batch.js.map +1 -0
  103. package/dist/tools/composite.d.ts +9 -0
  104. package/dist/tools/composite.d.ts.map +1 -0
  105. package/dist/tools/composite.js +353 -0
  106. package/dist/tools/composite.js.map +1 -0
  107. package/dist/tools/discovery.d.ts +9 -0
  108. package/dist/tools/discovery.d.ts.map +1 -0
  109. package/dist/tools/discovery.js +245 -0
  110. package/dist/tools/discovery.js.map +1 -0
  111. package/dist/tools/execution.d.ts +9 -0
  112. package/dist/tools/execution.d.ts.map +1 -0
  113. package/dist/tools/execution.js +130 -0
  114. package/dist/tools/execution.js.map +1 -0
  115. package/dist/tools/memory.d.ts +9 -0
  116. package/dist/tools/memory.d.ts.map +1 -0
  117. package/dist/tools/memory.js +152 -0
  118. package/dist/tools/memory.js.map +1 -0
  119. package/dist/tools/operations.d.ts +9 -0
  120. package/dist/tools/operations.d.ts.map +1 -0
  121. package/dist/tools/operations.js +214 -0
  122. package/dist/tools/operations.js.map +1 -0
  123. package/dist/tools/pagination.d.ts +9 -0
  124. package/dist/tools/pagination.d.ts.map +1 -0
  125. package/dist/tools/pagination.js +133 -0
  126. package/dist/tools/pagination.js.map +1 -0
  127. package/dist/tools/validation.d.ts +9 -0
  128. package/dist/tools/validation.d.ts.map +1 -0
  129. package/dist/tools/validation.js +705 -0
  130. package/dist/tools/validation.js.map +1 -0
  131. package/dist/types/index.d.ts +145 -0
  132. package/dist/types/index.d.ts.map +1 -0
  133. package/dist/types/index.js +3 -0
  134. package/dist/types/index.js.map +1 -0
  135. package/dist/types/operations.d.ts +30 -0
  136. package/dist/types/operations.d.ts.map +1 -0
  137. package/dist/types/operations.js +3 -0
  138. package/dist/types/operations.js.map +1 -0
  139. package/dist/utils/conditions.d.ts +20 -0
  140. package/dist/utils/conditions.d.ts.map +1 -0
  141. package/dist/utils/conditions.js +78 -0
  142. package/dist/utils/conditions.js.map +1 -0
  143. package/dist/utils/formatters.d.ts +35 -0
  144. package/dist/utils/formatters.d.ts.map +1 -0
  145. package/dist/utils/formatters.js +337 -0
  146. package/dist/utils/formatters.js.map +1 -0
  147. package/package.json +46 -0
@@ -0,0 +1,705 @@
1
+ // ConnectWise PSA MCP Server — Validation & Approval Tools
2
+ //
3
+ // cw_validate_timesheets: 8-rule validation engine
4
+ // cw_accrual_balance: fetch and format accrual balances for a member
5
+ // cw_approve_timesheets: full approval workflow — validate, reject violations, approve clean sheets
6
+ import { readFileSync, existsSync } from 'fs';
7
+ import { dirname, join } from 'path';
8
+ import { fileURLToPath } from 'url';
9
+ import { getAPI } from '../services/connectwise-api.js';
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = dirname(__filename);
12
+ // ---------------------------------------------------------------------------
13
+ // Tool definitions
14
+ // ---------------------------------------------------------------------------
15
+ export const validationTools = [
16
+ {
17
+ name: 'cw_validate_timesheets',
18
+ description: 'Validate all time entries for a member within a date range against 8 business rules. ' +
19
+ 'Returns a list of violations tagged as either "reject" (must be corrected) or "warn" (advisory). ' +
20
+ 'Rules: 5-minute rounding, deduction rounding, overlap detection, missing notes, department match, overtime flag, pause rules, and charge-code weekly limits.',
21
+ inputSchema: {
22
+ type: 'object',
23
+ properties: {
24
+ member_identifier: {
25
+ type: 'string',
26
+ description: 'ConnectWise member identifier (login name)',
27
+ },
28
+ start_date: {
29
+ type: 'string',
30
+ description: 'Period start date in ISO format (YYYY-MM-DD)',
31
+ },
32
+ end_date: {
33
+ type: 'string',
34
+ description: 'Period end date in ISO format (YYYY-MM-DD)',
35
+ },
36
+ },
37
+ required: ['member_identifier', 'start_date', 'end_date'],
38
+ },
39
+ },
40
+ {
41
+ name: 'cw_accrual_balance',
42
+ description: 'Retrieve and display the current accrual balances (vacation, sick, PTO, holiday, etc.) for a ConnectWise member.',
43
+ inputSchema: {
44
+ type: 'object',
45
+ properties: {
46
+ member_identifier: {
47
+ type: 'string',
48
+ description: 'ConnectWise member identifier (login name)',
49
+ },
50
+ },
51
+ required: ['member_identifier'],
52
+ },
53
+ },
54
+ {
55
+ name: 'cw_approve_timesheets',
56
+ description: 'Full timesheet approval workflow. Fetches timesheets for one or all members in a date range, ' +
57
+ 'validates each against 8 business rules, then: rejects entries with REJECT violations (two-step: ' +
58
+ 'reject individual entries first with comments, then reject the timesheet), and approves timesheets ' +
59
+ 'with no REJECT violations. Use dry_run=true to preview without making changes. ' +
60
+ 'Dry-run by default for safety.',
61
+ inputSchema: {
62
+ type: 'object',
63
+ properties: {
64
+ start_date: {
65
+ type: 'string',
66
+ description: 'Period start date in YYYY-MM-DD format',
67
+ },
68
+ end_date: {
69
+ type: 'string',
70
+ description: 'Period end date in YYYY-MM-DD format',
71
+ },
72
+ member_identifier: {
73
+ type: 'string',
74
+ description: 'Optional: specific member identifier. If omitted, processes ALL members with timesheets in the period.',
75
+ },
76
+ dry_run: {
77
+ type: 'boolean',
78
+ description: 'If true, validate and report but do NOT approve/reject anything in ConnectWise. Default: true (safe by default).',
79
+ },
80
+ },
81
+ required: ['start_date', 'end_date'],
82
+ },
83
+ },
84
+ ];
85
+ // ---------------------------------------------------------------------------
86
+ // Validation helpers
87
+ // ---------------------------------------------------------------------------
88
+ // ---------------------------------------------------------------------------
89
+ // Timezone handling
90
+ // ---------------------------------------------------------------------------
91
+ // ConnectWise stores times in UTC but they represent local time (Eastern).
92
+ // All validation (5-min rounding, overtime, overlaps, pauses) must use
93
+ // local time, not UTC. Default: America/Toronto (ET).
94
+ const LOCAL_TZ = process.env.CW_TIMEZONE ?? 'America/Toronto';
95
+ /** Convert a UTC ISO datetime string to local time components. */
96
+ function toLocal(iso) {
97
+ // Parse the UTC date, then get its representation in local timezone
98
+ const utc = new Date(iso);
99
+ // Use Intl to get the local time parts
100
+ const parts = new Intl.DateTimeFormat('en-CA', {
101
+ timeZone: LOCAL_TZ,
102
+ year: 'numeric', month: '2-digit', day: '2-digit',
103
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
104
+ hour12: false,
105
+ }).formatToParts(utc);
106
+ const get = (type) => {
107
+ const part = parts.find(p => p.type === type);
108
+ return part ? parseInt(part.value, 10) : 0;
109
+ };
110
+ // Build a Date-like object with local time values
111
+ const local = new Date(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second'));
112
+ return local;
113
+ }
114
+ /** Round a number to 2 decimal places for hour comparisons. */
115
+ function round2(n) {
116
+ return Math.round(n * 100) / 100;
117
+ }
118
+ /** Parse an ISO datetime string and return local { hours, minutes }. */
119
+ function parseHHMM(iso) {
120
+ if (!iso)
121
+ return null;
122
+ const local = toLocal(iso);
123
+ return { hours: local.getHours(), minutes: local.getMinutes() };
124
+ }
125
+ /** Return total minutes since midnight in LOCAL time for a time string. */
126
+ function toMinutes(iso) {
127
+ const t = parseHHMM(iso);
128
+ if (!t)
129
+ return null;
130
+ return t.hours * 60 + t.minutes;
131
+ }
132
+ /** Extract local YYYY-MM-DD from an ISO datetime string (in local timezone). */
133
+ function toDateKey(iso) {
134
+ const local = toLocal(iso);
135
+ const y = local.getFullYear();
136
+ const m = String(local.getMonth() + 1).padStart(2, '0');
137
+ const d = String(local.getDate()).padStart(2, '0');
138
+ return `${y}-${m}-${d}`;
139
+ }
140
+ // ---------------------------------------------------------------------------
141
+ // Allowed deduct values (15-second increments expressed as fractions of 1 hour)
142
+ // ---------------------------------------------------------------------------
143
+ const VALID_DEDUCTS = new Set([0, 0.08, 0.17, 0.25, 0.33, 0.42, 0.5, 0.58, 0.67, 0.75, 0.83, 0.92, 1.0]);
144
+ let _chargeCodeRules = null;
145
+ function getChargeCodeRules() {
146
+ if (_chargeCodeRules)
147
+ return _chargeCodeRules;
148
+ try {
149
+ // Local override takes priority over shipped defaults
150
+ const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT ?? join(__dirname, '../../../..');
151
+ const localPath = join(pluginRoot, 'config', 'charge-code-rules.local.json');
152
+ const defaultPath = join(pluginRoot, 'config', 'charge-code-rules.json');
153
+ const configPath = existsSync(localPath) ? localPath : defaultPath;
154
+ _chargeCodeRules = JSON.parse(readFileSync(configPath, 'utf-8'));
155
+ }
156
+ catch {
157
+ _chargeCodeRules = []; // No config file found — no charge code limits enforced
158
+ }
159
+ return _chargeCodeRules;
160
+ }
161
+ /** Get the applicable hour limit for a charge code given a role category. */
162
+ function getChargeCodeLimit(codeName, roleCategory) {
163
+ const rules = getChargeCodeRules();
164
+ const rule = rules.find(r => r.charge_code_name === codeName && r.enabled);
165
+ if (!rule)
166
+ return null;
167
+ if (roleCategory === 'gestion' && rule.max_hours_gestion !== null)
168
+ return rule.max_hours_gestion;
169
+ if (roleCategory === 'dispatch' && rule.max_hours_dispatch !== null)
170
+ return rule.max_hours_dispatch;
171
+ return rule.max_hours_regular; // null means no limit
172
+ }
173
+ // ---------------------------------------------------------------------------
174
+ // 8-rule validation engine
175
+ // ---------------------------------------------------------------------------
176
+ function validateEntries(entries, roleCategory = 'regular') {
177
+ const violations = [];
178
+ // Group by date for overlap and pause checks
179
+ const byDate = new Map();
180
+ for (const e of entries) {
181
+ const key = toDateKey(e.dateEntered);
182
+ const bucket = byDate.get(key) ?? [];
183
+ bucket.push(e);
184
+ byDate.set(key, bucket);
185
+ }
186
+ // Group by ISO-week + charge code for weekly cap checks
187
+ function isoWeek(dateStr) {
188
+ const d = new Date(dateStr);
189
+ const jan1 = new Date(d.getFullYear(), 0, 1);
190
+ const week = Math.ceil(((d.getTime() - jan1.getTime()) / 86400000 + jan1.getDay() + 1) / 7);
191
+ return `${d.getFullYear()}-W${String(week).padStart(2, '0')}`;
192
+ }
193
+ const weeklyChargeHours = new Map(); // "<week>:<chargeCode>" -> hours
194
+ // -------------------------------------------------------------------------
195
+ // Per-entry rules
196
+ // -------------------------------------------------------------------------
197
+ for (const entry of entries) {
198
+ // Rule 1: 5-minute rounding
199
+ const startT = parseHHMM(entry.timeStart);
200
+ const endT = parseHHMM(entry.timeEnd);
201
+ if (startT && startT.minutes % 5 !== 0) {
202
+ violations.push({
203
+ rule: '5min_rounding',
204
+ severity: 'reject',
205
+ entry_id: entry.id,
206
+ message: `timeStart ${entry.timeStart} is not on a 5-minute boundary`,
207
+ details: { timeStart: entry.timeStart, minutes: startT.minutes },
208
+ });
209
+ }
210
+ if (endT && endT.minutes % 5 !== 0) {
211
+ violations.push({
212
+ rule: '5min_rounding',
213
+ severity: 'reject',
214
+ entry_id: entry.id,
215
+ message: `timeEnd ${entry.timeEnd} is not on a 5-minute boundary`,
216
+ details: { timeEnd: entry.timeEnd, minutes: endT.minutes },
217
+ });
218
+ }
219
+ // Rule 2: deduction rounding
220
+ const deduct = round2(entry.hoursDeduct ?? 0);
221
+ if (!VALID_DEDUCTS.has(deduct)) {
222
+ violations.push({
223
+ rule: 'deduct_rounding',
224
+ severity: 'reject',
225
+ entry_id: entry.id,
226
+ message: `hoursDeduct ${deduct} is not a valid deduction value`,
227
+ details: { hoursDeduct: deduct, validValues: [...VALID_DEDUCTS] },
228
+ });
229
+ }
230
+ // Rule 4: missing notes
231
+ if ((entry.actualHours ?? 0) > 0 && !entry.notes?.trim()) {
232
+ violations.push({
233
+ rule: 'missing_notes',
234
+ severity: 'reject',
235
+ entry_id: entry.id,
236
+ message: 'Entry has hours but no notes',
237
+ details: { actualHours: entry.actualHours },
238
+ });
239
+ }
240
+ // Rule 6: overtime flag (warning only)
241
+ if (startT && endT) {
242
+ const startMin = startT.hours * 60 + startT.minutes;
243
+ const endMin = endT.hours * 60 + endT.minutes;
244
+ const beforeWork = startMin < 8 * 60; // before 08:00
245
+ const afterWork = endMin > 17 * 60; // after 17:00
246
+ if (beforeWork || afterWork) {
247
+ const wt = (entry.workType?.name ?? '').toLowerCase();
248
+ const isOvertime = wt.includes('overtime') || wt === 'ot' || wt.includes('majoré') || wt.includes('off-hours');
249
+ if (!isOvertime) {
250
+ violations.push({
251
+ rule: 'overtime_flag',
252
+ severity: 'warn',
253
+ entry_id: entry.id,
254
+ message: `Entry spans outside 08:00–17:00 but work type is "${wt || 'unset'}" (not overtime)`,
255
+ details: { timeStart: entry.timeStart, timeEnd: entry.timeEnd, workType: wt },
256
+ });
257
+ }
258
+ }
259
+ }
260
+ // Rule 8: charge-code weekly limits (loaded from config/charge-code-rules.json)
261
+ const chargeCodeName = entry.chargeCode?.name;
262
+ if (chargeCodeName) {
263
+ const limit = getChargeCodeLimit(chargeCodeName, roleCategory);
264
+ if (limit !== null) {
265
+ const weekKey = `${isoWeek(toDateKey(entry.dateEntered))}:${chargeCodeName}`;
266
+ const accumulated = (weeklyChargeHours.get(weekKey) ?? 0) + (entry.actualHours ?? 0);
267
+ weeklyChargeHours.set(weekKey, accumulated);
268
+ if (accumulated > limit) {
269
+ violations.push({
270
+ rule: 'charge_code_limits',
271
+ severity: 'reject',
272
+ entry_id: entry.id,
273
+ message: `Charge code "${chargeCodeName}" exceeds weekly limit of ${limit}h for role "${roleCategory}" (accumulated: ${round2(accumulated)}h)`,
274
+ details: { chargeCode: chargeCodeName, limit, accumulated: round2(accumulated), roleCategory },
275
+ });
276
+ }
277
+ }
278
+ }
279
+ }
280
+ // -------------------------------------------------------------------------
281
+ // Per-day rules (overlap detection, pause rules)
282
+ // -------------------------------------------------------------------------
283
+ for (const [date, dayEntries] of byDate) {
284
+ // Rule 3: overlap detection
285
+ // Sort by timeStart ascending
286
+ const sorted = [...dayEntries].sort((a, b) => {
287
+ const am = toMinutes(a.timeStart) ?? 0;
288
+ const bm = toMinutes(b.timeStart) ?? 0;
289
+ return am - bm;
290
+ });
291
+ for (let i = 0; i < sorted.length - 1; i++) {
292
+ const current = sorted[i];
293
+ const next = sorted[i + 1];
294
+ const currentEnd = toMinutes(current.timeEnd);
295
+ const nextStart = toMinutes(next.timeStart);
296
+ if (currentEnd !== null && nextStart !== null && currentEnd > nextStart) {
297
+ // Check if the overlap is accounted for by a compensatory deduction.
298
+ // The actual overlap is the time where both entries run simultaneously.
299
+ // If either entry's deduction covers that overlap, no double-billing.
300
+ const overlapMin = currentEnd - nextStart;
301
+ const currentDeductMin = (current.hoursDeduct ?? 0) * 60;
302
+ const nextDeductMin = (next.hoursDeduct ?? 0) * 60;
303
+ // If either entry's deduction covers the overlap duration (within
304
+ // 5-minute tolerance), the overlap is intentionally accounted for
305
+ const isCompensated = (currentDeductMin > 0 && currentDeductMin >= overlapMin - 5) ||
306
+ (nextDeductMin > 0 && nextDeductMin >= overlapMin - 5);
307
+ if (!isCompensated) {
308
+ violations.push({
309
+ rule: 'overlap_detection',
310
+ severity: 'reject',
311
+ entry_id: next.id,
312
+ message: `Entry #${next.id} starts at ${next.timeStart} before entry #${current.id} ends at ${current.timeEnd} on ${date}`,
313
+ details: {
314
+ date,
315
+ overlapWithEntryId: current.id,
316
+ conflictStart: next.timeStart,
317
+ conflictEnd: current.timeEnd,
318
+ },
319
+ });
320
+ }
321
+ }
322
+ }
323
+ // Rule 7: pause rules — max 0.25h deduct per AM / PM period per day
324
+ // Exempt leave/vacation entries — full-day leave uses 08:00-17:00 with 1h
325
+ // deduct as the standard template; this is not a "pause".
326
+ const LEAVE_KEYWORDS = ['vacances', 'vacation', 'congé', 'conge', 'sick', 'holiday', 'pto', 'férié', 'ferie', 'leave', 'absence'];
327
+ const isLeaveEntry = (e) => {
328
+ const wt = (e.workType?.name ?? '').toLowerCase();
329
+ return LEAVE_KEYWORDS.some(kw => wt.includes(kw));
330
+ };
331
+ // Identify compensatory deductions: if entry A has a deduct and another
332
+ // entry B is nested inside A's time range with duration ≈ A's deduct,
333
+ // then A's deduct is compensatory (accounts for B), not a pause.
334
+ const isCompensatoryDeduct = (entry) => {
335
+ if ((entry.hoursDeduct ?? 0) <= 0)
336
+ return false;
337
+ const entryStart = toMinutes(entry.timeStart);
338
+ const entryEnd = toMinutes(entry.timeEnd);
339
+ if (entryStart === null || entryEnd === null)
340
+ return false;
341
+ const deductMin = (entry.hoursDeduct ?? 0) * 60;
342
+ // Check if any other entry on this day is nested inside this entry's range
343
+ for (const other of dayEntries) {
344
+ if (other.id === entry.id)
345
+ continue;
346
+ const otherStart = toMinutes(other.timeStart);
347
+ const otherEnd = toMinutes(other.timeEnd);
348
+ if (otherStart === null || otherEnd === null)
349
+ continue;
350
+ if (otherStart >= entryStart && otherEnd <= entryEnd) {
351
+ const otherDuration = otherEnd - otherStart;
352
+ if (Math.abs(deductMin - otherDuration) <= 5)
353
+ return true;
354
+ }
355
+ }
356
+ return false;
357
+ };
358
+ // Filter out leave entries and compensatory deductions before summing
359
+ const pauseEntries = dayEntries.filter(e => !isLeaveEntry(e) && !isCompensatoryDeduct(e));
360
+ const amEntries = pauseEntries.filter((e) => {
361
+ const t = parseHHMM(e.timeStart);
362
+ return t !== null && t.hours < 12;
363
+ });
364
+ const pmEntries = pauseEntries.filter((e) => {
365
+ const t = parseHHMM(e.timeStart);
366
+ return t !== null && t.hours >= 12;
367
+ });
368
+ const amDeduct = amEntries.reduce((s, e) => s + (e.hoursDeduct ?? 0), 0);
369
+ const pmDeduct = pmEntries.reduce((s, e) => s + (e.hoursDeduct ?? 0), 0);
370
+ if (round2(amDeduct) > 0.25) {
371
+ // Flag the last AM entry as the carrier of the violation
372
+ const lastAm = amEntries[amEntries.length - 1];
373
+ if (lastAm) {
374
+ violations.push({
375
+ rule: 'pause_rules',
376
+ severity: 'reject',
377
+ entry_id: lastAm.id,
378
+ message: `AM deductions for ${date} total ${round2(amDeduct)}h — maximum is 0.25h`,
379
+ details: { date, period: 'AM', totalDeduct: round2(amDeduct), limit: 0.25 },
380
+ });
381
+ }
382
+ }
383
+ if (round2(pmDeduct) > 0.25) {
384
+ const lastPm = pmEntries[pmEntries.length - 1];
385
+ if (lastPm) {
386
+ violations.push({
387
+ rule: 'pause_rules',
388
+ severity: 'reject',
389
+ entry_id: lastPm.id,
390
+ message: `PM deductions for ${date} total ${round2(pmDeduct)}h — maximum is 0.25h`,
391
+ details: { date, period: 'PM', totalDeduct: round2(pmDeduct), limit: 0.25 },
392
+ });
393
+ }
394
+ }
395
+ }
396
+ // Note: Rule 5 (department_match) requires member profile data.
397
+ // The check is intentionally omitted here to avoid an extra API round-trip
398
+ // inside the validation loop; it is surfaced as a design-time warning instead.
399
+ // If the caller provides member department info in a future parameter, add it here.
400
+ return violations;
401
+ }
402
+ // ---------------------------------------------------------------------------
403
+ // text helper
404
+ // ---------------------------------------------------------------------------
405
+ function text(t) {
406
+ return { content: [{ type: 'text', text: t }] };
407
+ }
408
+ // ---------------------------------------------------------------------------
409
+ // Handler
410
+ // ---------------------------------------------------------------------------
411
+ export async function handleValidationTool(toolName, args) {
412
+ const api = getAPI();
413
+ switch (toolName) {
414
+ // -----------------------------------------------------------------------
415
+ case 'cw_validate_timesheets': {
416
+ const memberIdentifier = String(args['member_identifier'] ?? '').trim();
417
+ const startDate = String(args['start_date'] ?? '').trim();
418
+ const endDate = String(args['end_date'] ?? '').trim();
419
+ if (!memberIdentifier || !startDate || !endDate) {
420
+ return text('Error: member_identifier, start_date, and end_date are all required.');
421
+ }
422
+ // Basic ISO date format check
423
+ const isoDateRe = /^\d{4}-\d{2}-\d{2}$/;
424
+ if (!isoDateRe.test(startDate) || !isoDateRe.test(endDate)) {
425
+ return text('Error: start_date and end_date must be in YYYY-MM-DD format.');
426
+ }
427
+ // Fetch time entries
428
+ let entries;
429
+ try {
430
+ const conditions = `member/identifier='${memberIdentifier}' AND dateEntered>=[${startDate}] AND dateEntered<=[${endDate}]`;
431
+ const rawEntries = await api.paginatedFetch('/time/entries', conditions, undefined, 1000);
432
+ entries = rawEntries.items;
433
+ }
434
+ catch (err) {
435
+ const msg = err instanceof Error ? err.message : String(err);
436
+ return text(`Error fetching time entries: ${msg}`);
437
+ }
438
+ if (entries.length === 0) {
439
+ return text(`No time entries found for member "${memberIdentifier}" between ${startDate} and ${endDate}.`);
440
+ }
441
+ const violations = validateEntries(entries);
442
+ const rejects = violations.filter((v) => v.severity === 'reject');
443
+ const warns = violations.filter((v) => v.severity === 'warn');
444
+ const passed = rejects.length === 0;
445
+ const result = {
446
+ member: memberIdentifier,
447
+ period: `${startDate} to ${endDate}`,
448
+ totalEntries: entries.length,
449
+ violations,
450
+ passed,
451
+ };
452
+ // Build human-readable summary
453
+ const lines = [
454
+ `Validation result for ${memberIdentifier} (${startDate} to ${endDate})`,
455
+ `Total entries : ${entries.length}`,
456
+ `Status : ${passed ? 'PASSED' : 'FAILED'}`,
457
+ `Violations : ${rejects.length} reject${rejects.length !== 1 ? 's' : ''}, ${warns.length} warning${warns.length !== 1 ? 's' : ''}`,
458
+ '',
459
+ ];
460
+ if (violations.length === 0) {
461
+ lines.push('All entries passed validation.');
462
+ }
463
+ else {
464
+ if (rejects.length > 0) {
465
+ lines.push('--- REJECTS (must be corrected) ---');
466
+ for (const v of rejects) {
467
+ lines.push(` Entry #${v.entry_id} [${v.rule}]: ${v.message}`);
468
+ }
469
+ lines.push('');
470
+ }
471
+ if (warns.length > 0) {
472
+ lines.push('--- WARNINGS (advisory) ---');
473
+ for (const v of warns) {
474
+ lines.push(` Entry #${v.entry_id} [${v.rule}]: ${v.message}`);
475
+ }
476
+ }
477
+ }
478
+ lines.push('');
479
+ lines.push('--- Full JSON result ---');
480
+ lines.push(JSON.stringify(result, null, 2));
481
+ return text(lines.join('\n'));
482
+ }
483
+ // -----------------------------------------------------------------------
484
+ case 'cw_accrual_balance': {
485
+ const memberIdentifier = String(args['member_identifier'] ?? '').trim();
486
+ if (!memberIdentifier) {
487
+ return text('Error: member_identifier is required.');
488
+ }
489
+ let accruals;
490
+ try {
491
+ const raw = await api.request({
492
+ path: '/time/accruals',
493
+ method: 'GET',
494
+ params: { conditions: `member/identifier='${memberIdentifier}'` },
495
+ });
496
+ accruals = Array.isArray(raw) ? raw : [];
497
+ }
498
+ catch (err) {
499
+ const msg = err instanceof Error ? err.message : String(err);
500
+ return text(`Error fetching accruals: ${msg}`);
501
+ }
502
+ if (accruals.length === 0) {
503
+ return text(`No accrual records found for member "${memberIdentifier}".`);
504
+ }
505
+ const lines = [
506
+ `Accrual balances for ${memberIdentifier}`,
507
+ '',
508
+ `${'Type'.padEnd(22)} ${'Current Bal'.padStart(12)} ${'YTD Hours'.padStart(12)} ${'Available'.padStart(12)}`,
509
+ `${'-'.repeat(22)} ${'-'.repeat(12)} ${'-'.repeat(12)} ${'-'.repeat(12)}`,
510
+ ];
511
+ for (const raw of accruals) {
512
+ const record = raw;
513
+ const typeName = record.accrualType?.name ?? 'Unknown';
514
+ const current = round2(record.currentBalance ?? 0);
515
+ const ytd = round2(record.ytdHours ?? 0);
516
+ const available = round2(record.availableHours ?? record.currentBalance ?? 0);
517
+ lines.push(`${typeName.padEnd(22)} ${String(current).padStart(12)} ${String(ytd).padStart(12)} ${String(available).padStart(12)}`);
518
+ }
519
+ lines.push('');
520
+ lines.push('--- Raw JSON ---');
521
+ lines.push(JSON.stringify(accruals, null, 2));
522
+ return text(lines.join('\n'));
523
+ }
524
+ // -----------------------------------------------------------------------
525
+ case 'cw_approve_timesheets': {
526
+ return await approveTimesheets(args, api);
527
+ }
528
+ // -----------------------------------------------------------------------
529
+ default:
530
+ return text(`Unknown validation tool: ${toolName}`);
531
+ }
532
+ }
533
+ async function approveTimesheets(args, api) {
534
+ const startDate = String(args['start_date'] ?? '').trim();
535
+ const endDate = String(args['end_date'] ?? '').trim();
536
+ const memberFilter = args['member_identifier'] ? String(args['member_identifier']).trim() : null;
537
+ const dryRun = args['dry_run'] !== false; // Default true — safe by default
538
+ if (!startDate || !endDate) {
539
+ return text('Error: start_date and end_date are required (YYYY-MM-DD).');
540
+ }
541
+ const lines = [];
542
+ lines.push(`## Timesheet Approval Workflow`);
543
+ lines.push(`Period: ${startDate} to ${endDate}`);
544
+ lines.push(`Mode: ${dryRun ? 'DRY RUN (no changes will be made)' : 'LIVE — will approve/reject in ConnectWise'}`);
545
+ if (memberFilter)
546
+ lines.push(`Member filter: ${memberFilter}`);
547
+ lines.push('');
548
+ // Step 1: Fetch timesheets in the period
549
+ let timesheetConditions = `dateStart>=[${startDate}] AND dateStart<=[${endDate}]`;
550
+ if (memberFilter) {
551
+ timesheetConditions += ` AND member/identifier='${memberFilter}'`;
552
+ }
553
+ // Only process Open or PendingApproval timesheets
554
+ // Note: CW timesheet `status` is a flat string, not an object — no /name accessor
555
+ timesheetConditions += ` AND (status='Open' OR status='PendingApproval')`;
556
+ let timesheets;
557
+ try {
558
+ const raw = await api.paginatedFetch('/time/sheets', timesheetConditions, undefined, 500);
559
+ timesheets = raw.items;
560
+ }
561
+ catch (err) {
562
+ const msg = err instanceof Error ? err.message : String(err);
563
+ return text(`Error fetching timesheets: ${msg}`);
564
+ }
565
+ if (timesheets.length === 0) {
566
+ return text(`No Open or PendingApproval timesheets found for ${memberFilter ?? 'any member'} between ${startDate} and ${endDate}.`);
567
+ }
568
+ lines.push(`Found ${timesheets.length} timesheet(s) to process.`);
569
+ lines.push('');
570
+ const fetchResults = await Promise.all(timesheets.map(async (ts) => {
571
+ const memberId = ts.member?.identifier ?? 'unknown';
572
+ try {
573
+ const entryConds = `member/identifier='${memberId}' AND dateEntered>=[${ts.dateStart}] AND dateEntered<=[${ts.dateEnd}]`;
574
+ const raw = await api.paginatedFetch('/time/entries', entryConds, undefined, 1000);
575
+ return { ts, entries: raw.items, error: null };
576
+ }
577
+ catch (err) {
578
+ const msg = err instanceof Error ? err.message : String(err);
579
+ return { ts, entries: null, error: msg };
580
+ }
581
+ }));
582
+ const validated = fetchResults.map(fr => {
583
+ if (!fr.entries || fr.entries.length === 0) {
584
+ return { ...fr, violations: [], rejects: [], warns: [] };
585
+ }
586
+ const violations = validateEntries(fr.entries);
587
+ return {
588
+ ...fr,
589
+ violations,
590
+ rejects: violations.filter(v => v.severity === 'reject'),
591
+ warns: violations.filter(v => v.severity === 'warn'),
592
+ };
593
+ });
594
+ lines.push(`Fetched & validated ${timesheets.length} timesheets in parallel.`);
595
+ lines.push('');
596
+ // Step 4: PARALLEL APPROVE/REJECT — execute actions for all timesheets at once
597
+ const results = await Promise.all(validated.map(async (v) => {
598
+ const memberId = v.ts.member?.identifier ?? 'unknown';
599
+ const memberName = v.ts.member?.name ?? memberId;
600
+ const tsId = v.ts.id;
601
+ // Error during fetch
602
+ if (v.error) {
603
+ return { member: memberId, timesheetId: tsId, action: 'ERROR', rejectCount: 0, warnCount: 0, entryCount: 0, rejectedEntryIds: [], message: v.error };
604
+ }
605
+ // No entries
606
+ if (!v.entries || v.entries.length === 0) {
607
+ return { member: memberId, timesheetId: tsId, action: 'SKIPPED', rejectCount: 0, warnCount: 0, entryCount: 0, rejectedEntryIds: [], message: 'No entries' };
608
+ }
609
+ const entryCount = v.entries.length;
610
+ if (v.rejects.length > 0) {
611
+ // REJECT path
612
+ const entryIdsToReject = [...new Set(v.rejects.map(viol => viol.entry_id))];
613
+ if (!dryRun) {
614
+ // Reject individual entries IN PARALLEL
615
+ const rejectionResults = await Promise.all(entryIdsToReject.map(async (entryId) => {
616
+ const entryViolations = v.rejects.filter(viol => viol.entry_id === entryId);
617
+ const comment = 'Automated rejection: ' + entryViolations.map(viol => `[${viol.rule}] ${viol.message}`).join('; ');
618
+ try {
619
+ await api.requestWithRetry({
620
+ path: `/time/entries/${entryId}/reject`,
621
+ method: 'POST',
622
+ body: { approvalType: 'Tier1Update', comment },
623
+ });
624
+ return { entryId, success: true };
625
+ }
626
+ catch (err) {
627
+ return { entryId, success: false };
628
+ }
629
+ }));
630
+ const rejectedIds = rejectionResults.filter(r => r.success).map(r => r.entryId);
631
+ // Then reject the timesheet (must come AFTER entries)
632
+ try {
633
+ await api.requestWithRetry({
634
+ path: `/time/sheets/${tsId}/reject`,
635
+ method: 'POST',
636
+ body: { approvalType: 'Tier1Update' },
637
+ });
638
+ }
639
+ catch { /* logged in summary */ }
640
+ return { member: memberId, timesheetId: tsId, action: 'REJECTED', rejectCount: v.rejects.length, warnCount: v.warns.length, entryCount, rejectedEntryIds: rejectedIds, message: `${rejectedIds.length} entries rejected` };
641
+ }
642
+ else {
643
+ return { member: memberId, timesheetId: tsId, action: 'REJECTED', rejectCount: v.rejects.length, warnCount: v.warns.length, entryCount, rejectedEntryIds: entryIdsToReject, message: 'DRY RUN' };
644
+ }
645
+ }
646
+ else {
647
+ // APPROVE path
648
+ if (!dryRun) {
649
+ try {
650
+ await api.requestWithRetry({
651
+ path: `/time/sheets/${tsId}/approve`,
652
+ method: 'POST',
653
+ body: { approvalType: 'Tier1Update' },
654
+ });
655
+ }
656
+ catch { /* logged in summary */ }
657
+ return { member: memberId, timesheetId: tsId, action: 'APPROVED', rejectCount: 0, warnCount: v.warns.length, entryCount, rejectedEntryIds: [], message: v.warns.length > 0 ? `Approved with ${v.warns.length} warnings` : 'Clean approval' };
658
+ }
659
+ else {
660
+ return { member: memberId, timesheetId: tsId, action: 'APPROVED', rejectCount: 0, warnCount: v.warns.length, entryCount, rejectedEntryIds: [], message: 'DRY RUN' };
661
+ }
662
+ }
663
+ }));
664
+ // Step 5: Build human-readable output from results
665
+ for (const r of results) {
666
+ const memberName = timesheets.find(ts => ts.id === r.timesheetId)?.member?.name ?? r.member;
667
+ lines.push(`### ${memberName} (timesheet #${r.timesheetId})`);
668
+ lines.push(` Entries: ${r.entryCount} | Rejects: ${r.rejectCount} | Warnings: ${r.warnCount}`);
669
+ lines.push(` Decision: **${r.action}** — ${r.message}`);
670
+ // Show violation details
671
+ const v = validated.find(v => v.ts.id === r.timesheetId);
672
+ if (v && v.rejects.length > 0) {
673
+ for (const viol of v.rejects) {
674
+ lines.push(` - Entry #${viol.entry_id} [${viol.rule}]: ${viol.message}`);
675
+ }
676
+ }
677
+ if (v && v.warns.length > 0) {
678
+ for (const viol of v.warns) {
679
+ lines.push(` ⚠ Entry #${viol.entry_id} [${viol.rule}]: ${viol.message}`);
680
+ }
681
+ }
682
+ lines.push('');
683
+ }
684
+ // Summary
685
+ const approved = results.filter(r => r.action === 'APPROVED').length;
686
+ const rejected = results.filter(r => r.action === 'REJECTED').length;
687
+ const skipped = results.filter(r => r.action === 'SKIPPED').length;
688
+ const errors = results.filter(r => r.action === 'ERROR').length;
689
+ lines.push('---');
690
+ lines.push('## Summary');
691
+ lines.push(`| Status | Count |`);
692
+ lines.push(`|--------|-------|`);
693
+ lines.push(`| Approved | ${approved} |`);
694
+ lines.push(`| Rejected | ${rejected} |`);
695
+ lines.push(`| Skipped | ${skipped} |`);
696
+ lines.push(`| Errors | ${errors} |`);
697
+ lines.push(`| **Total** | **${results.length}** |`);
698
+ if (dryRun) {
699
+ lines.push('');
700
+ lines.push('> **This was a DRY RUN.** No changes were made in ConnectWise.');
701
+ lines.push('> To execute for real, run again with `dry_run: false`.');
702
+ }
703
+ return text(lines.join('\n'));
704
+ }
705
+ //# sourceMappingURL=validation.js.map