@equilateral_ai/mindmeld 3.0.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 (86) hide show
  1. package/README.md +300 -0
  2. package/hooks/README.md +494 -0
  3. package/hooks/pre-compact.js +392 -0
  4. package/hooks/session-start.js +264 -0
  5. package/package.json +90 -0
  6. package/scripts/harvest.js +561 -0
  7. package/scripts/init-project.js +437 -0
  8. package/scripts/inject.js +388 -0
  9. package/src/collaboration/CollaborationPrompt.js +460 -0
  10. package/src/core/AlertEngine.js +813 -0
  11. package/src/core/AlertNotifier.js +363 -0
  12. package/src/core/CorrelationAnalyzer.js +774 -0
  13. package/src/core/CurationEngine.js +688 -0
  14. package/src/core/LLMPatternDetector.js +508 -0
  15. package/src/core/LoadBearingDetector.js +242 -0
  16. package/src/core/NotificationService.js +1032 -0
  17. package/src/core/PatternValidator.js +355 -0
  18. package/src/core/README.md +160 -0
  19. package/src/core/RapportOrchestrator.js +446 -0
  20. package/src/core/RelevanceDetector.js +577 -0
  21. package/src/core/StandardsIngestion.js +575 -0
  22. package/src/core/TeamLoadBearingDetector.js +431 -0
  23. package/src/database/dbOperations.js +105 -0
  24. package/src/handlers/activity/activityGetMe.js +98 -0
  25. package/src/handlers/activity/activityGetTeam.js +130 -0
  26. package/src/handlers/alerts/alertsAcknowledge.js +91 -0
  27. package/src/handlers/alerts/alertsGet.js +250 -0
  28. package/src/handlers/collaborators/collaboratorAdd.js +201 -0
  29. package/src/handlers/collaborators/collaboratorInvite.js +218 -0
  30. package/src/handlers/collaborators/collaboratorList.js +88 -0
  31. package/src/handlers/collaborators/collaboratorRemove.js +127 -0
  32. package/src/handlers/collaborators/inviteAccept.js +122 -0
  33. package/src/handlers/context/contextGet.js +57 -0
  34. package/src/handlers/context/invariantsGet.js +74 -0
  35. package/src/handlers/context/loopsGet.js +82 -0
  36. package/src/handlers/context/notesCreate.js +74 -0
  37. package/src/handlers/context/purposeGet.js +78 -0
  38. package/src/handlers/correlations/correlationsDeveloperGet.js +226 -0
  39. package/src/handlers/correlations/correlationsGet.js +93 -0
  40. package/src/handlers/correlations/correlationsProjectGet.js +161 -0
  41. package/src/handlers/github/githubConnectionStatus.js +49 -0
  42. package/src/handlers/github/githubDiscoverPatterns.js +364 -0
  43. package/src/handlers/github/githubOAuthCallback.js +166 -0
  44. package/src/handlers/github/githubOAuthStart.js +59 -0
  45. package/src/handlers/github/githubPatternsReview.js +109 -0
  46. package/src/handlers/github/githubReposList.js +105 -0
  47. package/src/handlers/helpers/checkSuperAdmin.js +85 -0
  48. package/src/handlers/helpers/dbOperations.js +53 -0
  49. package/src/handlers/helpers/errorHandler.js +49 -0
  50. package/src/handlers/helpers/index.js +106 -0
  51. package/src/handlers/helpers/lambdaWrapper.js +60 -0
  52. package/src/handlers/helpers/responseUtil.js +55 -0
  53. package/src/handlers/helpers/subscriptionTiers.js +1168 -0
  54. package/src/handlers/notifications/getPreferences.js +84 -0
  55. package/src/handlers/notifications/sendNotification.js +170 -0
  56. package/src/handlers/notifications/updatePreferences.js +316 -0
  57. package/src/handlers/patterns/patternUsagePost.js +182 -0
  58. package/src/handlers/patterns/patternViolationPost.js +185 -0
  59. package/src/handlers/projects/projectCreate.js +107 -0
  60. package/src/handlers/projects/projectDelete.js +82 -0
  61. package/src/handlers/projects/projectGet.js +95 -0
  62. package/src/handlers/projects/projectUpdate.js +118 -0
  63. package/src/handlers/reports/aiLeverage.js +206 -0
  64. package/src/handlers/reports/engineeringInvestment.js +132 -0
  65. package/src/handlers/reports/riskForecast.js +186 -0
  66. package/src/handlers/reports/standardsRoi.js +162 -0
  67. package/src/handlers/scheduled/analyzeCorrelations.js +178 -0
  68. package/src/handlers/scheduled/analyzeGitHistory.js +510 -0
  69. package/src/handlers/scheduled/generateAlerts.js +135 -0
  70. package/src/handlers/scheduled/refreshActivity.js +21 -0
  71. package/src/handlers/scheduled/scanCompliance.js +334 -0
  72. package/src/handlers/sessions/sessionEndPost.js +180 -0
  73. package/src/handlers/sessions/sessionStandardsPost.js +135 -0
  74. package/src/handlers/stripe/addonManagePost.js +240 -0
  75. package/src/handlers/stripe/billingPortalPost.js +93 -0
  76. package/src/handlers/stripe/enterpriseCheckoutPost.js +272 -0
  77. package/src/handlers/stripe/seatsUpdatePost.js +185 -0
  78. package/src/handlers/stripe/subscriptionCancelDelete.js +169 -0
  79. package/src/handlers/stripe/subscriptionCreatePost.js +221 -0
  80. package/src/handlers/stripe/subscriptionUpdatePut.js +163 -0
  81. package/src/handlers/stripe/webhookPost.js +454 -0
  82. package/src/handlers/users/cognitoPostConfirmation.js +150 -0
  83. package/src/handlers/users/userEntitlementsGet.js +89 -0
  84. package/src/handlers/users/userGet.js +114 -0
  85. package/src/handlers/webhooks/githubWebhook.js +223 -0
  86. package/src/index.js +969 -0
@@ -0,0 +1,813 @@
1
+ /**
2
+ * Rapport v3 - Alert Engine
3
+ *
4
+ * Purpose: Identifies struggling developers and generates attention alerts
5
+ *
6
+ * Alert Types:
7
+ * - stale_commits: No commits in X days (had previous activity)
8
+ * - low_conversion: Low session-to-commit conversion rate
9
+ * - no_ai_usage: Active committer not using AI assistance
10
+ * - high_violation_rate: Developer frequently violating standards
11
+ * - stalled_patterns: Developer's patterns not maturing/being used
12
+ * - declining_activity: Activity trending downward
13
+ *
14
+ * Severity Levels:
15
+ * - info: Informational, no action required
16
+ * - warning: Attention recommended
17
+ * - critical: Immediate attention required
18
+ *
19
+ * Based on: /Users/jamesford/Source/rapport/docs/PHASE_7_ENGINEERING_INTELLIGENCE.md
20
+ */
21
+
22
+ /**
23
+ * Database operations are injected at runtime.
24
+ * When used from Lambda handlers, executeQuery comes from helpers.
25
+ * When used from CLI/scripts, it can be provided via constructor.
26
+ */
27
+ let executeQuery = null;
28
+
29
+ /**
30
+ * Set the database query function
31
+ * @param {Function} queryFn - Function that executes database queries
32
+ */
33
+ function setExecuteQuery(queryFn) {
34
+ executeQuery = queryFn;
35
+ }
36
+
37
+ /**
38
+ * Get the database query function, throwing if not set
39
+ */
40
+ function getExecuteQuery() {
41
+ if (!executeQuery) {
42
+ throw new Error('AlertEngine: executeQuery not initialized. Call setExecuteQuery() first.');
43
+ }
44
+ return executeQuery;
45
+ }
46
+
47
+ // Lazy-load AlertNotifier to avoid circular dependencies
48
+ let AlertNotifierModule = null;
49
+ function getAlertNotifierModule() {
50
+ if (!AlertNotifierModule) {
51
+ AlertNotifierModule = require('./AlertNotifier');
52
+ }
53
+ return AlertNotifierModule;
54
+ }
55
+
56
+ /**
57
+ * Default threshold configuration
58
+ */
59
+ const DEFAULT_THRESHOLDS = {
60
+ // Stale commits thresholds (days)
61
+ staleCommits: {
62
+ enabled: true,
63
+ infoDays: 7,
64
+ warningDays: 10,
65
+ criticalDays: 14,
66
+ requirePreviousActivity: true
67
+ },
68
+
69
+ // Session-to-commit conversion thresholds (percentage)
70
+ lowConversion: {
71
+ enabled: true,
72
+ minSessions: 5, // Minimum sessions to evaluate
73
+ infoThreshold: 30, // Below 30% = info
74
+ warningThreshold: 15, // Below 15% = warning
75
+ criticalThreshold: 5 // Below 5% = critical
76
+ },
77
+
78
+ // No AI usage thresholds
79
+ noAiUsage: {
80
+ enabled: true,
81
+ minCommits: 5, // Minimum commits to trigger (active developer)
82
+ lookbackDays: 30
83
+ },
84
+
85
+ // High violation rate thresholds (percentage of sessions with violations)
86
+ highViolationRate: {
87
+ enabled: true,
88
+ minSessions: 5, // Minimum sessions to evaluate
89
+ infoThreshold: 30, // Above 30% violation rate = info
90
+ warningThreshold: 50, // Above 50% = warning
91
+ criticalThreshold: 70 // Above 70% = critical
92
+ },
93
+
94
+ // Stalled patterns thresholds
95
+ stalledPatterns: {
96
+ enabled: true,
97
+ minPatterns: 3, // Minimum patterns discovered
98
+ staleDays: 30, // No usage/promotion in X days
99
+ minProvisionalDays: 14 // Provisional for too long
100
+ },
101
+
102
+ // Declining activity thresholds
103
+ decliningActivity: {
104
+ enabled: true,
105
+ lookbackWeeks: 4, // Compare 4 weeks
106
+ declineThreshold: 50 // 50% decline triggers alert
107
+ },
108
+
109
+ // Alert aggregation settings
110
+ aggregation: {
111
+ cooldownHours: 24, // Don't create duplicate alerts within cooldown
112
+ maxAlertsPerUser: 5, // Maximum active alerts per user
113
+ expirationDays: 7 // Auto-expire alerts after X days
114
+ }
115
+ };
116
+
117
+ class AlertEngine {
118
+ constructor(config = {}) {
119
+ this.thresholds = this.mergeThresholds(DEFAULT_THRESHOLDS, config.thresholds || {});
120
+ this.dryRun = config.dryRun || false;
121
+ this.enableNotifications = config.enableNotifications !== false; // Default to enabled
122
+ this.alertNotifier = null; // Lazy-initialized
123
+ }
124
+
125
+ /**
126
+ * Get or create AlertNotifier instance
127
+ */
128
+ getNotifier() {
129
+ if (!this.alertNotifier && this.enableNotifications) {
130
+ const module = getAlertNotifierModule();
131
+ // Ensure AlertNotifier has access to executeQuery
132
+ module.setExecuteQuery(executeQuery);
133
+ this.alertNotifier = new module.AlertNotifier();
134
+ }
135
+ return this.alertNotifier;
136
+ }
137
+
138
+ /**
139
+ * Deep merge threshold configurations
140
+ */
141
+ mergeThresholds(defaults, overrides) {
142
+ const result = { ...defaults };
143
+ for (const key of Object.keys(overrides)) {
144
+ if (typeof overrides[key] === 'object' && !Array.isArray(overrides[key])) {
145
+ result[key] = { ...defaults[key], ...overrides[key] };
146
+ } else {
147
+ result[key] = overrides[key];
148
+ }
149
+ }
150
+ return result;
151
+ }
152
+
153
+ /**
154
+ * Generate all alerts for a company or all companies
155
+ *
156
+ * @param {string|null} companyId - Optional company ID to filter
157
+ * @returns {Promise<Object>} Alert generation summary
158
+ */
159
+ async generateAlerts(companyId = null) {
160
+ const summary = {
161
+ startedAt: new Date().toISOString(),
162
+ alertsCreated: 0,
163
+ alertsSkipped: 0,
164
+ alertsExpired: 0,
165
+ byType: {},
166
+ errors: []
167
+ };
168
+
169
+ try {
170
+ console.log('[AlertEngine] Starting alert generation...');
171
+
172
+ // Refresh activity views first
173
+ await this.refreshActivityViews();
174
+
175
+ // Generate each alert type
176
+ if (this.thresholds.staleCommits.enabled) {
177
+ const count = await this.generateStaleCommitAlerts(companyId);
178
+ summary.byType.stale_commits = count;
179
+ summary.alertsCreated += count;
180
+ }
181
+
182
+ if (this.thresholds.lowConversion.enabled) {
183
+ const count = await this.generateLowConversionAlerts(companyId);
184
+ summary.byType.low_conversion = count;
185
+ summary.alertsCreated += count;
186
+ }
187
+
188
+ if (this.thresholds.noAiUsage.enabled) {
189
+ const count = await this.generateNoAiUsageAlerts(companyId);
190
+ summary.byType.no_ai_usage = count;
191
+ summary.alertsCreated += count;
192
+ }
193
+
194
+ if (this.thresholds.highViolationRate.enabled) {
195
+ const count = await this.generateHighViolationAlerts(companyId);
196
+ summary.byType.high_violation_rate = count;
197
+ summary.alertsCreated += count;
198
+ }
199
+
200
+ if (this.thresholds.stalledPatterns.enabled) {
201
+ const count = await this.generateStalledPatternAlerts(companyId);
202
+ summary.byType.stalled_patterns = count;
203
+ summary.alertsCreated += count;
204
+ }
205
+
206
+ if (this.thresholds.decliningActivity.enabled) {
207
+ const count = await this.generateDecliningActivityAlerts(companyId);
208
+ summary.byType.declining_activity = count;
209
+ summary.alertsCreated += count;
210
+ }
211
+
212
+ // Auto-expire old alerts
213
+ summary.alertsExpired = await this.expireOldAlerts();
214
+
215
+ // Send notifications for new alerts
216
+ if (summary.alertsCreated > 0 && this.enableNotifications && !this.dryRun) {
217
+ try {
218
+ const notificationResults = await this.sendAlertNotifications(companyId);
219
+ summary.notifications = notificationResults;
220
+ } catch (error) {
221
+ console.error('[AlertEngine] Failed to send notifications:', error);
222
+ summary.notifications = { error: error.message };
223
+ // Don't fail the whole operation if notifications fail
224
+ }
225
+ }
226
+
227
+ summary.completedAt = new Date().toISOString();
228
+ console.log(`[AlertEngine] Generated ${summary.alertsCreated} alerts, expired ${summary.alertsExpired}`);
229
+
230
+ } catch (error) {
231
+ console.error('[AlertEngine] Error generating alerts:', error);
232
+ summary.errors.push(error.message);
233
+ }
234
+
235
+ return summary;
236
+ }
237
+
238
+ /**
239
+ * Send notifications for newly created alerts
240
+ *
241
+ * @param {string|null} companyId - Optional company filter
242
+ * @returns {Promise<Object>} Notification results
243
+ */
244
+ async sendAlertNotifications(companyId = null) {
245
+ const notifier = this.getNotifier();
246
+ if (!notifier) {
247
+ return { skipped: true, reason: 'notifications_disabled' };
248
+ }
249
+
250
+ // Get alerts created in the last minute (newly generated)
251
+ const query = `
252
+ SELECT
253
+ aa.alert_id,
254
+ aa.email_address,
255
+ aa.company_id,
256
+ u."User_Display_Name" as user_name,
257
+ aa.alert_type,
258
+ aa.severity,
259
+ aa.details,
260
+ aa.created_at
261
+ FROM rapport.attention_alerts aa
262
+ JOIN "Users" u ON aa.email_address = u."Email_Address"
263
+ WHERE aa.created_at > NOW() - INTERVAL '1 minute'
264
+ AND aa.status = 'active'
265
+ ${companyId ? 'AND aa.company_id = $1' : ''}
266
+ ORDER BY aa.created_at DESC
267
+ `;
268
+
269
+ const params = companyId ? [companyId] : [];
270
+ const result = await executeQuery(query, params);
271
+
272
+ if (result.rows.length === 0) {
273
+ return { notified: 0, skipped: 0, failed: 0 };
274
+ }
275
+
276
+ return await notifier.notifyForAlerts(result.rows);
277
+ }
278
+
279
+ /**
280
+ * Refresh materialized views for accurate data
281
+ */
282
+ async refreshActivityViews() {
283
+ try {
284
+ await executeQuery('SELECT rapport.refresh_developer_activity()');
285
+ console.log('[AlertEngine] Developer activity view refreshed');
286
+ } catch (error) {
287
+ // View might not exist yet, that's OK
288
+ console.log('[AlertEngine] Could not refresh activity view:', error.message);
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Generate stale commits alerts
294
+ * Developers who haven't committed in X days
295
+ */
296
+ async generateStaleCommitAlerts(companyId = null) {
297
+ const config = this.thresholds.staleCommits;
298
+ const cooldownHours = this.thresholds.aggregation.cooldownHours;
299
+
300
+ const query = `
301
+ INSERT INTO rapport.attention_alerts (
302
+ email_address, company_id, alert_type, severity, details, expires_at
303
+ )
304
+ SELECT
305
+ email_address,
306
+ company_id,
307
+ 'stale_commits',
308
+ CASE
309
+ WHEN days_since_commit >= $1 THEN 'critical'
310
+ WHEN days_since_commit >= $2 THEN 'warning'
311
+ ELSE 'info'
312
+ END,
313
+ jsonb_build_object(
314
+ 'days_since_commit', days_since_commit,
315
+ 'last_commit', last_commit,
316
+ 'commits_30d', commits_30d,
317
+ 'generated_by', 'AlertEngine'
318
+ ),
319
+ NOW() + INTERVAL '${this.thresholds.aggregation.expirationDays} days'
320
+ FROM rapport.mv_developer_activity
321
+ WHERE days_since_commit >= $3
322
+ ${config.requirePreviousActivity ? 'AND commits_30d > 0' : ''}
323
+ ${companyId ? 'AND company_id = $4' : ''}
324
+ AND NOT EXISTS (
325
+ SELECT 1 FROM rapport.attention_alerts aa
326
+ WHERE aa.email_address = mv_developer_activity.email_address
327
+ AND aa.alert_type = 'stale_commits'
328
+ AND (aa.status = 'active' OR aa.created_at > NOW() - INTERVAL '${cooldownHours} hours')
329
+ )
330
+ RETURNING alert_id
331
+ `;
332
+
333
+ const params = [
334
+ config.criticalDays,
335
+ config.warningDays,
336
+ config.infoDays
337
+ ];
338
+ if (companyId) params.push(companyId);
339
+
340
+ if (this.dryRun) {
341
+ console.log('[AlertEngine] [DRY RUN] Would generate stale_commits alerts');
342
+ return 0;
343
+ }
344
+
345
+ const result = await executeQuery(query, params);
346
+ console.log(`[AlertEngine] Generated ${result.rowCount} stale_commits alerts`);
347
+ return result.rowCount;
348
+ }
349
+
350
+ /**
351
+ * Generate low conversion alerts
352
+ * Developers with low session-to-commit conversion rate
353
+ */
354
+ async generateLowConversionAlerts(companyId = null) {
355
+ const config = this.thresholds.lowConversion;
356
+ const cooldownHours = this.thresholds.aggregation.cooldownHours;
357
+
358
+ const query = `
359
+ INSERT INTO rapport.attention_alerts (
360
+ email_address, company_id, alert_type, severity, details, expires_at
361
+ )
362
+ SELECT
363
+ email_address,
364
+ company_id,
365
+ 'low_conversion',
366
+ CASE
367
+ WHEN session_to_commit_conversion_pct < $1 THEN 'critical'
368
+ WHEN session_to_commit_conversion_pct < $2 THEN 'warning'
369
+ ELSE 'info'
370
+ END,
371
+ jsonb_build_object(
372
+ 'sessions_30d', sessions_30d,
373
+ 'commits_30d', commits_30d,
374
+ 'conversion_pct', session_to_commit_conversion_pct,
375
+ 'generated_by', 'AlertEngine'
376
+ ),
377
+ NOW() + INTERVAL '${this.thresholds.aggregation.expirationDays} days'
378
+ FROM rapport.mv_developer_activity
379
+ WHERE sessions_30d >= $3
380
+ AND session_to_commit_conversion_pct < $4
381
+ ${companyId ? 'AND company_id = $5' : ''}
382
+ AND NOT EXISTS (
383
+ SELECT 1 FROM rapport.attention_alerts aa
384
+ WHERE aa.email_address = mv_developer_activity.email_address
385
+ AND aa.alert_type = 'low_conversion'
386
+ AND (aa.status = 'active' OR aa.created_at > NOW() - INTERVAL '${cooldownHours} hours')
387
+ )
388
+ RETURNING alert_id
389
+ `;
390
+
391
+ const params = [
392
+ config.criticalThreshold,
393
+ config.warningThreshold,
394
+ config.minSessions,
395
+ config.infoThreshold
396
+ ];
397
+ if (companyId) params.push(companyId);
398
+
399
+ if (this.dryRun) {
400
+ console.log('[AlertEngine] [DRY RUN] Would generate low_conversion alerts');
401
+ return 0;
402
+ }
403
+
404
+ const result = await executeQuery(query, params);
405
+ console.log(`[AlertEngine] Generated ${result.rowCount} low_conversion alerts`);
406
+ return result.rowCount;
407
+ }
408
+
409
+ /**
410
+ * Generate no AI usage alerts
411
+ * Developers actively committing but not using AI sessions
412
+ */
413
+ async generateNoAiUsageAlerts(companyId = null) {
414
+ const config = this.thresholds.noAiUsage;
415
+ const cooldownHours = this.thresholds.aggregation.cooldownHours;
416
+
417
+ const query = `
418
+ INSERT INTO rapport.attention_alerts (
419
+ email_address, company_id, alert_type, severity, details, expires_at
420
+ )
421
+ SELECT
422
+ email_address,
423
+ company_id,
424
+ 'no_ai_usage',
425
+ 'info',
426
+ jsonb_build_object(
427
+ 'sessions_30d', sessions_30d,
428
+ 'commits_30d', commits_30d,
429
+ 'note', 'Developer has commits but no AI sessions',
430
+ 'generated_by', 'AlertEngine'
431
+ ),
432
+ NOW() + INTERVAL '${this.thresholds.aggregation.expirationDays} days'
433
+ FROM rapport.mv_developer_activity
434
+ WHERE sessions_30d = 0
435
+ AND commits_30d >= $1
436
+ ${companyId ? 'AND company_id = $2' : ''}
437
+ AND NOT EXISTS (
438
+ SELECT 1 FROM rapport.attention_alerts aa
439
+ WHERE aa.email_address = mv_developer_activity.email_address
440
+ AND aa.alert_type = 'no_ai_usage'
441
+ AND (aa.status = 'active' OR aa.created_at > NOW() - INTERVAL '${cooldownHours} hours')
442
+ )
443
+ RETURNING alert_id
444
+ `;
445
+
446
+ const params = [config.minCommits];
447
+ if (companyId) params.push(companyId);
448
+
449
+ if (this.dryRun) {
450
+ console.log('[AlertEngine] [DRY RUN] Would generate no_ai_usage alerts');
451
+ return 0;
452
+ }
453
+
454
+ const result = await executeQuery(query, params);
455
+ console.log(`[AlertEngine] Generated ${result.rowCount} no_ai_usage alerts`);
456
+ return result.rowCount;
457
+ }
458
+
459
+ /**
460
+ * Generate high violation rate alerts
461
+ * Developers with high rate of standards violations
462
+ */
463
+ async generateHighViolationAlerts(companyId = null) {
464
+ const config = this.thresholds.highViolationRate;
465
+ const cooldownHours = this.thresholds.aggregation.cooldownHours;
466
+
467
+ // First, calculate violation rates per developer
468
+ const query = `
469
+ INSERT INTO rapport.attention_alerts (
470
+ email_address, company_id, alert_type, severity, details, expires_at
471
+ )
472
+ SELECT
473
+ s.email_address,
474
+ da.company_id,
475
+ 'high_violation_rate',
476
+ CASE
477
+ WHEN (COUNT(*) FILTER (WHERE ss.violated = true)::decimal / NULLIF(COUNT(*), 0) * 100) >= $1 THEN 'critical'
478
+ WHEN (COUNT(*) FILTER (WHERE ss.violated = true)::decimal / NULLIF(COUNT(*), 0) * 100) >= $2 THEN 'warning'
479
+ ELSE 'info'
480
+ END,
481
+ jsonb_build_object(
482
+ 'total_sessions', COUNT(DISTINCT s.session_id),
483
+ 'standards_shown', COUNT(*),
484
+ 'violations', COUNT(*) FILTER (WHERE ss.violated = true),
485
+ 'violation_rate', ROUND((COUNT(*) FILTER (WHERE ss.violated = true)::decimal / NULLIF(COUNT(*), 0) * 100), 1),
486
+ 'generated_by', 'AlertEngine'
487
+ ),
488
+ NOW() + INTERVAL '${this.thresholds.aggregation.expirationDays} days'
489
+ FROM rapport.sessions s
490
+ JOIN rapport.session_standards ss ON s.session_id = ss.session_id
491
+ LEFT JOIN rapport.mv_developer_activity da ON s.email_address = da.email_address
492
+ WHERE s.started_at > NOW() - INTERVAL '30 days'
493
+ ${companyId ? 'AND da.company_id = $4' : ''}
494
+ GROUP BY s.email_address, da.company_id
495
+ HAVING COUNT(DISTINCT s.session_id) >= $3
496
+ AND (COUNT(*) FILTER (WHERE ss.violated = true)::decimal / NULLIF(COUNT(*), 0) * 100) >= $5
497
+ AND NOT EXISTS (
498
+ SELECT 1 FROM rapport.attention_alerts aa
499
+ WHERE aa.email_address = s.email_address
500
+ AND aa.alert_type = 'high_violation_rate'
501
+ AND (aa.status = 'active' OR aa.created_at > NOW() - INTERVAL '${cooldownHours} hours')
502
+ )
503
+ RETURNING alert_id
504
+ `;
505
+
506
+ const params = [
507
+ config.criticalThreshold,
508
+ config.warningThreshold,
509
+ config.minSessions,
510
+ config.infoThreshold
511
+ ];
512
+ if (companyId) params.splice(3, 0, companyId);
513
+
514
+ if (this.dryRun) {
515
+ console.log('[AlertEngine] [DRY RUN] Would generate high_violation_rate alerts');
516
+ return 0;
517
+ }
518
+
519
+ try {
520
+ const result = await executeQuery(query, params);
521
+ console.log(`[AlertEngine] Generated ${result.rowCount} high_violation_rate alerts`);
522
+ return result.rowCount;
523
+ } catch (error) {
524
+ // session_standards table might not have data yet
525
+ console.log('[AlertEngine] Could not generate violation alerts:', error.message);
526
+ return 0;
527
+ }
528
+ }
529
+
530
+ /**
531
+ * Generate stalled pattern alerts
532
+ * Developers with patterns that aren't maturing or being used
533
+ */
534
+ async generateStalledPatternAlerts(companyId = null) {
535
+ const config = this.thresholds.stalledPatterns;
536
+ const cooldownHours = this.thresholds.aggregation.cooldownHours;
537
+
538
+ const query = `
539
+ INSERT INTO rapport.attention_alerts (
540
+ email_address, company_id, alert_type, severity, details, expires_at
541
+ )
542
+ SELECT
543
+ p.discovered_by as email_address,
544
+ proj.company_id,
545
+ 'stalled_patterns',
546
+ 'warning',
547
+ jsonb_build_object(
548
+ 'total_patterns', COUNT(*),
549
+ 'provisional_patterns', COUNT(*) FILTER (WHERE p.maturity = 'provisional'),
550
+ 'stale_patterns', COUNT(*) FILTER (WHERE p.last_used < NOW() - INTERVAL '${config.staleDays} days' OR p.last_used IS NULL),
551
+ 'oldest_provisional_days', EXTRACT(DAY FROM NOW() - MIN(p.discovered_at) FILTER (WHERE p.maturity = 'provisional')),
552
+ 'generated_by', 'AlertEngine'
553
+ ),
554
+ NOW() + INTERVAL '${this.thresholds.aggregation.expirationDays} days'
555
+ FROM rapport.patterns p
556
+ JOIN rapport.projects proj ON p.project_id = proj.project_id
557
+ WHERE p.discovered_by IS NOT NULL
558
+ ${companyId ? 'AND proj.company_id = $2' : ''}
559
+ GROUP BY p.discovered_by, proj.company_id
560
+ HAVING COUNT(*) >= $1
561
+ AND (
562
+ -- Too many provisional patterns that haven't matured
563
+ COUNT(*) FILTER (
564
+ WHERE p.maturity = 'provisional'
565
+ AND p.discovered_at < NOW() - INTERVAL '${config.minProvisionalDays} days'
566
+ ) > COUNT(*) / 2
567
+ OR
568
+ -- Patterns not being used
569
+ COUNT(*) FILTER (
570
+ WHERE p.last_used < NOW() - INTERVAL '${config.staleDays} days'
571
+ OR p.last_used IS NULL
572
+ ) > COUNT(*) / 2
573
+ )
574
+ AND NOT EXISTS (
575
+ SELECT 1 FROM rapport.attention_alerts aa
576
+ WHERE aa.email_address = p.discovered_by
577
+ AND aa.alert_type = 'stalled_patterns'
578
+ AND (aa.status = 'active' OR aa.created_at > NOW() - INTERVAL '${cooldownHours} hours')
579
+ )
580
+ RETURNING alert_id
581
+ `;
582
+
583
+ const params = [config.minPatterns];
584
+ if (companyId) params.push(companyId);
585
+
586
+ if (this.dryRun) {
587
+ console.log('[AlertEngine] [DRY RUN] Would generate stalled_patterns alerts');
588
+ return 0;
589
+ }
590
+
591
+ try {
592
+ const result = await executeQuery(query, params);
593
+ console.log(`[AlertEngine] Generated ${result.rowCount} stalled_patterns alerts`);
594
+ return result.rowCount;
595
+ } catch (error) {
596
+ console.log('[AlertEngine] Could not generate stalled pattern alerts:', error.message);
597
+ return 0;
598
+ }
599
+ }
600
+
601
+ /**
602
+ * Generate declining activity alerts
603
+ * Developers whose activity is trending downward
604
+ */
605
+ async generateDecliningActivityAlerts(companyId = null) {
606
+ const config = this.thresholds.decliningActivity;
607
+ const cooldownHours = this.thresholds.aggregation.cooldownHours;
608
+
609
+ // Compare current week to average of previous weeks
610
+ const query = `
611
+ WITH weekly_sessions AS (
612
+ SELECT
613
+ email_address,
614
+ DATE_TRUNC('week', started_at) as week,
615
+ COUNT(*) as session_count
616
+ FROM rapport.sessions
617
+ WHERE started_at > NOW() - INTERVAL '${config.lookbackWeeks} weeks'
618
+ GROUP BY email_address, DATE_TRUNC('week', started_at)
619
+ ),
620
+ developer_trends AS (
621
+ SELECT
622
+ email_address,
623
+ AVG(session_count) FILTER (
624
+ WHERE week < DATE_TRUNC('week', NOW())
625
+ ) as avg_previous_weeks,
626
+ SUM(session_count) FILTER (
627
+ WHERE week = DATE_TRUNC('week', NOW())
628
+ ) as current_week
629
+ FROM weekly_sessions
630
+ GROUP BY email_address
631
+ HAVING AVG(session_count) FILTER (WHERE week < DATE_TRUNC('week', NOW())) > 0
632
+ )
633
+ INSERT INTO rapport.attention_alerts (
634
+ email_address, company_id, alert_type, severity, details, expires_at
635
+ )
636
+ SELECT
637
+ dt.email_address,
638
+ da.company_id,
639
+ 'declining_activity',
640
+ CASE
641
+ WHEN COALESCE(dt.current_week, 0) = 0 THEN 'warning'
642
+ ELSE 'info'
643
+ END,
644
+ jsonb_build_object(
645
+ 'avg_previous_weeks', ROUND(dt.avg_previous_weeks, 1),
646
+ 'current_week', COALESCE(dt.current_week, 0),
647
+ 'decline_pct', ROUND((1 - COALESCE(dt.current_week, 0)::decimal / dt.avg_previous_weeks) * 100, 1),
648
+ 'generated_by', 'AlertEngine'
649
+ ),
650
+ NOW() + INTERVAL '${this.thresholds.aggregation.expirationDays} days'
651
+ FROM developer_trends dt
652
+ LEFT JOIN rapport.mv_developer_activity da ON dt.email_address = da.email_address
653
+ WHERE (1 - COALESCE(dt.current_week, 0)::decimal / dt.avg_previous_weeks) * 100 >= $1
654
+ ${companyId ? 'AND da.company_id = $2' : ''}
655
+ AND NOT EXISTS (
656
+ SELECT 1 FROM rapport.attention_alerts aa
657
+ WHERE aa.email_address = dt.email_address
658
+ AND aa.alert_type = 'declining_activity'
659
+ AND (aa.status = 'active' OR aa.created_at > NOW() - INTERVAL '${cooldownHours} hours')
660
+ )
661
+ RETURNING alert_id
662
+ `;
663
+
664
+ const params = [config.declineThreshold];
665
+ if (companyId) params.push(companyId);
666
+
667
+ if (this.dryRun) {
668
+ console.log('[AlertEngine] [DRY RUN] Would generate declining_activity alerts');
669
+ return 0;
670
+ }
671
+
672
+ try {
673
+ const result = await executeQuery(query, params);
674
+ console.log(`[AlertEngine] Generated ${result.rowCount} declining_activity alerts`);
675
+ return result.rowCount;
676
+ } catch (error) {
677
+ console.log('[AlertEngine] Could not generate declining activity alerts:', error.message);
678
+ return 0;
679
+ }
680
+ }
681
+
682
+ /**
683
+ * Expire old alerts that have passed their expiration date
684
+ */
685
+ async expireOldAlerts() {
686
+ if (this.dryRun) {
687
+ console.log('[AlertEngine] [DRY RUN] Would expire old alerts');
688
+ return 0;
689
+ }
690
+
691
+ const result = await executeQuery(`
692
+ UPDATE rapport.attention_alerts
693
+ SET status = 'resolved', resolved_at = NOW()
694
+ WHERE expires_at < NOW()
695
+ AND status = 'active'
696
+ RETURNING alert_id
697
+ `);
698
+
699
+ console.log(`[AlertEngine] Expired ${result.rowCount} old alerts`);
700
+ return result.rowCount;
701
+ }
702
+
703
+ /**
704
+ * Get alert statistics for a company
705
+ *
706
+ * @param {string} companyId - Company ID
707
+ * @returns {Promise<Object>} Alert statistics
708
+ */
709
+ async getAlertStats(companyId) {
710
+ const result = await executeQuery(`
711
+ SELECT
712
+ alert_type,
713
+ severity,
714
+ status,
715
+ COUNT(*) as count
716
+ FROM rapport.attention_alerts
717
+ WHERE company_id = $1
718
+ AND created_at > NOW() - INTERVAL '30 days'
719
+ GROUP BY alert_type, severity, status
720
+ ORDER BY alert_type, severity, status
721
+ `, [companyId]);
722
+
723
+ const stats = {
724
+ byType: {},
725
+ bySeverity: { info: 0, warning: 0, critical: 0 },
726
+ byStatus: { active: 0, acknowledged: 0, resolved: 0 },
727
+ total: 0
728
+ };
729
+
730
+ for (const row of result.rows) {
731
+ // By type
732
+ if (!stats.byType[row.alert_type]) {
733
+ stats.byType[row.alert_type] = 0;
734
+ }
735
+ stats.byType[row.alert_type] += parseInt(row.count);
736
+
737
+ // By severity
738
+ if (stats.bySeverity[row.severity] !== undefined) {
739
+ stats.bySeverity[row.severity] += parseInt(row.count);
740
+ }
741
+
742
+ // By status
743
+ if (stats.byStatus[row.status] !== undefined) {
744
+ stats.byStatus[row.status] += parseInt(row.count);
745
+ }
746
+
747
+ stats.total += parseInt(row.count);
748
+ }
749
+
750
+ return stats;
751
+ }
752
+
753
+ /**
754
+ * Get struggling developers for a company
755
+ *
756
+ * @param {string} companyId - Company ID
757
+ * @returns {Promise<Array>} List of developers with alert summaries
758
+ */
759
+ async getStrugglingDevelopers(companyId) {
760
+ const result = await executeQuery(`
761
+ SELECT
762
+ aa.email_address,
763
+ u."User_Display_Name" as display_name,
764
+ array_agg(DISTINCT aa.alert_type) as alert_types,
765
+ MAX(aa.severity) as max_severity,
766
+ COUNT(*) as active_alert_count,
767
+ MIN(aa.created_at) as first_alert_at
768
+ FROM rapport.attention_alerts aa
769
+ JOIN "Users" u ON aa.email_address = u."Email_Address"
770
+ WHERE aa.company_id = $1
771
+ AND aa.status = 'active'
772
+ GROUP BY aa.email_address, u."User_Display_Name"
773
+ ORDER BY
774
+ CASE MAX(aa.severity)
775
+ WHEN 'critical' THEN 1
776
+ WHEN 'warning' THEN 2
777
+ ELSE 3
778
+ END,
779
+ COUNT(*) DESC
780
+ `, [companyId]);
781
+
782
+ return result.rows.map(row => ({
783
+ email: row.email_address,
784
+ displayName: row.display_name,
785
+ alertTypes: row.alert_types,
786
+ maxSeverity: row.max_severity,
787
+ activeAlertCount: parseInt(row.active_alert_count),
788
+ firstAlertAt: row.first_alert_at
789
+ }));
790
+ }
791
+
792
+ /**
793
+ * Get configuration (for debugging/admin)
794
+ */
795
+ getConfiguration() {
796
+ return {
797
+ thresholds: this.thresholds,
798
+ dryRun: this.dryRun
799
+ };
800
+ }
801
+
802
+ /**
803
+ * Update thresholds at runtime
804
+ *
805
+ * @param {Object} newThresholds - New threshold values
806
+ */
807
+ updateThresholds(newThresholds) {
808
+ this.thresholds = this.mergeThresholds(this.thresholds, newThresholds);
809
+ console.log('[AlertEngine] Thresholds updated:', this.thresholds);
810
+ }
811
+ }
812
+
813
+ module.exports = { AlertEngine, DEFAULT_THRESHOLDS, setExecuteQuery };