@memberjunction/server 2.14.0 → 2.16.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.
@@ -1,18 +1,34 @@
1
- import { BaseEntity, CompositeKey, EntityFieldTSType, EntityPermissionType, LogError, Metadata, RunView, RunViewParams, RunViewResult, UserInfo } from '@memberjunction/core';
1
+ import {
2
+ BaseEntity,
3
+ BaseEntityEvent,
4
+ CompositeKey,
5
+ EntityFieldTSType,
6
+ EntityPermissionType,
7
+ LogError,
8
+ Metadata,
9
+ RunView,
10
+ RunViewParams,
11
+ RunViewResult,
12
+ UserInfo,
13
+ } from '@memberjunction/core';
2
14
  import { AuditLogEntity, UserViewEntity } from '@memberjunction/core-entities';
3
15
  import { UserCache } from '@memberjunction/sqlserver-dataprovider';
4
16
  import { PubSubEngine } from 'type-graphql';
5
17
  import { GraphQLError } from 'graphql';
6
18
  import { DataSource } from 'typeorm';
19
+ import { httpTransport, CloudEvent, emitterFor } from 'cloudevents';
7
20
 
8
21
  import { RunViewGenericParams, UserPayload } from '../types.js';
9
22
  import { RunDynamicViewInput, RunViewByIDInput, RunViewByNameInput } from './RunViewResolver.js';
10
23
  import { DeleteOptionsInput } from './DeleteOptionsInput.js';
11
- import { MJGlobal } from '@memberjunction/global';
24
+ import { MJEvent, MJEventType, MJGlobal } from '@memberjunction/global';
12
25
  import { PUSH_STATUS_UPDATES_TOPIC } from './PushStatusResolver.js';
13
26
  import { FieldMapper } from '@memberjunction/graphql-dataprovider';
14
27
 
15
28
  export class ResolverBase {
29
+ private _emit = process.env.CLOUDEVENTS_HTTP_TRANSPORT ? emitterFor(httpTransport(process.env.CLOUDEVENTS_HTTP_TRANSPORT)) : null;
30
+ private _cloudeventsHeaders = process.env.CLOUDEVENTS_HTTP_HEADERS ? JSON.parse(process.env.CLOUDEVENTS_HTTP_HEADERS) : {};
31
+
16
32
  protected MapFieldNamesToCodeNames(entityName: string, dataObject: any) {
17
33
  // for the given entity name provided, check to see if there are any fields
18
34
  // where the code name is different from the field name, and for just those
@@ -28,7 +44,7 @@ export class ResolverBase {
28
44
  entityInfo.Fields.forEach((f) => {
29
45
  if (dataObject.hasOwnProperty(f.Name)) {
30
46
  // GraphQL doesn't allow us to pass back fields with __ so we are mapping our special field cases that start with __mj_ to _mj__ for transport - they are converted back on the other side automatically
31
- const mappedFieldName = mapper.MapFieldName(f.CodeName)
47
+ const mappedFieldName = mapper.MapFieldName(f.CodeName);
32
48
  if (mappedFieldName !== f.Name) {
33
49
  dataObject[mappedFieldName] = dataObject[f.Name];
34
50
  delete dataObject[f.Name];
@@ -134,7 +150,7 @@ export class ResolverBase {
134
150
  if (!entity) throw new Error(`Entity ${viewInput.EntityName} not found in metadata`);
135
151
 
136
152
  const viewInfo: UserViewEntity = {
137
- ID: "",
153
+ ID: '',
138
154
  Entity: viewInput.EntityName,
139
155
  EntityID: entity.ID,
140
156
  EntityBaseView: entity.BaseView as string,
@@ -164,22 +180,23 @@ export class ResolverBase {
164
180
  }
165
181
  }
166
182
 
167
- async RunViewsGeneric(viewInputs: (RunViewByNameInput & RunViewByIDInput & RunDynamicViewInput)[], dataSource: DataSource, userPayload: UserPayload, pubSub: PubSubEngine) {
183
+ async RunViewsGeneric(
184
+ viewInputs: (RunViewByNameInput & RunViewByIDInput & RunDynamicViewInput)[],
185
+ dataSource: DataSource,
186
+ userPayload: UserPayload,
187
+ pubSub: PubSubEngine
188
+ ) {
168
189
  let md: Metadata | null = null;
169
190
  let params: RunViewGenericParams[] = [];
170
- for(const viewInput of viewInputs) {
191
+ for (const viewInput of viewInputs) {
171
192
  try {
172
193
  let viewInfo: UserViewEntity | null = null;
173
194
 
174
- if(viewInput.ViewName) {
175
- viewInfo = this.safeFirstArrayElement(
176
- await this.findBy(dataSource, 'User Views', { Name: viewInput.ViewName })
177
- );
178
- }
179
- else if(viewInput.ViewID) {
195
+ if (viewInput.ViewName) {
196
+ viewInfo = this.safeFirstArrayElement(await this.findBy(dataSource, 'User Views', { Name: viewInput.ViewName }));
197
+ } else if (viewInput.ViewID) {
180
198
  viewInfo = this.safeFirstArrayElement(await this.findBy(dataSource, 'User Views', { ID: viewInput.ViewID }));
181
- }
182
- else if(viewInput.EntityName) {
199
+ } else if (viewInput.EntityName) {
183
200
  md = md || new Metadata();
184
201
  const entity = md.Entities.find((e) => e.Name === viewInput.EntityName);
185
202
  if (!entity) {
@@ -188,14 +205,13 @@ export class ResolverBase {
188
205
 
189
206
  // only providing a few bits of data here, but it's enough to get the view to run
190
207
  viewInfo = {
191
- ID: "",
208
+ ID: '',
192
209
  Entity: viewInput.EntityName,
193
210
  EntityID: entity.ID,
194
211
  EntityBaseView: entity.BaseView,
195
212
  } as UserViewEntity;
196
- }
197
- else{
198
- throw new Error("Unable to determine input type");
213
+ } else {
214
+ throw new Error('Unable to determine input type');
199
215
  }
200
216
 
201
217
  params.push({
@@ -214,9 +230,8 @@ export class ResolverBase {
214
230
  auditLogDescription: viewInput.AuditLogDescription,
215
231
  resultType: viewInput.ResultType,
216
232
  userPayload,
217
- pubSub
233
+ pubSub,
218
234
  });
219
-
220
235
  } catch (err) {
221
236
  LogError(err);
222
237
  return null;
@@ -227,7 +242,26 @@ export class ResolverBase {
227
242
  return results;
228
243
  }
229
244
 
245
+ protected async EmitCloudEvent({ component, event, eventCode, args }: MJEvent) {
246
+ if (this._emit && event === MJEventType.ComponentEvent && eventCode === BaseEntity.BaseEventCode) {
247
+ const extendedType = args instanceof BaseEntityEvent ? `.${args.type}` : '';
248
+ const type = `MemberJunction.${event}${extendedType}`;
249
+ const source = `${process.env.CLOUDEVENTS_SOURCE ?? 'MemberJunction'}`;
250
+ const [subject, rawData] = args instanceof BaseEntityEvent ? [args.baseEntity.EntityInfo.CodeName, args.payload] : [undefined, args];
251
+ const data = (typeof rawData === 'object' ? rawData : { payload: rawData }) ?? {};
252
+ const cloudEvent = new CloudEvent({ source, subject, type, data });
230
253
 
254
+ try {
255
+ const cloudeventTransportResponse = await this._emit(cloudEvent, { headers: this._cloudeventsHeaders });
256
+ const cloudeventResponse = JSON.stringify(cloudeventTransportResponse);
257
+ if (/error/i.test(cloudeventResponse)) {
258
+ console.error('CloudEvent ERROR', cloudeventResponse);
259
+ }
260
+ } catch (e) {
261
+ console.error('CloudEvent ERROR', JSON.stringify(e));
262
+ }
263
+ }
264
+ }
231
265
 
232
266
  protected CheckUserReadPermissions(entityName: string, userPayload: UserPayload | null) {
233
267
  const md = new Metadata();
@@ -326,10 +360,12 @@ export class ResolverBase {
326
360
  const rv = new RunView();
327
361
  let RunViewParams: RunViewParams[] = [];
328
362
  let contextUser: UserInfo | null = null;
329
- for(const param of params){
363
+ for (const param of params) {
330
364
  if (param.viewInfo && param.userPayload) {
331
365
  md = md || new Metadata();
332
- const user: UserInfo = UserCache.Users.find((u) => u.Email.toLowerCase().trim() === param.userPayload?.email.toLowerCase().trim());
366
+ const user: UserInfo = UserCache.Users.find(
367
+ (u) => u.Email.toLowerCase().trim() === param.userPayload?.email.toLowerCase().trim()
368
+ );
333
369
  if (!user) {
334
370
  throw new Error(`User ${param.userPayload?.email} not found in metadata`);
335
371
  }
@@ -337,7 +373,7 @@ export class ResolverBase {
337
373
  contextUser = contextUser || user;
338
374
 
339
375
  const entityInfo = md.Entities.find((e) => e.Name === param.viewInfo.Entity);
340
- if (!entityInfo){
376
+ if (!entityInfo) {
341
377
  throw new Error(`Entity ${param.viewInfo.Entity} not found in metadata`);
342
378
  }
343
379
  }
@@ -381,7 +417,7 @@ export class ResolverBase {
381
417
 
382
418
  // go through the result and convert all fields that start with __mj_*** to _mj__*** for GraphQL transport
383
419
  const mapper = new FieldMapper();
384
- for(const runViewResult of runViewResults){
420
+ for (const runViewResult of runViewResults) {
385
421
  if (runViewResult && runViewResult.Success) {
386
422
  for (const result of runViewResult.Results) {
387
423
  mapper.MapFields(result);
@@ -390,8 +426,7 @@ export class ResolverBase {
390
426
  }
391
427
 
392
428
  return runViewResults;
393
- }
394
- catch (err) {
429
+ } catch (err) {
395
430
  console.log(err);
396
431
  throw err;
397
432
  }
@@ -453,8 +488,9 @@ export class ResolverBase {
453
488
  auditLog.UserID = userInfo.ID;
454
489
  auditLog.AuditLogTypeID = auditLogType.ID;
455
490
 
456
- if (authorization)
491
+ if (authorization) {
457
492
  auditLog.AuthorizationID = authorization.ID;
493
+ }
458
494
 
459
495
  if (status?.trim().toLowerCase() === 'success') auditLog.Status = 'Success';
460
496
  else auditLog.Status = 'Failed';
@@ -501,8 +537,10 @@ export class ResolverBase {
501
537
 
502
538
  protected ListenForEntityMessages(entityObject: BaseEntity, pubSub: PubSubEngine, userPayload: UserPayload) {
503
539
  // listen for events from the entityObject in case it is a long running task and we can push messages back to the client via pubSub
504
- MJGlobal.Instance.GetEventListener(false).subscribe((event) => {
540
+ MJGlobal.Instance.GetEventListener(false).subscribe(async (event) => {
505
541
  if (event) {
542
+ await this.EmitCloudEvent(event);
543
+
506
544
  if (event.component === entityObject && event.args && event.args.message) {
507
545
  // message from our entity object, relay it to the client
508
546
  pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
@@ -554,8 +592,9 @@ export class ResolverBase {
554
592
  const entityInfo = entityObject.EntityInfo;
555
593
  const clientNewValues = {};
556
594
  Object.keys(input).forEach((key) => {
557
- if (key !== 'OldValues___')
595
+ if (key !== 'OldValues___') {
558
596
  clientNewValues[key] = input[key];
597
+ }
559
598
  }); // grab all the props except for the OldValues property
560
599
 
561
600
  if (entityInfo.TrackRecordChanges || !input.OldValues___) {
@@ -574,8 +613,7 @@ export class ResolverBase {
574
613
  if (input.OldValues___) {
575
614
  // we DO have OldValues, so we need to do a more in depth analysis
576
615
  this.TestAndSetClientOldValuesToDBValues(input, clientNewValues, entityObject);
577
- }
578
- else {
616
+ } else {
579
617
  // no OldValues, so we can just set the new values from input
580
618
  entityObject.SetMany(input);
581
619
  }
@@ -585,8 +623,7 @@ export class ResolverBase {
585
623
  extensions: { code: 'LOAD_ENTITY_ERROR', entityName },
586
624
  });
587
625
  }
588
- }
589
- else {
626
+ } else {
590
627
  // we get here if we are NOT tracking changes and we DO have OldValues, so we can load from them
591
628
  const oldValues = {};
592
629
  // for each item in the oldValues array, add it to the oldValues object
@@ -18,6 +18,7 @@ import {
18
18
  SkipAPIRunScriptRequest,
19
19
  SkipAPIRequestAPIKey,
20
20
  SkipRequestPhase,
21
+ SkipAPIAgentNote,
21
22
  } from '@memberjunction/skip-types';
22
23
 
23
24
  import { PUSH_STATUS_UPDATES_TOPIC } from '../generic/PushStatusResolver.js';
@@ -75,7 +76,7 @@ export class AskSkipResultType {
75
76
  @Resolver(AskSkipResultType)
76
77
  export class AskSkipResolver {
77
78
  private static _defaultNewChatName = 'New Chat';
78
- private static _maxHistoricalMessages = 20;
79
+ private static _maxHistoricalMessages = 30;
79
80
 
80
81
  /**
81
82
  * Handles a simple chat request from a user to Skip, using a particular data record
@@ -146,7 +147,7 @@ export class AskSkipResolver {
146
147
  }
147
148
  }
148
149
 
149
- const input = this.buildSkipAPIRequest(messages, ConversationId, dataContext, 'chat_with_a_record', false, false);
150
+ const input = await this.buildSkipAPIRequest(messages, ConversationId, dataContext, 'chat_with_a_record', false, false, false, user);
150
151
  messages.push({
151
152
  content: UserQuestion,
152
153
  role: 'user',
@@ -219,16 +220,19 @@ export class AskSkipResolver {
219
220
  }
220
221
  }
221
222
 
222
- protected buildSkipAPIRequest(
223
+ protected async buildSkipAPIRequest(
223
224
  messages: SkipMessage[],
224
225
  conversationId: string,
225
226
  dataContext: DataContext,
226
227
  requestPhase: SkipRequestPhase,
227
228
  includeEntities: boolean,
228
- includeQueries: boolean
229
- ): SkipAPIRequest {
229
+ includeQueries: boolean,
230
+ includeNotes: boolean,
231
+ contextUser: UserInfo
232
+ ): Promise<SkipAPIRequest> {
230
233
  const entities = includeEntities ? this.BuildSkipEntities() : [];
231
234
  const queries = includeQueries ? this.BuildSkipQueries() : [];
235
+ const notes = includeNotes ? await this.BuildSkipAgentNotes(contextUser) : [];
232
236
  const input: SkipAPIRequest = {
233
237
  apiKeys: this.buildSkipAPIKeys(),
234
238
  organizationInfo: configInfo?.askSkip?.organizationInfo,
@@ -239,6 +243,7 @@ export class AskSkipResolver {
239
243
  requestPhase: requestPhase,
240
244
  entities: entities,
241
245
  queries: queries,
246
+ notes: notes
242
247
  };
243
248
  return input;
244
249
  }
@@ -260,7 +265,7 @@ export class AskSkipResolver {
260
265
  if (!user) throw new Error(`User ${userPayload.email} not found in UserCache`);
261
266
  const dataContext: DataContext = new DataContext();
262
267
  await dataContext.Load(DataContextId, dataSource, true, false, 0, user);
263
- const input = this.buildSkipAPIRequest([], '', dataContext, 'run_existing_script', false, false);
268
+ const input = await this.buildSkipAPIRequest([], '', dataContext, 'run_existing_script', false, false, false, user);
264
269
  return this.handleSimpleSkipPostRequest(input);
265
270
  }
266
271
 
@@ -318,7 +323,8 @@ export class AskSkipResolver {
318
323
  AskSkipResolver._maxHistoricalMessages
319
324
  );
320
325
 
321
- const input = this.buildSkipAPIRequest(messages, ConversationId, dataContext, 'initial_request', true, true);
326
+ const conversationDetailCount = 1
327
+ const input = await this.buildSkipAPIRequest(messages, ConversationId, dataContext, 'initial_request', true, true, true, user);
322
328
 
323
329
  return this.HandleSkipRequest(
324
330
  input,
@@ -332,7 +338,8 @@ export class AskSkipResolver {
332
338
  convoEntity,
333
339
  convoDetailEntity,
334
340
  dataContext,
335
- dataContextEntity
341
+ dataContextEntity,
342
+ conversationDetailCount,
336
343
  );
337
344
  }
338
345
 
@@ -376,6 +383,44 @@ export class AskSkipResolver {
376
383
  });
377
384
  }
378
385
 
386
+ /**
387
+ * Builds up the array of notes that are applicable for Skip to receive from MJAPI
388
+ */
389
+ protected async BuildSkipAgentNotes(contextUser: UserInfo): Promise<SkipAPIAgentNote[]> {
390
+ try {
391
+ const md = new Metadata();
392
+ if (md.EntityByName('AI Agent Notes')) {
393
+ const rv = new RunView();
394
+ const result = await rv.RunView({
395
+ EntityName: "AI Agent Notes",
396
+ ExtraFilter: "Agent='Skip'"
397
+ }, contextUser)
398
+ if (result && result.Success) {
399
+ return result.Results.map((r) => {
400
+ return {
401
+ id: r.ID,
402
+ typeId: r.TypeID,
403
+ type: r.Type,
404
+ note: r.Note,
405
+ createdAt: r.__mj_CreatedAt,
406
+ updatedAt: r.__mj_UpdatedAt,
407
+ }
408
+ });
409
+ }
410
+ else
411
+ return [];
412
+ }
413
+ else {
414
+ console.warn(`No AI Agent Notes entity found in the metadata, so no notes will be sent to Skip`);
415
+ return []; // no agent notes configured in this MJ system, so not an error, just return empty array
416
+ }
417
+ }
418
+ catch (e) {
419
+ LogError(e);
420
+ return []; // non- fatal error just return an empty array
421
+ }
422
+ }
423
+
379
424
  protected BuildSkipEntities(): SkipEntityInfo[] {
380
425
  // build the entity info for skip in its format which is
381
426
  // narrower in scope than our native MJ metadata
@@ -677,10 +722,34 @@ export class AskSkipResolver {
677
722
  convoEntity: ConversationEntity,
678
723
  convoDetailEntity: ConversationDetailEntity,
679
724
  dataContext: DataContext,
680
- dataContextEntity: DataContextEntity
725
+ dataContextEntity: DataContextEntity,
726
+ conversationDetailCount: number
681
727
  ): Promise<AskSkipResultType> {
682
728
  LogStatus(` >>> HandleSkipRequest: Sending request to Skip API: ${___skipAPIurl}`);
683
729
 
730
+ if (conversationDetailCount > 10) {
731
+ // At this point it is likely that we are stuck in a loop, so we stop here
732
+ pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
733
+ message: JSON.stringify({
734
+ type: 'AskSkip',
735
+ status: 'Error',
736
+ conversationID: ConversationId,
737
+ message: 'Analysis failed to run, please try again later and if this continues, contact your support desk.',
738
+ }),
739
+ sessionId: userPayload.sessionId,
740
+ });
741
+
742
+ return {
743
+ Success: false,
744
+ Status: 'Error',
745
+ Result: `Exceeded maximum attempts to answer the question ${UserQuestion}`,
746
+ ResponsePhase: SkipResponsePhase.AnalysisComplete,
747
+ ConversationId: ConversationId,
748
+ UserMessageConversationDetailId: '',
749
+ AIMessageConversationDetailId: '',
750
+ };
751
+ }
752
+
684
753
  const response = await sendPostRequest(
685
754
  ___skipAPIurl,
686
755
  input,
@@ -736,7 +805,8 @@ export class AskSkipResolver {
736
805
  convoEntity,
737
806
  convoDetailEntity,
738
807
  dataContext,
739
- dataContextEntity
808
+ dataContextEntity,
809
+ conversationDetailCount
740
810
  );
741
811
  } else if (apiResponse.responsePhase === 'clarifying_question') {
742
812
  // need to send the request back to the user for a clarifying question
@@ -925,7 +995,8 @@ export class AskSkipResolver {
925
995
  convoEntity: ConversationEntity,
926
996
  convoDetailEntity: ConversationDetailEntity,
927
997
  dataContext: DataContext,
928
- dataContextEntity: DataContextEntity
998
+ dataContextEntity: DataContextEntity,
999
+ conversationDetailCount: number
929
1000
  ): Promise<AskSkipResultType> {
930
1001
  // our job in this method is to go through each of the data requests from the Skip API, get the data, and then go back to the Skip API again and to the next phase
931
1002
  try {
@@ -1039,6 +1110,7 @@ export class AskSkipResolver {
1039
1110
  apiRequest.dataContext = <DataContext>CopyScalarsAndArrays(dataContext); // we are casting this to DataContext as we're pushing this to the Skip API, and we don't want to send the real DataContext object, just a copy of the scalar and array properties
1040
1111
  apiRequest.requestPhase = 'data_gathering_response';
1041
1112
  }
1113
+ conversationDetailCount++;
1042
1114
  // we have all of the data now, add it to the data context and then submit it back to the Skip API
1043
1115
  return this.HandleSkipRequest(
1044
1116
  apiRequest,
@@ -1052,7 +1124,8 @@ export class AskSkipResolver {
1052
1124
  convoEntity,
1053
1125
  convoDetailEntity,
1054
1126
  dataContext,
1055
- dataContextEntity
1127
+ dataContextEntity,
1128
+ conversationDetailCount
1056
1129
  );
1057
1130
  } catch (e) {
1058
1131
  LogError(e);
@@ -0,0 +1,19 @@
1
+ import { Field, ObjectType, Query, Resolver } from 'type-graphql';
2
+ import { Public } from '../directives/index.js';
3
+ import packageJson from '../../package.json' assert { type: 'json' };
4
+
5
+ @ObjectType()
6
+ export class Info {
7
+ @Field(() => String)
8
+ @Public()
9
+ Version: string;
10
+ }
11
+
12
+ @Resolver(Info)
13
+ export class InfoResolver {
14
+ @Query(() => Info)
15
+ @Public()
16
+ async Info() {
17
+ return { Version: packageJson.version };
18
+ }
19
+ }