@equilateral_ai/mindmeld 3.3.1 → 3.5.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 (72) hide show
  1. package/README.md +1 -10
  2. package/hooks/pre-compact.js +213 -25
  3. package/hooks/session-end.js +112 -3
  4. package/hooks/session-start.js +635 -41
  5. package/hooks/subagent-start.js +150 -0
  6. package/hooks/subagent-stop.js +184 -0
  7. package/package.json +8 -7
  8. package/scripts/init-project.js +74 -33
  9. package/scripts/mcp-bridge.js +220 -0
  10. package/src/core/CorrelationAnalyzer.js +157 -0
  11. package/src/core/LLMPatternDetector.js +198 -0
  12. package/src/core/RelevanceDetector.js +123 -36
  13. package/src/core/StandardsIngestion.js +119 -18
  14. package/src/handlers/activity/activityGetMe.js +1 -1
  15. package/src/handlers/activity/activityGetTeam.js +100 -55
  16. package/src/handlers/admin/adminSetup.js +216 -0
  17. package/src/handlers/alerts/alertsAcknowledge.js +6 -6
  18. package/src/handlers/alerts/alertsGet.js +11 -11
  19. package/src/handlers/analytics/activitySummaryGet.js +34 -35
  20. package/src/handlers/analytics/coachingGet.js +11 -11
  21. package/src/handlers/analytics/convergenceGet.js +236 -0
  22. package/src/handlers/analytics/developerScoreGet.js +41 -111
  23. package/src/handlers/collaborators/collaboratorInvite.js +1 -1
  24. package/src/handlers/company/companyUsersDelete.js +141 -0
  25. package/src/handlers/company/companyUsersGet.js +90 -0
  26. package/src/handlers/company/companyUsersPost.js +267 -0
  27. package/src/handlers/company/companyUsersPut.js +76 -0
  28. package/src/handlers/correlations/correlationsDeveloperGet.js +12 -12
  29. package/src/handlers/correlations/correlationsGet.js +8 -8
  30. package/src/handlers/correlations/correlationsProjectGet.js +5 -5
  31. package/src/handlers/enterprise/controlTowerGet.js +224 -0
  32. package/src/handlers/enterprise/enterpriseOnboardingSetup.js +48 -9
  33. package/src/handlers/enterprise/enterpriseOnboardingStatus.js +1 -3
  34. package/src/handlers/github/githubConnectionStatus.js +1 -1
  35. package/src/handlers/github/githubDiscoverPatterns.js +4 -2
  36. package/src/handlers/github/githubPatternsReview.js +7 -36
  37. package/src/handlers/health/healthGet.js +55 -0
  38. package/src/handlers/helpers/checkSuperAdmin.js +13 -14
  39. package/src/handlers/helpers/mindmeldMcpCore.js +594 -0
  40. package/src/handlers/helpers/subscriptionTiers.js +27 -27
  41. package/src/handlers/mcp/mcpHandler.js +569 -0
  42. package/src/handlers/mcp/mindmeldMcpHandler.js +124 -0
  43. package/src/handlers/mcp/mindmeldMcpStreamHandler.js +243 -0
  44. package/src/handlers/notifications/sendNotification.js +18 -18
  45. package/src/handlers/patterns/patternEvaluatePromotionPost.js +173 -0
  46. package/src/handlers/projects/projectCreate.js +124 -10
  47. package/src/handlers/projects/projectDelete.js +4 -4
  48. package/src/handlers/projects/projectGet.js +8 -8
  49. package/src/handlers/projects/projectUpdate.js +4 -4
  50. package/src/handlers/reports/aiLeverage.js +34 -30
  51. package/src/handlers/reports/engineeringInvestment.js +16 -16
  52. package/src/handlers/reports/riskForecast.js +41 -21
  53. package/src/handlers/reports/standardsRoi.js +101 -9
  54. package/src/handlers/scheduled/maturityUpdateJob.js +166 -0
  55. package/src/handlers/sessions/sessionStandardsPost.js +43 -7
  56. package/src/handlers/standards/discoveriesGet.js +93 -0
  57. package/src/handlers/standards/projectStandardsGet.js +2 -2
  58. package/src/handlers/standards/projectStandardsPut.js +2 -2
  59. package/src/handlers/standards/standardsRelevantPost.js +107 -12
  60. package/src/handlers/standards/standardsTransition.js +112 -15
  61. package/src/handlers/stripe/billingPortalPost.js +1 -1
  62. package/src/handlers/stripe/enterpriseCheckoutPost.js +2 -2
  63. package/src/handlers/stripe/subscriptionCreatePost.js +2 -2
  64. package/src/handlers/stripe/webhookPost.js +42 -14
  65. package/src/handlers/user/apiTokenCreate.js +71 -0
  66. package/src/handlers/user/apiTokenList.js +64 -0
  67. package/src/handlers/user/userSplashGet.js +90 -73
  68. package/src/handlers/users/cognitoPostConfirmation.js +37 -1
  69. package/src/handlers/users/cognitoPreSignUp.js +114 -0
  70. package/src/handlers/users/userGet.js +15 -11
  71. package/src/handlers/webhooks/githubWebhook.js +117 -125
  72. package/src/index.js +8 -5
@@ -0,0 +1,243 @@
1
+ /**
2
+ * MindMeld MCP Streaming Handler — Lambda Function URL (RESPONSE_STREAM)
3
+ *
4
+ * Implements MCP Streamable HTTP transport (protocol version 2025-03-26):
5
+ * - POST: JSON-RPC messages, responds with JSON or SSE stream
6
+ * - GET: Legacy SSE handshake (endpoint event + close)
7
+ * - DELETE: Session close
8
+ * - OPTIONS: CORS preflight
9
+ *
10
+ * Auth: X-MindMeld-Token header OR Authorization: Bearer token
11
+ *
12
+ * Uses awslambda.streamifyResponse() for Lambda Function URL streaming.
13
+ * The `awslambda` global is provided by the Lambda Node.js runtime.
14
+ */
15
+
16
+ const { validateApiToken, handleJsonRpc, CORS_HEADERS } = require('./mindmeldMcpCore');
17
+ const crypto = require('crypto');
18
+
19
+ /**
20
+ * Write an SSE event to the response stream
21
+ */
22
+ function writeSSE(stream, data, eventType) {
23
+ if (eventType) {
24
+ stream.write(`event: ${eventType}\n`);
25
+ }
26
+ stream.write(`data: ${JSON.stringify(data)}\n\n`);
27
+ }
28
+
29
+ /**
30
+ * Parse Lambda Function URL event (different format from API Gateway)
31
+ */
32
+ function parseRequest(event) {
33
+ const http = event.requestContext?.http || {};
34
+ return {
35
+ method: http.method || 'GET',
36
+ path: http.path || '/',
37
+ headers: event.headers || {},
38
+ body: event.body || '',
39
+ isBase64Encoded: event.isBase64Encoded || false,
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Create a response stream with headers
45
+ */
46
+ function createStream(responseStream, statusCode, headers) {
47
+ return awslambda.HttpResponseStream.from(responseStream, {
48
+ statusCode,
49
+ headers: { ...CORS_HEADERS, ...headers },
50
+ });
51
+ }
52
+
53
+ // Lambda Function URL streaming handler
54
+ const streamHandler = async (event, responseStream, _context) => {
55
+ const { method, headers, body, isBase64Encoded } = parseRequest(event);
56
+
57
+ // === OPTIONS: CORS preflight ===
58
+ if (method === 'OPTIONS') {
59
+ const stream = createStream(responseStream, 200, {
60
+ 'Access-Control-Max-Age': '86400',
61
+ });
62
+ stream.end();
63
+ return;
64
+ }
65
+
66
+ // === DELETE: Session close ===
67
+ if (method === 'DELETE') {
68
+ const stream = createStream(responseStream, 200, {
69
+ 'Content-Type': 'application/json',
70
+ });
71
+ stream.end();
72
+ return;
73
+ }
74
+
75
+ // === GET: Legacy SSE handshake ===
76
+ if (method === 'GET') {
77
+ const authResult = await validateApiToken(headers);
78
+ if (authResult.error) {
79
+ const stream = createStream(responseStream, 401, {
80
+ 'Content-Type': 'application/json',
81
+ });
82
+ stream.write(JSON.stringify({ error: authResult.message }));
83
+ stream.end();
84
+ return;
85
+ }
86
+
87
+ const sessionId = crypto.randomUUID();
88
+ const stream = createStream(responseStream, 200, {
89
+ 'Content-Type': 'text/event-stream',
90
+ 'Cache-Control': 'no-cache',
91
+ 'Connection': 'keep-alive',
92
+ 'Mcp-Session-Id': sessionId,
93
+ });
94
+
95
+ // Send endpoint event — legacy SSE clients expect this
96
+ stream.write(`event: endpoint\ndata: ${parseRequest(event).path}\n\n`);
97
+
98
+ // For Streamable HTTP clients probing via GET: close after endpoint event.
99
+ // The client will then POST to us for actual JSON-RPC messages.
100
+ stream.end();
101
+ return;
102
+ }
103
+
104
+ // === POST: Streamable HTTP JSON-RPC ===
105
+ if (method !== 'POST') {
106
+ const stream = createStream(responseStream, 405, {
107
+ 'Content-Type': 'application/json',
108
+ });
109
+ stream.write(JSON.stringify({ error: 'Method not allowed' }));
110
+ stream.end();
111
+ return;
112
+ }
113
+
114
+ // Auth
115
+ const authResult = await validateApiToken(headers);
116
+ if (authResult.error) {
117
+ const stream = createStream(responseStream, 200, {
118
+ 'Content-Type': 'application/json',
119
+ });
120
+ stream.write(JSON.stringify({
121
+ jsonrpc: '2.0',
122
+ error: { code: -32000, message: authResult.message },
123
+ id: null,
124
+ }));
125
+ stream.end();
126
+ return;
127
+ }
128
+
129
+ // Parse body (Function URL may base64-encode it)
130
+ let parsedBody;
131
+ try {
132
+ const raw = isBase64Encoded ? Buffer.from(body, 'base64').toString() : body;
133
+ parsedBody = JSON.parse(raw);
134
+ } catch (e) {
135
+ const stream = createStream(responseStream, 400, {
136
+ 'Content-Type': 'application/json',
137
+ });
138
+ stream.write(JSON.stringify({
139
+ jsonrpc: '2.0',
140
+ error: { code: -32700, message: 'Parse error: invalid JSON' },
141
+ id: null,
142
+ }));
143
+ stream.end();
144
+ return;
145
+ }
146
+
147
+ // Session ID management
148
+ const sessionId = headers['mcp-session-id'] || crypto.randomUUID();
149
+
150
+ // Check if client wants SSE response
151
+ const acceptHeader = headers['accept'] || '';
152
+ const wantsSSE = acceptHeader.includes('text/event-stream');
153
+
154
+ if (wantsSSE) {
155
+ // === SSE streaming response ===
156
+ const stream = createStream(responseStream, 200, {
157
+ 'Content-Type': 'text/event-stream',
158
+ 'Cache-Control': 'no-cache',
159
+ 'Mcp-Session-Id': sessionId,
160
+ });
161
+
162
+ if (Array.isArray(parsedBody)) {
163
+ for (const msg of parsedBody) {
164
+ const response = await handleJsonRpc(msg, authResult.user);
165
+ if (response) {
166
+ writeSSE(stream, response, 'message');
167
+ }
168
+ }
169
+ } else {
170
+ const response = await handleJsonRpc(parsedBody, authResult.user);
171
+ if (response) {
172
+ writeSSE(stream, response, 'message');
173
+ }
174
+ }
175
+
176
+ stream.end();
177
+ } else {
178
+ // === Standard JSON response ===
179
+ const responseHeaders = {
180
+ 'Content-Type': 'application/json',
181
+ 'Mcp-Session-Id': sessionId,
182
+ };
183
+
184
+ if (Array.isArray(parsedBody)) {
185
+ const responses = [];
186
+ for (const msg of parsedBody) {
187
+ const response = await handleJsonRpc(msg, authResult.user);
188
+ if (response) responses.push(response);
189
+ }
190
+ const stream = createStream(responseStream,
191
+ responses.length > 0 ? 200 : 202, responseHeaders);
192
+ if (responses.length > 0) {
193
+ stream.write(JSON.stringify(responses));
194
+ }
195
+ stream.end();
196
+ } else {
197
+ const response = await handleJsonRpc(parsedBody, authResult.user);
198
+ if (!response) {
199
+ const stream = createStream(responseStream, 202, responseHeaders);
200
+ stream.end();
201
+ } else {
202
+ const stream = createStream(responseStream, 200, responseHeaders);
203
+ stream.write(JSON.stringify(response));
204
+ stream.end();
205
+ }
206
+ }
207
+ }
208
+ };
209
+
210
+ // awslambda is a global provided by the Lambda Node.js runtime.
211
+ // In local/test environments it won't exist — export the raw handler for testing.
212
+ if (typeof awslambda !== 'undefined') {
213
+ exports.handler = awslambda.streamifyResponse(streamHandler);
214
+ } else {
215
+ // Fallback for local testing: wrap as standard async handler
216
+ exports.handler = async (event) => {
217
+ let result = { statusCode: 200, headers: {}, body: '' };
218
+ const mockStream = {
219
+ _headers: {},
220
+ _statusCode: 200,
221
+ _chunks: [],
222
+ write(chunk) { this._chunks.push(chunk); },
223
+ end() {},
224
+ };
225
+ // Mock awslambda.HttpResponseStream.from
226
+ const originalFrom = mockStream;
227
+ const createMockStream = (_rs, meta) => {
228
+ mockStream._statusCode = meta.statusCode;
229
+ mockStream._headers = meta.headers;
230
+ return mockStream;
231
+ };
232
+ global.awslambda = {
233
+ HttpResponseStream: { from: createMockStream },
234
+ };
235
+ await streamHandler(event, mockStream, {});
236
+ delete global.awslambda;
237
+ return {
238
+ statusCode: mockStream._statusCode,
239
+ headers: mockStream._headers,
240
+ body: mockStream._chunks.join(''),
241
+ };
242
+ };
243
+ }
@@ -52,13 +52,13 @@ exports.handler = wrapHandler(async ({ requestContext, body }) => {
52
52
  // Check authorization (only admins and managers can send notifications)
53
53
  const authCheck = await executeQuery(`
54
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
55
+ ue.admin,
56
+ ue.manager,
57
+ u.super_admin,
58
+ ue.company_id
59
+ FROM rapport.user_entitlements ue
60
+ JOIN rapport.users u ON ue.email_address = u.email_address
61
+ WHERE ue.email_address = $1
62
62
  `, [email]);
63
63
 
64
64
  if (authCheck.rowCount === 0) {
@@ -66,7 +66,7 @@ exports.handler = wrapHandler(async ({ requestContext, body }) => {
66
66
  }
67
67
 
68
68
  const userRole = authCheck.rows[0];
69
- const isAuthorized = userRole.Super_Admin || userRole.Admin || userRole.Manager;
69
+ const isAuthorized = userRole.super_admin || userRole.admin || userRole.manager;
70
70
 
71
71
  if (!isAuthorized) {
72
72
  return createErrorResponse(403, 'Only admins and managers can send notifications');
@@ -89,18 +89,18 @@ exports.handler = wrapHandler(async ({ requestContext, body }) => {
89
89
  } else if (recipients === 'all_admins') {
90
90
  // All company admins
91
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]);
92
+ SELECT ue.email_address
93
+ FROM rapport.user_entitlements ue
94
+ WHERE ue.company_id = $1 AND ue.admin = true
95
+ `, [userRole.company_id]);
96
96
  recipientEmails = admins.rows.map(r => r.email_address);
97
97
  } else if (recipients === 'all_managers') {
98
98
  // All company managers
99
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]);
100
+ SELECT ue.email_address
101
+ FROM rapport.user_entitlements ue
102
+ WHERE ue.company_id = $1 AND (ue.manager = true OR ue.admin = true)
103
+ `, [userRole.company_id]);
104
104
  recipientEmails = managers.rows.map(r => r.email_address);
105
105
  } else {
106
106
  return createErrorResponse(400, 'Invalid recipients format');
@@ -118,8 +118,8 @@ exports.handler = wrapHandler(async ({ requestContext, body }) => {
118
118
  // Get preferences for all recipients
119
119
  const prefsQuery = await executeQuery(`
120
120
  SELECT email_address, rapport.get_notification_preferences(email_address) as preferences
121
- FROM "Users"
122
- WHERE "Email_Address" = ANY($1)
121
+ FROM rapport.users
122
+ WHERE email_address = ANY($1)
123
123
  `, [recipientEmails]);
124
124
 
125
125
  const prefsMap = new Map(prefsQuery.rows.map(r => [r.email_address, r.preferences]));
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Pattern Evaluate Promotion Handler
3
+ * Checks if a pattern meets promotion thresholds for becoming a standard
4
+ *
5
+ * POST /api/patterns/evaluate-promotion
6
+ * Body: { pattern, project_id, evaluate_only }
7
+ *
8
+ * Called by: pre-compact.js hook (evaluateForPromotion method)
9
+ *
10
+ * Promotion Criteria:
11
+ * - handoff_count >= 10 (used 10+ times)
12
+ * - success_rate >= 0.70 (70% success)
13
+ * - developer_count >= 3 (used by 3+ developers)
14
+ */
15
+
16
+ const { wrapHandler, executeQuery, createSuccessResponse, createErrorResponse, handleError } = require('./helpers');
17
+
18
+ /**
19
+ * Evaluate pattern for promotion to standard
20
+ */
21
+ async function evaluatePatternPromotion({ body: requestBody = {}, requestContext }) {
22
+ try {
23
+ const Request_ID = requestContext?.requestId || 'unknown';
24
+
25
+ const {
26
+ pattern,
27
+ project_id,
28
+ evaluate_only = true
29
+ } = requestBody;
30
+
31
+ // Validate required fields
32
+ if (!pattern || !pattern.element) {
33
+ return createErrorResponse(400, 'pattern.element is required');
34
+ }
35
+
36
+ // Generate pattern_id from element
37
+ const patternId = pattern.pattern_id || `pat_${pattern.element.toLowerCase().replace(/\s+/g, '_').substring(0, 50)}`;
38
+
39
+ // Look up pattern in rapport.patterns
40
+ const patternQuery = `
41
+ SELECT
42
+ p.pattern_id,
43
+ p.intent,
44
+ p.maturity,
45
+ p.handoff_count,
46
+ p.successful_handoffs,
47
+ p.failed_handoffs,
48
+ p.discovered_at,
49
+ p.last_used
50
+ FROM rapport.patterns p
51
+ WHERE p.pattern_id = $1
52
+ `;
53
+
54
+ const patternResult = await executeQuery(patternQuery, [patternId]);
55
+
56
+ if (patternResult.rows.length === 0) {
57
+ return createSuccessResponse(
58
+ {
59
+ Records: [{
60
+ pattern_id: patternId,
61
+ eligible: false,
62
+ reason: 'not_found',
63
+ message: 'Pattern not found in patterns table'
64
+ }]
65
+ },
66
+ 'Pattern not found',
67
+ { Total_Records: 0, Request_ID, Timestamp: new Date().toISOString() }
68
+ );
69
+ }
70
+
71
+ const pat = patternResult.rows[0];
72
+
73
+ // Query usage metrics
74
+ const metricsQuery = `
75
+ SELECT
76
+ COUNT(DISTINCT pu.email_address) as developer_count,
77
+ COUNT(DISTINCT pu.session_id) as session_count,
78
+ COUNT(*) FILTER (WHERE pu.success = true) as successes,
79
+ COUNT(*) as total_uses
80
+ FROM rapport.pattern_usage pu
81
+ WHERE pu.pattern_id = $1
82
+ `;
83
+
84
+ const metricsResult = await executeQuery(metricsQuery, [patternId]);
85
+ const metrics = metricsResult.rows[0];
86
+
87
+ const handoffCount = parseInt(pat.handoff_count) || 0;
88
+ const successRate = handoffCount > 0
89
+ ? (parseInt(pat.successful_handoffs) || 0) / handoffCount
90
+ : 0;
91
+ const developerCount = parseInt(metrics.developer_count) || 0;
92
+ const sessionCount = parseInt(metrics.session_count) || 0;
93
+
94
+ // Check promotion thresholds
95
+ const eligible =
96
+ handoffCount >= 10 &&
97
+ successRate >= 0.70 &&
98
+ developerCount >= 3;
99
+
100
+ const response = {
101
+ pattern_id: patternId,
102
+ eligible: eligible,
103
+ metrics: {
104
+ handoff_count: handoffCount,
105
+ success_rate: parseFloat(successRate.toFixed(3)),
106
+ developer_count: developerCount,
107
+ session_count: sessionCount,
108
+ maturity: pat.maturity,
109
+ success_correlation: parseFloat(successRate.toFixed(3))
110
+ },
111
+ thresholds: {
112
+ min_handoffs: 10,
113
+ min_success_rate: 0.70,
114
+ min_developers: 3
115
+ },
116
+ proposed_category: pattern.category || null
117
+ };
118
+
119
+ // If eligible and not evaluate_only, create curation candidate
120
+ if (eligible && !evaluate_only) {
121
+ const candidateQuery = `
122
+ INSERT INTO rapport.curation_candidates (
123
+ pattern_id,
124
+ proposed_category,
125
+ evidence,
126
+ status,
127
+ created_at
128
+ ) VALUES ($1, $2, $3, 'pending', NOW())
129
+ ON CONFLICT (pattern_id) WHERE status = 'pending'
130
+ DO UPDATE SET
131
+ evidence = EXCLUDED.evidence,
132
+ created_at = NOW()
133
+ RETURNING candidate_id
134
+ `;
135
+
136
+ const evidence = {
137
+ handoff_count: handoffCount,
138
+ success_rate: successRate,
139
+ developer_count: developerCount,
140
+ session_count: sessionCount,
141
+ maturity: pat.maturity,
142
+ evaluated_at: new Date().toISOString()
143
+ };
144
+
145
+ try {
146
+ const candidateResult = await executeQuery(candidateQuery, [
147
+ patternId,
148
+ pattern.category || 'uncategorized',
149
+ JSON.stringify(evidence)
150
+ ]);
151
+
152
+ if (candidateResult.rows.length > 0) {
153
+ response.candidate_id = candidateResult.rows[0].candidate_id;
154
+ }
155
+ } catch (candidateError) {
156
+ // Candidate creation failed — still return eligibility result
157
+ console.error('[patternEvaluatePromotionPost] Candidate creation failed:', candidateError.message);
158
+ }
159
+ }
160
+
161
+ return createSuccessResponse(
162
+ { Records: [response] },
163
+ eligible ? 'Pattern eligible for promotion' : 'Pattern does not meet promotion criteria',
164
+ { Total_Records: 1, Request_ID, Timestamp: new Date().toISOString() }
165
+ );
166
+
167
+ } catch (error) {
168
+ console.error('Handler Error:', error);
169
+ return handleError(error);
170
+ }
171
+ }
172
+
173
+ exports.handler = wrapHandler(evaluatePatternPromotion);
@@ -7,6 +7,8 @@
7
7
  */
8
8
 
9
9
  const { wrapHandler, executeQuery, createSuccessResponse, createErrorResponse, handleError, checkSubscriptionLimits } = require('./helpers');
10
+ const crypto = require('crypto');
11
+ const https = require('https');
10
12
 
11
13
  /**
12
14
  * Create project
@@ -27,24 +29,24 @@ async function createProject({ body: requestBody = {}, requestContext }) {
27
29
 
28
30
  // Check user has admin access to company
29
31
  const adminQuery = `
30
- SELECT ue."Admin", c."Company_Name"
31
- FROM "UserEntitlements" ue
32
- JOIN "Company" c ON ue."Company_ID" = c."Company_ID"
33
- WHERE ue."Email_Address" = $1
34
- AND ue."Company_ID" = $2
32
+ SELECT ue.admin, c.company_name
33
+ FROM rapport.user_entitlements ue
34
+ JOIN rapport.companies c ON ue.company_id = c.company_id
35
+ WHERE ue.email_address = $1
36
+ AND ue.company_id = $2
35
37
  `;
36
38
  const adminCheck = await executeQuery(adminQuery, [email, Company_ID]);
37
39
 
38
- if (adminCheck.rowCount === 0 || !adminCheck.rows[0].Admin) {
40
+ if (adminCheck.rowCount === 0 || !adminCheck.rows[0].admin) {
39
41
  return createErrorResponse(403, 'Admin access required to create projects');
40
42
  }
41
43
 
42
44
  // Check project limit for subscription tier
43
45
  const clientQuery = `
44
46
  SELECT c.subscription_tier
45
- FROM "Client" c
46
- JOIN "UserEntitlements" ue ON c."Client_ID" = ue."Client_ID"
47
- WHERE ue."Email_Address" = $1 AND ue."Company_ID" = $2
47
+ FROM rapport.clients c
48
+ JOIN rapport.user_entitlements ue ON c.client_id = ue.client_id
49
+ WHERE ue.email_address = $1 AND ue.company_id = $2
48
50
  `;
49
51
  const clientResult = await executeQuery(clientQuery, [email, Company_ID]);
50
52
  const subscriptionTier = clientResult.rows[0]?.subscription_tier || 'free';
@@ -112,8 +114,18 @@ async function createProject({ body: requestBody = {}, requestContext }) {
112
114
  `;
113
115
  await executeQuery(collabQuery, [project_id, email]);
114
116
 
117
+ // Auto-create GitHub webhook if repo_url is a GitHub URL
118
+ let webhookCreated = false;
119
+ if (repo_url && repo_url.includes('github.com')) {
120
+ try {
121
+ webhookCreated = await createGitHubWebhook(email, repo_url, Company_ID);
122
+ } catch (webhookErr) {
123
+ console.warn('Webhook creation failed (non-blocking):', webhookErr.message);
124
+ }
125
+ }
126
+
115
127
  return createSuccessResponse(
116
- { Records: result.rows },
128
+ { Records: result.rows, webhook_created: webhookCreated },
117
129
  'Project created successfully',
118
130
  {
119
131
  Total_Records: result.rowCount,
@@ -131,4 +143,106 @@ async function createProject({ body: requestBody = {}, requestContext }) {
131
143
  }
132
144
  }
133
145
 
146
+ /**
147
+ * Create GitHub webhook on a repo when a project is connected
148
+ * Uses the customer's stored GitHub OAuth token and a per-repo secret
149
+ */
150
+ async function createGitHubWebhook(email, repoUrl, companyId) {
151
+ // Extract owner/repo from URL
152
+ const match = repoUrl.match(/github\.com[/:]([^/]+)\/([^/.]+)/);
153
+ if (!match) {
154
+ console.log('Could not parse GitHub owner/repo from:', repoUrl);
155
+ return false;
156
+ }
157
+ const [, owner, repo] = match;
158
+
159
+ // Get the user's GitHub token
160
+ const connResult = await executeQuery(`
161
+ SELECT access_token_encrypted FROM rapport.github_connections
162
+ WHERE email_address = $1 AND revoked = FALSE
163
+ `, [email]);
164
+
165
+ if (connResult.rowCount === 0) {
166
+ console.log('No GitHub connection for', email);
167
+ return false;
168
+ }
169
+
170
+ const encryptionKey = process.env.GITHUB_TOKEN_ENCRYPTION_KEY;
171
+ if (!encryptionKey) return false;
172
+
173
+ const accessToken = decryptToken(connResult.rows[0].access_token_encrypted, encryptionKey);
174
+
175
+ // Generate per-repo webhook secret
176
+ const webhookSecret = crypto.randomBytes(32).toString('hex');
177
+ const webhookUrl = process.env.WEBHOOK_URL || 'https://api.mindmeld.dev/api/webhooks/github';
178
+
179
+ // Create webhook via GitHub API
180
+ const payload = JSON.stringify({
181
+ name: 'web',
182
+ active: true,
183
+ events: ['push', 'pull_request'],
184
+ config: {
185
+ url: webhookUrl,
186
+ content_type: 'json',
187
+ secret: webhookSecret,
188
+ insecure_ssl: '0'
189
+ }
190
+ });
191
+
192
+ const response = await new Promise((resolve, reject) => {
193
+ const req = https.request({
194
+ hostname: 'api.github.com',
195
+ path: `/repos/${owner}/${repo}/hooks`,
196
+ method: 'POST',
197
+ headers: {
198
+ 'Authorization': `Bearer ${accessToken}`,
199
+ 'User-Agent': 'MindMeld-App',
200
+ 'Accept': 'application/vnd.github+json',
201
+ 'Content-Type': 'application/json',
202
+ 'Content-Length': Buffer.byteLength(payload)
203
+ }
204
+ }, (res) => {
205
+ let data = '';
206
+ res.on('data', chunk => data += chunk);
207
+ res.on('end', () => {
208
+ try { resolve({ statusCode: res.statusCode, body: JSON.parse(data) }); }
209
+ catch (e) { resolve({ statusCode: res.statusCode, body: data }); }
210
+ });
211
+ });
212
+ req.on('error', reject);
213
+ req.write(payload);
214
+ req.end();
215
+ });
216
+
217
+ if (response.statusCode !== 201) {
218
+ console.warn(`GitHub webhook creation returned ${response.statusCode}:`, JSON.stringify(response.body).substring(0, 200));
219
+ return false;
220
+ }
221
+
222
+ const githubWebhookId = response.body.id;
223
+
224
+ // Register repo in git_repositories with per-repo webhook secret
225
+ // Unique constraint is on (company_id, repo_url)
226
+ await executeQuery(`
227
+ INSERT INTO rapport.git_repositories (
228
+ repo_id, repo_name, repo_url, company_id, webhook_secret, created_at, updated_at
229
+ ) VALUES (gen_random_uuid(), $1, $2, $3, $4, NOW(), NOW())
230
+ ON CONFLICT (company_id, repo_url) DO UPDATE SET
231
+ webhook_secret = EXCLUDED.webhook_secret,
232
+ updated_at = NOW()
233
+ `, [`${owner}/${repo}`, repoUrl, companyId, webhookSecret]);
234
+
235
+ console.log(`Created GitHub webhook ${githubWebhookId} on ${owner}/${repo}`);
236
+ return true;
237
+ }
238
+
239
+ function decryptToken(encryptedData, key) {
240
+ const [ivHex, encrypted] = encryptedData.split(':');
241
+ const iv = Buffer.from(ivHex, 'hex');
242
+ const decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(key, 'hex'), iv);
243
+ let decrypted = decipher.update(encrypted, 'hex', 'utf8');
244
+ decrypted += decipher.final('utf8');
245
+ return decrypted;
246
+ }
247
+
134
248
  exports.handler = wrapHandler(createProject);
@@ -29,14 +29,14 @@ async function deleteProject({ queryStringParameters: queryParams = {}, requestC
29
29
  p.project_id,
30
30
  p.company_id,
31
31
  pc.role,
32
- ue."Admin" as company_admin
32
+ ue.admin as company_admin
33
33
  FROM rapport.projects p
34
34
  LEFT JOIN rapport.project_collaborators pc
35
35
  ON p.project_id = pc.project_id
36
36
  AND pc.email_address = $1
37
- LEFT JOIN "UserEntitlements" ue
38
- ON ue."Email_Address" = $1
39
- AND ue."Company_ID" = p.company_id
37
+ LEFT JOIN rapport.user_entitlements ue
38
+ ON ue.email_address = $1
39
+ AND ue.company_id = p.company_id
40
40
  WHERE p.project_id = $2
41
41
  `;
42
42
  const accessCheck = await executeQuery(accessQuery, [email, projectId]);