@memberjunction/server 3.4.0 → 4.1.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.
- package/README.md +689 -513
- package/dist/agents/skip-agent.d.ts +65 -0
- package/dist/agents/skip-agent.d.ts.map +1 -1
- package/dist/agents/skip-agent.js +63 -5
- package/dist/agents/skip-agent.js.map +1 -1
- package/dist/agents/skip-sdk.d.ts +163 -0
- package/dist/agents/skip-sdk.d.ts.map +1 -1
- package/dist/agents/skip-sdk.js +143 -12
- package/dist/agents/skip-sdk.js.map +1 -1
- package/dist/apolloServer/index.d.ts +0 -1
- package/dist/apolloServer/index.d.ts.map +1 -1
- package/dist/auth/APIKeyScopeAuth.d.ts +82 -0
- package/dist/auth/APIKeyScopeAuth.d.ts.map +1 -1
- package/dist/auth/APIKeyScopeAuth.js +78 -0
- package/dist/auth/APIKeyScopeAuth.js.map +1 -1
- package/dist/auth/AuthProviderFactory.d.ts +35 -0
- package/dist/auth/AuthProviderFactory.d.ts.map +1 -1
- package/dist/auth/AuthProviderFactory.js +51 -4
- package/dist/auth/AuthProviderFactory.js.map +1 -1
- package/dist/auth/BaseAuthProvider.d.ts +21 -0
- package/dist/auth/BaseAuthProvider.d.ts.map +1 -1
- package/dist/auth/BaseAuthProvider.js +24 -9
- package/dist/auth/BaseAuthProvider.js.map +1 -1
- package/dist/auth/IAuthProvider.d.ts +32 -0
- package/dist/auth/IAuthProvider.d.ts.map +1 -1
- package/dist/auth/exampleNewUserSubClass.d.ts +5 -1
- package/dist/auth/exampleNewUserSubClass.d.ts.map +1 -1
- package/dist/auth/exampleNewUserSubClass.js +21 -6
- package/dist/auth/exampleNewUserSubClass.js.map +1 -1
- package/dist/auth/index.d.ts +14 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +35 -22
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/initializeProviders.d.ts +3 -0
- package/dist/auth/initializeProviders.d.ts.map +1 -1
- package/dist/auth/initializeProviders.js +6 -0
- package/dist/auth/initializeProviders.js.map +1 -1
- package/dist/auth/newUsers.d.ts.map +1 -1
- package/dist/auth/newUsers.js +14 -3
- package/dist/auth/newUsers.js.map +1 -1
- package/dist/auth/providers/Auth0Provider.d.ts +9 -0
- package/dist/auth/providers/Auth0Provider.d.ts.map +1 -1
- package/dist/auth/providers/Auth0Provider.js +10 -0
- package/dist/auth/providers/Auth0Provider.js.map +1 -1
- package/dist/auth/providers/CognitoProvider.d.ts +9 -0
- package/dist/auth/providers/CognitoProvider.d.ts.map +1 -1
- package/dist/auth/providers/CognitoProvider.js +10 -0
- package/dist/auth/providers/CognitoProvider.js.map +1 -1
- package/dist/auth/providers/GoogleProvider.d.ts +9 -0
- package/dist/auth/providers/GoogleProvider.d.ts.map +1 -1
- package/dist/auth/providers/GoogleProvider.js +11 -1
- package/dist/auth/providers/GoogleProvider.js.map +1 -1
- package/dist/auth/providers/MSALProvider.d.ts +9 -0
- package/dist/auth/providers/MSALProvider.d.ts.map +1 -1
- package/dist/auth/providers/MSALProvider.js +10 -0
- package/dist/auth/providers/MSALProvider.js.map +1 -1
- package/dist/auth/providers/OktaProvider.d.ts +9 -0
- package/dist/auth/providers/OktaProvider.d.ts.map +1 -1
- package/dist/auth/providers/OktaProvider.js +10 -0
- package/dist/auth/providers/OktaProvider.js.map +1 -1
- package/dist/config.d.ts +12 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +42 -8
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +8 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +26 -4
- package/dist/context.js.map +1 -1
- package/dist/directives/Public.js +2 -0
- package/dist/directives/Public.js.map +1 -1
- package/dist/entitySubclasses/entityPermissions.server.d.ts +7 -2
- package/dist/entitySubclasses/entityPermissions.server.d.ts.map +1 -1
- package/dist/entitySubclasses/entityPermissions.server.js +26 -8
- package/dist/entitySubclasses/entityPermissions.server.js.map +1 -1
- package/dist/generated/generated.d.ts +539 -2
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +9985 -14951
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/DeleteOptionsInput.d.ts +3 -0
- package/dist/generic/DeleteOptionsInput.d.ts.map +1 -1
- package/dist/generic/DeleteOptionsInput.js +3 -2
- package/dist/generic/DeleteOptionsInput.js.map +1 -1
- package/dist/generic/KeyInputOutputTypes.js +0 -6
- package/dist/generic/KeyInputOutputTypes.js.map +1 -1
- package/dist/generic/KeyValuePairInput.d.ts +4 -0
- package/dist/generic/KeyValuePairInput.d.ts.map +1 -1
- package/dist/generic/KeyValuePairInput.js +4 -2
- package/dist/generic/KeyValuePairInput.js.map +1 -1
- package/dist/generic/PushStatusResolver.js +0 -3
- package/dist/generic/PushStatusResolver.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +58 -0
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +203 -18
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts +22 -0
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +42 -108
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +94 -37
- package/dist/index.js.map +1 -1
- package/dist/orm.d.ts.map +1 -1
- package/dist/orm.js +2 -1
- package/dist/orm.js.map +1 -1
- package/dist/resolvers/APIKeyResolver.d.ts +74 -0
- package/dist/resolvers/APIKeyResolver.d.ts.map +1 -1
- package/dist/resolvers/APIKeyResolver.js +49 -10
- package/dist/resolvers/APIKeyResolver.js.map +1 -1
- package/dist/resolvers/ActionResolver.d.ts +189 -0
- package/dist/resolvers/ActionResolver.d.ts.map +1 -1
- package/dist/resolvers/ActionResolver.js +152 -21
- package/dist/resolvers/ActionResolver.js.map +1 -1
- package/dist/resolvers/ColorResolver.js +0 -5
- package/dist/resolvers/ColorResolver.js.map +1 -1
- package/dist/resolvers/ComponentRegistryResolver.d.ts +65 -0
- package/dist/resolvers/ComponentRegistryResolver.d.ts.map +1 -1
- package/dist/resolvers/ComponentRegistryResolver.js +118 -40
- package/dist/resolvers/ComponentRegistryResolver.js.map +1 -1
- package/dist/resolvers/CreateQueryResolver.d.ts +47 -0
- package/dist/resolvers/CreateQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/CreateQueryResolver.js +92 -116
- package/dist/resolvers/CreateQueryResolver.js.map +1 -1
- package/dist/resolvers/DatasetResolver.js +2 -14
- package/dist/resolvers/DatasetResolver.js.map +1 -1
- package/dist/resolvers/EntityCommunicationsResolver.d.ts +40 -0
- package/dist/resolvers/EntityCommunicationsResolver.d.ts.map +1 -1
- package/dist/resolvers/EntityCommunicationsResolver.js +2 -36
- package/dist/resolvers/EntityCommunicationsResolver.js.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.js +0 -7
- package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
- package/dist/resolvers/FileCategoryResolver.d.ts +1 -1
- package/dist/resolvers/FileCategoryResolver.d.ts.map +1 -1
- package/dist/resolvers/FileCategoryResolver.js +15 -3
- package/dist/resolvers/FileCategoryResolver.js.map +1 -1
- package/dist/resolvers/FileResolver.d.ts +16 -0
- package/dist/resolvers/FileResolver.d.ts.map +1 -1
- package/dist/resolvers/FileResolver.js +59 -74
- package/dist/resolvers/FileResolver.js.map +1 -1
- package/dist/resolvers/GetDataContextDataResolver.d.ts +18 -1
- package/dist/resolvers/GetDataContextDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataContextDataResolver.js +17 -9
- package/dist/resolvers/GetDataContextDataResolver.js.map +1 -1
- package/dist/resolvers/GetDataResolver.d.ts +19 -0
- package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataResolver.js +35 -35
- package/dist/resolvers/GetDataResolver.js.map +1 -1
- package/dist/resolvers/InfoResolver.d.ts +2 -2
- package/dist/resolvers/InfoResolver.d.ts.map +1 -1
- package/dist/resolvers/InfoResolver.js +17 -20
- package/dist/resolvers/InfoResolver.js.map +1 -1
- package/dist/resolvers/MCPResolver.d.ts +325 -1
- package/dist/resolvers/MCPResolver.d.ts.map +1 -1
- package/dist/resolvers/MCPResolver.js +931 -24
- package/dist/resolvers/MCPResolver.js.map +1 -1
- package/dist/resolvers/MergeRecordsResolver.js +3 -29
- package/dist/resolvers/MergeRecordsResolver.js.map +1 -1
- package/dist/resolvers/PotentialDuplicateRecordResolver.d.ts.map +1 -1
- package/dist/resolvers/PotentialDuplicateRecordResolver.js +0 -3
- package/dist/resolvers/PotentialDuplicateRecordResolver.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts +20 -0
- package/dist/resolvers/QueryResolver.d.ts.map +1 -1
- package/dist/resolvers/QueryResolver.js +44 -36
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/ReportResolver.d.ts +3 -0
- package/dist/resolvers/ReportResolver.d.ts.map +1 -1
- package/dist/resolvers/ReportResolver.js +9 -10
- package/dist/resolvers/ReportResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts +54 -0
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +116 -40
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/RunAIPromptResolver.d.ts +42 -0
- package/dist/resolvers/RunAIPromptResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIPromptResolver.js +95 -22
- package/dist/resolvers/RunAIPromptResolver.js.map +1 -1
- package/dist/resolvers/RunTemplateResolver.js +9 -6
- package/dist/resolvers/RunTemplateResolver.js.map +1 -1
- package/dist/resolvers/RunTestResolver.d.ts +12 -0
- package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
- package/dist/resolvers/RunTestResolver.js +35 -21
- package/dist/resolvers/RunTestResolver.js.map +1 -1
- package/dist/resolvers/SqlLoggingConfigResolver.d.ts +312 -0
- package/dist/resolvers/SqlLoggingConfigResolver.d.ts.map +1 -1
- package/dist/resolvers/SqlLoggingConfigResolver.js +295 -45
- package/dist/resolvers/SqlLoggingConfigResolver.js.map +1 -1
- package/dist/resolvers/SyncDataResolver.d.ts +21 -0
- package/dist/resolvers/SyncDataResolver.d.ts.map +1 -1
- package/dist/resolvers/SyncDataResolver.js +36 -22
- package/dist/resolvers/SyncDataResolver.js.map +1 -1
- package/dist/resolvers/SyncRolesUsersResolver.d.ts +14 -0
- package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
- package/dist/resolvers/SyncRolesUsersResolver.js +54 -21
- package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
- package/dist/resolvers/TaskResolver.d.ts +13 -0
- package/dist/resolvers/TaskResolver.d.ts.map +1 -1
- package/dist/resolvers/TaskResolver.js +22 -7
- package/dist/resolvers/TaskResolver.js.map +1 -1
- package/dist/resolvers/TelemetryResolver.d.ts +22 -0
- package/dist/resolvers/TelemetryResolver.d.ts.map +1 -1
- package/dist/resolvers/TelemetryResolver.js +45 -79
- package/dist/resolvers/TelemetryResolver.js.map +1 -1
- package/dist/resolvers/TransactionGroupResolver.js +11 -13
- package/dist/resolvers/TransactionGroupResolver.js.map +1 -1
- package/dist/resolvers/UserFavoriteResolver.js +3 -12
- package/dist/resolvers/UserFavoriteResolver.js.map +1 -1
- package/dist/resolvers/UserResolver.js +10 -0
- package/dist/resolvers/UserResolver.js.map +1 -1
- package/dist/resolvers/UserViewResolver.js +4 -0
- package/dist/resolvers/UserViewResolver.js.map +1 -1
- package/dist/resolvers/VersionHistoryResolver.d.ts +39 -0
- package/dist/resolvers/VersionHistoryResolver.d.ts.map +1 -0
- package/dist/resolvers/VersionHistoryResolver.js +208 -0
- package/dist/resolvers/VersionHistoryResolver.js.map +1 -0
- package/dist/rest/EntityCRUDHandler.d.ts +19 -0
- package/dist/rest/EntityCRUDHandler.d.ts.map +1 -1
- package/dist/rest/EntityCRUDHandler.js +55 -0
- package/dist/rest/EntityCRUDHandler.js.map +1 -1
- package/dist/rest/OAuthCallbackHandler.d.ts +143 -0
- package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -0
- package/dist/rest/OAuthCallbackHandler.js +634 -0
- package/dist/rest/OAuthCallbackHandler.js.map +1 -0
- package/dist/rest/RESTEndpointHandler.d.ts +120 -0
- package/dist/rest/RESTEndpointHandler.d.ts.map +1 -1
- package/dist/rest/RESTEndpointHandler.js +213 -24
- package/dist/rest/RESTEndpointHandler.js.map +1 -1
- package/dist/rest/ViewOperationsHandler.d.ts +19 -0
- package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
- package/dist/rest/ViewOperationsHandler.js +39 -0
- package/dist/rest/ViewOperationsHandler.js.map +1 -1
- package/dist/rest/index.d.ts +1 -0
- package/dist/rest/index.d.ts.map +1 -1
- package/dist/rest/index.js +1 -0
- package/dist/rest/index.js.map +1 -1
- package/dist/rest/setupRESTEndpoints.d.ts +35 -0
- package/dist/rest/setupRESTEndpoints.d.ts.map +1 -1
- package/dist/rest/setupRESTEndpoints.js +15 -1
- package/dist/rest/setupRESTEndpoints.js.map +1 -1
- package/dist/services/ScheduledJobsService.d.ts +31 -0
- package/dist/services/ScheduledJobsService.d.ts.map +1 -1
- package/dist/services/ScheduledJobsService.js +38 -4
- package/dist/services/ScheduledJobsService.js.map +1 -1
- package/dist/services/TaskOrchestrator.d.ts +73 -0
- package/dist/services/TaskOrchestrator.d.ts.map +1 -1
- package/dist/services/TaskOrchestrator.js +137 -15
- package/dist/services/TaskOrchestrator.js.map +1 -1
- package/dist/types.d.ts +14 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +0 -13
- package/dist/types.js.map +1 -1
- package/dist/util.d.ts +37 -1
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +55 -8
- package/dist/util.js.map +1 -1
- package/package.json +83 -78
- package/src/auth/exampleNewUserSubClass.ts +1 -5
- package/src/auth/newUsers.ts +4 -2
- package/src/entitySubclasses/entityPermissions.server.ts +1 -3
- package/src/generated/generated.ts +4707 -2664
- package/src/index.ts +73 -62
- package/src/resolvers/FileCategoryResolver.ts +1 -1
- package/src/resolvers/InfoResolver.ts +10 -6
- package/src/resolvers/MCPResolver.ts +910 -10
- package/src/resolvers/PotentialDuplicateRecordResolver.ts +0 -4
- package/src/resolvers/VersionHistoryResolver.ts +177 -0
- package/src/rest/OAuthCallbackHandler.ts +766 -0
- package/src/rest/RESTEndpointHandler.ts +58 -35
- package/src/rest/index.ts +2 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
}
|