@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.
- package/README.md +300 -0
- package/hooks/README.md +494 -0
- package/hooks/pre-compact.js +392 -0
- package/hooks/session-start.js +264 -0
- package/package.json +90 -0
- package/scripts/harvest.js +561 -0
- package/scripts/init-project.js +437 -0
- package/scripts/inject.js +388 -0
- package/src/collaboration/CollaborationPrompt.js +460 -0
- package/src/core/AlertEngine.js +813 -0
- package/src/core/AlertNotifier.js +363 -0
- package/src/core/CorrelationAnalyzer.js +774 -0
- package/src/core/CurationEngine.js +688 -0
- package/src/core/LLMPatternDetector.js +508 -0
- package/src/core/LoadBearingDetector.js +242 -0
- package/src/core/NotificationService.js +1032 -0
- package/src/core/PatternValidator.js +355 -0
- package/src/core/README.md +160 -0
- package/src/core/RapportOrchestrator.js +446 -0
- package/src/core/RelevanceDetector.js +577 -0
- package/src/core/StandardsIngestion.js +575 -0
- package/src/core/TeamLoadBearingDetector.js +431 -0
- package/src/database/dbOperations.js +105 -0
- package/src/handlers/activity/activityGetMe.js +98 -0
- package/src/handlers/activity/activityGetTeam.js +130 -0
- package/src/handlers/alerts/alertsAcknowledge.js +91 -0
- package/src/handlers/alerts/alertsGet.js +250 -0
- package/src/handlers/collaborators/collaboratorAdd.js +201 -0
- package/src/handlers/collaborators/collaboratorInvite.js +218 -0
- package/src/handlers/collaborators/collaboratorList.js +88 -0
- package/src/handlers/collaborators/collaboratorRemove.js +127 -0
- package/src/handlers/collaborators/inviteAccept.js +122 -0
- package/src/handlers/context/contextGet.js +57 -0
- package/src/handlers/context/invariantsGet.js +74 -0
- package/src/handlers/context/loopsGet.js +82 -0
- package/src/handlers/context/notesCreate.js +74 -0
- package/src/handlers/context/purposeGet.js +78 -0
- package/src/handlers/correlations/correlationsDeveloperGet.js +226 -0
- package/src/handlers/correlations/correlationsGet.js +93 -0
- package/src/handlers/correlations/correlationsProjectGet.js +161 -0
- package/src/handlers/github/githubConnectionStatus.js +49 -0
- package/src/handlers/github/githubDiscoverPatterns.js +364 -0
- package/src/handlers/github/githubOAuthCallback.js +166 -0
- package/src/handlers/github/githubOAuthStart.js +59 -0
- package/src/handlers/github/githubPatternsReview.js +109 -0
- package/src/handlers/github/githubReposList.js +105 -0
- package/src/handlers/helpers/checkSuperAdmin.js +85 -0
- package/src/handlers/helpers/dbOperations.js +53 -0
- package/src/handlers/helpers/errorHandler.js +49 -0
- package/src/handlers/helpers/index.js +106 -0
- package/src/handlers/helpers/lambdaWrapper.js +60 -0
- package/src/handlers/helpers/responseUtil.js +55 -0
- package/src/handlers/helpers/subscriptionTiers.js +1168 -0
- package/src/handlers/notifications/getPreferences.js +84 -0
- package/src/handlers/notifications/sendNotification.js +170 -0
- package/src/handlers/notifications/updatePreferences.js +316 -0
- package/src/handlers/patterns/patternUsagePost.js +182 -0
- package/src/handlers/patterns/patternViolationPost.js +185 -0
- package/src/handlers/projects/projectCreate.js +107 -0
- package/src/handlers/projects/projectDelete.js +82 -0
- package/src/handlers/projects/projectGet.js +95 -0
- package/src/handlers/projects/projectUpdate.js +118 -0
- package/src/handlers/reports/aiLeverage.js +206 -0
- package/src/handlers/reports/engineeringInvestment.js +132 -0
- package/src/handlers/reports/riskForecast.js +186 -0
- package/src/handlers/reports/standardsRoi.js +162 -0
- package/src/handlers/scheduled/analyzeCorrelations.js +178 -0
- package/src/handlers/scheduled/analyzeGitHistory.js +510 -0
- package/src/handlers/scheduled/generateAlerts.js +135 -0
- package/src/handlers/scheduled/refreshActivity.js +21 -0
- package/src/handlers/scheduled/scanCompliance.js +334 -0
- package/src/handlers/sessions/sessionEndPost.js +180 -0
- package/src/handlers/sessions/sessionStandardsPost.js +135 -0
- package/src/handlers/stripe/addonManagePost.js +240 -0
- package/src/handlers/stripe/billingPortalPost.js +93 -0
- package/src/handlers/stripe/enterpriseCheckoutPost.js +272 -0
- package/src/handlers/stripe/seatsUpdatePost.js +185 -0
- package/src/handlers/stripe/subscriptionCancelDelete.js +169 -0
- package/src/handlers/stripe/subscriptionCreatePost.js +221 -0
- package/src/handlers/stripe/subscriptionUpdatePut.js +163 -0
- package/src/handlers/stripe/webhookPost.js +454 -0
- package/src/handlers/users/cognitoPostConfirmation.js +150 -0
- package/src/handlers/users/userEntitlementsGet.js +89 -0
- package/src/handlers/users/userGet.js +114 -0
- package/src/handlers/webhooks/githubWebhook.js +223 -0
- 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
|
+
}
|