@memberjunction/server 3.3.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 (296) hide show
  1. package/README.md +59 -0
  2. package/dist/agents/skip-agent.d.ts +65 -0
  3. package/dist/agents/skip-agent.d.ts.map +1 -1
  4. package/dist/agents/skip-agent.js +63 -5
  5. package/dist/agents/skip-agent.js.map +1 -1
  6. package/dist/agents/skip-sdk.d.ts +163 -0
  7. package/dist/agents/skip-sdk.d.ts.map +1 -1
  8. package/dist/agents/skip-sdk.js +143 -12
  9. package/dist/agents/skip-sdk.js.map +1 -1
  10. package/dist/apolloServer/TransactionPlugin.d.ts +4 -0
  11. package/dist/apolloServer/TransactionPlugin.d.ts.map +1 -0
  12. package/dist/apolloServer/TransactionPlugin.js +46 -0
  13. package/dist/apolloServer/TransactionPlugin.js.map +1 -0
  14. package/dist/apolloServer/index.d.ts +0 -1
  15. package/dist/apolloServer/index.d.ts.map +1 -1
  16. package/dist/auth/APIKeyScopeAuth.d.ts +82 -0
  17. package/dist/auth/APIKeyScopeAuth.d.ts.map +1 -1
  18. package/dist/auth/APIKeyScopeAuth.js +78 -0
  19. package/dist/auth/APIKeyScopeAuth.js.map +1 -1
  20. package/dist/auth/AuthProviderFactory.d.ts +35 -0
  21. package/dist/auth/AuthProviderFactory.d.ts.map +1 -1
  22. package/dist/auth/AuthProviderFactory.js +51 -4
  23. package/dist/auth/AuthProviderFactory.js.map +1 -1
  24. package/dist/auth/BaseAuthProvider.d.ts +22 -0
  25. package/dist/auth/BaseAuthProvider.d.ts.map +1 -1
  26. package/dist/auth/BaseAuthProvider.js +25 -8
  27. package/dist/auth/BaseAuthProvider.js.map +1 -1
  28. package/dist/auth/IAuthProvider.d.ts +33 -0
  29. package/dist/auth/IAuthProvider.d.ts.map +1 -1
  30. package/dist/auth/__tests__/backward-compatibility.test.d.ts +2 -0
  31. package/dist/auth/__tests__/backward-compatibility.test.d.ts.map +1 -0
  32. package/dist/auth/__tests__/backward-compatibility.test.js +135 -0
  33. package/dist/auth/__tests__/backward-compatibility.test.js.map +1 -0
  34. package/dist/auth/exampleNewUserSubClass.d.ts +5 -1
  35. package/dist/auth/exampleNewUserSubClass.d.ts.map +1 -1
  36. package/dist/auth/exampleNewUserSubClass.js +21 -6
  37. package/dist/auth/exampleNewUserSubClass.js.map +1 -1
  38. package/dist/auth/index.d.ts +14 -0
  39. package/dist/auth/index.d.ts.map +1 -1
  40. package/dist/auth/index.js +35 -22
  41. package/dist/auth/index.js.map +1 -1
  42. package/dist/auth/initializeProviders.d.ts +3 -0
  43. package/dist/auth/initializeProviders.d.ts.map +1 -1
  44. package/dist/auth/initializeProviders.js +6 -0
  45. package/dist/auth/initializeProviders.js.map +1 -1
  46. package/dist/auth/newUsers.js +11 -2
  47. package/dist/auth/newUsers.js.map +1 -1
  48. package/dist/auth/providers/Auth0Provider.d.ts +9 -0
  49. package/dist/auth/providers/Auth0Provider.d.ts.map +1 -1
  50. package/dist/auth/providers/Auth0Provider.js +10 -0
  51. package/dist/auth/providers/Auth0Provider.js.map +1 -1
  52. package/dist/auth/providers/CognitoProvider.d.ts +9 -0
  53. package/dist/auth/providers/CognitoProvider.d.ts.map +1 -1
  54. package/dist/auth/providers/CognitoProvider.js +10 -0
  55. package/dist/auth/providers/CognitoProvider.js.map +1 -1
  56. package/dist/auth/providers/GoogleProvider.d.ts +9 -0
  57. package/dist/auth/providers/GoogleProvider.d.ts.map +1 -1
  58. package/dist/auth/providers/GoogleProvider.js +11 -1
  59. package/dist/auth/providers/GoogleProvider.js.map +1 -1
  60. package/dist/auth/providers/MSALProvider.d.ts +9 -0
  61. package/dist/auth/providers/MSALProvider.d.ts.map +1 -1
  62. package/dist/auth/providers/MSALProvider.js +10 -0
  63. package/dist/auth/providers/MSALProvider.js.map +1 -1
  64. package/dist/auth/providers/OktaProvider.d.ts +9 -0
  65. package/dist/auth/providers/OktaProvider.d.ts.map +1 -1
  66. package/dist/auth/providers/OktaProvider.js +10 -0
  67. package/dist/auth/providers/OktaProvider.js.map +1 -1
  68. package/dist/config.d.ts +12 -0
  69. package/dist/config.d.ts.map +1 -1
  70. package/dist/config.js +44 -10
  71. package/dist/config.js.map +1 -1
  72. package/dist/context.d.ts +8 -1
  73. package/dist/context.d.ts.map +1 -1
  74. package/dist/context.js +26 -4
  75. package/dist/context.js.map +1 -1
  76. package/dist/directives/Public.js +2 -0
  77. package/dist/directives/Public.js.map +1 -1
  78. package/dist/entitySubclasses/entityPermissions.server.d.ts +7 -2
  79. package/dist/entitySubclasses/entityPermissions.server.d.ts.map +1 -1
  80. package/dist/entitySubclasses/entityPermissions.server.js +26 -8
  81. package/dist/entitySubclasses/entityPermissions.server.js.map +1 -1
  82. package/dist/generated/generated.d.ts +954 -2
  83. package/dist/generated/generated.d.ts.map +1 -1
  84. package/dist/generated/generated.js +12183 -14532
  85. package/dist/generated/generated.js.map +1 -1
  86. package/dist/generic/DeleteOptionsInput.d.ts +3 -0
  87. package/dist/generic/DeleteOptionsInput.d.ts.map +1 -1
  88. package/dist/generic/DeleteOptionsInput.js +3 -2
  89. package/dist/generic/DeleteOptionsInput.js.map +1 -1
  90. package/dist/generic/KeyInputOutputTypes.js +0 -6
  91. package/dist/generic/KeyInputOutputTypes.js.map +1 -1
  92. package/dist/generic/KeyValuePairInput.d.ts +4 -0
  93. package/dist/generic/KeyValuePairInput.d.ts.map +1 -1
  94. package/dist/generic/KeyValuePairInput.js +4 -2
  95. package/dist/generic/KeyValuePairInput.js.map +1 -1
  96. package/dist/generic/PushStatusResolver.js +0 -3
  97. package/dist/generic/PushStatusResolver.js.map +1 -1
  98. package/dist/generic/ResolverBase.d.ts +59 -0
  99. package/dist/generic/ResolverBase.d.ts.map +1 -1
  100. package/dist/generic/ResolverBase.js +233 -18
  101. package/dist/generic/ResolverBase.js.map +1 -1
  102. package/dist/generic/RunViewResolver.d.ts +22 -0
  103. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  104. package/dist/generic/RunViewResolver.js +42 -108
  105. package/dist/generic/RunViewResolver.js.map +1 -1
  106. package/dist/index.d.ts +2 -2
  107. package/dist/index.d.ts.map +1 -1
  108. package/dist/index.js +84 -39
  109. package/dist/index.js.map +1 -1
  110. package/dist/orm.d.ts.map +1 -1
  111. package/dist/orm.js +2 -1
  112. package/dist/orm.js.map +1 -1
  113. package/dist/resolvers/APIKeyResolver.d.ts +76 -1
  114. package/dist/resolvers/APIKeyResolver.d.ts.map +1 -1
  115. package/dist/resolvers/APIKeyResolver.js +53 -11
  116. package/dist/resolvers/APIKeyResolver.js.map +1 -1
  117. package/dist/resolvers/ActionResolver.d.ts +191 -1
  118. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  119. package/dist/resolvers/ActionResolver.js +156 -22
  120. package/dist/resolvers/ActionResolver.js.map +1 -1
  121. package/dist/resolvers/ColorResolver.js +0 -5
  122. package/dist/resolvers/ColorResolver.js.map +1 -1
  123. package/dist/resolvers/ComponentRegistryResolver.d.ts +65 -0
  124. package/dist/resolvers/ComponentRegistryResolver.d.ts.map +1 -1
  125. package/dist/resolvers/ComponentRegistryResolver.js +118 -40
  126. package/dist/resolvers/ComponentRegistryResolver.js.map +1 -1
  127. package/dist/resolvers/CreateQueryResolver.d.ts +47 -0
  128. package/dist/resolvers/CreateQueryResolver.d.ts.map +1 -1
  129. package/dist/resolvers/CreateQueryResolver.js +92 -116
  130. package/dist/resolvers/CreateQueryResolver.js.map +1 -1
  131. package/dist/resolvers/DatasetResolver.d.ts +5 -4
  132. package/dist/resolvers/DatasetResolver.d.ts.map +1 -1
  133. package/dist/resolvers/DatasetResolver.js +9 -18
  134. package/dist/resolvers/DatasetResolver.js.map +1 -1
  135. package/dist/resolvers/EntityCommunicationsResolver.d.ts +42 -1
  136. package/dist/resolvers/EntityCommunicationsResolver.d.ts.map +1 -1
  137. package/dist/resolvers/EntityCommunicationsResolver.js +5 -37
  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 +20 -2
  148. package/dist/resolvers/GetDataContextDataResolver.d.ts.map +1 -1
  149. package/dist/resolvers/GetDataContextDataResolver.js +27 -12
  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 +361 -0
  159. package/dist/resolvers/MCPResolver.d.ts.map +1 -0
  160. package/dist/resolvers/MCPResolver.js +1270 -0
  161. package/dist/resolvers/MCPResolver.js.map +1 -0
  162. package/dist/resolvers/MergeRecordsResolver.d.ts +2 -1
  163. package/dist/resolvers/MergeRecordsResolver.d.ts.map +1 -1
  164. package/dist/resolvers/MergeRecordsResolver.js +6 -30
  165. package/dist/resolvers/MergeRecordsResolver.js.map +1 -1
  166. package/dist/resolvers/PotentialDuplicateRecordResolver.d.ts.map +1 -1
  167. package/dist/resolvers/PotentialDuplicateRecordResolver.js +0 -3
  168. package/dist/resolvers/PotentialDuplicateRecordResolver.js.map +1 -1
  169. package/dist/resolvers/QueryResolver.d.ts +22 -1
  170. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  171. package/dist/resolvers/QueryResolver.js +50 -37
  172. package/dist/resolvers/QueryResolver.js.map +1 -1
  173. package/dist/resolvers/ReportResolver.d.ts +5 -1
  174. package/dist/resolvers/ReportResolver.d.ts.map +1 -1
  175. package/dist/resolvers/ReportResolver.js +13 -11
  176. package/dist/resolvers/ReportResolver.js.map +1 -1
  177. package/dist/resolvers/RunAIAgentResolver.d.ts +54 -0
  178. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  179. package/dist/resolvers/RunAIAgentResolver.js +118 -40
  180. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  181. package/dist/resolvers/RunAIPromptResolver.d.ts +42 -0
  182. package/dist/resolvers/RunAIPromptResolver.d.ts.map +1 -1
  183. package/dist/resolvers/RunAIPromptResolver.js +98 -22
  184. package/dist/resolvers/RunAIPromptResolver.js.map +1 -1
  185. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  186. package/dist/resolvers/RunTemplateResolver.js +10 -6
  187. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  188. package/dist/resolvers/RunTestResolver.d.ts +12 -0
  189. package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
  190. package/dist/resolvers/RunTestResolver.js +35 -21
  191. package/dist/resolvers/RunTestResolver.js.map +1 -1
  192. package/dist/resolvers/SqlLoggingConfigResolver.d.ts +312 -0
  193. package/dist/resolvers/SqlLoggingConfigResolver.d.ts.map +1 -1
  194. package/dist/resolvers/SqlLoggingConfigResolver.js +295 -45
  195. package/dist/resolvers/SqlLoggingConfigResolver.js.map +1 -1
  196. package/dist/resolvers/SyncDataResolver.d.ts +21 -0
  197. package/dist/resolvers/SyncDataResolver.d.ts.map +1 -1
  198. package/dist/resolvers/SyncDataResolver.js +36 -22
  199. package/dist/resolvers/SyncDataResolver.js.map +1 -1
  200. package/dist/resolvers/SyncRolesUsersResolver.d.ts +14 -0
  201. package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
  202. package/dist/resolvers/SyncRolesUsersResolver.js +54 -21
  203. package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
  204. package/dist/resolvers/TaskResolver.d.ts +13 -0
  205. package/dist/resolvers/TaskResolver.d.ts.map +1 -1
  206. package/dist/resolvers/TaskResolver.js +23 -7
  207. package/dist/resolvers/TaskResolver.js.map +1 -1
  208. package/dist/resolvers/TelemetryResolver.d.ts +22 -0
  209. package/dist/resolvers/TelemetryResolver.d.ts.map +1 -1
  210. package/dist/resolvers/TelemetryResolver.js +45 -79
  211. package/dist/resolvers/TelemetryResolver.js.map +1 -1
  212. package/dist/resolvers/TransactionGroupResolver.js +11 -13
  213. package/dist/resolvers/TransactionGroupResolver.js.map +1 -1
  214. package/dist/resolvers/UserFavoriteResolver.js +3 -12
  215. package/dist/resolvers/UserFavoriteResolver.js.map +1 -1
  216. package/dist/resolvers/UserResolver.d.ts.map +1 -1
  217. package/dist/resolvers/UserResolver.js +14 -0
  218. package/dist/resolvers/UserResolver.js.map +1 -1
  219. package/dist/resolvers/UserViewResolver.js +4 -0
  220. package/dist/resolvers/UserViewResolver.js.map +1 -1
  221. package/dist/resolvers/VersionHistoryResolver.d.ts +39 -0
  222. package/dist/resolvers/VersionHistoryResolver.d.ts.map +1 -0
  223. package/dist/resolvers/VersionHistoryResolver.js +208 -0
  224. package/dist/resolvers/VersionHistoryResolver.js.map +1 -0
  225. package/dist/rest/EntityCRUDHandler.d.ts +19 -0
  226. package/dist/rest/EntityCRUDHandler.d.ts.map +1 -1
  227. package/dist/rest/EntityCRUDHandler.js +55 -0
  228. package/dist/rest/EntityCRUDHandler.js.map +1 -1
  229. package/dist/rest/OAuthCallbackHandler.d.ts +143 -0
  230. package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -0
  231. package/dist/rest/OAuthCallbackHandler.js +634 -0
  232. package/dist/rest/OAuthCallbackHandler.js.map +1 -0
  233. package/dist/rest/RESTEndpointHandler.d.ts +120 -0
  234. package/dist/rest/RESTEndpointHandler.d.ts.map +1 -1
  235. package/dist/rest/RESTEndpointHandler.js +213 -24
  236. package/dist/rest/RESTEndpointHandler.js.map +1 -1
  237. package/dist/rest/ViewOperationsHandler.d.ts +19 -0
  238. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  239. package/dist/rest/ViewOperationsHandler.js +39 -0
  240. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  241. package/dist/rest/index.d.ts +1 -0
  242. package/dist/rest/index.d.ts.map +1 -1
  243. package/dist/rest/index.js +1 -0
  244. package/dist/rest/index.js.map +1 -1
  245. package/dist/rest/setupRESTEndpoints.d.ts +35 -0
  246. package/dist/rest/setupRESTEndpoints.d.ts.map +1 -1
  247. package/dist/rest/setupRESTEndpoints.js +15 -1
  248. package/dist/rest/setupRESTEndpoints.js.map +1 -1
  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 -77
  266. package/src/auth/BaseAuthProvider.ts +3 -0
  267. package/src/auth/IAuthProvider.ts +5 -0
  268. package/src/auth/exampleNewUserSubClass.ts +1 -5
  269. package/src/config.ts +2 -2
  270. package/src/entitySubclasses/entityPermissions.server.ts +1 -3
  271. package/src/generated/generated.ts +6245 -2558
  272. package/src/generic/ResolverBase.ts +89 -3
  273. package/src/index.ts +71 -64
  274. package/src/resolvers/APIKeyResolver.ts +8 -1
  275. package/src/resolvers/ActionResolver.ts +8 -1
  276. package/src/resolvers/DatasetResolver.ts +11 -4
  277. package/src/resolvers/EntityCommunicationsResolver.ts +5 -1
  278. package/src/resolvers/GetDataContextDataResolver.ts +14 -6
  279. package/src/resolvers/InfoResolver.ts +5 -1
  280. package/src/resolvers/MCPResolver.ts +1380 -0
  281. package/src/resolvers/MergeRecordsResolver.ts +5 -1
  282. package/src/resolvers/PotentialDuplicateRecordResolver.ts +0 -4
  283. package/src/resolvers/QueryResolver.ts +17 -3
  284. package/src/resolvers/ReportResolver.ts +8 -1
  285. package/src/resolvers/RunAIAgentResolver.ts +6 -0
  286. package/src/resolvers/RunAIPromptResolver.ts +10 -1
  287. package/src/resolvers/RunTemplateResolver.ts +4 -1
  288. package/src/resolvers/TaskResolver.ts +3 -0
  289. package/src/resolvers/UserResolver.ts +15 -3
  290. package/src/resolvers/VersionHistoryResolver.ts +177 -0
  291. package/src/rest/OAuthCallbackHandler.ts +766 -0
  292. package/src/rest/RESTEndpointHandler.ts +58 -35
  293. package/src/rest/index.ts +2 -1
  294. package/src/rest/setupRESTEndpoints.ts +13 -12
  295. package/src/resolvers/AskSkipResolver.ts +0 -3446
  296. package/src/scheduler/LearningCycleScheduler.ts +0 -320
@@ -1,16 +1,20 @@
1
1
  import { BaseEntity, BaseEntityEvent, CompositeKey, EntityFieldTSType, LogDebug, LogError, LogStatus, Metadata, } from '@memberjunction/core';
2
2
  import { UserCache } from '@memberjunction/sqlserver-dataprovider';
3
+ import { AuthorizationError } from 'type-graphql';
3
4
  import { GraphQLError } from 'graphql';
5
+ import { GetAPIKeyEngine } from '@memberjunction/api-keys';
4
6
  import { httpTransport, CloudEvent, emitterFor } from 'cloudevents';
5
7
  import { MJEventType, MJGlobal, ENCRYPTED_SENTINEL, IsValueEncrypted, IsOnlyTimezoneShift } from '@memberjunction/global';
6
8
  import { EncryptionEngine } from '@memberjunction/encryption';
7
9
  import { PUSH_STATUS_UPDATES_TOPIC } from './PushStatusResolver.js';
8
10
  import { FieldMapper } from '@memberjunction/graphql-dataprovider';
9
11
  export class ResolverBase {
10
- static _emit = process.env.CLOUDEVENTS_HTTP_TRANSPORT ? emitterFor(httpTransport(process.env.CLOUDEVENTS_HTTP_TRANSPORT)) : null;
11
- static _cloudeventsHeaders = process.env.CLOUDEVENTS_HTTP_HEADERS ? JSON.parse(process.env.CLOUDEVENTS_HTTP_HEADERS) : {};
12
- 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'; }
13
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.
14
18
  const g = MJGlobal.Instance.GetGlobalObjectStore();
15
19
  if (!g[ResolverBase._eventSubscriptionKey]) {
16
20
  LogDebug(`>>>>> MJServer.ResolverBase.EventSubscriptions: Creating new Map - this should only happen once per server instance <<<<<<`);
@@ -18,15 +22,35 @@ export class ResolverBase {
18
22
  }
19
23
  return g[ResolverBase._eventSubscriptionKey];
20
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
+ */
21
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
22
44
  if (dataObject) {
23
45
  const md = new Metadata();
24
46
  const entityInfo = md.Entities.find((e) => e.Name === entityName);
25
47
  if (!entityInfo)
26
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_'));
27
50
  const mapper = new FieldMapper();
28
51
  entityInfo.Fields.forEach((f) => {
29
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
30
54
  const mappedFieldName = mapper.MapFieldName(f.CodeName);
31
55
  if (mappedFieldName !== f.Name) {
32
56
  dataObject[mappedFieldName] = dataObject[f.Name];
@@ -34,18 +58,22 @@ export class ResolverBase {
34
58
  }
35
59
  }
36
60
  });
61
+ // Handle encrypted fields - data from raw SQL queries is still encrypted
37
62
  const encryptedFields = entityInfo.EncryptedFields;
38
63
  if (encryptedFields.length > 0) {
39
64
  for (const field of encryptedFields) {
40
65
  const fieldName = field.CodeName;
41
66
  const value = dataObject[fieldName];
67
+ // Skip null/undefined values
42
68
  if (value === null || value === undefined)
43
69
  continue;
70
+ // Check if value is encrypted (raw SQL returns encrypted values)
44
71
  const engine = EncryptionEngine.Instance;
45
72
  await engine.Config(false, contextUser);
46
73
  const keyMarker = field.EncryptionKeyID ? engine.GetKeyByID(field.EncryptionKeyID)?.Marker : undefined;
47
74
  if (typeof value === 'string' && IsValueEncrypted(value, keyMarker)) {
48
75
  if (field.AllowDecryptInAPI) {
76
+ // Decrypt the value for the client
49
77
  if (contextUser) {
50
78
  try {
51
79
  const decryptedValue = await engine.Decrypt(value, contextUser);
@@ -53,16 +81,20 @@ export class ResolverBase {
53
81
  }
54
82
  catch (err) {
55
83
  LogError(`Failed to decrypt field ${fieldName} for API response: ${err}`);
84
+ // On decryption failure, use sentinel for safety
56
85
  dataObject[fieldName] = ENCRYPTED_SENTINEL;
57
86
  }
58
87
  }
59
88
  else {
89
+ // No context user, can't decrypt - use sentinel
60
90
  dataObject[fieldName] = ENCRYPTED_SENTINEL;
61
91
  }
62
92
  }
63
93
  else if (!field.SendEncryptedValue) {
94
+ // AllowDecryptInAPI=false and SendEncryptedValue=false - use sentinel
64
95
  dataObject[fieldName] = ENCRYPTED_SENTINEL;
65
96
  }
97
+ // else: AllowDecryptInAPI=false and SendEncryptedValue=true - keep encrypted value as-is
66
98
  }
67
99
  }
68
100
  }
@@ -70,6 +102,7 @@ export class ResolverBase {
70
102
  return dataObject;
71
103
  }
72
104
  async ArrayMapFieldNamesToCodeNames(entityName, dataObjectArray, contextUser) {
105
+ // iterate through the array and call MapFieldNamesToCodeNames for each element
73
106
  if (dataObjectArray && dataObjectArray.length > 0) {
74
107
  for (const element of dataObjectArray) {
75
108
  await this.MapFieldNamesToCodeNames(entityName, element, contextUser);
@@ -77,6 +110,20 @@ export class ResolverBase {
77
110
  }
78
111
  return dataObjectArray;
79
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
+ */
80
127
  async FilterEncryptedFieldsForAPI(entityName, dataObject, contextUser) {
81
128
  if (!dataObject)
82
129
  return dataObject;
@@ -84,40 +131,54 @@ export class ResolverBase {
84
131
  const entityInfo = md.Entities.find((e) => e.Name === entityName);
85
132
  if (!entityInfo)
86
133
  return dataObject;
134
+ // Find all encrypted fields that need filtering
87
135
  const encryptedFields = entityInfo.EncryptedFields;
88
136
  if (encryptedFields.length === 0)
89
137
  return dataObject;
138
+ // Process each encrypted field
90
139
  for (const field of encryptedFields) {
91
140
  const fieldName = field.CodeName;
92
141
  const value = dataObject[fieldName];
142
+ // Skip null/undefined values
93
143
  if (value === null || value === undefined)
94
144
  continue;
145
+ // If AllowDecryptInAPI is true, the decrypted value passes through
95
146
  if (field.AllowDecryptInAPI)
96
147
  continue;
148
+ // AllowDecryptInAPI is false - we need to filter the value
97
149
  const engine = EncryptionEngine.Instance;
98
150
  await engine.Config(false, contextUser);
99
151
  const keyMarker = field.EncryptionKeyID ? engine.GetKeyByID(field.EncryptionKeyID)?.Marker : undefined;
100
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)
101
155
  if (typeof value === 'string' && !IsValueEncrypted(value, keyMarker)) {
102
156
  try {
103
157
  const encryptedValue = await engine.Encrypt(value, field.EncryptionKeyID, contextUser);
104
158
  dataObject[fieldName] = encryptedValue;
105
159
  }
106
160
  catch (err) {
161
+ // If re-encryption fails, use sentinel for safety
107
162
  LogError(`Failed to re-encrypt field ${fieldName} for API response: ${err}`);
108
163
  dataObject[fieldName] = ENCRYPTED_SENTINEL;
109
164
  }
110
165
  }
166
+ // If already encrypted (shouldn't happen normally), keep as-is
111
167
  }
112
168
  else {
169
+ // SendEncryptedValue is false - replace with sentinel
113
170
  dataObject[fieldName] = ENCRYPTED_SENTINEL;
114
171
  }
115
172
  }
116
173
  return dataObject;
117
174
  }
175
+ /**
176
+ * Filters encrypted fields for an array of data objects
177
+ */
118
178
  async ArrayFilterEncryptedFieldsForAPI(entityName, dataObjectArray, contextUser) {
119
179
  if (!dataObjectArray || dataObjectArray.length === 0)
120
180
  return dataObjectArray;
181
+ // Check if entity has any encrypted fields first to avoid unnecessary processing
121
182
  const md = new Metadata();
122
183
  const entityInfo = md.Entities.find((e) => e.Name === entityName);
123
184
  if (!entityInfo)
@@ -125,27 +186,33 @@ export class ResolverBase {
125
186
  const encryptedFields = entityInfo.Fields.filter(f => f.Encrypt && !f.AllowDecryptInAPI);
126
187
  if (encryptedFields.length === 0)
127
188
  return dataObjectArray;
189
+ // Process each element
128
190
  for (const element of dataObjectArray) {
129
191
  await this.FilterEncryptedFieldsForAPI(entityName, element, contextUser);
130
192
  }
131
193
  return dataObjectArray;
132
194
  }
133
195
  async findBy(provider, entity, params, contextUser) {
196
+ // build the SQL query based on the params passed in
134
197
  const rv = provider;
135
198
  const e = provider.Entities.find((e) => e.Name === entity);
136
199
  if (!e)
137
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
138
202
  let extraFilter = "";
139
203
  const keys = Object.keys(params);
140
204
  keys.forEach((k, i) => {
141
205
  if (i > 0)
142
206
  extraFilter += ' AND ';
207
+ // look up the field in the entityInfo to see if it needs quotes
143
208
  const field = e.Fields.find((f) => f.Name === k);
144
209
  if (!field)
145
210
  throw new Error(`Field ${k} not found in entity ${entity}`);
146
211
  const quotes = field.NeedsQuotes ? "'" : '';
147
212
  extraFilter += `${k} = ${quotes}${params[k]}${quotes}`;
148
213
  });
214
+ // ok, now we have a SQL string, run it and return the results
215
+ // use the SQLServerDataProvider
149
216
  const result = await rv.RunView({
150
217
  EntityName: entity,
151
218
  ExtraFilter: extraFilter,
@@ -159,6 +226,7 @@ export class ResolverBase {
159
226
  }
160
227
  async RunViewByNameGeneric(viewInput, provider, userPayload, pubSub) {
161
228
  try {
229
+ // Log aggregate input for debugging
162
230
  if (viewInput.Aggregates?.length) {
163
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 })))}`);
164
232
  }
@@ -183,6 +251,7 @@ export class ResolverBase {
183
251
  }
184
252
  async RunViewByIDGeneric(viewInput, provider, userPayload, pubSub) {
185
253
  try {
254
+ // Log aggregate input for debugging
186
255
  if (viewInput.Aggregates?.length) {
187
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 })))}`);
188
257
  }
@@ -198,6 +267,7 @@ export class ResolverBase {
198
267
  }
199
268
  async RunDynamicViewGeneric(viewInput, provider, userPayload, pubSub) {
200
269
  try {
270
+ // Log aggregate input for debugging
201
271
  if (viewInput.Aggregates?.length) {
202
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 })))}`);
203
273
  }
@@ -210,7 +280,7 @@ export class ResolverBase {
210
280
  Entity: viewInput.EntityName,
211
281
  EntityID: entity.ID,
212
282
  EntityBaseView: entity.BaseView,
213
- };
283
+ }; // only providing a few bits of data here, but it's enough to get the view to run
214
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);
215
285
  }
216
286
  catch (err) {
@@ -237,6 +307,7 @@ export class ResolverBase {
237
307
  if (!entity) {
238
308
  throw new Error(`Entity ${viewInput.EntityName} not found in metadata`);
239
309
  }
310
+ // only providing a few bits of data here, but it's enough to get the view to run
240
311
  viewInfo = {
241
312
  ID: '',
242
313
  Entity: viewInput.EntityName,
@@ -276,7 +347,7 @@ export class ResolverBase {
276
347
  let results = await this.RunViewsGenericInternal(params);
277
348
  return results;
278
349
  }
279
- static _priorEmittedData = [];
350
+ static { this._priorEmittedData = []; }
280
351
  async EmitCloudEvent({ component, event, eventCode, args }) {
281
352
  if (ResolverBase._emit && event === MJEventType.ComponentEvent && eventCode === BaseEntity.BaseEventCode) {
282
353
  const extendedType = args instanceof BaseEntityEvent ? `.${args.type}` : '';
@@ -286,20 +357,23 @@ export class ResolverBase {
286
357
  const data = args?.baseEntity?.GetAll() ?? {};
287
358
  const cloudEvent = new CloudEvent({ type, source, subject, data });
288
359
  try {
360
+ // check to see if the combination of Entity and pkey was already emitted, if so, Log that condtion next
289
361
  const pkey = args.baseEntity.PrimaryKeys;
290
362
  const emittedData = { Entity: args.baseEntity.EntityInfo.Name, PKey: pkey };
291
363
  if (ResolverBase._priorEmittedData.find((e) => {
292
364
  if (e.Entity !== emittedData.Entity)
293
365
  return false;
366
+ // if we get here compare the pkeys
294
367
  const pkey2 = e.PKey;
295
368
  if (pkey.KeyValuePairs.length !== pkey2.KeyValuePairs.length)
296
369
  return false;
297
370
  for (const kv of pkey.KeyValuePairs) {
371
+ // find the match by field name
298
372
  const kv2 = pkey2.KeyValuePairs.find((k) => k.FieldName === kv.FieldName);
299
373
  if (!kv2 || kv2.Value !== kv.Value)
300
374
  return false;
301
375
  }
302
- return true;
376
+ return true; // if we get here, all the keys matched
303
377
  })) {
304
378
  console.log(`IMPORTANT: CloudEvent already emitted for ${JSON.stringify(emittedData)}`);
305
379
  }
@@ -321,8 +395,9 @@ export class ResolverBase {
321
395
  if (!userPayload) {
322
396
  throw new Error(`userPayload is null`);
323
397
  }
398
+ // first check permissions, the logged in user must have read permissions on the entity to run the view
324
399
  if (entityInfo) {
325
- 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
326
401
  if (!userInfo) {
327
402
  throw new Error(`User ${userPayload.email} not found in metadata`);
328
403
  }
@@ -335,10 +410,63 @@ export class ResolverBase {
335
410
  throw new Error(`Entity not found in metadata`);
336
411
  }
337
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
+ */
423
+ async CheckAPIKeyScopeAuthorization(scopePath, resource, userPayload) {
424
+ // Skip scope check for OAuth/JWT auth (no API key)
425
+ if (!userPayload.apiKeyHash) {
426
+ return;
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.
435
+ const systemUser = UserCache.Instance.Users.find(u => u.Type === 'System');
436
+ if (!systemUser) {
437
+ throw new Error('System user not found');
438
+ }
439
+ const apiKeyEngine = GetAPIKeyEngine();
440
+ // Check for full_access scope first (god power - bypasses all other checks)
441
+ const fullAccessResult = await apiKeyEngine.Authorize(userPayload.apiKeyHash, 'MJAPI', 'full_access', '*', systemUser, { endpoint: '/graphql', method: 'POST' });
442
+ if (fullAccessResult.Allowed) {
443
+ // full_access granted - skip specific scope check
444
+ return;
445
+ }
446
+ // Check specific scope
447
+ const result = await apiKeyEngine.Authorize(userPayload.apiKeyHash, 'MJAPI', scopePath, resource, systemUser, {
448
+ endpoint: '/graphql',
449
+ method: 'POST'
450
+ });
451
+ if (!result.Allowed) {
452
+ // Provide specific, actionable error message
453
+ throw new AuthorizationError(`Access denied. This API key requires the '${scopePath}' scope ` +
454
+ `for resource '${resource}' to perform this operation. ` +
455
+ `Please update the API key's scopes or use an API key with appropriate permissions. ` +
456
+ `Denial reason: ${result.Reason}`);
457
+ }
458
+ }
459
+ /**
460
+ * Optimized RunViewGenericInternal implementation with:
461
+ * - Field filtering at source (Fix #7)
462
+ * - Improved error handling (Fix #9)
463
+ */
338
464
  async RunViewGenericInternal(provider, viewInfo, extraFilter, orderBy, userSearchString, excludeUserViewRunID, overrideExcludeFilter, saveViewResults, fields, ignoreMaxRows, excludeDataFromAllPriorViewRuns, forceAuditLog, auditLogDescription, resultType, userPayload, maxRows, startRow, aggregates) {
339
465
  try {
340
466
  if (!viewInfo || !userPayload)
341
467
  return null;
468
+ // Check API key scope authorization for view operations
469
+ await this.CheckAPIKeyScopeAuthorization('view:run', viewInfo.Entity, userPayload);
342
470
  const md = provider;
343
471
  const user = UserCache.Users.find((u) => u.Email.toLowerCase().trim() === userPayload?.email.toLowerCase().trim());
344
472
  if (!user)
@@ -347,18 +475,23 @@ export class ResolverBase {
347
475
  if (!entityInfo)
348
476
  throw new Error(`Entity ${viewInfo.Entity} not found in metadata`);
349
477
  const rv = md;
478
+ // Determine result type
350
479
  let rt = 'simple';
351
480
  if (resultType?.trim().toLowerCase() === 'count_only') {
352
481
  rt = 'count_only';
353
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
354
485
  let optimizedFields = fields;
355
486
  if (fields?.length) {
487
+ // Always ensure primary keys are included for proper record handling
356
488
  const primaryKeys = entityInfo.PrimaryKeys.map(pk => pk.Name);
357
489
  const missingPrimaryKeys = primaryKeys.filter(pk => !fields.find(f => f.toLowerCase() === pk.toLowerCase()));
358
490
  if (missingPrimaryKeys.length) {
359
491
  optimizedFields = [...fields, ...missingPrimaryKeys];
360
492
  }
361
493
  }
494
+ // Log aggregate request for debugging
362
495
  if (aggregates?.length) {
363
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 })))}`);
364
497
  }
@@ -368,7 +501,7 @@ export class ResolverBase {
368
501
  EntityName: viewInfo.Entity,
369
502
  ExtraFilter: extraFilter,
370
503
  OrderBy: orderBy,
371
- Fields: optimizedFields,
504
+ Fields: optimizedFields, // Use optimized fields list
372
505
  UserSearchString: userSearchString,
373
506
  ExcludeUserViewRunID: excludeUserViewRunID,
374
507
  OverrideExcludeFilter: overrideExcludeFilter,
@@ -382,19 +515,23 @@ export class ResolverBase {
382
515
  ResultType: rt,
383
516
  Aggregates: aggregates,
384
517
  }, user);
518
+ // Log aggregate results for debugging
385
519
  if (aggregates?.length) {
386
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)}`);
387
521
  }
522
+ // Process results for GraphQL transport
388
523
  const mapper = new FieldMapper();
389
524
  if (result?.Success && result.Results?.length) {
390
525
  for (const r of result.Results) {
391
526
  mapper.MapFields(r);
392
527
  }
528
+ // Filter encrypted fields before sending to API client
393
529
  await this.ArrayFilterEncryptedFieldsForAPI(viewInfo.Entity, result.Results, user);
394
530
  }
395
531
  return result;
396
532
  }
397
533
  catch (err) {
534
+ // Fix #9: Improved error handling with structured logging
398
535
  const error = err;
399
536
  LogError({
400
537
  service: 'RunView',
@@ -402,18 +539,27 @@ export class ResolverBase {
402
539
  error: error.message,
403
540
  entityName: viewInfo?.Entity,
404
541
  errorType: error.constructor.name,
542
+ // Only include stack trace for non-validation errors
405
543
  stack: error.message?.includes('not found in metadata') ? undefined : error.stack
406
544
  });
407
545
  throw err;
408
546
  }
409
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
+ */
410
554
  async RunViewsGenericInternal(params) {
411
555
  try {
556
+ // Skip processing if no params
412
557
  if (!params.length)
413
558
  return [];
414
559
  let md = null;
415
560
  const rv = params[0].provider;
416
561
  let runViewParams = [];
562
+ // Fix #1: Get user info only once for all queries
417
563
  let contextUser = null;
418
564
  if (params[0]?.userPayload?.email) {
419
565
  const userEmail = params[0].userPayload.email.toLowerCase().trim();
@@ -423,10 +569,13 @@ export class ResolverBase {
423
569
  }
424
570
  contextUser = user;
425
571
  }
572
+ // Create a map of entities to validate only once per entity
426
573
  const validatedEntities = new Set();
427
574
  md = new Metadata();
575
+ // Transform parameters
428
576
  for (const param of params) {
429
577
  if (param.viewInfo) {
578
+ // Validate entity only once per entity type
430
579
  const entityName = param.viewInfo.Entity;
431
580
  if (!validatedEntities.has(entityName)) {
432
581
  const entityInfo = md.Entities.find(e => e.Name === entityName);
@@ -436,10 +585,12 @@ export class ResolverBase {
436
585
  validatedEntities.add(entityName);
437
586
  }
438
587
  }
588
+ // Determine result type
439
589
  let rt = 'simple';
440
590
  if (param.resultType?.trim().toLowerCase() === 'count_only') {
441
591
  rt = 'count_only';
442
592
  }
593
+ // Build parameters
443
594
  runViewParams.push({
444
595
  ViewID: param.viewInfo.ID,
445
596
  ViewName: param.viewInfo.Name,
@@ -461,7 +612,9 @@ export class ResolverBase {
461
612
  Aggregates: param.aggregates,
462
613
  });
463
614
  }
615
+ // Fix #4: Run views in a single batch through RunViews
464
616
  const runViewResults = await rv.RunViews(runViewParams, contextUser);
617
+ // Process results
465
618
  const mapper = new FieldMapper();
466
619
  for (let i = 0; i < runViewResults.length; i++) {
467
620
  const runViewResult = runViewResults[i];
@@ -469,6 +622,8 @@ export class ResolverBase {
469
622
  for (const result of runViewResult.Results) {
470
623
  mapper.MapFields(result);
471
624
  }
625
+ // Filter encrypted fields before sending to API client
626
+ // Use the corresponding param's entity name
472
627
  const entityName = params[i]?.viewInfo?.Entity;
473
628
  if (entityName && contextUser) {
474
629
  await this.ArrayFilterEncryptedFieldsForAPI(entityName, runViewResult.Results, contextUser);
@@ -478,6 +633,7 @@ export class ResolverBase {
478
633
  return runViewResults;
479
634
  }
480
635
  catch (err) {
636
+ // Fix #9: Structured error logging with less verbosity
481
637
  console.log(err);
482
638
  throw err;
483
639
  }
@@ -525,7 +681,7 @@ export class ResolverBase {
525
681
  throw new Error(`User ${userPayload?.email} not found in metadata`);
526
682
  if (!auditLogType)
527
683
  throw new Error(`Audit Log Type ${auditLogTypeName} not found in metadata`);
528
- 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
529
685
  auditLog.NewRecord();
530
686
  auditLog.UserID = userInfo.ID;
531
687
  auditLog.AuditLogTypeID = auditLogType.ID;
@@ -567,7 +723,7 @@ export class ResolverBase {
567
723
  if (!userPayload)
568
724
  return undefined;
569
725
  if (userPayload.userRecord)
570
- return userPayload.userRecord;
726
+ return userPayload.userRecord; // if we have a user record, use that directly
571
727
  if (!userPayload.email)
572
728
  return undefined;
573
729
  return UserCache.Users.find((u) => u.Email.toLowerCase().trim() === userPayload.email.toLowerCase().trim());
@@ -576,12 +732,18 @@ export class ResolverBase {
576
732
  return Metadata.Provider.ConfigData.MJCoreSchemaName;
577
733
  }
578
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.
579
738
  const uniqueKey = entityObject.EntityInfo.Name;
580
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
581
741
  LogDebug(`ResolverBase.ListenForEntityMessages: About to call MJGlobal.Instance.GetEventListener() to get the event listener subscription for ${uniqueKey}`);
582
742
  const theSub = MJGlobal.Instance.GetEventListener(false).subscribe(async (event) => {
583
743
  if (event) {
584
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
585
747
  if (baseEntity?.EntityInfo?.Name !== uniqueKey) {
586
748
  return;
587
749
  }
@@ -592,6 +754,7 @@ export class ResolverBase {
592
754
  LogDebug(`ResolverBase.ListenForEntityMessages: EmitCloudEvent() completed successfully`);
593
755
  if (event.args && event.args instanceof BaseEntityEvent) {
594
756
  const baseEntityEvent = event.args;
757
+ // message from our entity object, relay it to the client
595
758
  LogDebug('ResolverBase.ListenForEntityMessages: About to publish PUSH_STATUS_UPDATES_TOPIC');
596
759
  pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
597
760
  message: JSON.stringify({
@@ -610,16 +773,23 @@ export class ResolverBase {
610
773
  }
611
774
  }
612
775
  async CreateRecord(entityName, input, provider, userPayload, pubSub) {
776
+ // Check API key scope authorization for entity create operations
777
+ await this.CheckAPIKeyScopeAuthorization('entity:create', entityName, userPayload);
613
778
  if (await this.BeforeCreate(provider, input)) {
779
+ // fire event and proceed if it wasn't cancelled
614
780
  const entityObject = await provider.GetEntityObject(entityName, this.GetUserFromPayload(userPayload));
615
781
  entityObject.NewRecord();
616
782
  entityObject.SetMany(input);
617
783
  this.ListenForEntityMessages(entityObject, pubSub, userPayload);
784
+ // Pass the transactionScopeId from the user payload to the save operation
618
785
  if (await entityObject.Save()) {
619
- 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
620
788
  const contextUser = this.GetUserFromPayload(userPayload);
789
+ // MapFieldNamesToCodeNames now handles encryption filtering as well
621
790
  return await this.MapFieldNamesToCodeNames(entityName, entityObject.GetAll(), contextUser);
622
791
  }
792
+ // save failed, throw error with message
623
793
  else {
624
794
  throw new GraphQLError(entityObject.LatestResult?.CompleteMessage ?? 'Unknown error creating record', {
625
795
  extensions: { code: 'CREATE_ENTITY_ERROR', entityName },
@@ -629,12 +799,16 @@ export class ResolverBase {
629
799
  else
630
800
  return null;
631
801
  }
802
+ // Before/After CREATE Event Hooks for Sub-Classes to Override
632
803
  async BeforeCreate(provider, input) {
633
804
  return true;
634
805
  }
635
806
  async AfterCreate(provider, input) { }
636
807
  async UpdateRecord(entityName, input, provider, userPayload, pubSub) {
808
+ // Check API key scope authorization for entity update operations
809
+ await this.CheckAPIKeyScopeAuthorization('entity:update', entityName, userPayload);
637
810
  if (await this.BeforeUpdate(provider, input)) {
811
+ // fire event and proceed if it wasn't cancelled
638
812
  const userInfo = this.GetUserFromPayload(userPayload);
639
813
  const entityObject = await provider.GetEntityObject(entityName, userInfo);
640
814
  const entityInfo = entityObject.EntityInfo;
@@ -643,8 +817,9 @@ export class ResolverBase {
643
817
  if (key !== 'OldValues___') {
644
818
  clientNewValues[key] = input[key];
645
819
  }
646
- });
820
+ }); // grab all the props except for the OldValues property
647
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
648
823
  const cKey = new CompositeKey(entityInfo.PrimaryKeys.map((pk) => {
649
824
  return {
650
825
  FieldName: pk.Name,
@@ -652,28 +827,38 @@ export class ResolverBase {
652
827
  };
653
828
  }));
654
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.
655
831
  if (input.OldValues___) {
832
+ // we DO have OldValues, so we need to do a more in depth analysis
656
833
  await this.TestAndSetClientOldValuesToDBValues(input, clientNewValues, entityObject, userInfo);
657
834
  }
658
835
  else {
836
+ // no OldValues, so we can just set the new values from input
659
837
  entityObject.SetMany(input);
660
838
  }
661
839
  }
662
840
  else {
841
+ // save failed, return null
663
842
  throw new GraphQLError(`Record not found for ${entityName} with key ${JSON.stringify(cKey)}`, {
664
843
  extensions: { code: 'LOAD_ENTITY_ERROR', entityName },
665
844
  });
666
845
  }
667
846
  }
668
847
  else {
848
+ // we get here if we are NOT tracking changes and we DO have OldValues, so we can load from them
669
849
  const oldValues = {};
850
+ // for each item in the oldValues array, add it to the oldValues object
670
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
671
853
  await entityObject.LoadFromData(oldValues);
854
+ // 2) set the new values from the input, not including the OldValues property
672
855
  entityObject.SetMany(clientNewValues);
673
856
  }
674
857
  this.ListenForEntityMessages(entityObject, pubSub, userPayload);
675
858
  if (await entityObject.Save()) {
676
- 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
677
862
  return await this.MapFieldNamesToCodeNames(entityName, entityObject.GetAll(), userInfo);
678
863
  }
679
864
  else {
@@ -687,13 +872,23 @@ export class ResolverBase {
687
872
  extensions: { code: 'SAVE_ENTITY_ERROR', entityName },
688
873
  });
689
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
+ */
690
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
691
884
  const clientOldValues = {};
885
+ // for each item in the oldValues array, add it to the clientOldValues object
692
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
693
888
  const field = entityObject.EntityInfo.Fields.find((f) => f.CodeName === item.Key);
694
889
  let val = item.Value;
695
890
  if ((val === null || val === undefined) && field.DefaultValue !== null && field.DefaultValue !== undefined && !field.AllowsNull)
696
- val = field.DefaultValue;
891
+ val = field.DefaultValue; // set default value as the field was never set and it does NOT allow nulls
697
892
  if (field) {
698
893
  switch (field.TSType) {
699
894
  case EntityFieldTSType.Number:
@@ -726,17 +921,21 @@ export class ResolverBase {
726
921
  val = val === null || val === undefined || val === 'false' || val === '0' || parseInt(val) === 0 ? false : true;
727
922
  break;
728
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.
729
925
  if (val !== null && val !== undefined && val.toString().trim() !== '' && !isNaN(val))
730
926
  val = parseInt(val);
731
927
  val = val !== null && val !== undefined ? new Date(val) : null;
732
928
  break;
733
929
  default:
734
- break;
930
+ break; // already a string
735
931
  }
736
932
  }
737
933
  clientOldValues[item.Key] = val;
738
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
739
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
740
939
  const dbDifferences = [];
741
940
  Object.keys(clientOldValues).forEach((key) => {
742
941
  const f = entityObject.EntityInfo.Fields.find((f) => f.CodeName === key);
@@ -756,12 +955,13 @@ export class ResolverBase {
756
955
  different = true;
757
956
  }
758
957
  else if (clientDate.getTime() !== dbDate.getTime()) {
958
+ // Check if this is a datetime/datetime2 field with only a timezone hour shift
759
959
  const sqlType = f?.SQLFullType?.trim().toLowerCase() || '';
760
960
  if (sqlType !== 'datetimeoffset' && IsOnlyTimezoneShift(clientDate, dbDate)) {
761
961
  console.warn(`Timezone hour shift detected on field "${key}" (${sqlType || 'datetime'}). ` +
762
962
  `Client: ${clientDate.toISOString()}, DB: ${dbDate.toISOString()}. ` +
763
963
  `Consider using datetimeoffset to avoid timezone ambiguity.`);
764
- different = false;
964
+ different = false; // Allow timezone shifts through for non-datetimeoffset fields
765
965
  }
766
966
  else {
767
967
  different = true;
@@ -777,6 +977,7 @@ export class ResolverBase {
777
977
  break;
778
978
  }
779
979
  if (different && f && !f.ReadOnly) {
980
+ // only include updateable fields
780
981
  dbDifferences.push({
781
982
  FieldName: key,
782
983
  ClientOldValue: clientOldValues[key],
@@ -785,10 +986,13 @@ export class ResolverBase {
785
986
  }
786
987
  });
787
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
788
991
  const clientDifferences = [];
789
992
  Object.keys(clientOldValues).forEach((key) => {
790
993
  const f = entityObject.EntityInfo.Fields.find((f) => f.CodeName === key);
791
994
  if (clientOldValues[key] !== clientNewValues[key] && f && f.AllowUpdateAPI && !f.IsPrimaryKey) {
995
+ // only include updateable fields
792
996
  clientDifferences.push({
793
997
  FieldName: key,
794
998
  ClientOldValue: clientOldValues[key],
@@ -796,6 +1000,8 @@ export class ResolverBase {
796
1000
  });
797
1001
  }
798
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
799
1005
  const overlap = clientDifferences.filter((cd) => dbDifferences.find((dd) => dd.FieldName === cd.FieldName));
800
1006
  if (overlap.length > 0) {
801
1007
  const msg = {
@@ -804,6 +1010,7 @@ export class ResolverBase {
804
1010
  DBDifferences: dbDifferences,
805
1011
  Overlap: overlap,
806
1012
  };
1013
+ // Log as warning to console and ErrorLog table instead of throwing error
807
1014
  console.warn('Entity save inconsistency detected but allowing save to continue:', JSON.stringify(msg));
808
1015
  LogError({
809
1016
  service: 'ResolverBase',
@@ -816,6 +1023,7 @@ export class ResolverBase {
816
1023
  overlap: overlap
817
1024
  }
818
1025
  });
1026
+ // Create ErrorLog record in the database
819
1027
  try {
820
1028
  const md = new Metadata();
821
1029
  const errorLogEntity = await md.GetEntityObject('Error Logs', contextUser);
@@ -834,16 +1042,21 @@ export class ResolverBase {
834
1042
  }
835
1043
  }
836
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...
837
1047
  entityObject.SetMany(clientNewValues);
838
1048
  }
839
1049
  async DeleteRecord(entityName, key, options, provider, userPayload, pubSub) {
1050
+ // Check API key scope authorization for entity delete operations
1051
+ await this.CheckAPIKeyScopeAuthorization('entity:delete', entityName, userPayload);
840
1052
  if (await this.BeforeDelete(provider, key)) {
1053
+ // fire event and proceed if it wasn't cancelled
841
1054
  const entityObject = await provider.GetEntityObject(entityName, this.GetUserFromPayload(userPayload));
842
1055
  await entityObject.InnerLoad(key);
843
- 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.
844
1057
  this.ListenForEntityMessages(entityObject, pubSub, userPayload);
845
1058
  if (await entityObject.Delete(options)) {
846
- await this.AfterDelete(provider, key);
1059
+ await this.AfterDelete(provider, key); // fire event
847
1060
  return returnValue;
848
1061
  }
849
1062
  else {
@@ -858,10 +1071,12 @@ export class ResolverBase {
858
1071
  });
859
1072
  }
860
1073
  }
1074
+ // Before/After DELETE Event Hooks for Sub-Classes to Override
861
1075
  async BeforeDelete(provider, key) {
862
1076
  return true;
863
1077
  }
864
1078
  async AfterDelete(provider, key) { }
1079
+ // Before/After UPDATE Event Hooks for Sub-Classes to Override
865
1080
  async BeforeUpdate(provider, input) {
866
1081
  return true;
867
1082
  }