@memberjunction/server 2.94.0 → 2.95.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.
@@ -54,7 +54,7 @@ import {
54
54
  UserNotificationEntity,
55
55
  AIAgentEntityExtended
56
56
  } from '@memberjunction/core-entities';
57
- import { apiKey, baseUrl, configInfo, graphqlPort, mj_core_schema } from '../config.js';
57
+ import { apiKey, baseUrl, publicUrl, configInfo, graphqlPort, graphqlRootPath, mj_core_schema } from '../config.js';
58
58
  import mssql from 'mssql';
59
59
 
60
60
  import { registerEnumType } from 'type-graphql';
@@ -66,6 +66,133 @@ import { AIEngine } from '@memberjunction/aiengine';
66
66
  import { deleteAccessToken, GetDataAccessToken, registerAccessToken, tokenExists } from './GetDataResolver.js';
67
67
  import e from 'express';
68
68
 
69
+ /**
70
+ * Store for active conversation streams
71
+ * Maps conversationID to the last status message received
72
+ */
73
+ class ActiveConversationStreams {
74
+ private static instance: ActiveConversationStreams;
75
+ private streams: Map<string, {
76
+ lastStatus: string,
77
+ lastUpdate: Date,
78
+ startTime: Date, // When processing actually started
79
+ sessionIds: Set<string> // Track which sessions are listening
80
+ }> = new Map();
81
+
82
+ private constructor() {}
83
+
84
+ static getInstance(): ActiveConversationStreams {
85
+ if (!ActiveConversationStreams.instance) {
86
+ ActiveConversationStreams.instance = new ActiveConversationStreams();
87
+ }
88
+ return ActiveConversationStreams.instance;
89
+ }
90
+
91
+ updateStatus(conversationId: string, status: string, sessionId?: string) {
92
+ const existing = this.streams.get(conversationId);
93
+ if (existing) {
94
+ existing.lastStatus = status;
95
+ existing.lastUpdate = new Date();
96
+ if (sessionId) {
97
+ existing.sessionIds.add(sessionId);
98
+ }
99
+ } else {
100
+ const now = new Date();
101
+ this.streams.set(conversationId, {
102
+ lastStatus: status,
103
+ lastUpdate: now,
104
+ startTime: now, // Track when processing started
105
+ sessionIds: sessionId ? new Set([sessionId]) : new Set()
106
+ });
107
+ }
108
+ }
109
+
110
+ getStatus(conversationId: string): string | null {
111
+ const stream = this.streams.get(conversationId);
112
+ return stream ? stream.lastStatus : null;
113
+ }
114
+
115
+ getStartTime(conversationId: string): Date | null {
116
+ const stream = this.streams.get(conversationId);
117
+ return stream ? stream.startTime : null;
118
+ }
119
+
120
+ addSession(conversationId: string, sessionId: string) {
121
+ const stream = this.streams.get(conversationId);
122
+ if (stream) {
123
+ stream.sessionIds.add(sessionId);
124
+ } else {
125
+ // If no stream exists yet, create one with default status
126
+ const now = new Date();
127
+ this.streams.set(conversationId, {
128
+ lastStatus: 'Processing...',
129
+ lastUpdate: now,
130
+ startTime: now, // Track when processing started
131
+ sessionIds: new Set([sessionId])
132
+ });
133
+ }
134
+ }
135
+
136
+ removeConversation(conversationId: string) {
137
+ this.streams.delete(conversationId);
138
+ }
139
+
140
+ isActive(conversationId: string): boolean {
141
+ const stream = this.streams.get(conversationId);
142
+ if (!stream) return false;
143
+
144
+ // Consider a stream inactive if no update in last 5 minutes
145
+ const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
146
+ return stream.lastUpdate > fiveMinutesAgo;
147
+ }
148
+
149
+ getSessionIds(conversationId: string): string[] {
150
+ const stream = this.streams.get(conversationId);
151
+ return stream ? Array.from(stream.sessionIds) : [];
152
+ }
153
+
154
+ /**
155
+ * Clean up stale streams that haven't been updated in a while
156
+ * This prevents memory leaks from abandoned conversations
157
+ */
158
+ cleanupStaleStreams() {
159
+ const now = new Date();
160
+ const staleThreshold = new Date(now.getTime() - 30 * 60 * 1000); // 30 minutes
161
+
162
+ const staleConversations: string[] = [];
163
+ this.streams.forEach((stream, conversationId) => {
164
+ if (stream.lastUpdate < staleThreshold) {
165
+ staleConversations.push(conversationId);
166
+ }
167
+ });
168
+
169
+ staleConversations.forEach(conversationId => {
170
+ this.streams.delete(conversationId);
171
+ LogStatus(`Cleaned up stale stream for conversation ${conversationId}`);
172
+ });
173
+
174
+ if (staleConversations.length > 0) {
175
+ LogStatus(`Cleaned up ${staleConversations.length} stale conversation streams`);
176
+ }
177
+ }
178
+ }
179
+
180
+ const activeStreams = ActiveConversationStreams.getInstance();
181
+
182
+ // Set up periodic cleanup of stale streams (every 10 minutes)
183
+ setInterval(() => {
184
+ activeStreams.cleanupStaleStreams();
185
+ }, 10 * 60 * 1000);
186
+
187
+ @ObjectType()
188
+ class ReattachConversationResponse {
189
+ @Field(() => String, { nullable: true })
190
+ lastStatusMessage?: string;
191
+
192
+ @Field(() => Date, { nullable: true })
193
+ startTime?: Date;
194
+ }
195
+
69
196
  /**
70
197
  * Enumeration representing the different phases of a Skip response
71
198
  * Corresponds to the lifecycle of a Skip AI interaction
@@ -932,7 +1059,9 @@ cycle.`);
932
1059
  organizationID: skipConfigInfo.orgID,
933
1060
  organizationInfo: configInfo?.askSkip?.organizationInfo,
934
1061
  apiKeys: this.buildSkipAPIKeys(),
935
- callingServerURL: accessToken ? `${baseUrl}:${graphqlPort}` : undefined,
1062
+ // Favors public URL for conciseness or when behind a proxy for local development
1063
+ // otherwise uses base URL and GraphQL port/path from configuration
1064
+ callingServerURL: accessToken ? (publicUrl || `${baseUrl}:${graphqlPort}${graphqlRootPath}`) : undefined,
936
1065
  callingServerAPIKey: accessToken ? apiKey : undefined,
937
1066
  callingServerAccessToken: accessToken ? accessToken.Token : undefined
938
1067
  };
@@ -1331,6 +1460,110 @@ cycle.`);
1331
1460
  ];
1332
1461
  }
1333
1462
 
1463
+ /**
1464
+ * Re-attaches the current session to receive status updates for a processing conversation
1465
+ * This is needed after page reloads to resume receiving push notifications
1466
+ */
1467
+ @Query(() => ReattachConversationResponse)
1468
+ async ReattachToProcessingConversation(
1469
+ @Arg('ConversationId', () => String) ConversationId: string,
1470
+ @Ctx() { userPayload }: AppContext,
1471
+ @PubSub() pubSub: PubSubEngine
1472
+ ): Promise<ReattachConversationResponse | null> {
1473
+ try {
1474
+ const md = new Metadata();
1475
+ const user = UserCache.Instance.Users.find((u) => u.Email.trim().toLowerCase() === userPayload.email.trim().toLowerCase());
1476
+ if (!user) {
1477
+ LogError(`User ${userPayload.email} not found in UserCache`);
1478
+ return null;
1479
+ }
1480
+
1481
+ // Load the conversation
1482
+ const convoEntity = await md.GetEntityObject<ConversationEntity>('Conversations', user);
1483
+ const loadResult = await convoEntity.Load(ConversationId);
1484
+
1485
+ if (!loadResult) {
1486
+ LogError(`Could not load conversation ${ConversationId} for re-attachment`);
1487
+ return null;
1488
+ }
1489
+
1490
+ // Check if the conversation belongs to this user
1491
+ if (convoEntity.UserID !== user.ID) {
1492
+ LogError(`Conversation ${ConversationId} does not belong to user ${user.Email}`);
1493
+ return null;
1494
+ }
1495
+
1496
+ // If the conversation is processing, reattach the session to receive updates
1497
+ if (convoEntity.Status === 'Processing') {
1498
+ // Add this session to the active streams for this conversation
1499
+ activeStreams.addSession(ConversationId, userPayload.sessionId);
1500
+
1501
+ // Get the last known status message and start time from our cache
1502
+ const lastStatusMessage = activeStreams.getStatus(ConversationId) || 'Processing...';
1503
+ const startTime = activeStreams.getStartTime(ConversationId);
1504
+
1505
+ // Check if the stream is still active
1506
+ const isStreamActive = activeStreams.isActive(ConversationId);
1507
+
1508
+ if (isStreamActive) {
1509
+ // Send the last known status to the frontend
1510
+ const statusMessage = {
1511
+ type: 'AskSkip',
1512
+ status: 'OK',
1513
+ ResponsePhase: 'Processing',
1514
+ conversationID: convoEntity.ID,
1515
+ message: lastStatusMessage,
1516
+ };
1517
+
1518
+ pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
1519
+ pushStatusUpdates: {
1520
+ message: JSON.stringify(statusMessage),
1521
+ sessionId: userPayload.sessionId
1522
+ }
1523
+ });
1524
+
1525
+ LogStatus(`Re-attached session ${userPayload.sessionId} to active stream for conversation ${ConversationId}, last status: ${lastStatusMessage}`);
1526
+
1527
+ // Return the status and start time
1528
+ return {
1529
+ lastStatusMessage,
1530
+ startTime: startTime || convoEntity.__mj_UpdatedAt
1531
+ };
1532
+ } else {
1533
+ // Stream is inactive or doesn't exist, just send default status
1534
+ const statusMessage = {
1535
+ type: 'AskSkip',
1536
+ status: 'OK',
1537
+ ResponsePhase: 'Processing',
1538
+ conversationID: convoEntity.ID,
1539
+ message: 'Processing...',
1540
+ };
1541
+
1542
+ pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
1543
+ pushStatusUpdates: {
1544
+ message: JSON.stringify(statusMessage),
1545
+ sessionId: userPayload.sessionId
1546
+ }
1547
+ });
1548
+
1549
+ LogStatus(`Re-attached session ${userPayload.sessionId} to conversation ${ConversationId}, but stream is inactive`);
1550
+
1551
+ // Return default start time since stream is inactive
1552
+ return {
1553
+ lastStatusMessage: 'Processing...',
1554
+ startTime: convoEntity.__mj_UpdatedAt
1555
+ };
1556
+ }
1557
+ } else {
1558
+ LogStatus(`Conversation ${ConversationId} is not processing (Status: ${convoEntity.Status})`);
1559
+ return null;
1560
+ }
1561
+ } catch (error) {
1562
+ LogError(`Error re-attaching to conversation: ${error}`);
1563
+ return null;
1564
+ }
1565
+ }
1566
+
1334
1567
  /**
1335
1568
  * Executes an analysis query with Skip
1336
1569
  * This is the primary entry point for general Skip conversations
@@ -1372,7 +1605,7 @@ cycle.`);
1372
1605
  );
1373
1606
 
1374
1607
  // Set the conversation status to 'Processing' when a request is initiated
1375
- await this.setConversationStatus(convoEntity, 'Processing', userPayload);
1608
+ await this.setConversationStatus(convoEntity, 'Processing', userPayload, pubSub);
1376
1609
 
1377
1610
  // now load up the messages. We will load up ALL of the messages for this conversation, and then pass them to the Skip API
1378
1611
  const messages: SkipMessage[] = await this.LoadConversationDetailsIntoSkipMessages(
@@ -2196,7 +2429,7 @@ cycle.`);
2196
2429
 
2197
2430
  if (conversationDetailCount > 10) {
2198
2431
  // Set status of conversation to Available since we still want to allow the user to ask questions
2199
- await this.setConversationStatus(convoEntity, 'Available', userPayload);
2432
+ await this.setConversationStatus(convoEntity, 'Available', userPayload, pubSub);
2200
2433
 
2201
2434
  // At this point it is likely that we are stuck in a loop, so we stop here
2202
2435
  pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
@@ -2241,22 +2474,31 @@ cycle.`);
2241
2474
  }) => {
2242
2475
  LogStatus(JSON.stringify(message, null, 4));
2243
2476
  if (message.type === 'status_update') {
2244
- pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
2245
- message: JSON.stringify({
2246
- type: 'AskSkip',
2247
- status: 'OK',
2248
- conversationID: ConversationId,
2249
- ResponsePhase: message.value.responsePhase,
2250
- message: message.value.messages[0].content,
2251
- }),
2252
- sessionId: userPayload.sessionId,
2253
- });
2477
+ const statusContent = message.value.messages[0].content;
2478
+
2479
+ // Store the status in our active streams cache
2480
+ activeStreams.updateStatus(ConversationId, statusContent, userPayload.sessionId);
2481
+
2482
+ // Publish to all sessions listening to this conversation
2483
+ const sessionIds = activeStreams.getSessionIds(ConversationId);
2484
+ for (const sessionId of sessionIds) {
2485
+ pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
2486
+ message: JSON.stringify({
2487
+ type: 'AskSkip',
2488
+ status: 'OK',
2489
+ conversationID: ConversationId,
2490
+ ResponsePhase: message.value.responsePhase,
2491
+ message: statusContent,
2492
+ }),
2493
+ sessionId: sessionId,
2494
+ });
2495
+ }
2254
2496
  }
2255
2497
  }
2256
2498
  );
2257
2499
  } catch (error) {
2258
2500
  // Set conversation status to Available on error so user can try again
2259
- await this.setConversationStatus(convoEntity, 'Available', userPayload);
2501
+ await this.setConversationStatus(convoEntity, 'Available', userPayload, pubSub);
2260
2502
 
2261
2503
  // Log the error for debugging
2262
2504
  LogError(`Error in HandleSkipChatRequest sendPostRequest: ${error}`);
@@ -2339,7 +2581,7 @@ cycle.`);
2339
2581
  }
2340
2582
  } else {
2341
2583
  // Set status of conversation to Available since we still want to allow the user to ask questions
2342
- await this.setConversationStatus(convoEntity, 'Available', userPayload);
2584
+ await this.setConversationStatus(convoEntity, 'Available', userPayload, pubSub);
2343
2585
 
2344
2586
  pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
2345
2587
  message: JSON.stringify({
@@ -2511,7 +2753,7 @@ cycle.`);
2511
2753
  convoDetailEntityAI.CompletionTime = endTime.getTime() - startTime.getTime();
2512
2754
 
2513
2755
  // Set conversation status back to Available since we need user input for the clarifying question
2514
- await this.setConversationStatus(convoEntity, 'Available', userPayload);
2756
+ await this.setConversationStatus(convoEntity, 'Available', userPayload, pubSub);
2515
2757
 
2516
2758
  if (await convoDetailEntityAI.Save()) {
2517
2759
  return {
@@ -2908,13 +3150,42 @@ cycle.`);
2908
3150
  };
2909
3151
  }
2910
3152
 
2911
- private async setConversationStatus(convoEntity: ConversationEntity, status: 'Processing' | 'Available', userPayload: UserPayload): Promise<boolean> {
3153
+ private async setConversationStatus(convoEntity: ConversationEntity, status: 'Processing' | 'Available', userPayload: UserPayload, pubSub?: PubSubEngine): Promise<boolean> {
2912
3154
  if (convoEntity.Status !== status) {
2913
3155
  convoEntity.Status = status;
2914
3156
 
2915
3157
  const convoSaveResult = await convoEntity.Save();
2916
3158
  if (!convoSaveResult) {
2917
3159
  LogError(`Error updating conversation status to '${status}'`, undefined, convoEntity.LatestResult);
3160
+ } else {
3161
+ // If conversation is now Available (completed), remove it from active streams
3162
+ if (status === 'Available') {
3163
+ activeStreams.removeConversation(convoEntity.ID);
3164
+ LogStatus(`Removed conversation ${convoEntity.ID} from active streams (status changed to Available)`);
3165
+ } else if (status === 'Processing') {
3166
+ // If conversation is starting to process, add the session to active streams
3167
+ activeStreams.addSession(convoEntity.ID, userPayload.sessionId);
3168
+ LogStatus(`Added session ${userPayload.sessionId} to active streams for conversation ${convoEntity.ID}`);
3169
+ }
3170
+
3171
+ if (pubSub) {
3172
+ // Publish status update to notify frontend of conversation status change
3173
+ const statusMessage = {
3174
+ type: 'ConversationStatusUpdate',
3175
+ conversationID: convoEntity.ID,
3176
+ status: status,
3177
+ timestamp: new Date().toISOString()
3178
+ };
3179
+
3180
+ pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
3181
+ pushStatusUpdates: {
3182
+ message: JSON.stringify(statusMessage),
3183
+ sessionId: userPayload.sessionId
3184
+ }
3185
+ });
3186
+
3187
+ LogStatus(`Published conversation status update for ${convoEntity.ID}: ${status}`);
3188
+ }
2918
3189
  }
2919
3190
  return convoSaveResult;
2920
3191
  }