@memberjunction/server 2.94.0 → 2.96.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 (97) hide show
  1. package/dist/config.d.ts +5 -1
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js +2 -1
  4. package/dist/config.js.map +1 -1
  5. package/dist/context.d.ts.map +1 -1
  6. package/dist/context.js +4 -0
  7. package/dist/context.js.map +1 -1
  8. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  9. package/dist/resolvers/ActionResolver.js +5 -4
  10. package/dist/resolvers/ActionResolver.js.map +1 -1
  11. package/dist/resolvers/AskSkipResolver.d.ts +8 -3
  12. package/dist/resolvers/AskSkipResolver.d.ts.map +1 -1
  13. package/dist/resolvers/AskSkipResolver.js +241 -25
  14. package/dist/resolvers/AskSkipResolver.js.map +1 -1
  15. package/dist/resolvers/CreateQueryResolver.d.ts +2 -2
  16. package/dist/resolvers/CreateQueryResolver.d.ts.map +1 -1
  17. package/dist/resolvers/CreateQueryResolver.js +25 -17
  18. package/dist/resolvers/CreateQueryResolver.js.map +1 -1
  19. package/dist/resolvers/DatasetResolver.d.ts +2 -2
  20. package/dist/resolvers/DatasetResolver.d.ts.map +1 -1
  21. package/dist/resolvers/DatasetResolver.js +6 -5
  22. package/dist/resolvers/DatasetResolver.js.map +1 -1
  23. package/dist/resolvers/EntityRecordNameResolver.d.ts +4 -4
  24. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  25. package/dist/resolvers/EntityRecordNameResolver.js +6 -5
  26. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  27. package/dist/resolvers/FileCategoryResolver.d.ts.map +1 -1
  28. package/dist/resolvers/FileCategoryResolver.js +10 -11
  29. package/dist/resolvers/FileCategoryResolver.js.map +1 -1
  30. package/dist/resolvers/FileResolver.d.ts.map +1 -1
  31. package/dist/resolvers/FileResolver.js +5 -6
  32. package/dist/resolvers/FileResolver.js.map +1 -1
  33. package/dist/resolvers/GetDataContextDataResolver.js +2 -2
  34. package/dist/resolvers/GetDataContextDataResolver.js.map +1 -1
  35. package/dist/resolvers/GetDataResolver.js +3 -3
  36. package/dist/resolvers/GetDataResolver.js.map +1 -1
  37. package/dist/resolvers/MergeRecordsResolver.d.ts +3 -3
  38. package/dist/resolvers/MergeRecordsResolver.d.ts.map +1 -1
  39. package/dist/resolvers/MergeRecordsResolver.js +8 -7
  40. package/dist/resolvers/MergeRecordsResolver.js.map +1 -1
  41. package/dist/resolvers/PotentialDuplicateRecordResolver.d.ts +1 -1
  42. package/dist/resolvers/PotentialDuplicateRecordResolver.d.ts.map +1 -1
  43. package/dist/resolvers/PotentialDuplicateRecordResolver.js +4 -3
  44. package/dist/resolvers/PotentialDuplicateRecordResolver.js.map +1 -1
  45. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  46. package/dist/resolvers/QueryResolver.js +19 -14
  47. package/dist/resolvers/QueryResolver.js.map +1 -1
  48. package/dist/resolvers/ReportResolver.d.ts +2 -2
  49. package/dist/resolvers/ReportResolver.d.ts.map +1 -1
  50. package/dist/resolvers/ReportResolver.js +8 -6
  51. package/dist/resolvers/ReportResolver.js.map +1 -1
  52. package/dist/resolvers/RunAIAgentResolver.d.ts +3 -7
  53. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  54. package/dist/resolvers/RunAIAgentResolver.js +8 -5
  55. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  56. package/dist/resolvers/RunAIPromptResolver.d.ts +3 -7
  57. package/dist/resolvers/RunAIPromptResolver.d.ts.map +1 -1
  58. package/dist/resolvers/RunAIPromptResolver.js +10 -8
  59. package/dist/resolvers/RunAIPromptResolver.js.map +1 -1
  60. package/dist/resolvers/RunTemplateResolver.d.ts +2 -4
  61. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  62. package/dist/resolvers/RunTemplateResolver.js +5 -4
  63. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  64. package/dist/resolvers/SqlLoggingConfigResolver.d.ts.map +1 -1
  65. package/dist/resolvers/SqlLoggingConfigResolver.js +7 -7
  66. package/dist/resolvers/SqlLoggingConfigResolver.js.map +1 -1
  67. package/dist/resolvers/UserFavoriteResolver.d.ts +3 -4
  68. package/dist/resolvers/UserFavoriteResolver.d.ts.map +1 -1
  69. package/dist/resolvers/UserFavoriteResolver.js +10 -68
  70. package/dist/resolvers/UserFavoriteResolver.js.map +1 -1
  71. package/dist/resolvers/UserViewResolver.d.ts +1 -1
  72. package/dist/resolvers/UserViewResolver.d.ts.map +1 -1
  73. package/dist/resolvers/UserViewResolver.js +3 -4
  74. package/dist/resolvers/UserViewResolver.js.map +1 -1
  75. package/package.json +39 -39
  76. package/src/config.ts +2 -0
  77. package/src/context.ts +5 -0
  78. package/src/resolvers/ActionResolver.ts +5 -4
  79. package/src/resolvers/AskSkipResolver.ts +300 -29
  80. package/src/resolvers/CreateQueryResolver.ts +28 -17
  81. package/src/resolvers/DatasetResolver.ts +5 -4
  82. package/src/resolvers/EntityRecordNameResolver.ts +8 -6
  83. package/src/resolvers/FileCategoryResolver.ts +9 -10
  84. package/src/resolvers/FileResolver.ts +6 -7
  85. package/src/resolvers/GetDataContextDataResolver.ts +2 -2
  86. package/src/resolvers/GetDataResolver.ts +2 -2
  87. package/src/resolvers/InfoResolver.ts +1 -1
  88. package/src/resolvers/MergeRecordsResolver.ts +7 -6
  89. package/src/resolvers/PotentialDuplicateRecordResolver.ts +3 -2
  90. package/src/resolvers/QueryResolver.ts +22 -15
  91. package/src/resolvers/ReportResolver.ts +9 -6
  92. package/src/resolvers/RunAIAgentResolver.ts +12 -4
  93. package/src/resolvers/RunAIPromptResolver.ts +12 -8
  94. package/src/resolvers/RunTemplateResolver.ts +5 -5
  95. package/src/resolvers/SqlLoggingConfigResolver.ts +7 -6
  96. package/src/resolvers/UserFavoriteResolver.ts +8 -67
  97. package/src/resolvers/UserViewResolver.ts +3 -3
@@ -1,5 +1,5 @@
1
1
  import { Arg, Ctx, Field, Mutation, ObjectType, PubSub, PubSubEngine, Query, Resolver } from 'type-graphql';
2
- import { LogError, LogStatus, Metadata, RunView, UserInfo, CompositeKey, EntityFieldInfo, EntityInfo, EntityRelationshipInfo, EntitySaveOptions, EntityDeleteOptions } from '@memberjunction/core';
2
+ import { LogError, LogStatus, Metadata, RunView, UserInfo, CompositeKey, EntityFieldInfo, EntityInfo, EntityRelationshipInfo, EntitySaveOptions, EntityDeleteOptions, IMetadataProvider } from '@memberjunction/core';
3
3
  import { AppContext, UserPayload, MJ_SERVER_EVENT_CODE } from '../types.js';
4
4
  import { BehaviorSubject } from 'rxjs';
5
5
  import { take } from 'rxjs/operators';
@@ -54,18 +54,145 @@ 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';
61
61
  import { MJGlobal, CopyScalarsAndArrays } from '@memberjunction/global';
62
- import { sendPostRequest } from '../util.js';
62
+ import { GetReadWriteProvider, sendPostRequest } from '../util.js';
63
63
  import { GetAIAPIKey } from '@memberjunction/ai';
64
64
  import { CompositeKeyInputType } from '../generic/KeyInputOutputTypes.js';
65
65
  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
@@ -352,7 +479,7 @@ export class AskSkipResolver {
352
479
  @Arg('ConversationId', () => String) ConversationId: string,
353
480
  @Arg('EntityName', () => String) EntityName: string,
354
481
  @Arg('CompositeKey', () => CompositeKeyInputType) compositeKey: CompositeKeyInputType,
355
- @Ctx() { dataSource, userPayload }: AppContext,
482
+ @Ctx() { dataSource, userPayload, providers }: AppContext,
356
483
  @PubSub() pubSub: PubSubEngine
357
484
  ) {
358
485
  // In this function we're simply going to call the Skip API and pass along the message from the user
@@ -371,14 +498,14 @@ export class AskSkipResolver {
371
498
  );
372
499
  }
373
500
 
374
- const md = new Metadata();
501
+ const md = GetReadWriteProvider(providers);
375
502
  const { convoEntity, dataContextEntity, convoDetailEntity, dataContext } = await this.HandleSkipChatInitialObjectLoading(
376
503
  dataSource,
377
504
  ConversationId,
378
505
  UserQuestion,
379
506
  user,
380
507
  userPayload,
381
- md,
508
+ md as unknown as Metadata,
382
509
  null
383
510
  );
384
511
 
@@ -431,7 +558,7 @@ export class AskSkipResolver {
431
558
  */
432
559
  @Mutation(() => AskSkipResultType)
433
560
  async ExecuteAskSkipLearningCycle(
434
- @Ctx() { dataSource, userPayload }: AppContext,
561
+ @Ctx() { dataSource, userPayload, providers }: AppContext,
435
562
  @Arg('ForceEntityRefresh', () => Boolean, { nullable: true }) ForceEntityRefresh?: boolean
436
563
  ) {
437
564
  const skipConfigInfo = configInfo.askSkip;
@@ -485,7 +612,7 @@ export class AskSkipResolver {
485
612
  }
486
613
 
487
614
  // Get the Skip agent ID
488
- const md = new Metadata();
615
+ const md = GetReadWriteProvider(providers);
489
616
  const skipAgent = AIEngine.Instance.GetAgentByName('Skip');
490
617
  if (!skipAgent) {
491
618
  throw new Error("Skip agent not found in AIEngine");
@@ -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, providers }: AppContext,
1471
+ @PubSub() pubSub: PubSubEngine
1472
+ ): Promise<ReattachConversationResponse | null> {
1473
+ try {
1474
+ const md = GetReadWriteProvider(providers);
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
@@ -1348,13 +1581,13 @@ cycle.`);
1348
1581
  async ExecuteAskSkipAnalysisQuery(
1349
1582
  @Arg('UserQuestion', () => String) UserQuestion: string,
1350
1583
  @Arg('ConversationId', () => String) ConversationId: string,
1351
- @Ctx() { dataSource, userPayload }: AppContext,
1584
+ @Ctx() { dataSource, userPayload, providers }: AppContext,
1352
1585
  @PubSub() pubSub: PubSubEngine,
1353
1586
  @Arg('DataContextId', () => String, { nullable: true }) DataContextId?: string,
1354
1587
  @Arg('ForceEntityRefresh', () => Boolean, { nullable: true }) ForceEntityRefresh?: boolean,
1355
1588
  @Arg('StartTime', () => Date, { nullable: true }) StartTime?: Date
1356
1589
  ) {
1357
- const md = new Metadata();
1590
+ const md = GetReadWriteProvider(providers);
1358
1591
  const user = UserCache.Instance.Users.find((u) => u.Email.trim().toLowerCase() === userPayload.email.trim().toLowerCase());
1359
1592
  if (!user) throw new Error(`User ${userPayload.email} not found in UserCache`);
1360
1593
 
@@ -1367,12 +1600,12 @@ cycle.`);
1367
1600
  UserQuestion,
1368
1601
  user,
1369
1602
  userPayload,
1370
- md,
1603
+ md as unknown as Metadata,
1371
1604
  DataContextId
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(
@@ -1392,7 +1625,7 @@ cycle.`);
1392
1625
  ConversationId,
1393
1626
  userPayload,
1394
1627
  pubSub,
1395
- md,
1628
+ md as unknown as Metadata,
1396
1629
  convoEntity,
1397
1630
  convoDetailEntity,
1398
1631
  dataContext,
@@ -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
  }
@@ -1,10 +1,10 @@
1
1
  import { Arg, Ctx, Field, InputType, Mutation, ObjectType, registerEnumType, Resolver, PubSub, PubSubEngine } from 'type-graphql';
2
2
  import { AppContext } from '../types.js';
3
- import { LogError, Metadata, RunView, UserInfo, CompositeKey } from '@memberjunction/core';
3
+ import { LogError, Metadata, RunView, UserInfo, CompositeKey, DatabaseProviderBase } from '@memberjunction/core';
4
4
  import { RequireSystemUser } from '../directives/RequireSystemUser.js';
5
5
  import { QueryCategoryEntity, QueryPermissionEntity } from '@memberjunction/core-entities';
6
6
  import { QueryResolver } from '../generated/generated.js';
7
- import { GetReadWriteProvider } from '../util.js';
7
+ import { GetReadOnlyProvider, GetReadWriteProvider } from '../util.js';
8
8
  import { DeleteOptionsInput } from '../generic/DeleteOptionsInput.js';
9
9
  import { QueryEntityExtended } from '@memberjunction/core-entities-server';
10
10
 
@@ -312,8 +312,8 @@ export class QueryResolverExtended extends QueryResolver {
312
312
  // Handle CategoryPath if provided
313
313
  let finalCategoryID = input.CategoryID;
314
314
  if (input.CategoryPath) {
315
- const md = new Metadata();
316
- finalCategoryID = await this.findOrCreateCategoryPath(input.CategoryPath, md, context.userPayload.userRecord);
315
+ const p = GetReadOnlyProvider(context.providers, {allowFallbackToReadWrite: true});
316
+ finalCategoryID = await this.findOrCreateCategoryPath(input.CategoryPath, p, context.userPayload.userRecord);
317
317
  }
318
318
 
319
319
  // Use QueryEntityExtended which handles AI processing
@@ -345,7 +345,7 @@ export class QueryResolverExtended extends QueryResolver {
345
345
  const queryID = record.ID;
346
346
 
347
347
  if (input.Permissions && input.Permissions.length > 0) {
348
- await this.createPermissions(input.Permissions, queryID, context.userPayload.userRecord);
348
+ await this.createPermissions(provider, input.Permissions, queryID, context.userPayload.userRecord);
349
349
  await record.RefreshRelatedMetadata(true); // force DB update since we just created new permissions
350
350
  }
351
351
 
@@ -374,13 +374,12 @@ export class QueryResolverExtended extends QueryResolver {
374
374
  }
375
375
  }
376
376
 
377
- protected async createPermissions(permissions: QueryPermissionInputType[], queryID: string, contextUser: UserInfo): Promise<QueryPermissionType[]> {
377
+ protected async createPermissions(p: DatabaseProviderBase, permissions: QueryPermissionInputType[], queryID: string, contextUser: UserInfo): Promise<QueryPermissionType[]> {
378
378
  // Create permissions if provided
379
379
  const createdPermissions: QueryPermissionType[] = [];
380
380
  if (permissions && permissions.length > 0) {
381
- const md = new Metadata();
382
381
  for (const perm of permissions) {
383
- const permissionEntity = await md.GetEntityObject<QueryPermissionEntity>('Query Permissions', contextUser);
382
+ const permissionEntity = await p.GetEntityObject<QueryPermissionEntity>('Query Permissions', contextUser);
384
383
  if (permissionEntity) {
385
384
  permissionEntity.QueryID = queryID;
386
385
  permissionEntity.RoleID = perm.RoleID;
@@ -427,8 +426,20 @@ export class QueryResolverExtended extends QueryResolver {
427
426
  // Handle CategoryPath if provided
428
427
  let finalCategoryID = input.CategoryID;
429
428
  if (input.CategoryPath) {
430
- const md = new Metadata();
431
- finalCategoryID = await this.findOrCreateCategoryPath(input.CategoryPath, md, context.userPayload.userRecord);
429
+ finalCategoryID = await this.findOrCreateCategoryPath(input.CategoryPath, provider, context.userPayload.userRecord);
430
+ }
431
+
432
+ // now make sure there is NO existing query by the same name in the specified category
433
+ const existingQueryResult = await provider.RunView({
434
+ EntityName: 'Queries',
435
+ ExtraFilter: `Name='${input.Name}' AND CategoryID='${finalCategoryID}'`
436
+ }, context.userPayload.userRecord);
437
+ if (existingQueryResult.Success && existingQueryResult.Results?.length > 0) {
438
+ // we have a match! Let's return an error
439
+ return {
440
+ Success: false,
441
+ ErrorMessage: `Query with name '${input.Name}' already exists in the specified ${input.CategoryID ? 'category' : 'categoryPath'}`
442
+ };
432
443
  }
433
444
 
434
445
  // Update fields that were provided
@@ -481,7 +492,7 @@ export class QueryResolverExtended extends QueryResolver {
481
492
  }
482
493
 
483
494
  // Create new permissions
484
- await this.createPermissions(input.Permissions, queryID, context.userPayload.userRecord);
495
+ await this.createPermissions(provider, input.Permissions, queryID, context.userPayload.userRecord);
485
496
 
486
497
  // Refresh the metadata to get updated permissions
487
498
  await queryEntity.RefreshRelatedMetadata(true);
@@ -608,7 +619,7 @@ export class QueryResolverExtended extends QueryResolver {
608
619
  * @param contextUser - User context for operations
609
620
  * @returns The ID of the final category in the path
610
621
  */
611
- private async findOrCreateCategoryPath(categoryPath: string, md: Metadata, contextUser: UserInfo): Promise<string> {
622
+ private async findOrCreateCategoryPath(categoryPath: string, p: DatabaseProviderBase, contextUser: UserInfo): Promise<string> {
612
623
  if (!categoryPath || categoryPath.trim() === '') {
613
624
  throw new Error('CategoryPath cannot be empty');
614
625
  }
@@ -625,7 +636,7 @@ export class QueryResolverExtended extends QueryResolver {
625
636
  const categoryName = pathParts[i];
626
637
 
627
638
  // Look for existing category at this level
628
- const existingCategory = await this.findCategoryByNameAndParent(categoryName, currentParentID, contextUser);
639
+ const existingCategory = await this.findCategoryByNameAndParent(p, categoryName, currentParentID, contextUser);
629
640
 
630
641
  if (existingCategory) {
631
642
  currentCategoryID = existingCategory.ID;
@@ -633,7 +644,7 @@ export class QueryResolverExtended extends QueryResolver {
633
644
  } else {
634
645
  try {
635
646
  // Create new category
636
- const newCategory = await md.GetEntityObject<QueryCategoryEntity>("Query Categories", contextUser);
647
+ const newCategory = await p.GetEntityObject<QueryCategoryEntity>("Query Categories", contextUser);
637
648
  if (!newCategory) {
638
649
  throw new Error(`Failed to create entity object for Query Categories`);
639
650
  }
@@ -652,7 +663,7 @@ export class QueryResolverExtended extends QueryResolver {
652
663
  currentParentID = newCategory.ID;
653
664
 
654
665
  // Refresh metadata after each category creation to ensure it's available for subsequent lookups
655
- await md.Refresh();
666
+ await p.Refresh();
656
667
  } catch (error) {
657
668
  throw new Error(`Failed to create category '${categoryName}': ${error instanceof Error ? error.message : String(error)}`);
658
669
  }
@@ -673,9 +684,9 @@ export class QueryResolverExtended extends QueryResolver {
673
684
  * @param contextUser - User context for database operations
674
685
  * @returns The matching category entity or null if not found
675
686
  */
676
- private async findCategoryByNameAndParent(categoryName: string, parentID: string | null, contextUser: UserInfo): Promise<QueryCategoryEntity | null> {
687
+ private async findCategoryByNameAndParent(provider: DatabaseProviderBase, categoryName: string, parentID: string | null, contextUser: UserInfo): Promise<QueryCategoryEntity | null> {
677
688
  try {
678
- const rv = new RunView();
689
+ const rv = provider;
679
690
  const parentFilter = parentID ? `ParentID='${parentID}'` : 'ParentID IS NULL';
680
691
  const nameFilter = `LOWER(Name) = LOWER('${categoryName.replace(/'/g, "''")}')`; // Escape single quotes
681
692
 
@@ -1,6 +1,7 @@
1
1
  import { Arg, Ctx, Field, InputType, Int, ObjectType, Query, Resolver } from 'type-graphql';
2
2
  import { AppContext } from '../types.js';
3
3
  import { LogError, Metadata } from '@memberjunction/core';
4
+ import { GetReadOnlyProvider } from '../util.js';
4
5
 
5
6
  @ObjectType()
6
7
  export class DatasetResultType {
@@ -38,11 +39,11 @@ export class DatasetResolverExtended {
38
39
  @Query(() => DatasetResultType)
39
40
  async GetDatasetByName(
40
41
  @Arg('DatasetName', () => String) DatasetName: string,
41
- @Ctx() {}: AppContext,
42
+ @Ctx() {providers}: AppContext,
42
43
  @Arg('ItemFilters', () => [DatasetItemFilterTypeGQL], { nullable: 'itemsAndList' }) ItemFilters?: DatasetItemFilterTypeGQL[]
43
44
  ) {
44
45
  try {
45
- const md = new Metadata();
46
+ const md = GetReadOnlyProvider(providers, {allowFallbackToReadWrite: true});
46
47
  const result = await md.GetDatasetByName(DatasetName, ItemFilters);
47
48
  if (result) {
48
49
  return {
@@ -89,11 +90,11 @@ export class DatasetStatusResolver {
89
90
  @Query(() => DatasetStatusResultType)
90
91
  async GetDatasetStatusByName(
91
92
  @Arg('DatasetName', () => String) DatasetName: string,
92
- @Ctx() {}: AppContext,
93
+ @Ctx() {providers}: AppContext,
93
94
  @Arg('ItemFilters', () => [DatasetItemFilterTypeGQL], { nullable: 'itemsAndList' }) ItemFilters?: DatasetItemFilterTypeGQL[]
94
95
  ) {
95
96
  try {
96
- const md = new Metadata();
97
+ const md = GetReadOnlyProvider(providers, {allowFallbackToReadWrite: true});
97
98
  const result = await md.GetDatasetStatusByName(DatasetName, ItemFilters);
98
99
  if (result) {
99
100
  return {
@@ -1,7 +1,8 @@
1
- import { Metadata, CompositeKey } from '@memberjunction/core';
1
+ import { Metadata, CompositeKey, DatabaseProviderBase } from '@memberjunction/core';
2
2
  import { Arg, Ctx, Field, InputType, ObjectType, Query, Resolver } from 'type-graphql';
3
3
  import { AppContext } from '../types.js';
4
4
  import { CompositeKeyInputType, CompositeKeyOutputType } from '../generic/KeyInputOutputTypes.js';
5
+ import { GetReadOnlyProvider } from '../util.js';
5
6
 
6
7
  @InputType()
7
8
  export class EntityRecordNameInput {
@@ -36,26 +37,27 @@ export class EntityRecordNameResolver {
36
37
  async GetEntityRecordName(
37
38
  @Arg('EntityName', () => String) EntityName: string,
38
39
  @Arg('CompositeKey', () => CompositeKeyInputType) primaryKey: CompositeKey,
39
- @Ctx() { userPayload }: AppContext
40
+ @Ctx() { providers, userPayload }: AppContext
40
41
  ): Promise<EntityRecordNameResult> {
41
- const md = new Metadata();
42
+ const md = GetReadOnlyProvider(providers, {allowFallbackToReadWrite: true});
43
+
42
44
  return await this.InnerGetEntityRecordName(md, EntityName, primaryKey);
43
45
  }
44
46
 
45
47
  @Query(() => [EntityRecordNameResult])
46
48
  async GetEntityRecordNames(
47
49
  @Arg('info', () => [EntityRecordNameInput]) info: EntityRecordNameInput[],
48
- @Ctx() {}: AppContext
50
+ @Ctx() {providers}: AppContext
49
51
  ): Promise<EntityRecordNameResult[]> {
50
52
  const result: EntityRecordNameResult[] = [];
51
- const md = new Metadata();
53
+ const md = GetReadOnlyProvider(providers, {allowFallbackToReadWrite: true});
52
54
  for (const i of info) {
53
55
  result.push(await this.InnerGetEntityRecordName(md, i.EntityName, i.CompositeKey));
54
56
  }
55
57
  return result;
56
58
  }
57
59
 
58
- async InnerGetEntityRecordName(md: Metadata, EntityName: string, primaryKey: CompositeKeyInputType): Promise<EntityRecordNameResult> {
60
+ async InnerGetEntityRecordName(md: DatabaseProviderBase, EntityName: string, primaryKey: CompositeKeyInputType): Promise<EntityRecordNameResult> {
59
61
  const pk = new CompositeKey(primaryKey.KeyValuePairs);
60
62
  const e = md.Entities.find((e) => e.Name === EntityName);
61
63
  if (e) {