@memberjunction/server 3.4.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. package/dist/agents/skip-agent.d.ts +65 -0
  2. package/dist/agents/skip-agent.d.ts.map +1 -1
  3. package/dist/agents/skip-agent.js +63 -5
  4. package/dist/agents/skip-agent.js.map +1 -1
  5. package/dist/agents/skip-sdk.d.ts +163 -0
  6. package/dist/agents/skip-sdk.d.ts.map +1 -1
  7. package/dist/agents/skip-sdk.js +143 -12
  8. package/dist/agents/skip-sdk.js.map +1 -1
  9. package/dist/apolloServer/TransactionPlugin.d.ts +4 -0
  10. package/dist/apolloServer/TransactionPlugin.d.ts.map +1 -0
  11. package/dist/apolloServer/TransactionPlugin.js +46 -0
  12. package/dist/apolloServer/TransactionPlugin.js.map +1 -0
  13. package/dist/apolloServer/index.d.ts +0 -1
  14. package/dist/apolloServer/index.d.ts.map +1 -1
  15. package/dist/auth/APIKeyScopeAuth.d.ts +82 -0
  16. package/dist/auth/APIKeyScopeAuth.d.ts.map +1 -1
  17. package/dist/auth/APIKeyScopeAuth.js +78 -0
  18. package/dist/auth/APIKeyScopeAuth.js.map +1 -1
  19. package/dist/auth/AuthProviderFactory.d.ts +35 -0
  20. package/dist/auth/AuthProviderFactory.d.ts.map +1 -1
  21. package/dist/auth/AuthProviderFactory.js +51 -4
  22. package/dist/auth/AuthProviderFactory.js.map +1 -1
  23. package/dist/auth/BaseAuthProvider.d.ts +21 -0
  24. package/dist/auth/BaseAuthProvider.d.ts.map +1 -1
  25. package/dist/auth/BaseAuthProvider.js +24 -9
  26. package/dist/auth/BaseAuthProvider.js.map +1 -1
  27. package/dist/auth/IAuthProvider.d.ts +32 -0
  28. package/dist/auth/IAuthProvider.d.ts.map +1 -1
  29. package/dist/auth/__tests__/backward-compatibility.test.d.ts +2 -0
  30. package/dist/auth/__tests__/backward-compatibility.test.d.ts.map +1 -0
  31. package/dist/auth/__tests__/backward-compatibility.test.js +135 -0
  32. package/dist/auth/__tests__/backward-compatibility.test.js.map +1 -0
  33. package/dist/auth/exampleNewUserSubClass.d.ts +5 -1
  34. package/dist/auth/exampleNewUserSubClass.d.ts.map +1 -1
  35. package/dist/auth/exampleNewUserSubClass.js +21 -6
  36. package/dist/auth/exampleNewUserSubClass.js.map +1 -1
  37. package/dist/auth/index.d.ts +14 -0
  38. package/dist/auth/index.d.ts.map +1 -1
  39. package/dist/auth/index.js +35 -22
  40. package/dist/auth/index.js.map +1 -1
  41. package/dist/auth/initializeProviders.d.ts +3 -0
  42. package/dist/auth/initializeProviders.d.ts.map +1 -1
  43. package/dist/auth/initializeProviders.js +6 -0
  44. package/dist/auth/initializeProviders.js.map +1 -1
  45. package/dist/auth/newUsers.js +11 -2
  46. package/dist/auth/newUsers.js.map +1 -1
  47. package/dist/auth/providers/Auth0Provider.d.ts +9 -0
  48. package/dist/auth/providers/Auth0Provider.d.ts.map +1 -1
  49. package/dist/auth/providers/Auth0Provider.js +10 -0
  50. package/dist/auth/providers/Auth0Provider.js.map +1 -1
  51. package/dist/auth/providers/CognitoProvider.d.ts +9 -0
  52. package/dist/auth/providers/CognitoProvider.d.ts.map +1 -1
  53. package/dist/auth/providers/CognitoProvider.js +10 -0
  54. package/dist/auth/providers/CognitoProvider.js.map +1 -1
  55. package/dist/auth/providers/GoogleProvider.d.ts +9 -0
  56. package/dist/auth/providers/GoogleProvider.d.ts.map +1 -1
  57. package/dist/auth/providers/GoogleProvider.js +11 -1
  58. package/dist/auth/providers/GoogleProvider.js.map +1 -1
  59. package/dist/auth/providers/MSALProvider.d.ts +9 -0
  60. package/dist/auth/providers/MSALProvider.d.ts.map +1 -1
  61. package/dist/auth/providers/MSALProvider.js +10 -0
  62. package/dist/auth/providers/MSALProvider.js.map +1 -1
  63. package/dist/auth/providers/OktaProvider.d.ts +9 -0
  64. package/dist/auth/providers/OktaProvider.d.ts.map +1 -1
  65. package/dist/auth/providers/OktaProvider.js +10 -0
  66. package/dist/auth/providers/OktaProvider.js.map +1 -1
  67. package/dist/config.d.ts +12 -0
  68. package/dist/config.d.ts.map +1 -1
  69. package/dist/config.js +42 -8
  70. package/dist/config.js.map +1 -1
  71. package/dist/context.d.ts +8 -1
  72. package/dist/context.d.ts.map +1 -1
  73. package/dist/context.js +26 -4
  74. package/dist/context.js.map +1 -1
  75. package/dist/directives/Public.js +2 -0
  76. package/dist/directives/Public.js.map +1 -1
  77. package/dist/entitySubclasses/entityPermissions.server.d.ts +7 -2
  78. package/dist/entitySubclasses/entityPermissions.server.d.ts.map +1 -1
  79. package/dist/entitySubclasses/entityPermissions.server.js +26 -8
  80. package/dist/entitySubclasses/entityPermissions.server.js.map +1 -1
  81. package/dist/generated/generated.d.ts +529 -6
  82. package/dist/generated/generated.d.ts.map +1 -1
  83. package/dist/generated/generated.js +10054 -15076
  84. package/dist/generated/generated.js.map +1 -1
  85. package/dist/generic/DeleteOptionsInput.d.ts +3 -0
  86. package/dist/generic/DeleteOptionsInput.d.ts.map +1 -1
  87. package/dist/generic/DeleteOptionsInput.js +3 -2
  88. package/dist/generic/DeleteOptionsInput.js.map +1 -1
  89. package/dist/generic/KeyInputOutputTypes.js +0 -6
  90. package/dist/generic/KeyInputOutputTypes.js.map +1 -1
  91. package/dist/generic/KeyValuePairInput.d.ts +4 -0
  92. package/dist/generic/KeyValuePairInput.d.ts.map +1 -1
  93. package/dist/generic/KeyValuePairInput.js +4 -2
  94. package/dist/generic/KeyValuePairInput.js.map +1 -1
  95. package/dist/generic/PushStatusResolver.js +0 -3
  96. package/dist/generic/PushStatusResolver.js.map +1 -1
  97. package/dist/generic/ResolverBase.d.ts +58 -0
  98. package/dist/generic/ResolverBase.d.ts.map +1 -1
  99. package/dist/generic/ResolverBase.js +203 -18
  100. package/dist/generic/ResolverBase.js.map +1 -1
  101. package/dist/generic/RunViewResolver.d.ts +22 -0
  102. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  103. package/dist/generic/RunViewResolver.js +42 -108
  104. package/dist/generic/RunViewResolver.js.map +1 -1
  105. package/dist/index.d.ts.map +1 -1
  106. package/dist/index.js +82 -37
  107. package/dist/index.js.map +1 -1
  108. package/dist/orm.d.ts.map +1 -1
  109. package/dist/orm.js +2 -1
  110. package/dist/orm.js.map +1 -1
  111. package/dist/resolvers/APIKeyResolver.d.ts +74 -0
  112. package/dist/resolvers/APIKeyResolver.d.ts.map +1 -1
  113. package/dist/resolvers/APIKeyResolver.js +49 -10
  114. package/dist/resolvers/APIKeyResolver.js.map +1 -1
  115. package/dist/resolvers/ActionResolver.d.ts +189 -0
  116. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  117. package/dist/resolvers/ActionResolver.js +152 -21
  118. package/dist/resolvers/ActionResolver.js.map +1 -1
  119. package/dist/resolvers/AskSkipResolver.d.ts +123 -0
  120. package/dist/resolvers/AskSkipResolver.d.ts.map +1 -0
  121. package/dist/resolvers/AskSkipResolver.js +1788 -0
  122. package/dist/resolvers/AskSkipResolver.js.map +1 -0
  123. package/dist/resolvers/ColorResolver.js +0 -5
  124. package/dist/resolvers/ColorResolver.js.map +1 -1
  125. package/dist/resolvers/ComponentRegistryResolver.d.ts +65 -0
  126. package/dist/resolvers/ComponentRegistryResolver.d.ts.map +1 -1
  127. package/dist/resolvers/ComponentRegistryResolver.js +118 -40
  128. package/dist/resolvers/ComponentRegistryResolver.js.map +1 -1
  129. package/dist/resolvers/CreateQueryResolver.d.ts +47 -0
  130. package/dist/resolvers/CreateQueryResolver.d.ts.map +1 -1
  131. package/dist/resolvers/CreateQueryResolver.js +92 -116
  132. package/dist/resolvers/CreateQueryResolver.js.map +1 -1
  133. package/dist/resolvers/DatasetResolver.js +2 -14
  134. package/dist/resolvers/DatasetResolver.js.map +1 -1
  135. package/dist/resolvers/EntityCommunicationsResolver.d.ts +40 -0
  136. package/dist/resolvers/EntityCommunicationsResolver.d.ts.map +1 -1
  137. package/dist/resolvers/EntityCommunicationsResolver.js +2 -36
  138. package/dist/resolvers/EntityCommunicationsResolver.js.map +1 -1
  139. package/dist/resolvers/EntityRecordNameResolver.js +0 -7
  140. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  141. package/dist/resolvers/FileCategoryResolver.js +13 -1
  142. package/dist/resolvers/FileCategoryResolver.js.map +1 -1
  143. package/dist/resolvers/FileResolver.d.ts +16 -0
  144. package/dist/resolvers/FileResolver.d.ts.map +1 -1
  145. package/dist/resolvers/FileResolver.js +59 -74
  146. package/dist/resolvers/FileResolver.js.map +1 -1
  147. package/dist/resolvers/GetDataContextDataResolver.d.ts +18 -1
  148. package/dist/resolvers/GetDataContextDataResolver.d.ts.map +1 -1
  149. package/dist/resolvers/GetDataContextDataResolver.js +17 -9
  150. package/dist/resolvers/GetDataContextDataResolver.js.map +1 -1
  151. package/dist/resolvers/GetDataResolver.d.ts +19 -0
  152. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  153. package/dist/resolvers/GetDataResolver.js +35 -35
  154. package/dist/resolvers/GetDataResolver.js.map +1 -1
  155. package/dist/resolvers/InfoResolver.d.ts.map +1 -1
  156. package/dist/resolvers/InfoResolver.js +4 -7
  157. package/dist/resolvers/InfoResolver.js.map +1 -1
  158. package/dist/resolvers/MCPResolver.d.ts +325 -1
  159. package/dist/resolvers/MCPResolver.d.ts.map +1 -1
  160. package/dist/resolvers/MCPResolver.js +931 -24
  161. package/dist/resolvers/MCPResolver.js.map +1 -1
  162. package/dist/resolvers/MergeRecordsResolver.js +3 -29
  163. package/dist/resolvers/MergeRecordsResolver.js.map +1 -1
  164. package/dist/resolvers/PotentialDuplicateRecordResolver.d.ts.map +1 -1
  165. package/dist/resolvers/PotentialDuplicateRecordResolver.js +0 -3
  166. package/dist/resolvers/PotentialDuplicateRecordResolver.js.map +1 -1
  167. package/dist/resolvers/QueryResolver.d.ts +20 -0
  168. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  169. package/dist/resolvers/QueryResolver.js +44 -36
  170. package/dist/resolvers/QueryResolver.js.map +1 -1
  171. package/dist/resolvers/ReportResolver.d.ts +3 -0
  172. package/dist/resolvers/ReportResolver.d.ts.map +1 -1
  173. package/dist/resolvers/ReportResolver.js +9 -10
  174. package/dist/resolvers/ReportResolver.js.map +1 -1
  175. package/dist/resolvers/RunAIAgentResolver.d.ts +54 -0
  176. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  177. package/dist/resolvers/RunAIAgentResolver.js +116 -40
  178. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  179. package/dist/resolvers/RunAIPromptResolver.d.ts +42 -0
  180. package/dist/resolvers/RunAIPromptResolver.d.ts.map +1 -1
  181. package/dist/resolvers/RunAIPromptResolver.js +95 -22
  182. package/dist/resolvers/RunAIPromptResolver.js.map +1 -1
  183. package/dist/resolvers/RunTemplateResolver.js +9 -6
  184. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  185. package/dist/resolvers/RunTestResolver.d.ts +12 -0
  186. package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
  187. package/dist/resolvers/RunTestResolver.js +35 -21
  188. package/dist/resolvers/RunTestResolver.js.map +1 -1
  189. package/dist/resolvers/SqlLoggingConfigResolver.d.ts +312 -0
  190. package/dist/resolvers/SqlLoggingConfigResolver.d.ts.map +1 -1
  191. package/dist/resolvers/SqlLoggingConfigResolver.js +295 -45
  192. package/dist/resolvers/SqlLoggingConfigResolver.js.map +1 -1
  193. package/dist/resolvers/SyncDataResolver.d.ts +21 -0
  194. package/dist/resolvers/SyncDataResolver.d.ts.map +1 -1
  195. package/dist/resolvers/SyncDataResolver.js +36 -22
  196. package/dist/resolvers/SyncDataResolver.js.map +1 -1
  197. package/dist/resolvers/SyncRolesUsersResolver.d.ts +14 -0
  198. package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
  199. package/dist/resolvers/SyncRolesUsersResolver.js +54 -21
  200. package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
  201. package/dist/resolvers/TaskResolver.d.ts +13 -0
  202. package/dist/resolvers/TaskResolver.d.ts.map +1 -1
  203. package/dist/resolvers/TaskResolver.js +22 -7
  204. package/dist/resolvers/TaskResolver.js.map +1 -1
  205. package/dist/resolvers/TelemetryResolver.d.ts +22 -0
  206. package/dist/resolvers/TelemetryResolver.d.ts.map +1 -1
  207. package/dist/resolvers/TelemetryResolver.js +45 -79
  208. package/dist/resolvers/TelemetryResolver.js.map +1 -1
  209. package/dist/resolvers/TransactionGroupResolver.js +11 -13
  210. package/dist/resolvers/TransactionGroupResolver.js.map +1 -1
  211. package/dist/resolvers/UserFavoriteResolver.js +3 -12
  212. package/dist/resolvers/UserFavoriteResolver.js.map +1 -1
  213. package/dist/resolvers/UserResolver.js +10 -0
  214. package/dist/resolvers/UserResolver.js.map +1 -1
  215. package/dist/resolvers/UserViewResolver.js +4 -0
  216. package/dist/resolvers/UserViewResolver.js.map +1 -1
  217. package/dist/resolvers/VersionHistoryResolver.d.ts +39 -0
  218. package/dist/resolvers/VersionHistoryResolver.d.ts.map +1 -0
  219. package/dist/resolvers/VersionHistoryResolver.js +208 -0
  220. package/dist/resolvers/VersionHistoryResolver.js.map +1 -0
  221. package/dist/rest/EntityCRUDHandler.d.ts +19 -0
  222. package/dist/rest/EntityCRUDHandler.d.ts.map +1 -1
  223. package/dist/rest/EntityCRUDHandler.js +55 -0
  224. package/dist/rest/EntityCRUDHandler.js.map +1 -1
  225. package/dist/rest/OAuthCallbackHandler.d.ts +143 -0
  226. package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -0
  227. package/dist/rest/OAuthCallbackHandler.js +634 -0
  228. package/dist/rest/OAuthCallbackHandler.js.map +1 -0
  229. package/dist/rest/RESTEndpointHandler.d.ts +120 -0
  230. package/dist/rest/RESTEndpointHandler.d.ts.map +1 -1
  231. package/dist/rest/RESTEndpointHandler.js +213 -24
  232. package/dist/rest/RESTEndpointHandler.js.map +1 -1
  233. package/dist/rest/ViewOperationsHandler.d.ts +19 -0
  234. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  235. package/dist/rest/ViewOperationsHandler.js +39 -0
  236. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  237. package/dist/rest/index.d.ts +1 -0
  238. package/dist/rest/index.d.ts.map +1 -1
  239. package/dist/rest/index.js +1 -0
  240. package/dist/rest/index.js.map +1 -1
  241. package/dist/rest/setupRESTEndpoints.d.ts +35 -0
  242. package/dist/rest/setupRESTEndpoints.d.ts.map +1 -1
  243. package/dist/rest/setupRESTEndpoints.js +15 -1
  244. package/dist/rest/setupRESTEndpoints.js.map +1 -1
  245. package/dist/scheduler/LearningCycleScheduler.d.ts +4 -0
  246. package/dist/scheduler/LearningCycleScheduler.d.ts.map +1 -0
  247. package/dist/scheduler/LearningCycleScheduler.js +4 -0
  248. package/dist/scheduler/LearningCycleScheduler.js.map +1 -0
  249. package/dist/services/ScheduledJobsService.d.ts +31 -0
  250. package/dist/services/ScheduledJobsService.d.ts.map +1 -1
  251. package/dist/services/ScheduledJobsService.js +38 -4
  252. package/dist/services/ScheduledJobsService.js.map +1 -1
  253. package/dist/services/TaskOrchestrator.d.ts +73 -0
  254. package/dist/services/TaskOrchestrator.d.ts.map +1 -1
  255. package/dist/services/TaskOrchestrator.js +137 -15
  256. package/dist/services/TaskOrchestrator.js.map +1 -1
  257. package/dist/types.d.ts +14 -0
  258. package/dist/types.d.ts.map +1 -1
  259. package/dist/types.js +0 -13
  260. package/dist/types.js.map +1 -1
  261. package/dist/util.d.ts +37 -1
  262. package/dist/util.d.ts.map +1 -1
  263. package/dist/util.js +55 -8
  264. package/dist/util.js.map +1 -1
  265. package/package.json +79 -78
  266. package/src/auth/exampleNewUserSubClass.ts +1 -5
  267. package/src/entitySubclasses/entityPermissions.server.ts +1 -3
  268. package/src/generated/generated.ts +4682 -2681
  269. package/src/index.ts +61 -62
  270. package/src/resolvers/InfoResolver.ts +5 -1
  271. package/src/resolvers/MCPResolver.ts +910 -10
  272. package/src/resolvers/PotentialDuplicateRecordResolver.ts +0 -4
  273. package/src/resolvers/VersionHistoryResolver.ts +177 -0
  274. package/src/rest/OAuthCallbackHandler.ts +766 -0
  275. package/src/rest/RESTEndpointHandler.ts +58 -35
  276. package/src/rest/index.ts +2 -1
  277. package/src/rest/setupRESTEndpoints.ts +13 -12
@@ -9,10 +9,12 @@ import { EncryptionEngine } from '@memberjunction/encryption';
9
9
  import { PUSH_STATUS_UPDATES_TOPIC } from './PushStatusResolver.js';
10
10
  import { FieldMapper } from '@memberjunction/graphql-dataprovider';
11
11
  export class ResolverBase {
12
- static _emit = process.env.CLOUDEVENTS_HTTP_TRANSPORT ? emitterFor(httpTransport(process.env.CLOUDEVENTS_HTTP_TRANSPORT)) : null;
13
- static _cloudeventsHeaders = process.env.CLOUDEVENTS_HTTP_HEADERS ? JSON.parse(process.env.CLOUDEVENTS_HTTP_HEADERS) : {};
14
- static _eventSubscriptionKey = '___MJServer___ResolverBase___EventSubscriptions';
12
+ static { this._emit = process.env.CLOUDEVENTS_HTTP_TRANSPORT ? emitterFor(httpTransport(process.env.CLOUDEVENTS_HTTP_TRANSPORT)) : null; }
13
+ static { this._cloudeventsHeaders = process.env.CLOUDEVENTS_HTTP_HEADERS ? JSON.parse(process.env.CLOUDEVENTS_HTTP_HEADERS) : {}; }
14
+ static { this._eventSubscriptionKey = '___MJServer___ResolverBase___EventSubscriptions'; }
15
15
  get EventSubscriptions() {
16
+ // here we use the global object store instead of a static member becuase in some cases based on import code paths/bundling/etc, the static member
17
+ // could actually be duplicated and we'd end up with multiple instances of the same map, which would be bad.
16
18
  const g = MJGlobal.Instance.GetGlobalObjectStore();
17
19
  if (!g[ResolverBase._eventSubscriptionKey]) {
18
20
  LogDebug(`>>>>> MJServer.ResolverBase.EventSubscriptions: Creating new Map - this should only happen once per server instance <<<<<<`);
@@ -20,15 +22,35 @@ export class ResolverBase {
20
22
  }
21
23
  return g[ResolverBase._eventSubscriptionKey];
22
24
  }
25
+ /**
26
+ * Maps field names to their GraphQL-safe CodeNames and handles encryption for API responses.
27
+ *
28
+ * For encrypted fields coming from raw SQL queries (not entity objects):
29
+ * - AllowDecryptInAPI=true: Decrypt the value before sending to client
30
+ * - AllowDecryptInAPI=false + SendEncryptedValue=true: Keep encrypted ciphertext
31
+ * - AllowDecryptInAPI=false + SendEncryptedValue=false: Replace with sentinel
32
+ *
33
+ * @param entityName - The entity name
34
+ * @param dataObject - The data object with field values
35
+ * @param contextUser - Optional user context for decryption (required for encrypted fields)
36
+ * @returns The processed data object
37
+ */
23
38
  async MapFieldNamesToCodeNames(entityName, dataObject, contextUser) {
39
+ // for the given entity name provided, check to see if there are any fields
40
+ // where the code name is different from the field name, and for just those
41
+ // fields, iterate through the dataObject and REPLACE the property that has the field name
42
+ // with the CodeName, because we can't transfer those via GraphQL as they are not
43
+ // valid property names in GraphQL
24
44
  if (dataObject) {
25
45
  const md = new Metadata();
26
46
  const entityInfo = md.Entities.find((e) => e.Name === entityName);
27
47
  if (!entityInfo)
28
48
  throw new Error(`Entity ${entityName} not found in metadata`);
49
+ // const fields = entityInfo.Fields.filter((f) => f.Name !== f.CodeName || f.Name.startsWith('__mj_'));
29
50
  const mapper = new FieldMapper();
30
51
  entityInfo.Fields.forEach((f) => {
31
52
  if (dataObject.hasOwnProperty(f.Name)) {
53
+ // 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
32
54
  const mappedFieldName = mapper.MapFieldName(f.CodeName);
33
55
  if (mappedFieldName !== f.Name) {
34
56
  dataObject[mappedFieldName] = dataObject[f.Name];
@@ -36,18 +58,22 @@ export class ResolverBase {
36
58
  }
37
59
  }
38
60
  });
61
+ // Handle encrypted fields - data from raw SQL queries is still encrypted
39
62
  const encryptedFields = entityInfo.EncryptedFields;
40
63
  if (encryptedFields.length > 0) {
41
64
  for (const field of encryptedFields) {
42
65
  const fieldName = field.CodeName;
43
66
  const value = dataObject[fieldName];
67
+ // Skip null/undefined values
44
68
  if (value === null || value === undefined)
45
69
  continue;
70
+ // Check if value is encrypted (raw SQL returns encrypted values)
46
71
  const engine = EncryptionEngine.Instance;
47
72
  await engine.Config(false, contextUser);
48
73
  const keyMarker = field.EncryptionKeyID ? engine.GetKeyByID(field.EncryptionKeyID)?.Marker : undefined;
49
74
  if (typeof value === 'string' && IsValueEncrypted(value, keyMarker)) {
50
75
  if (field.AllowDecryptInAPI) {
76
+ // Decrypt the value for the client
51
77
  if (contextUser) {
52
78
  try {
53
79
  const decryptedValue = await engine.Decrypt(value, contextUser);
@@ -55,16 +81,20 @@ export class ResolverBase {
55
81
  }
56
82
  catch (err) {
57
83
  LogError(`Failed to decrypt field ${fieldName} for API response: ${err}`);
84
+ // On decryption failure, use sentinel for safety
58
85
  dataObject[fieldName] = ENCRYPTED_SENTINEL;
59
86
  }
60
87
  }
61
88
  else {
89
+ // No context user, can't decrypt - use sentinel
62
90
  dataObject[fieldName] = ENCRYPTED_SENTINEL;
63
91
  }
64
92
  }
65
93
  else if (!field.SendEncryptedValue) {
94
+ // AllowDecryptInAPI=false and SendEncryptedValue=false - use sentinel
66
95
  dataObject[fieldName] = ENCRYPTED_SENTINEL;
67
96
  }
97
+ // else: AllowDecryptInAPI=false and SendEncryptedValue=true - keep encrypted value as-is
68
98
  }
69
99
  }
70
100
  }
@@ -72,6 +102,7 @@ export class ResolverBase {
72
102
  return dataObject;
73
103
  }
74
104
  async ArrayMapFieldNamesToCodeNames(entityName, dataObjectArray, contextUser) {
105
+ // iterate through the array and call MapFieldNamesToCodeNames for each element
75
106
  if (dataObjectArray && dataObjectArray.length > 0) {
76
107
  for (const element of dataObjectArray) {
77
108
  await this.MapFieldNamesToCodeNames(entityName, element, contextUser);
@@ -79,6 +110,20 @@ export class ResolverBase {
79
110
  }
80
111
  return dataObjectArray;
81
112
  }
113
+ /**
114
+ * Filters encrypted field values before sending to the API client.
115
+ *
116
+ * For each encrypted field in the entity:
117
+ * - If AllowDecryptInAPI is true: value passes through unchanged (already decrypted by data provider)
118
+ * - If AllowDecryptInAPI is false and SendEncryptedValue is true: re-encrypt and send ciphertext
119
+ * - If AllowDecryptInAPI is false and SendEncryptedValue is false: replace with sentinel value
120
+ *
121
+ * @param entityName - Name of the entity
122
+ * @param dataObject - The data object containing field values
123
+ * @param encryptionEngine - Optional encryption engine for re-encryption (lazy loaded if needed)
124
+ * @param contextUser - User context for encryption operations
125
+ * @returns The filtered data object
126
+ */
82
127
  async FilterEncryptedFieldsForAPI(entityName, dataObject, contextUser) {
83
128
  if (!dataObject)
84
129
  return dataObject;
@@ -86,40 +131,54 @@ export class ResolverBase {
86
131
  const entityInfo = md.Entities.find((e) => e.Name === entityName);
87
132
  if (!entityInfo)
88
133
  return dataObject;
134
+ // Find all encrypted fields that need filtering
89
135
  const encryptedFields = entityInfo.EncryptedFields;
90
136
  if (encryptedFields.length === 0)
91
137
  return dataObject;
138
+ // Process each encrypted field
92
139
  for (const field of encryptedFields) {
93
140
  const fieldName = field.CodeName;
94
141
  const value = dataObject[fieldName];
142
+ // Skip null/undefined values
95
143
  if (value === null || value === undefined)
96
144
  continue;
145
+ // If AllowDecryptInAPI is true, the decrypted value passes through
97
146
  if (field.AllowDecryptInAPI)
98
147
  continue;
148
+ // AllowDecryptInAPI is false - we need to filter the value
99
149
  const engine = EncryptionEngine.Instance;
100
150
  await engine.Config(false, contextUser);
101
151
  const keyMarker = field.EncryptionKeyID ? engine.GetKeyByID(field.EncryptionKeyID)?.Marker : undefined;
102
152
  if (field.SendEncryptedValue) {
153
+ // Re-encrypt the value before sending
154
+ // Only re-encrypt if it's not already encrypted (data provider decrypted it)
103
155
  if (typeof value === 'string' && !IsValueEncrypted(value, keyMarker)) {
104
156
  try {
105
157
  const encryptedValue = await engine.Encrypt(value, field.EncryptionKeyID, contextUser);
106
158
  dataObject[fieldName] = encryptedValue;
107
159
  }
108
160
  catch (err) {
161
+ // If re-encryption fails, use sentinel for safety
109
162
  LogError(`Failed to re-encrypt field ${fieldName} for API response: ${err}`);
110
163
  dataObject[fieldName] = ENCRYPTED_SENTINEL;
111
164
  }
112
165
  }
166
+ // If already encrypted (shouldn't happen normally), keep as-is
113
167
  }
114
168
  else {
169
+ // SendEncryptedValue is false - replace with sentinel
115
170
  dataObject[fieldName] = ENCRYPTED_SENTINEL;
116
171
  }
117
172
  }
118
173
  return dataObject;
119
174
  }
175
+ /**
176
+ * Filters encrypted fields for an array of data objects
177
+ */
120
178
  async ArrayFilterEncryptedFieldsForAPI(entityName, dataObjectArray, contextUser) {
121
179
  if (!dataObjectArray || dataObjectArray.length === 0)
122
180
  return dataObjectArray;
181
+ // Check if entity has any encrypted fields first to avoid unnecessary processing
123
182
  const md = new Metadata();
124
183
  const entityInfo = md.Entities.find((e) => e.Name === entityName);
125
184
  if (!entityInfo)
@@ -127,27 +186,33 @@ export class ResolverBase {
127
186
  const encryptedFields = entityInfo.Fields.filter(f => f.Encrypt && !f.AllowDecryptInAPI);
128
187
  if (encryptedFields.length === 0)
129
188
  return dataObjectArray;
189
+ // Process each element
130
190
  for (const element of dataObjectArray) {
131
191
  await this.FilterEncryptedFieldsForAPI(entityName, element, contextUser);
132
192
  }
133
193
  return dataObjectArray;
134
194
  }
135
195
  async findBy(provider, entity, params, contextUser) {
196
+ // build the SQL query based on the params passed in
136
197
  const rv = provider;
137
198
  const e = provider.Entities.find((e) => e.Name === entity);
138
199
  if (!e)
139
200
  throw new Error(`Entity ${entity} not found in metadata`);
201
+ // now build a SQL string using the entityInfo and using the properties in the params object
140
202
  let extraFilter = "";
141
203
  const keys = Object.keys(params);
142
204
  keys.forEach((k, i) => {
143
205
  if (i > 0)
144
206
  extraFilter += ' AND ';
207
+ // look up the field in the entityInfo to see if it needs quotes
145
208
  const field = e.Fields.find((f) => f.Name === k);
146
209
  if (!field)
147
210
  throw new Error(`Field ${k} not found in entity ${entity}`);
148
211
  const quotes = field.NeedsQuotes ? "'" : '';
149
212
  extraFilter += `${k} = ${quotes}${params[k]}${quotes}`;
150
213
  });
214
+ // ok, now we have a SQL string, run it and return the results
215
+ // use the SQLServerDataProvider
151
216
  const result = await rv.RunView({
152
217
  EntityName: entity,
153
218
  ExtraFilter: extraFilter,
@@ -161,6 +226,7 @@ export class ResolverBase {
161
226
  }
162
227
  async RunViewByNameGeneric(viewInput, provider, userPayload, pubSub) {
163
228
  try {
229
+ // Log aggregate input for debugging
164
230
  if (viewInput.Aggregates?.length) {
165
231
  LogStatus(`[ResolverBase] RunViewByNameGeneric received aggregates: viewName=${viewInput.ViewName}, aggregateCount=${viewInput.Aggregates.length}, aggregates=${JSON.stringify(viewInput.Aggregates.map(a => ({ expression: a.expression, alias: a.alias })))}`);
166
232
  }
@@ -185,6 +251,7 @@ export class ResolverBase {
185
251
  }
186
252
  async RunViewByIDGeneric(viewInput, provider, userPayload, pubSub) {
187
253
  try {
254
+ // Log aggregate input for debugging
188
255
  if (viewInput.Aggregates?.length) {
189
256
  LogStatus(`[ResolverBase] RunViewByIDGeneric received aggregates: viewID=${viewInput.ViewID}, aggregateCount=${viewInput.Aggregates.length}, aggregates=${JSON.stringify(viewInput.Aggregates.map(a => ({ expression: a.expression, alias: a.alias })))}`);
190
257
  }
@@ -200,6 +267,7 @@ export class ResolverBase {
200
267
  }
201
268
  async RunDynamicViewGeneric(viewInput, provider, userPayload, pubSub) {
202
269
  try {
270
+ // Log aggregate input for debugging
203
271
  if (viewInput.Aggregates?.length) {
204
272
  LogStatus(`[ResolverBase] RunDynamicViewGeneric received aggregates: entityName=${viewInput.EntityName}, aggregateCount=${viewInput.Aggregates.length}, aggregates=${JSON.stringify(viewInput.Aggregates.map(a => ({ expression: a.expression, alias: a.alias })))}`);
205
273
  }
@@ -212,7 +280,7 @@ export class ResolverBase {
212
280
  Entity: viewInput.EntityName,
213
281
  EntityID: entity.ID,
214
282
  EntityBaseView: entity.BaseView,
215
- };
283
+ }; // only providing a few bits of data here, but it's enough to get the view to run
216
284
  return this.RunViewGenericInternal(provider, viewInfo, viewInput.ExtraFilter, viewInput.OrderBy, viewInput.UserSearchString, viewInput.ExcludeUserViewRunID, viewInput.OverrideExcludeFilter, false, viewInput.Fields, viewInput.IgnoreMaxRows, false, viewInput.ForceAuditLog, viewInput.AuditLogDescription, viewInput.ResultType, userPayload, viewInput.MaxRows, viewInput.StartRow, viewInput.Aggregates);
217
285
  }
218
286
  catch (err) {
@@ -239,6 +307,7 @@ export class ResolverBase {
239
307
  if (!entity) {
240
308
  throw new Error(`Entity ${viewInput.EntityName} not found in metadata`);
241
309
  }
310
+ // only providing a few bits of data here, but it's enough to get the view to run
242
311
  viewInfo = {
243
312
  ID: '',
244
313
  Entity: viewInput.EntityName,
@@ -278,7 +347,7 @@ export class ResolverBase {
278
347
  let results = await this.RunViewsGenericInternal(params);
279
348
  return results;
280
349
  }
281
- static _priorEmittedData = [];
350
+ static { this._priorEmittedData = []; }
282
351
  async EmitCloudEvent({ component, event, eventCode, args }) {
283
352
  if (ResolverBase._emit && event === MJEventType.ComponentEvent && eventCode === BaseEntity.BaseEventCode) {
284
353
  const extendedType = args instanceof BaseEntityEvent ? `.${args.type}` : '';
@@ -288,20 +357,23 @@ export class ResolverBase {
288
357
  const data = args?.baseEntity?.GetAll() ?? {};
289
358
  const cloudEvent = new CloudEvent({ type, source, subject, data });
290
359
  try {
360
+ // check to see if the combination of Entity and pkey was already emitted, if so, Log that condtion next
291
361
  const pkey = args.baseEntity.PrimaryKeys;
292
362
  const emittedData = { Entity: args.baseEntity.EntityInfo.Name, PKey: pkey };
293
363
  if (ResolverBase._priorEmittedData.find((e) => {
294
364
  if (e.Entity !== emittedData.Entity)
295
365
  return false;
366
+ // if we get here compare the pkeys
296
367
  const pkey2 = e.PKey;
297
368
  if (pkey.KeyValuePairs.length !== pkey2.KeyValuePairs.length)
298
369
  return false;
299
370
  for (const kv of pkey.KeyValuePairs) {
371
+ // find the match by field name
300
372
  const kv2 = pkey2.KeyValuePairs.find((k) => k.FieldName === kv.FieldName);
301
373
  if (!kv2 || kv2.Value !== kv.Value)
302
374
  return false;
303
375
  }
304
- return true;
376
+ return true; // if we get here, all the keys matched
305
377
  })) {
306
378
  console.log(`IMPORTANT: CloudEvent already emitted for ${JSON.stringify(emittedData)}`);
307
379
  }
@@ -323,8 +395,9 @@ export class ResolverBase {
323
395
  if (!userPayload) {
324
396
  throw new Error(`userPayload is null`);
325
397
  }
398
+ // first check permissions, the logged in user must have read permissions on the entity to run the view
326
399
  if (entityInfo) {
327
- const userInfo = UserCache.Users.find((u) => u.Email.toLowerCase().trim() === userPayload.email.toLowerCase().trim());
400
+ const userInfo = UserCache.Users.find((u) => u.Email.toLowerCase().trim() === userPayload.email.toLowerCase().trim()); // get the user record from MD so we have ROLES attached, don't use the one from payload directly
328
401
  if (!userInfo) {
329
402
  throw new Error(`User ${userPayload.email} not found in metadata`);
330
403
  }
@@ -337,34 +410,62 @@ export class ResolverBase {
337
410
  throw new Error(`Entity not found in metadata`);
338
411
  }
339
412
  }
413
+ /**
414
+ * Checks API key scope authorization. Only performs check if request
415
+ * was authenticated via API key (apiKeyHash present in userPayload).
416
+ * For OAuth/JWT auth, this is a no-op.
417
+ *
418
+ * @param scopePath - The scope path (e.g., 'entity:read', 'agent:execute')
419
+ * @param resource - The resource name (e.g., entity name, agent name)
420
+ * @param userPayload - The user payload from context
421
+ * @throws AuthorizationError if API key lacks required scope
422
+ */
340
423
  async CheckAPIKeyScopeAuthorization(scopePath, resource, userPayload) {
424
+ // Skip scope check for OAuth/JWT auth (no API key)
341
425
  if (!userPayload.apiKeyHash) {
342
426
  return;
343
427
  }
428
+ // Get system user for authorization call
429
+ // NOTE: We use system user here because Authorize() needs to run internal
430
+ // database queries (loading scope rules, logging decisions). The system user
431
+ // ensures these queries work regardless of what permissions the API key's
432
+ // user has. The API key's associated user (in userPayload.userRecord) is
433
+ // used later when the actual operation executes - their permissions are
434
+ // the ultimate ceiling that scopes can only narrow, never expand.
344
435
  const systemUser = UserCache.Instance.Users.find(u => u.Type === 'System');
345
436
  if (!systemUser) {
346
437
  throw new Error('System user not found');
347
438
  }
348
439
  const apiKeyEngine = GetAPIKeyEngine();
440
+ // Check for full_access scope first (god power - bypasses all other checks)
349
441
  const fullAccessResult = await apiKeyEngine.Authorize(userPayload.apiKeyHash, 'MJAPI', 'full_access', '*', systemUser, { endpoint: '/graphql', method: 'POST' });
350
442
  if (fullAccessResult.Allowed) {
443
+ // full_access granted - skip specific scope check
351
444
  return;
352
445
  }
446
+ // Check specific scope
353
447
  const result = await apiKeyEngine.Authorize(userPayload.apiKeyHash, 'MJAPI', scopePath, resource, systemUser, {
354
448
  endpoint: '/graphql',
355
449
  method: 'POST'
356
450
  });
357
451
  if (!result.Allowed) {
452
+ // Provide specific, actionable error message
358
453
  throw new AuthorizationError(`Access denied. This API key requires the '${scopePath}' scope ` +
359
454
  `for resource '${resource}' to perform this operation. ` +
360
455
  `Please update the API key's scopes or use an API key with appropriate permissions. ` +
361
456
  `Denial reason: ${result.Reason}`);
362
457
  }
363
458
  }
459
+ /**
460
+ * Optimized RunViewGenericInternal implementation with:
461
+ * - Field filtering at source (Fix #7)
462
+ * - Improved error handling (Fix #9)
463
+ */
364
464
  async RunViewGenericInternal(provider, viewInfo, extraFilter, orderBy, userSearchString, excludeUserViewRunID, overrideExcludeFilter, saveViewResults, fields, ignoreMaxRows, excludeDataFromAllPriorViewRuns, forceAuditLog, auditLogDescription, resultType, userPayload, maxRows, startRow, aggregates) {
365
465
  try {
366
466
  if (!viewInfo || !userPayload)
367
467
  return null;
468
+ // Check API key scope authorization for view operations
368
469
  await this.CheckAPIKeyScopeAuthorization('view:run', viewInfo.Entity, userPayload);
369
470
  const md = provider;
370
471
  const user = UserCache.Users.find((u) => u.Email.toLowerCase().trim() === userPayload?.email.toLowerCase().trim());
@@ -374,18 +475,23 @@ export class ResolverBase {
374
475
  if (!entityInfo)
375
476
  throw new Error(`Entity ${viewInfo.Entity} not found in metadata`);
376
477
  const rv = md;
478
+ // Determine result type
377
479
  let rt = 'simple';
378
480
  if (resultType?.trim().toLowerCase() === 'count_only') {
379
481
  rt = 'count_only';
380
482
  }
483
+ // Fix #7: Implement field filtering - preprocess fields for more efficient field selection
484
+ // This is passed to RunView() which uses it to optimize the SQL query
381
485
  let optimizedFields = fields;
382
486
  if (fields?.length) {
487
+ // Always ensure primary keys are included for proper record handling
383
488
  const primaryKeys = entityInfo.PrimaryKeys.map(pk => pk.Name);
384
489
  const missingPrimaryKeys = primaryKeys.filter(pk => !fields.find(f => f.toLowerCase() === pk.toLowerCase()));
385
490
  if (missingPrimaryKeys.length) {
386
491
  optimizedFields = [...fields, ...missingPrimaryKeys];
387
492
  }
388
493
  }
494
+ // Log aggregate request for debugging
389
495
  if (aggregates?.length) {
390
496
  LogStatus(`[ResolverBase] RunViewGenericInternal with aggregates: entityName=${viewInfo.Entity}, viewName=${viewInfo.Name}, aggregateCount=${aggregates.length}, aggregates=${JSON.stringify(aggregates.map(a => ({ expression: a.expression, alias: a.alias })))}`);
391
497
  }
@@ -395,7 +501,7 @@ export class ResolverBase {
395
501
  EntityName: viewInfo.Entity,
396
502
  ExtraFilter: extraFilter,
397
503
  OrderBy: orderBy,
398
- Fields: optimizedFields,
504
+ Fields: optimizedFields, // Use optimized fields list
399
505
  UserSearchString: userSearchString,
400
506
  ExcludeUserViewRunID: excludeUserViewRunID,
401
507
  OverrideExcludeFilter: overrideExcludeFilter,
@@ -409,19 +515,23 @@ export class ResolverBase {
409
515
  ResultType: rt,
410
516
  Aggregates: aggregates,
411
517
  }, user);
518
+ // Log aggregate results for debugging
412
519
  if (aggregates?.length) {
413
520
  LogStatus(`[ResolverBase] RunView result aggregate info: entityName=${viewInfo.Entity}, hasAggregateResults=${!!result?.AggregateResults}, aggregateResultCount=${result?.AggregateResults?.length || 0}, aggregateExecutionTime=${result?.AggregateExecutionTime}, aggregateResults=${JSON.stringify(result?.AggregateResults)}`);
414
521
  }
522
+ // Process results for GraphQL transport
415
523
  const mapper = new FieldMapper();
416
524
  if (result?.Success && result.Results?.length) {
417
525
  for (const r of result.Results) {
418
526
  mapper.MapFields(r);
419
527
  }
528
+ // Filter encrypted fields before sending to API client
420
529
  await this.ArrayFilterEncryptedFieldsForAPI(viewInfo.Entity, result.Results, user);
421
530
  }
422
531
  return result;
423
532
  }
424
533
  catch (err) {
534
+ // Fix #9: Improved error handling with structured logging
425
535
  const error = err;
426
536
  LogError({
427
537
  service: 'RunView',
@@ -429,18 +539,27 @@ export class ResolverBase {
429
539
  error: error.message,
430
540
  entityName: viewInfo?.Entity,
431
541
  errorType: error.constructor.name,
542
+ // Only include stack trace for non-validation errors
432
543
  stack: error.message?.includes('not found in metadata') ? undefined : error.stack
433
544
  });
434
545
  throw err;
435
546
  }
436
547
  }
548
+ /**
549
+ * Optimized implementation that:
550
+ * 1. Fetches user info only once (fixes N+1 query)
551
+ * 2. Processes views in parallel for independent operations
552
+ * 3. Implements structured error logging
553
+ */
437
554
  async RunViewsGenericInternal(params) {
438
555
  try {
556
+ // Skip processing if no params
439
557
  if (!params.length)
440
558
  return [];
441
559
  let md = null;
442
560
  const rv = params[0].provider;
443
561
  let runViewParams = [];
562
+ // Fix #1: Get user info only once for all queries
444
563
  let contextUser = null;
445
564
  if (params[0]?.userPayload?.email) {
446
565
  const userEmail = params[0].userPayload.email.toLowerCase().trim();
@@ -450,10 +569,13 @@ export class ResolverBase {
450
569
  }
451
570
  contextUser = user;
452
571
  }
572
+ // Create a map of entities to validate only once per entity
453
573
  const validatedEntities = new Set();
454
574
  md = new Metadata();
575
+ // Transform parameters
455
576
  for (const param of params) {
456
577
  if (param.viewInfo) {
578
+ // Validate entity only once per entity type
457
579
  const entityName = param.viewInfo.Entity;
458
580
  if (!validatedEntities.has(entityName)) {
459
581
  const entityInfo = md.Entities.find(e => e.Name === entityName);
@@ -463,10 +585,12 @@ export class ResolverBase {
463
585
  validatedEntities.add(entityName);
464
586
  }
465
587
  }
588
+ // Determine result type
466
589
  let rt = 'simple';
467
590
  if (param.resultType?.trim().toLowerCase() === 'count_only') {
468
591
  rt = 'count_only';
469
592
  }
593
+ // Build parameters
470
594
  runViewParams.push({
471
595
  ViewID: param.viewInfo.ID,
472
596
  ViewName: param.viewInfo.Name,
@@ -488,7 +612,9 @@ export class ResolverBase {
488
612
  Aggregates: param.aggregates,
489
613
  });
490
614
  }
615
+ // Fix #4: Run views in a single batch through RunViews
491
616
  const runViewResults = await rv.RunViews(runViewParams, contextUser);
617
+ // Process results
492
618
  const mapper = new FieldMapper();
493
619
  for (let i = 0; i < runViewResults.length; i++) {
494
620
  const runViewResult = runViewResults[i];
@@ -496,6 +622,8 @@ export class ResolverBase {
496
622
  for (const result of runViewResult.Results) {
497
623
  mapper.MapFields(result);
498
624
  }
625
+ // Filter encrypted fields before sending to API client
626
+ // Use the corresponding param's entity name
499
627
  const entityName = params[i]?.viewInfo?.Entity;
500
628
  if (entityName && contextUser) {
501
629
  await this.ArrayFilterEncryptedFieldsForAPI(entityName, runViewResult.Results, contextUser);
@@ -505,6 +633,7 @@ export class ResolverBase {
505
633
  return runViewResults;
506
634
  }
507
635
  catch (err) {
636
+ // Fix #9: Structured error logging with less verbosity
508
637
  console.log(err);
509
638
  throw err;
510
639
  }
@@ -552,7 +681,7 @@ export class ResolverBase {
552
681
  throw new Error(`User ${userPayload?.email} not found in metadata`);
553
682
  if (!auditLogType)
554
683
  throw new Error(`Audit Log Type ${auditLogTypeName} not found in metadata`);
555
- const auditLog = await md.GetEntityObject('Audit Logs', userInfo);
684
+ const auditLog = await md.GetEntityObject('Audit Logs', userInfo); // must pass user context on back end as we're not authenticated the same way as the front end
556
685
  auditLog.NewRecord();
557
686
  auditLog.UserID = userInfo.ID;
558
687
  auditLog.AuditLogTypeID = auditLogType.ID;
@@ -594,7 +723,7 @@ export class ResolverBase {
594
723
  if (!userPayload)
595
724
  return undefined;
596
725
  if (userPayload.userRecord)
597
- return userPayload.userRecord;
726
+ return userPayload.userRecord; // if we have a user record, use that directly
598
727
  if (!userPayload.email)
599
728
  return undefined;
600
729
  return UserCache.Users.find((u) => u.Email.toLowerCase().trim() === userPayload.email.toLowerCase().trim());
@@ -603,12 +732,18 @@ export class ResolverBase {
603
732
  return Metadata.Provider.ConfigData.MJCoreSchemaName;
604
733
  }
605
734
  ListenForEntityMessages(entityObject, pubSub, userPayload) {
735
+ // The unique key is set up for each entity object via it's primary key to ensure that we only have one listener at most for each unique
736
+ // entity in the system. This is important because we don't want to have multiple listeners for the same entity as it could
737
+ // cause issues with multiple messages for the same event.
606
738
  const uniqueKey = entityObject.EntityInfo.Name;
607
739
  if (!this.EventSubscriptions.has(uniqueKey)) {
740
+ // 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
608
741
  LogDebug(`ResolverBase.ListenForEntityMessages: About to call MJGlobal.Instance.GetEventListener() to get the event listener subscription for ${uniqueKey}`);
609
742
  const theSub = MJGlobal.Instance.GetEventListener(false).subscribe(async (event) => {
610
743
  if (event) {
611
744
  const baseEntity = event.args?.baseEntity;
745
+ // Only process events for the entity type this subscription was created for
746
+ // This prevents duplicate CloudEvents when multiple entity types have active subscriptions
612
747
  if (baseEntity?.EntityInfo?.Name !== uniqueKey) {
613
748
  return;
614
749
  }
@@ -619,6 +754,7 @@ export class ResolverBase {
619
754
  LogDebug(`ResolverBase.ListenForEntityMessages: EmitCloudEvent() completed successfully`);
620
755
  if (event.args && event.args instanceof BaseEntityEvent) {
621
756
  const baseEntityEvent = event.args;
757
+ // message from our entity object, relay it to the client
622
758
  LogDebug('ResolverBase.ListenForEntityMessages: About to publish PUSH_STATUS_UPDATES_TOPIC');
623
759
  pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
624
760
  message: JSON.stringify({
@@ -637,17 +773,23 @@ export class ResolverBase {
637
773
  }
638
774
  }
639
775
  async CreateRecord(entityName, input, provider, userPayload, pubSub) {
776
+ // Check API key scope authorization for entity create operations
640
777
  await this.CheckAPIKeyScopeAuthorization('entity:create', entityName, userPayload);
641
778
  if (await this.BeforeCreate(provider, input)) {
779
+ // fire event and proceed if it wasn't cancelled
642
780
  const entityObject = await provider.GetEntityObject(entityName, this.GetUserFromPayload(userPayload));
643
781
  entityObject.NewRecord();
644
782
  entityObject.SetMany(input);
645
783
  this.ListenForEntityMessages(entityObject, pubSub, userPayload);
784
+ // Pass the transactionScopeId from the user payload to the save operation
646
785
  if (await entityObject.Save()) {
647
- await this.AfterCreate(provider, input);
786
+ // save worked, fire the AfterCreate event and then return all the data
787
+ await this.AfterCreate(provider, input); // fire event
648
788
  const contextUser = this.GetUserFromPayload(userPayload);
789
+ // MapFieldNamesToCodeNames now handles encryption filtering as well
649
790
  return await this.MapFieldNamesToCodeNames(entityName, entityObject.GetAll(), contextUser);
650
791
  }
792
+ // save failed, throw error with message
651
793
  else {
652
794
  throw new GraphQLError(entityObject.LatestResult?.CompleteMessage ?? 'Unknown error creating record', {
653
795
  extensions: { code: 'CREATE_ENTITY_ERROR', entityName },
@@ -657,13 +799,16 @@ export class ResolverBase {
657
799
  else
658
800
  return null;
659
801
  }
802
+ // Before/After CREATE Event Hooks for Sub-Classes to Override
660
803
  async BeforeCreate(provider, input) {
661
804
  return true;
662
805
  }
663
806
  async AfterCreate(provider, input) { }
664
807
  async UpdateRecord(entityName, input, provider, userPayload, pubSub) {
808
+ // Check API key scope authorization for entity update operations
665
809
  await this.CheckAPIKeyScopeAuthorization('entity:update', entityName, userPayload);
666
810
  if (await this.BeforeUpdate(provider, input)) {
811
+ // fire event and proceed if it wasn't cancelled
667
812
  const userInfo = this.GetUserFromPayload(userPayload);
668
813
  const entityObject = await provider.GetEntityObject(entityName, userInfo);
669
814
  const entityInfo = entityObject.EntityInfo;
@@ -672,8 +817,9 @@ export class ResolverBase {
672
817
  if (key !== 'OldValues___') {
673
818
  clientNewValues[key] = input[key];
674
819
  }
675
- });
820
+ }); // grab all the props except for the OldValues property
676
821
  if (entityInfo.TrackRecordChanges || !input.OldValues___) {
822
+ // We get here because EITHER the entity tracks record changes OR the client did not provide OldValues, so we need to load the old values from the DB
677
823
  const cKey = new CompositeKey(entityInfo.PrimaryKeys.map((pk) => {
678
824
  return {
679
825
  FieldName: pk.Name,
@@ -681,28 +827,38 @@ export class ResolverBase {
681
827
  };
682
828
  }));
683
829
  if (await entityObject.InnerLoad(cKey)) {
830
+ // load worked, now, only IF we have OldValues, we need to check them against the values in the DB we just loaded.
684
831
  if (input.OldValues___) {
832
+ // we DO have OldValues, so we need to do a more in depth analysis
685
833
  await this.TestAndSetClientOldValuesToDBValues(input, clientNewValues, entityObject, userInfo);
686
834
  }
687
835
  else {
836
+ // no OldValues, so we can just set the new values from input
688
837
  entityObject.SetMany(input);
689
838
  }
690
839
  }
691
840
  else {
841
+ // save failed, return null
692
842
  throw new GraphQLError(`Record not found for ${entityName} with key ${JSON.stringify(cKey)}`, {
693
843
  extensions: { code: 'LOAD_ENTITY_ERROR', entityName },
694
844
  });
695
845
  }
696
846
  }
697
847
  else {
848
+ // we get here if we are NOT tracking changes and we DO have OldValues, so we can load from them
698
849
  const oldValues = {};
850
+ // for each item in the oldValues array, add it to the oldValues object
699
851
  input.OldValues___?.forEach((item) => (oldValues[item.Key] = item.Value));
852
+ // 1) load the old values, this will be the initial state of the object
700
853
  await entityObject.LoadFromData(oldValues);
854
+ // 2) set the new values from the input, not including the OldValues property
701
855
  entityObject.SetMany(clientNewValues);
702
856
  }
703
857
  this.ListenForEntityMessages(entityObject, pubSub, userPayload);
704
858
  if (await entityObject.Save()) {
705
- await this.AfterUpdate(provider, input);
859
+ // save worked, fire afterevent and return all the data
860
+ await this.AfterUpdate(provider, input); // fire event
861
+ // MapFieldNamesToCodeNames now handles encryption filtering as well
706
862
  return await this.MapFieldNamesToCodeNames(entityName, entityObject.GetAll(), userInfo);
707
863
  }
708
864
  else {
@@ -716,13 +872,23 @@ export class ResolverBase {
716
872
  extensions: { code: 'SAVE_ENTITY_ERROR', entityName },
717
873
  });
718
874
  }
875
+ /**
876
+ * This routine compares the OldValues property in the input object to the values in the DB that we just loaded. If there are differences, we need to check to see if the client
877
+ * is trying to update any of those fields (e.g. overlap). If there is overlap, we throw an error. If there is no overlap, we can proceed with the update even if the DB Values
878
+ * and the ClientOldValues are not 100% the same, so long as there is no overlap in the specific FIELDS that are different.
879
+ *
880
+ * ASSUMES: input object has an OldValues___ property that is an array of Key/Value pairs that represent the old values of the record that the client is trying to update.
881
+ */
719
882
  async TestAndSetClientOldValuesToDBValues(input, clientNewValues, entityObject, contextUser) {
883
+ // we have OldValues, so we need to compare them to the values we just loaded from the DB
720
884
  const clientOldValues = {};
885
+ // for each item in the oldValues array, add it to the clientOldValues object
721
886
  input.OldValues___.forEach((item) => {
887
+ // we need to do a quick transform on the values to make sure they match the TS Type for the given field because item.Value will always be a string
722
888
  const field = entityObject.EntityInfo.Fields.find((f) => f.CodeName === item.Key);
723
889
  let val = item.Value;
724
890
  if ((val === null || val === undefined) && field.DefaultValue !== null && field.DefaultValue !== undefined && !field.AllowsNull)
725
- val = field.DefaultValue;
891
+ val = field.DefaultValue; // set default value as the field was never set and it does NOT allow nulls
726
892
  if (field) {
727
893
  switch (field.TSType) {
728
894
  case EntityFieldTSType.Number:
@@ -755,17 +921,21 @@ export class ResolverBase {
755
921
  val = val === null || val === undefined || val === 'false' || val === '0' || parseInt(val) === 0 ? false : true;
756
922
  break;
757
923
  case EntityFieldTSType.Date:
924
+ // first, if val is a string and it is actually a number (milliseconds since epoch), convert it to a number.
758
925
  if (val !== null && val !== undefined && val.toString().trim() !== '' && !isNaN(val))
759
926
  val = parseInt(val);
760
927
  val = val !== null && val !== undefined ? new Date(val) : null;
761
928
  break;
762
929
  default:
763
- break;
930
+ break; // already a string
764
931
  }
765
932
  }
766
933
  clientOldValues[item.Key] = val;
767
934
  });
935
+ // clientOldValues now has all of the oldValues the CLIENT passed us. Now we need to build the same kind of object
936
+ // with the DB values
768
937
  const dbValues = entityObject.GetAll();
938
+ // now we need to compare clientOldValues and dbValues and have a new array that has entries for any differences and have FieldName, clientOldValue and dbValue as properties
769
939
  const dbDifferences = [];
770
940
  Object.keys(clientOldValues).forEach((key) => {
771
941
  const f = entityObject.EntityInfo.Fields.find((f) => f.CodeName === key);
@@ -785,12 +955,13 @@ export class ResolverBase {
785
955
  different = true;
786
956
  }
787
957
  else if (clientDate.getTime() !== dbDate.getTime()) {
958
+ // Check if this is a datetime/datetime2 field with only a timezone hour shift
788
959
  const sqlType = f?.SQLFullType?.trim().toLowerCase() || '';
789
960
  if (sqlType !== 'datetimeoffset' && IsOnlyTimezoneShift(clientDate, dbDate)) {
790
961
  console.warn(`Timezone hour shift detected on field "${key}" (${sqlType || 'datetime'}). ` +
791
962
  `Client: ${clientDate.toISOString()}, DB: ${dbDate.toISOString()}. ` +
792
963
  `Consider using datetimeoffset to avoid timezone ambiguity.`);
793
- different = false;
964
+ different = false; // Allow timezone shifts through for non-datetimeoffset fields
794
965
  }
795
966
  else {
796
967
  different = true;
@@ -806,6 +977,7 @@ export class ResolverBase {
806
977
  break;
807
978
  }
808
979
  if (different && f && !f.ReadOnly) {
980
+ // only include updateable fields
809
981
  dbDifferences.push({
810
982
  FieldName: key,
811
983
  ClientOldValue: clientOldValues[key],
@@ -814,10 +986,13 @@ export class ResolverBase {
814
986
  }
815
987
  });
816
988
  if (dbDifferences.length > 0) {
989
+ // now we have an array of any dbDifferences with length > 0, between the clientOldValues and the dbValues, we need to check to see if any of the differences are on fields that the client is trying to update
990
+ // first step is to get clientNewValues into an object that is like clientOldValues, get the diff and then compare that diff to the differences array that shows diff between DB and ClientOld
817
991
  const clientDifferences = [];
818
992
  Object.keys(clientOldValues).forEach((key) => {
819
993
  const f = entityObject.EntityInfo.Fields.find((f) => f.CodeName === key);
820
994
  if (clientOldValues[key] !== clientNewValues[key] && f && f.AllowUpdateAPI && !f.IsPrimaryKey) {
995
+ // only include updateable fields
821
996
  clientDifferences.push({
822
997
  FieldName: key,
823
998
  ClientOldValue: clientOldValues[key],
@@ -825,6 +1000,8 @@ export class ResolverBase {
825
1000
  });
826
1001
  }
827
1002
  });
1003
+ // now we have clientDifferences which shows what the client thinks they are changing. And, we have the dbDifferences array that shows changes between the clientOldValues and the dbValues
1004
+ // if there is ANY overlap in the FIELDS that appear in both arrays, we need to log a warning but allow the save to continue
828
1005
  const overlap = clientDifferences.filter((cd) => dbDifferences.find((dd) => dd.FieldName === cd.FieldName));
829
1006
  if (overlap.length > 0) {
830
1007
  const msg = {
@@ -833,6 +1010,7 @@ export class ResolverBase {
833
1010
  DBDifferences: dbDifferences,
834
1011
  Overlap: overlap,
835
1012
  };
1013
+ // Log as warning to console and ErrorLog table instead of throwing error
836
1014
  console.warn('Entity save inconsistency detected but allowing save to continue:', JSON.stringify(msg));
837
1015
  LogError({
838
1016
  service: 'ResolverBase',
@@ -845,6 +1023,7 @@ export class ResolverBase {
845
1023
  overlap: overlap
846
1024
  }
847
1025
  });
1026
+ // Create ErrorLog record in the database
848
1027
  try {
849
1028
  const md = new Metadata();
850
1029
  const errorLogEntity = await md.GetEntityObject('Error Logs', contextUser);
@@ -863,17 +1042,21 @@ export class ResolverBase {
863
1042
  }
864
1043
  }
865
1044
  }
1045
+ // If we get here that means we've not thrown an exception, so there is
1046
+ // NO OVERLAP, so we can set the new values from the data provided from the client now...
866
1047
  entityObject.SetMany(clientNewValues);
867
1048
  }
868
1049
  async DeleteRecord(entityName, key, options, provider, userPayload, pubSub) {
1050
+ // Check API key scope authorization for entity delete operations
869
1051
  await this.CheckAPIKeyScopeAuthorization('entity:delete', entityName, userPayload);
870
1052
  if (await this.BeforeDelete(provider, key)) {
1053
+ // fire event and proceed if it wasn't cancelled
871
1054
  const entityObject = await provider.GetEntityObject(entityName, this.GetUserFromPayload(userPayload));
872
1055
  await entityObject.InnerLoad(key);
873
- const returnValue = entityObject.GetAll();
1056
+ const returnValue = entityObject.GetAll(); // grab the values before we delete so we can return last state before delete if we are successful.
874
1057
  this.ListenForEntityMessages(entityObject, pubSub, userPayload);
875
1058
  if (await entityObject.Delete(options)) {
876
- await this.AfterDelete(provider, key);
1059
+ await this.AfterDelete(provider, key); // fire event
877
1060
  return returnValue;
878
1061
  }
879
1062
  else {
@@ -888,10 +1071,12 @@ export class ResolverBase {
888
1071
  });
889
1072
  }
890
1073
  }
1074
+ // Before/After DELETE Event Hooks for Sub-Classes to Override
891
1075
  async BeforeDelete(provider, key) {
892
1076
  return true;
893
1077
  }
894
1078
  async AfterDelete(provider, key) { }
1079
+ // Before/After UPDATE Event Hooks for Sub-Classes to Override
895
1080
  async BeforeUpdate(provider, input) {
896
1081
  return true;
897
1082
  }