@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,84 @@
1
+ /**
2
+ * Get Notification Preferences Handler
3
+ * Retrieves user's notification preferences
4
+ *
5
+ * GET /api/notifications/preferences
6
+ * Auth: Cognito JWT required
7
+ */
8
+
9
+ const { wrapHandler, executeQuery, createSuccessResponse, createErrorResponse } = require('./helpers');
10
+
11
+ exports.handler = wrapHandler(async ({ requestContext }) => {
12
+ const Request_ID = requestContext.requestId;
13
+ const email = requestContext.authorizer?.claims?.email || requestContext.authorizer?.jwt?.claims?.email;
14
+
15
+ if (!email) {
16
+ return createErrorResponse(401, 'Authentication required');
17
+ }
18
+
19
+ // Get user preferences using the helper function
20
+ const prefsResult = await executeQuery(`
21
+ SELECT rapport.get_notification_preferences($1) as preferences
22
+ `, [email]);
23
+
24
+ const preferences = prefsResult.rows[0]?.preferences || getDefaultPreferences();
25
+
26
+ // Get notification log summary (last 30 days)
27
+ const logSummary = await executeQuery(`
28
+ SELECT
29
+ notification_type,
30
+ channel,
31
+ status,
32
+ COUNT(*) as count
33
+ FROM rapport.notification_log
34
+ WHERE email_address = $1
35
+ AND created_at > NOW() - INTERVAL '30 days'
36
+ GROUP BY notification_type, channel, status
37
+ ORDER BY notification_type, channel
38
+ `, [email]);
39
+
40
+ // Get pending digests
41
+ const pendingDigests = await executeQuery(`
42
+ SELECT
43
+ digest_type,
44
+ scheduled_for,
45
+ status
46
+ FROM rapport.digest_queue
47
+ WHERE email_address = $1
48
+ AND status = 'pending'
49
+ ORDER BY scheduled_for
50
+ `, [email]);
51
+
52
+ return createSuccessResponse(
53
+ {
54
+ preferences: preferences,
55
+ notification_summary: logSummary.rows,
56
+ pending_digests: pendingDigests.rows
57
+ },
58
+ 'Preferences retrieved',
59
+ { Request_ID, Timestamp: new Date().toISOString() }
60
+ );
61
+ });
62
+
63
+ /**
64
+ * Get default notification preferences
65
+ */
66
+ function getDefaultPreferences() {
67
+ return {
68
+ email_enabled: true,
69
+ slack_enabled: true,
70
+ notification_types: {
71
+ pattern_promotion: { email: true, slack: true },
72
+ weekly_digest: { email: true, slack: false },
73
+ critical_violation: { email: true, slack: true },
74
+ team_alert: { email: true, slack: true },
75
+ curation_candidate: { email: true, slack: true }
76
+ },
77
+ project_overrides: {},
78
+ digest_frequency: 'weekly',
79
+ digest_day: 1,
80
+ quiet_hours_enabled: false,
81
+ quiet_hours_timezone: 'America/New_York',
82
+ slack_dm_enabled: true
83
+ };
84
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Send Notification Handler
3
+ * Sends email and/or Slack notifications based on user preferences
4
+ *
5
+ * POST /api/notifications/send
6
+ * Auth: Cognito JWT required (Admin or internal service)
7
+ *
8
+ * Body:
9
+ * {
10
+ * "type": "pattern_promotion|weekly_digest|critical_violation|team_alert|curation_candidate",
11
+ * "recipients": ["email@example.com"] | "all_project" | "all_admins",
12
+ * "projectId": "prj_xxx" (optional),
13
+ * "data": { ... notification-specific data ... }
14
+ * }
15
+ */
16
+
17
+ const { wrapHandler, executeQuery, createSuccessResponse, createErrorResponse } = require('./helpers');
18
+ const { NotificationService } = require('../../core/NotificationService');
19
+
20
+ // Initialize notification service (singleton for Lambda warm starts)
21
+ const notificationService = new NotificationService();
22
+
23
+ exports.handler = wrapHandler(async ({ requestContext, body }) => {
24
+ const Request_ID = requestContext.requestId;
25
+ const email = requestContext.authorizer?.claims?.email || requestContext.authorizer?.jwt?.claims?.email;
26
+
27
+ if (!email) {
28
+ return createErrorResponse(401, 'Authentication required');
29
+ }
30
+
31
+ // Validate required fields
32
+ const { type, recipients, projectId, data } = body;
33
+
34
+ if (!type) {
35
+ return createErrorResponse(400, 'Notification type is required');
36
+ }
37
+
38
+ if (!recipients) {
39
+ return createErrorResponse(400, 'Recipients are required');
40
+ }
41
+
42
+ if (!data) {
43
+ return createErrorResponse(400, 'Notification data is required');
44
+ }
45
+
46
+ // Validate notification type
47
+ const validTypes = ['pattern_promotion', 'weekly_digest', 'critical_violation', 'team_alert', 'curation_candidate'];
48
+ if (!validTypes.includes(type)) {
49
+ return createErrorResponse(400, `Invalid notification type. Valid types: ${validTypes.join(', ')}`);
50
+ }
51
+
52
+ // Check authorization (only admins and managers can send notifications)
53
+ const authCheck = await executeQuery(`
54
+ SELECT
55
+ ue."Admin",
56
+ ue."Manager",
57
+ u."Super_Admin",
58
+ ue."Company_ID"
59
+ FROM "UserEntitlements" ue
60
+ JOIN "Users" u ON ue."Email_Address" = u."Email_Address"
61
+ WHERE ue."Email_Address" = $1
62
+ `, [email]);
63
+
64
+ if (authCheck.rowCount === 0) {
65
+ return createErrorResponse(403, 'Access denied');
66
+ }
67
+
68
+ const userRole = authCheck.rows[0];
69
+ const isAuthorized = userRole.Super_Admin || userRole.Admin || userRole.Manager;
70
+
71
+ if (!isAuthorized) {
72
+ return createErrorResponse(403, 'Only admins and managers can send notifications');
73
+ }
74
+
75
+ // Resolve recipients
76
+ let recipientEmails = [];
77
+
78
+ if (Array.isArray(recipients)) {
79
+ // Direct email list
80
+ recipientEmails = recipients;
81
+ } else if (recipients === 'all_project' && projectId) {
82
+ // All project collaborators
83
+ const collaborators = await executeQuery(`
84
+ SELECT pc.email_address
85
+ FROM rapport.project_collaborators pc
86
+ WHERE pc.project_id = $1
87
+ `, [projectId]);
88
+ recipientEmails = collaborators.rows.map(r => r.email_address);
89
+ } else if (recipients === 'all_admins') {
90
+ // All company admins
91
+ const admins = await executeQuery(`
92
+ SELECT ue."Email_Address" as email_address
93
+ FROM "UserEntitlements" ue
94
+ WHERE ue."Company_ID" = $1 AND ue."Admin" = true
95
+ `, [userRole.Company_ID]);
96
+ recipientEmails = admins.rows.map(r => r.email_address);
97
+ } else if (recipients === 'all_managers') {
98
+ // All company managers
99
+ const managers = await executeQuery(`
100
+ SELECT ue."Email_Address" as email_address
101
+ FROM "UserEntitlements" ue
102
+ WHERE ue."Company_ID" = $1 AND (ue."Manager" = true OR ue."Admin" = true)
103
+ `, [userRole.Company_ID]);
104
+ recipientEmails = managers.rows.map(r => r.email_address);
105
+ } else {
106
+ return createErrorResponse(400, 'Invalid recipients format');
107
+ }
108
+
109
+ if (recipientEmails.length === 0) {
110
+ return createSuccessResponse({
111
+ sent: 0,
112
+ skipped: 0,
113
+ failed: 0,
114
+ message: 'No recipients found'
115
+ }, 'No notifications sent');
116
+ }
117
+
118
+ // Get preferences for all recipients
119
+ const prefsQuery = await executeQuery(`
120
+ SELECT email_address, rapport.get_notification_preferences(email_address) as preferences
121
+ FROM "Users"
122
+ WHERE "Email_Address" = ANY($1)
123
+ `, [recipientEmails]);
124
+
125
+ const prefsMap = new Map(prefsQuery.rows.map(r => [r.email_address, r.preferences]));
126
+
127
+ // Build recipient list with preferences
128
+ const recipientList = recipientEmails.map(recipientEmail => ({
129
+ email: recipientEmail,
130
+ preferences: prefsMap.get(recipientEmail) || null,
131
+ data: data,
132
+ projectId: projectId
133
+ }));
134
+
135
+ // Send batch notifications
136
+ const results = await notificationService.sendBatch(recipientList, type);
137
+
138
+ // Log notifications
139
+ for (const recipientEmail of recipientEmails) {
140
+ const status = results.errors.find(e => e.email === recipientEmail) ? 'failed' : 'sent';
141
+ const errorMessage = results.errors.find(e => e.email === recipientEmail)?.error || null;
142
+
143
+ await executeQuery(`
144
+ SELECT rapport.log_notification($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
145
+ `, [
146
+ recipientEmail,
147
+ type,
148
+ 'email', // Primary channel
149
+ status,
150
+ projectId || null,
151
+ data.referenceType || null,
152
+ data.referenceId || null,
153
+ JSON.stringify(data),
154
+ null, // message_id populated by NotificationService if available
155
+ errorMessage
156
+ ]);
157
+ }
158
+
159
+ return createSuccessResponse(
160
+ {
161
+ total: results.total,
162
+ sent: results.sent,
163
+ skipped: results.skipped,
164
+ failed: results.failed,
165
+ errors: results.errors.length > 0 ? results.errors : undefined
166
+ },
167
+ 'Notifications processed',
168
+ { Request_ID, Timestamp: new Date().toISOString() }
169
+ );
170
+ });
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Update Notification Preferences Handler
3
+ * Updates user's notification preferences
4
+ *
5
+ * PUT /api/notifications/preferences
6
+ * Auth: Cognito JWT required
7
+ *
8
+ * Body:
9
+ * {
10
+ * "email_enabled": true,
11
+ * "slack_enabled": true,
12
+ * "notification_types": {
13
+ * "pattern_promotion": { "email": true, "slack": true },
14
+ * "weekly_digest": { "email": true, "slack": false },
15
+ * "critical_violation": { "email": true, "slack": true },
16
+ * "team_alert": { "email": true, "slack": true },
17
+ * "curation_candidate": { "email": true, "slack": true }
18
+ * },
19
+ * "project_overrides": {
20
+ * "prj_xxx": { "email_enabled": false, "notification_types": {...} }
21
+ * },
22
+ * "digest_frequency": "weekly",
23
+ * "digest_day": 1,
24
+ * "quiet_hours_enabled": false,
25
+ * "quiet_hours_start": "22:00",
26
+ * "quiet_hours_end": "08:00",
27
+ * "quiet_hours_timezone": "America/New_York",
28
+ * "slack_channel": "#my-channel",
29
+ * "slack_dm_enabled": true
30
+ * }
31
+ */
32
+
33
+ const { wrapHandler, executeQuery, createSuccessResponse, createErrorResponse } = require('./helpers');
34
+
35
+ exports.handler = wrapHandler(async ({ requestContext, body }) => {
36
+ const Request_ID = requestContext.requestId;
37
+ const email = requestContext.authorizer?.claims?.email || requestContext.authorizer?.jwt?.claims?.email;
38
+
39
+ if (!email) {
40
+ return createErrorResponse(401, 'Authentication required');
41
+ }
42
+
43
+ // Validate and sanitize input
44
+ const validatedPrefs = validatePreferences(body);
45
+ if (validatedPrefs.error) {
46
+ return createErrorResponse(400, validatedPrefs.error);
47
+ }
48
+
49
+ // Default notification types for new users
50
+ const defaultNotificationTypes = {
51
+ pattern_promotion: { email: true, slack: true },
52
+ weekly_digest: { email: true, slack: false },
53
+ critical_violation: { email: true, slack: true },
54
+ team_alert: { email: true, slack: true },
55
+ curation_candidate: { email: true, slack: true }
56
+ };
57
+
58
+ const {
59
+ email_enabled = true,
60
+ slack_enabled = true,
61
+ notification_types = defaultNotificationTypes,
62
+ project_overrides = {},
63
+ digest_frequency = 'weekly',
64
+ digest_day = 1,
65
+ quiet_hours_enabled = false,
66
+ quiet_hours_start,
67
+ quiet_hours_end,
68
+ quiet_hours_timezone = 'America/New_York',
69
+ slack_channel,
70
+ slack_dm_enabled = true
71
+ } = validatedPrefs;
72
+
73
+ // Upsert preferences
74
+ const result = await executeQuery(`
75
+ INSERT INTO rapport.notification_preferences (
76
+ email_address,
77
+ email_enabled,
78
+ slack_enabled,
79
+ notification_types,
80
+ project_overrides,
81
+ digest_frequency,
82
+ digest_day,
83
+ quiet_hours_enabled,
84
+ quiet_hours_start,
85
+ quiet_hours_end,
86
+ quiet_hours_timezone,
87
+ slack_channel,
88
+ slack_dm_enabled
89
+ ) VALUES (
90
+ $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13
91
+ )
92
+ ON CONFLICT (email_address) DO UPDATE SET
93
+ email_enabled = COALESCE($2, rapport.notification_preferences.email_enabled),
94
+ slack_enabled = COALESCE($3, rapport.notification_preferences.slack_enabled),
95
+ notification_types = COALESCE($4, rapport.notification_preferences.notification_types),
96
+ project_overrides = COALESCE($5, rapport.notification_preferences.project_overrides),
97
+ digest_frequency = COALESCE($6, rapport.notification_preferences.digest_frequency),
98
+ digest_day = COALESCE($7, rapport.notification_preferences.digest_day),
99
+ quiet_hours_enabled = COALESCE($8, rapport.notification_preferences.quiet_hours_enabled),
100
+ quiet_hours_start = $9,
101
+ quiet_hours_end = $10,
102
+ quiet_hours_timezone = COALESCE($11, rapport.notification_preferences.quiet_hours_timezone),
103
+ slack_channel = $12,
104
+ slack_dm_enabled = COALESCE($13, rapport.notification_preferences.slack_dm_enabled),
105
+ updated_at = NOW()
106
+ RETURNING *
107
+ `, [
108
+ email,
109
+ email_enabled,
110
+ slack_enabled,
111
+ notification_types ? JSON.stringify(notification_types) : null,
112
+ project_overrides ? JSON.stringify(project_overrides) : null,
113
+ digest_frequency,
114
+ digest_day,
115
+ quiet_hours_enabled,
116
+ quiet_hours_start,
117
+ quiet_hours_end,
118
+ quiet_hours_timezone,
119
+ slack_channel,
120
+ slack_dm_enabled
121
+ ]);
122
+
123
+ // If digest frequency changed, update the digest queue
124
+ if (digest_frequency) {
125
+ await updateDigestQueue(email, digest_frequency, digest_day);
126
+ }
127
+
128
+ return createSuccessResponse(
129
+ {
130
+ preferences: {
131
+ email_enabled: result.rows[0].email_enabled,
132
+ slack_enabled: result.rows[0].slack_enabled,
133
+ notification_types: result.rows[0].notification_types,
134
+ project_overrides: result.rows[0].project_overrides,
135
+ digest_frequency: result.rows[0].digest_frequency,
136
+ digest_day: result.rows[0].digest_day,
137
+ quiet_hours_enabled: result.rows[0].quiet_hours_enabled,
138
+ quiet_hours_start: result.rows[0].quiet_hours_start,
139
+ quiet_hours_end: result.rows[0].quiet_hours_end,
140
+ quiet_hours_timezone: result.rows[0].quiet_hours_timezone,
141
+ slack_channel: result.rows[0].slack_channel,
142
+ slack_dm_enabled: result.rows[0].slack_dm_enabled
143
+ },
144
+ updated_at: result.rows[0].updated_at
145
+ },
146
+ 'Preferences updated',
147
+ { Request_ID, Timestamp: new Date().toISOString() }
148
+ );
149
+ });
150
+
151
+ /**
152
+ * Validate and sanitize preferences input
153
+ */
154
+ function validatePreferences(body) {
155
+ const result = {};
156
+
157
+ // Validate boolean fields
158
+ if (body.email_enabled !== undefined) {
159
+ if (typeof body.email_enabled !== 'boolean') {
160
+ return { error: 'email_enabled must be a boolean' };
161
+ }
162
+ result.email_enabled = body.email_enabled;
163
+ }
164
+
165
+ if (body.slack_enabled !== undefined) {
166
+ if (typeof body.slack_enabled !== 'boolean') {
167
+ return { error: 'slack_enabled must be a boolean' };
168
+ }
169
+ result.slack_enabled = body.slack_enabled;
170
+ }
171
+
172
+ if (body.quiet_hours_enabled !== undefined) {
173
+ if (typeof body.quiet_hours_enabled !== 'boolean') {
174
+ return { error: 'quiet_hours_enabled must be a boolean' };
175
+ }
176
+ result.quiet_hours_enabled = body.quiet_hours_enabled;
177
+ }
178
+
179
+ if (body.slack_dm_enabled !== undefined) {
180
+ if (typeof body.slack_dm_enabled !== 'boolean') {
181
+ return { error: 'slack_dm_enabled must be a boolean' };
182
+ }
183
+ result.slack_dm_enabled = body.slack_dm_enabled;
184
+ }
185
+
186
+ // Validate notification_types
187
+ if (body.notification_types !== undefined) {
188
+ if (typeof body.notification_types !== 'object') {
189
+ return { error: 'notification_types must be an object' };
190
+ }
191
+
192
+ const validTypes = ['pattern_promotion', 'weekly_digest', 'critical_violation', 'team_alert', 'curation_candidate'];
193
+ for (const type of Object.keys(body.notification_types)) {
194
+ if (!validTypes.includes(type)) {
195
+ return { error: `Invalid notification type: ${type}` };
196
+ }
197
+ const typePrefs = body.notification_types[type];
198
+ if (typeof typePrefs !== 'object') {
199
+ return { error: `notification_types.${type} must be an object` };
200
+ }
201
+ if (typePrefs.email !== undefined && typeof typePrefs.email !== 'boolean') {
202
+ return { error: `notification_types.${type}.email must be a boolean` };
203
+ }
204
+ if (typePrefs.slack !== undefined && typeof typePrefs.slack !== 'boolean') {
205
+ return { error: `notification_types.${type}.slack must be a boolean` };
206
+ }
207
+ }
208
+ result.notification_types = body.notification_types;
209
+ }
210
+
211
+ // Validate project_overrides
212
+ if (body.project_overrides !== undefined) {
213
+ if (typeof body.project_overrides !== 'object') {
214
+ return { error: 'project_overrides must be an object' };
215
+ }
216
+ result.project_overrides = body.project_overrides;
217
+ }
218
+
219
+ // Validate digest_frequency
220
+ if (body.digest_frequency !== undefined) {
221
+ const validFrequencies = ['daily', 'weekly', 'monthly', 'never'];
222
+ if (!validFrequencies.includes(body.digest_frequency)) {
223
+ return { error: `Invalid digest_frequency. Valid values: ${validFrequencies.join(', ')}` };
224
+ }
225
+ result.digest_frequency = body.digest_frequency;
226
+ }
227
+
228
+ // Validate digest_day
229
+ if (body.digest_day !== undefined) {
230
+ if (typeof body.digest_day !== 'number' || body.digest_day < 0 || body.digest_day > 31) {
231
+ return { error: 'digest_day must be a number between 0 and 31' };
232
+ }
233
+ result.digest_day = body.digest_day;
234
+ }
235
+
236
+ // Validate quiet hours
237
+ if (body.quiet_hours_start !== undefined) {
238
+ if (!/^\d{2}:\d{2}$/.test(body.quiet_hours_start)) {
239
+ return { error: 'quiet_hours_start must be in HH:MM format' };
240
+ }
241
+ result.quiet_hours_start = body.quiet_hours_start;
242
+ }
243
+
244
+ if (body.quiet_hours_end !== undefined) {
245
+ if (!/^\d{2}:\d{2}$/.test(body.quiet_hours_end)) {
246
+ return { error: 'quiet_hours_end must be in HH:MM format' };
247
+ }
248
+ result.quiet_hours_end = body.quiet_hours_end;
249
+ }
250
+
251
+ if (body.quiet_hours_timezone !== undefined) {
252
+ result.quiet_hours_timezone = body.quiet_hours_timezone;
253
+ }
254
+
255
+ // Validate slack_channel
256
+ if (body.slack_channel !== undefined) {
257
+ if (body.slack_channel !== null && typeof body.slack_channel !== 'string') {
258
+ return { error: 'slack_channel must be a string or null' };
259
+ }
260
+ result.slack_channel = body.slack_channel;
261
+ }
262
+
263
+ return result;
264
+ }
265
+
266
+ /**
267
+ * Update digest queue when frequency changes
268
+ */
269
+ async function updateDigestQueue(email, frequency, digestDay) {
270
+ // Remove existing pending digests
271
+ await executeQuery(`
272
+ DELETE FROM rapport.digest_queue
273
+ WHERE email_address = $1 AND status = 'pending'
274
+ `, [email]);
275
+
276
+ // If frequency is 'never', don't schedule anything
277
+ if (frequency === 'never') {
278
+ return;
279
+ }
280
+
281
+ // Calculate next scheduled time
282
+ const now = new Date();
283
+ let scheduledFor;
284
+
285
+ switch (frequency) {
286
+ case 'daily':
287
+ scheduledFor = new Date(now);
288
+ scheduledFor.setDate(scheduledFor.getDate() + 1);
289
+ scheduledFor.setHours(9, 0, 0, 0); // 9 AM next day
290
+ break;
291
+
292
+ case 'weekly':
293
+ scheduledFor = new Date(now);
294
+ const daysUntilNextDigestDay = (digestDay - now.getDay() + 7) % 7 || 7;
295
+ scheduledFor.setDate(scheduledFor.getDate() + daysUntilNextDigestDay);
296
+ scheduledFor.setHours(9, 0, 0, 0); // 9 AM
297
+ break;
298
+
299
+ case 'monthly':
300
+ scheduledFor = new Date(now);
301
+ scheduledFor.setMonth(scheduledFor.getMonth() + 1);
302
+ scheduledFor.setDate(digestDay || 1);
303
+ scheduledFor.setHours(9, 0, 0, 0); // 9 AM
304
+ break;
305
+
306
+ default:
307
+ return;
308
+ }
309
+
310
+ // Insert new digest into queue
311
+ await executeQuery(`
312
+ INSERT INTO rapport.digest_queue (email_address, digest_type, scheduled_for, status, digest_data)
313
+ VALUES ($1, $2, $3, 'pending', '{}'::jsonb)
314
+ ON CONFLICT (email_address, digest_type, scheduled_for) DO NOTHING
315
+ `, [email, frequency, scheduledFor.toISOString()]);
316
+ }