@memberjunction/server 5.24.0 → 5.25.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/dist/agents/skip-sdk.d.ts +12 -0
- package/dist/agents/skip-sdk.d.ts.map +1 -1
- package/dist/agents/skip-sdk.js +70 -1
- package/dist/agents/skip-sdk.js.map +1 -1
- package/dist/generated/generated.d.ts +492 -0
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +2731 -0
- package/dist/generated/generated.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/resolvers/ArtifactFileResolver.d.ts +15 -0
- package/dist/resolvers/ArtifactFileResolver.d.ts.map +1 -0
- package/dist/resolvers/ArtifactFileResolver.js +74 -0
- package/dist/resolvers/ArtifactFileResolver.js.map +1 -0
- package/dist/resolvers/AutotagPipelineResolver.d.ts +13 -0
- package/dist/resolvers/AutotagPipelineResolver.d.ts.map +1 -1
- package/dist/resolvers/AutotagPipelineResolver.js +103 -3
- package/dist/resolvers/AutotagPipelineResolver.js.map +1 -1
- package/dist/resolvers/FileResolver.d.ts.map +1 -1
- package/dist/resolvers/FileResolver.js +12 -32
- package/dist/resolvers/FileResolver.js.map +1 -1
- package/dist/resolvers/GeoResolver.d.ts +58 -0
- package/dist/resolvers/GeoResolver.d.ts.map +1 -0
- package/dist/resolvers/GeoResolver.js +302 -0
- package/dist/resolvers/GeoResolver.js.map +1 -0
- package/dist/resolvers/RunAIAgentResolver.d.ts +13 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +115 -20
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.d.ts +21 -80
- package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.js +129 -604
- package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeSystemUserResolver.d.ts +19 -0
- package/dist/resolvers/SearchKnowledgeSystemUserResolver.d.ts.map +1 -0
- package/dist/resolvers/SearchKnowledgeSystemUserResolver.js +149 -0
- package/dist/resolvers/SearchKnowledgeSystemUserResolver.js.map +1 -0
- package/package.json +63 -63
- package/src/__tests__/search-knowledge-tags.test.ts +177 -337
- package/src/__tests__/skip-sdk-organic-keys.test.ts +274 -0
- package/src/agents/skip-sdk.ts +83 -2
- package/src/generated/generated.ts +1884 -1
- package/src/index.ts +2 -0
- package/src/resolvers/ArtifactFileResolver.ts +71 -0
- package/src/resolvers/AutotagPipelineResolver.ts +118 -4
- package/src/resolvers/FileResolver.ts +12 -41
- package/src/resolvers/GeoResolver.ts +258 -0
- package/src/resolvers/RunAIAgentResolver.ts +137 -23
- package/src/resolvers/SearchKnowledgeResolver.ts +114 -715
- package/src/resolvers/SearchKnowledgeSystemUserResolver.ts +138 -0
package/src/index.ts
CHANGED
|
@@ -108,6 +108,7 @@ export * from './generic/DeleteOptionsInput.js';
|
|
|
108
108
|
export * from './agents/skip-agent.js';
|
|
109
109
|
export * from './agents/skip-sdk.js';
|
|
110
110
|
|
|
111
|
+
export * from './resolvers/GeoResolver.js';
|
|
111
112
|
export * from './resolvers/ColorResolver.js';
|
|
112
113
|
export * from './resolvers/ComponentRegistryResolver.js';
|
|
113
114
|
export * from './resolvers/DatasetResolver.js';
|
|
@@ -130,6 +131,7 @@ export * from './resolvers/ActionResolver.js';
|
|
|
130
131
|
export * from './resolvers/EntityCommunicationsResolver.js';
|
|
131
132
|
export * from './resolvers/EntityResolver.js';
|
|
132
133
|
export * from './resolvers/ISAEntityResolver.js';
|
|
134
|
+
export * from './resolvers/ArtifactFileResolver.js';
|
|
133
135
|
export * from './resolvers/FileCategoryResolver.js';
|
|
134
136
|
export * from './resolvers/FileResolver.js';
|
|
135
137
|
export * from './resolvers/InfoResolver.js';
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Resolver, Query, Arg, Ctx } from 'type-graphql';
|
|
2
|
+
import { Metadata, IMetadataProvider } from '@memberjunction/core';
|
|
3
|
+
import { MJArtifactVersionEntity, MJFileEntity } from '@memberjunction/core-entities';
|
|
4
|
+
import { FileStorageEngine } from '@memberjunction/storage';
|
|
5
|
+
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
6
|
+
import { AppContext } from '../types.js';
|
|
7
|
+
import { GetReadWriteProvider } from '../util.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* GraphQL resolver that produces a short-lived download URL for an artifact version
|
|
11
|
+
* stored as a binary file (ContentMode = 'File').
|
|
12
|
+
*
|
|
13
|
+
* Separating this from the generated FileResolver keeps the artifact-specific
|
|
14
|
+
* logic in one place and avoids mixing concerns in the large FileResolver class.
|
|
15
|
+
*/
|
|
16
|
+
@Resolver()
|
|
17
|
+
export class ArtifactFileResolver extends ResolverBase {
|
|
18
|
+
|
|
19
|
+
@Query(() => String, { description: 'Returns a pre-authenticated download URL for an artifact version whose ContentMode is "File".' })
|
|
20
|
+
async ArtifactFileDownloadUrl(
|
|
21
|
+
@Arg('artifactVersionId', () => String) artifactVersionId: string,
|
|
22
|
+
@Ctx() context: AppContext,
|
|
23
|
+
): Promise<string> {
|
|
24
|
+
const user = this.GetUserFromPayload(context.userPayload);
|
|
25
|
+
if (!user) {
|
|
26
|
+
throw new Error('Unauthorized');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const p = GetReadWriteProvider(context.providers);
|
|
30
|
+
|
|
31
|
+
// Load the artifact version
|
|
32
|
+
const artifactVersion = await p.GetEntityObject<MJArtifactVersionEntity>('MJ: Artifact Versions', user);
|
|
33
|
+
const loaded = await artifactVersion.Load(artifactVersionId);
|
|
34
|
+
if (!loaded) {
|
|
35
|
+
throw new Error(`Artifact version ${artifactVersionId} not found`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (artifactVersion.ContentMode !== 'File') {
|
|
39
|
+
throw new Error(`Artifact version ${artifactVersionId} is not a file artifact (ContentMode=${artifactVersion.ContentMode})`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!artifactVersion.FileID) {
|
|
43
|
+
throw new Error(`Artifact version ${artifactVersionId} has no associated file`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return this.buildDownloadUrl(artifactVersion.FileID, user, p);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Load the File + its storage account/provider and generate a signed URL. */
|
|
50
|
+
private async buildDownloadUrl(
|
|
51
|
+
fileId: string,
|
|
52
|
+
user: ReturnType<ResolverBase['GetUserFromPayload']>,
|
|
53
|
+
provider: IMetadataProvider,
|
|
54
|
+
): Promise<string> {
|
|
55
|
+
const fileEntity = await provider.GetEntityObject<MJFileEntity>('MJ: Files', user);
|
|
56
|
+
const fileLoaded = await fileEntity.Load(fileId);
|
|
57
|
+
if (!fileLoaded) {
|
|
58
|
+
throw new Error(`File record ${fileId} not found`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Find the storage account for this file's provider using cached metadata
|
|
62
|
+
await FileStorageEngine.Instance.Config(false, user!);
|
|
63
|
+
const matchingAccounts = FileStorageEngine.Instance.GetAccountsByProviderID(fileEntity.ProviderID);
|
|
64
|
+
if (matchingAccounts.length === 0) {
|
|
65
|
+
throw new Error(`No FileStorageAccount found for ProviderID ${fileEntity.ProviderID}. Cannot generate download URL.`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const driver = await FileStorageEngine.Instance.GetDriver(matchingAccounts[0].ID, user!);
|
|
69
|
+
return driver.CreatePreAuthDownloadUrl(fileEntity.ProviderKey ?? fileEntity.Name);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Resolver, Mutation, Ctx, Arg, ObjectType, Field } from 'type-graphql';
|
|
2
2
|
import { AppContext } from '../types.js';
|
|
3
|
-
import { LogError, LogStatus, Metadata } from '@memberjunction/core';
|
|
3
|
+
import { LogError, LogStatus, Metadata, RunView, UserInfo } from '@memberjunction/core';
|
|
4
4
|
import { MJContentProcessRunEntity } from '@memberjunction/core-entities';
|
|
5
5
|
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
6
6
|
import { ActionEngineServer } from '@memberjunction/actions';
|
|
@@ -67,11 +67,13 @@ export class AutotagPipelineResolver extends ResolverBase {
|
|
|
67
67
|
*/
|
|
68
68
|
private async runPipelineInBackground(
|
|
69
69
|
pipelineRunID: string,
|
|
70
|
-
currentUser:
|
|
70
|
+
currentUser: UserInfo,
|
|
71
71
|
contentSourceIDs?: string[],
|
|
72
72
|
forceReprocess?: boolean
|
|
73
73
|
): Promise<void> {
|
|
74
74
|
const startTime = Date.now();
|
|
75
|
+
const processRun = await this.createProcessRun(pipelineRunID, currentUser, contentSourceIDs);
|
|
76
|
+
|
|
75
77
|
try {
|
|
76
78
|
this.publishProgress(pipelineRunID, 'autotag', 0, 0, startTime, 'Initializing pipeline...');
|
|
77
79
|
|
|
@@ -83,22 +85,26 @@ export class AutotagPipelineResolver extends ResolverBase {
|
|
|
83
85
|
if (!action) {
|
|
84
86
|
LogError(`RunAutotagPipeline: Action 'Autotag and Vectorize Content' not found`);
|
|
85
87
|
this.publishProgress(pipelineRunID, 'error', 0, 0, startTime, 'Autotag action not found');
|
|
88
|
+
await this.completeProcessRun(processRun, 'Failed', 'Autotag action not found', currentUser);
|
|
86
89
|
return;
|
|
87
90
|
}
|
|
88
91
|
|
|
89
92
|
// Stage: autotagging — provide a progress callback that publishes per-item updates
|
|
93
|
+
// and keeps the process run record in sync
|
|
90
94
|
this.publishProgress(pipelineRunID, 'autotag', 0, 0, startTime, 'Running autotaggers...');
|
|
91
95
|
|
|
92
96
|
const progressCallback = (processed: number, total: number, currentItem?: string) => {
|
|
93
97
|
const pct = total > 0 ? Math.round((processed / total) * 80) : 0; // 0-80% for tagging
|
|
94
98
|
this.publishProgress(pipelineRunID, 'autotag', total, pct, startTime, currentItem || `${processed}/${total} items`);
|
|
99
|
+
this.updateProcessRunProgress(processRun, processed, total);
|
|
95
100
|
};
|
|
96
101
|
|
|
97
|
-
// Build action params
|
|
102
|
+
// Build action params — include the process run ID so the action can create detail records
|
|
98
103
|
const actionParams: Array<{ Name: string; Value: unknown; Type: 'Input' | 'Output' | 'Both' }> = [
|
|
99
104
|
{ Name: 'Autotag', Value: 1, Type: 'Input' },
|
|
100
105
|
{ Name: 'Vectorize', Value: 1, Type: 'Input' },
|
|
101
|
-
{ Name: '__progressCallback', Value: progressCallback, Type: 'Input' }
|
|
106
|
+
{ Name: '__progressCallback', Value: progressCallback, Type: 'Input' },
|
|
107
|
+
{ Name: 'ContentProcessRunID', Value: pipelineRunID, Type: 'Input' }
|
|
102
108
|
];
|
|
103
109
|
if (contentSourceIDs && contentSourceIDs.length > 0) {
|
|
104
110
|
actionParams.push({ Name: 'ContentSourceIDs', Value: contentSourceIDs, Type: 'Input' });
|
|
@@ -120,14 +126,122 @@ export class AutotagPipelineResolver extends ResolverBase {
|
|
|
120
126
|
if (result.Success) {
|
|
121
127
|
LogStatus(`RunAutotagPipeline: pipeline ${pipelineRunID} completed successfully`);
|
|
122
128
|
this.publishProgress(pipelineRunID, 'complete', 100, 100, startTime);
|
|
129
|
+
await this.completeProcessRun(processRun, 'Completed', undefined, currentUser);
|
|
123
130
|
} else {
|
|
124
131
|
LogError(`RunAutotagPipeline: pipeline ${pipelineRunID} failed: ${result.Message}`);
|
|
125
132
|
this.publishProgress(pipelineRunID, 'error', 0, 0, startTime, String(result.Message));
|
|
133
|
+
await this.completeProcessRun(processRun, 'Failed', String(result.Message), currentUser);
|
|
126
134
|
}
|
|
127
135
|
} catch (error) {
|
|
128
136
|
const msg = error instanceof Error ? error.message : String(error);
|
|
129
137
|
LogError(`RunAutotagPipeline pipeline ${pipelineRunID} failed: ${msg}`);
|
|
130
138
|
this.publishProgress(pipelineRunID, 'error', 0, 0, startTime, msg);
|
|
139
|
+
await this.completeProcessRun(processRun, 'Failed', msg, currentUser);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Create a ContentProcessRun record to track this pipeline execution.
|
|
145
|
+
* Returns the entity so it can be updated during and after the run.
|
|
146
|
+
*/
|
|
147
|
+
private async createProcessRun(
|
|
148
|
+
pipelineRunID: string,
|
|
149
|
+
currentUser: UserInfo,
|
|
150
|
+
contentSourceIDs?: string[]
|
|
151
|
+
): Promise<MJContentProcessRunEntity | null> {
|
|
152
|
+
try {
|
|
153
|
+
// Resolve the source ID — use the specified source or the first available
|
|
154
|
+
let sourceID: string | undefined;
|
|
155
|
+
if (contentSourceIDs && contentSourceIDs.length > 0) {
|
|
156
|
+
sourceID = contentSourceIDs[0];
|
|
157
|
+
} else {
|
|
158
|
+
// Load content sources to get any available source ID (SourceID is NOT NULL)
|
|
159
|
+
const rv = new RunView();
|
|
160
|
+
const result = await rv.RunView<{ ID: string }>({
|
|
161
|
+
EntityName: 'MJ: Content Sources',
|
|
162
|
+
Fields: ['ID'],
|
|
163
|
+
ResultType: 'simple',
|
|
164
|
+
MaxRows: 1
|
|
165
|
+
}, currentUser);
|
|
166
|
+
if (result.Success && result.Results.length > 0) {
|
|
167
|
+
sourceID = result.Results[0].ID;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!sourceID) {
|
|
172
|
+
LogError('RunAutotagPipeline: no content sources available, cannot create process run');
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const md = new Metadata();
|
|
177
|
+
const run = await md.GetEntityObject<MJContentProcessRunEntity>('MJ: Content Process Runs', currentUser);
|
|
178
|
+
run.NewRecord();
|
|
179
|
+
run.ID = pipelineRunID;
|
|
180
|
+
run.SourceID = sourceID;
|
|
181
|
+
run.StartTime = new Date();
|
|
182
|
+
run.Status = 'Running';
|
|
183
|
+
run.StartedByUserID = currentUser.ID;
|
|
184
|
+
run.ProcessedItems = 0;
|
|
185
|
+
run.TotalItemCount = 0;
|
|
186
|
+
run.LastProcessedOffset = 0;
|
|
187
|
+
run.ErrorCount = 0;
|
|
188
|
+
run.BatchSize = 20;
|
|
189
|
+
run.CancellationRequested = false;
|
|
190
|
+
const saved = await run.Save();
|
|
191
|
+
if (!saved) {
|
|
192
|
+
LogError(`RunAutotagPipeline: failed to create ContentProcessRun: ${run.LatestResult?.CompleteMessage}`);
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
LogStatus(`RunAutotagPipeline: created ContentProcessRun ${pipelineRunID}`);
|
|
196
|
+
return run;
|
|
197
|
+
} catch (error) {
|
|
198
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
199
|
+
LogError(`RunAutotagPipeline: error creating ContentProcessRun: ${msg}`);
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Update the process run record with current progress (fire-and-forget, non-blocking).
|
|
206
|
+
*/
|
|
207
|
+
private updateProcessRunProgress(
|
|
208
|
+
processRun: MJContentProcessRunEntity | null,
|
|
209
|
+
processedItems: number,
|
|
210
|
+
totalItems: number
|
|
211
|
+
): void {
|
|
212
|
+
if (!processRun) return;
|
|
213
|
+
processRun.ProcessedItems = processedItems;
|
|
214
|
+
processRun.TotalItemCount = totalItems;
|
|
215
|
+
processRun.LastProcessedOffset = processedItems;
|
|
216
|
+
// Fire-and-forget save — don't await to avoid slowing down the pipeline
|
|
217
|
+
processRun.Save().catch(e => {
|
|
218
|
+
LogError(`RunAutotagPipeline: error updating process run progress: ${e instanceof Error ? e.message : String(e)}`);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Mark the process run as completed, failed, or cancelled.
|
|
224
|
+
*/
|
|
225
|
+
private async completeProcessRun(
|
|
226
|
+
processRun: MJContentProcessRunEntity | null,
|
|
227
|
+
status: string,
|
|
228
|
+
errorMessage: string | undefined,
|
|
229
|
+
currentUser: import('@memberjunction/core').UserInfo
|
|
230
|
+
): Promise<void> {
|
|
231
|
+
if (!processRun) return;
|
|
232
|
+
try {
|
|
233
|
+
processRun.Status = status;
|
|
234
|
+
processRun.EndTime = new Date();
|
|
235
|
+
if (errorMessage) {
|
|
236
|
+
processRun.ErrorMessage = errorMessage;
|
|
237
|
+
processRun.ErrorCount = (processRun.ErrorCount ?? 0) + 1;
|
|
238
|
+
}
|
|
239
|
+
const saved = await processRun.Save();
|
|
240
|
+
if (!saved) {
|
|
241
|
+
LogError(`RunAutotagPipeline: failed to complete ContentProcessRun: ${processRun.LatestResult?.CompleteMessage}`);
|
|
242
|
+
}
|
|
243
|
+
} catch (error) {
|
|
244
|
+
LogError(`RunAutotagPipeline: error completing ContentProcessRun: ${error instanceof Error ? error.message : String(error)}`);
|
|
131
245
|
}
|
|
132
246
|
}
|
|
133
247
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { EntityPermissionType, Metadata, FieldValueCollection, EntitySaveOptions
|
|
1
|
+
import { EntityPermissionType, Metadata, FieldValueCollection, EntitySaveOptions } from '@memberjunction/core';
|
|
2
2
|
import { NormalizeUUID } from '@memberjunction/global';
|
|
3
3
|
import { MJFileEntity, MJFileStorageProviderEntity, MJFileStorageAccountEntity } from '@memberjunction/core-entities';
|
|
4
4
|
import {
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
FileSearchResult,
|
|
33
33
|
UserContextOptions,
|
|
34
34
|
ExtendedUserContextOptions,
|
|
35
|
-
|
|
35
|
+
FileStorageEngine,
|
|
36
36
|
} from '@memberjunction/storage';
|
|
37
37
|
import { CreateMJFileInput, MJFileResolver as FileResolverBase, MJFile_, UpdateMJFileInput } from '../generated/generated.js';
|
|
38
38
|
import { FieldMapper } from '@memberjunction/graphql-dataprovider';
|
|
@@ -648,12 +648,9 @@ export class FileResolver extends FileResolverBase {
|
|
|
648
648
|
const fileEntity = await md.GetEntityObject<MJFileEntity>('MJ: Files', user);
|
|
649
649
|
fileEntity.CheckPermissions(EntityPermissionType.Create, true);
|
|
650
650
|
|
|
651
|
-
// Initialize driver
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
providerEntity,
|
|
655
|
-
contextUser: user,
|
|
656
|
-
});
|
|
651
|
+
// Initialize driver via FileStorageEngine (handles credential decryption + token refresh)
|
|
652
|
+
await FileStorageEngine.Instance.Config(false, user);
|
|
653
|
+
const driver = await FileStorageEngine.Instance.GetDriver(accountEntity.ID, user);
|
|
657
654
|
|
|
658
655
|
const success = await driver.CreateDirectory(input.Path);
|
|
659
656
|
return success;
|
|
@@ -726,23 +723,12 @@ export class FileResolver extends FileResolverBase {
|
|
|
726
723
|
const fileEntity = await md.GetEntityObject<MJFileEntity>('MJ: Files', user);
|
|
727
724
|
fileEntity.CheckPermissions(EntityPermissionType.Read, true);
|
|
728
725
|
|
|
729
|
-
//
|
|
730
|
-
|
|
731
|
-
const
|
|
732
|
-
const
|
|
733
|
-
|
|
734
|
-
EntityName: 'MJ: File Storage Accounts',
|
|
735
|
-
ExtraFilter: `ID IN (${quotedIDs})`,
|
|
736
|
-
ResultType: 'entity_object',
|
|
737
|
-
},
|
|
738
|
-
user,
|
|
739
|
-
);
|
|
726
|
+
// Use cached accounts from the engine — no RunView needed
|
|
727
|
+
await FileStorageEngine.Instance.Config(false, user);
|
|
728
|
+
const normalizedIDs = new Set(input.AccountIDs.map((id: string) => NormalizeUUID(id)));
|
|
729
|
+
const accountEntities = FileStorageEngine.Instance.Accounts
|
|
730
|
+
.filter(a => normalizedIDs.has(NormalizeUUID(a.ID)));
|
|
740
731
|
|
|
741
|
-
if (!accountResult.Success) {
|
|
742
|
-
throw new Error(`Failed to load storage accounts: ${accountResult.ErrorMessage}`);
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
const accountEntities = accountResult.Results;
|
|
746
732
|
if (accountEntities.length === 0) {
|
|
747
733
|
throw new Error('No valid storage accounts found for the provided IDs');
|
|
748
734
|
}
|
|
@@ -754,24 +740,9 @@ export class FileResolver extends FileResolverBase {
|
|
|
754
740
|
console.warn(`[FileResolver] Accounts not found: ${missingIDs.join(', ')}`);
|
|
755
741
|
}
|
|
756
742
|
|
|
757
|
-
// Load providers
|
|
758
|
-
const providerIDs = [...new Set(accountEntities.map((a) => a.ProviderID))];
|
|
759
|
-
const quotedProviderIDs = providerIDs.map((id) => `'${id}'`).join(', ');
|
|
760
|
-
const providerResult = await rv.RunView<MJFileStorageProviderEntity>(
|
|
761
|
-
{
|
|
762
|
-
EntityName: 'MJ: File Storage Providers',
|
|
763
|
-
ExtraFilter: `ID IN (${quotedProviderIDs})`,
|
|
764
|
-
ResultType: 'entity_object',
|
|
765
|
-
},
|
|
766
|
-
user,
|
|
767
|
-
);
|
|
768
|
-
|
|
769
|
-
if (!providerResult.Success) {
|
|
770
|
-
throw new Error(`Failed to load storage providers: ${providerResult.ErrorMessage}`);
|
|
771
|
-
}
|
|
772
|
-
|
|
743
|
+
// Load providers from cached metadata
|
|
773
744
|
const providerMap = new Map<string, MJFileStorageProviderEntity>();
|
|
774
|
-
for (const provider of
|
|
745
|
+
for (const provider of FileStorageEngine.Instance.Providers) {
|
|
775
746
|
providerMap.set(provider.ID, provider);
|
|
776
747
|
}
|
|
777
748
|
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { Resolver, Query, Arg, Ctx, ObjectType, Field, InputType, Float, Int } from 'type-graphql';
|
|
2
|
+
import { AppContext } from '../types.js';
|
|
3
|
+
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
4
|
+
import { RunView, LogError, Metadata, UserInfo } from '@memberjunction/core';
|
|
5
|
+
import { MJCountryEntity, MJStateProvinceEntity } from '@memberjunction/core-entities';
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Types
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
@ObjectType()
|
|
12
|
+
export class GeoCountryResult {
|
|
13
|
+
@Field()
|
|
14
|
+
ID!: string;
|
|
15
|
+
|
|
16
|
+
@Field()
|
|
17
|
+
Name!: string;
|
|
18
|
+
|
|
19
|
+
@Field()
|
|
20
|
+
ISO2!: string;
|
|
21
|
+
|
|
22
|
+
@Field()
|
|
23
|
+
ISO3!: string;
|
|
24
|
+
|
|
25
|
+
@Field(() => Float, { nullable: true })
|
|
26
|
+
Latitude!: number | null;
|
|
27
|
+
|
|
28
|
+
@Field(() => Float, { nullable: true })
|
|
29
|
+
Longitude!: number | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
@ObjectType()
|
|
33
|
+
export class GeoStateProvinceResult {
|
|
34
|
+
@Field()
|
|
35
|
+
ID!: string;
|
|
36
|
+
|
|
37
|
+
@Field()
|
|
38
|
+
CountryID!: string;
|
|
39
|
+
|
|
40
|
+
@Field()
|
|
41
|
+
Name!: string;
|
|
42
|
+
|
|
43
|
+
@Field()
|
|
44
|
+
Code!: string;
|
|
45
|
+
|
|
46
|
+
@Field()
|
|
47
|
+
ISO3166_2!: string;
|
|
48
|
+
|
|
49
|
+
@Field(() => Float, { nullable: true })
|
|
50
|
+
Latitude!: number | null;
|
|
51
|
+
|
|
52
|
+
@Field(() => Float, { nullable: true })
|
|
53
|
+
Longitude!: number | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@ObjectType()
|
|
57
|
+
export class GeoResolveResult {
|
|
58
|
+
@Field()
|
|
59
|
+
Success!: boolean;
|
|
60
|
+
|
|
61
|
+
@Field({ nullable: true })
|
|
62
|
+
CountryID?: string;
|
|
63
|
+
|
|
64
|
+
@Field({ nullable: true })
|
|
65
|
+
CountryName?: string;
|
|
66
|
+
|
|
67
|
+
@Field({ nullable: true })
|
|
68
|
+
StateProvinceID?: string;
|
|
69
|
+
|
|
70
|
+
@Field({ nullable: true })
|
|
71
|
+
StateProvinceName?: string;
|
|
72
|
+
|
|
73
|
+
@Field(() => Float, { nullable: true })
|
|
74
|
+
Latitude?: number;
|
|
75
|
+
|
|
76
|
+
@Field(() => Float, { nullable: true })
|
|
77
|
+
Longitude?: number;
|
|
78
|
+
|
|
79
|
+
@Field({ nullable: true })
|
|
80
|
+
ErrorMessage?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Resolver
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* GraphQL resolver for geographic reference data resolution.
|
|
89
|
+
* Provides country/state text-to-reference matching via the GeoResolver service.
|
|
90
|
+
* Used by clients that need to resolve free-text location strings to structured
|
|
91
|
+
* reference data (Country/StateProvince IDs, centroids) without a full geocoding API call.
|
|
92
|
+
*/
|
|
93
|
+
@Resolver()
|
|
94
|
+
export class GeoResolver extends ResolverBase {
|
|
95
|
+
private _countries: MJCountryEntity[] | null = null;
|
|
96
|
+
private _states: MJStateProvinceEntity[] | null = null;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Lazily load all countries into memory (~250 records).
|
|
100
|
+
*/
|
|
101
|
+
private async GetCountries(contextUser: UserInfo | undefined): Promise<MJCountryEntity[]> {
|
|
102
|
+
if (this._countries) return this._countries;
|
|
103
|
+
const rv = new RunView();
|
|
104
|
+
const result = await rv.RunView<MJCountryEntity>({
|
|
105
|
+
EntityName: 'MJ: Countries',
|
|
106
|
+
ResultType: 'entity_object'
|
|
107
|
+
}, contextUser);
|
|
108
|
+
if (result.Success) {
|
|
109
|
+
this._countries = result.Results;
|
|
110
|
+
}
|
|
111
|
+
return this._countries ?? [];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Lazily load all state/provinces into memory (~5000 records).
|
|
116
|
+
*/
|
|
117
|
+
private async GetStates(contextUser: UserInfo | undefined): Promise<MJStateProvinceEntity[]> {
|
|
118
|
+
if (this._states) return this._states;
|
|
119
|
+
const rv = new RunView();
|
|
120
|
+
const result = await rv.RunView<MJStateProvinceEntity>({
|
|
121
|
+
EntityName: 'MJ: State Provinces',
|
|
122
|
+
ResultType: 'entity_object'
|
|
123
|
+
}, contextUser);
|
|
124
|
+
if (result.Success) {
|
|
125
|
+
this._states = result.Results;
|
|
126
|
+
}
|
|
127
|
+
return this._states ?? [];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Resolve a free-text country string to a Country reference record.
|
|
132
|
+
* Matches by Name, ISO2, ISO3, or CommonAliases.
|
|
133
|
+
*/
|
|
134
|
+
@Query(() => GeoResolveResult)
|
|
135
|
+
async ResolveCountry(
|
|
136
|
+
@Arg('input', () => String) input: string,
|
|
137
|
+
@Ctx() { userPayload }: AppContext
|
|
138
|
+
): Promise<GeoResolveResult> {
|
|
139
|
+
try {
|
|
140
|
+
const user = this.GetUserFromPayload(userPayload);
|
|
141
|
+
const countries = await this.GetCountries(user);
|
|
142
|
+
const normalized = input.trim().toLowerCase();
|
|
143
|
+
|
|
144
|
+
// 1. Exact match on Name, ISO2, ISO3
|
|
145
|
+
const exact = countries.find(c =>
|
|
146
|
+
c.Name.toLowerCase() === normalized ||
|
|
147
|
+
c.ISO2.toLowerCase() === normalized ||
|
|
148
|
+
c.ISO3.toLowerCase() === normalized
|
|
149
|
+
);
|
|
150
|
+
if (exact) {
|
|
151
|
+
return {
|
|
152
|
+
Success: true,
|
|
153
|
+
CountryID: exact.ID,
|
|
154
|
+
CountryName: exact.Name,
|
|
155
|
+
Latitude: exact.Latitude ?? undefined,
|
|
156
|
+
Longitude: exact.Longitude ?? undefined
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 2. CommonAliases search
|
|
161
|
+
const aliasMatch = countries.find(c => {
|
|
162
|
+
if (!c.CommonAliases) return false;
|
|
163
|
+
try {
|
|
164
|
+
const aliases: string[] = JSON.parse(c.CommonAliases);
|
|
165
|
+
return aliases.some(a => a.toLowerCase() === normalized);
|
|
166
|
+
} catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
if (aliasMatch) {
|
|
171
|
+
return {
|
|
172
|
+
Success: true,
|
|
173
|
+
CountryID: aliasMatch.ID,
|
|
174
|
+
CountryName: aliasMatch.Name,
|
|
175
|
+
Latitude: aliasMatch.Latitude ?? undefined,
|
|
176
|
+
Longitude: aliasMatch.Longitude ?? undefined
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { Success: false, ErrorMessage: `No country match for "${input}"` };
|
|
181
|
+
} catch (e: unknown) {
|
|
182
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
183
|
+
LogError(`GeoResolver.ResolveCountry error: ${msg}`);
|
|
184
|
+
return { Success: false, ErrorMessage: msg };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Resolve a free-text state/province string with country context.
|
|
190
|
+
* Country context is critical: "CA" = California (US) vs Canada (ISO2).
|
|
191
|
+
*/
|
|
192
|
+
@Query(() => GeoResolveResult)
|
|
193
|
+
async ResolveStateProvince(
|
|
194
|
+
@Arg('stateInput', () => String) stateInput: string,
|
|
195
|
+
@Arg('countryInput', () => String) countryInput: string,
|
|
196
|
+
@Ctx() { userPayload }: AppContext
|
|
197
|
+
): Promise<GeoResolveResult> {
|
|
198
|
+
try {
|
|
199
|
+
const user = this.GetUserFromPayload(userPayload);
|
|
200
|
+
|
|
201
|
+
// First resolve country
|
|
202
|
+
const countryResult = await this.ResolveCountry(countryInput, { userPayload } as AppContext);
|
|
203
|
+
if (!countryResult.Success || !countryResult.CountryID) {
|
|
204
|
+
return { Success: false, ErrorMessage: `Could not resolve country "${countryInput}"` };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const states = await this.GetStates(user);
|
|
208
|
+
const countryStates = states.filter(s => s.CountryID === countryResult.CountryID);
|
|
209
|
+
const normalized = stateInput.trim().toLowerCase();
|
|
210
|
+
|
|
211
|
+
// 1. Exact match on Name, Code, ISO3166_2
|
|
212
|
+
const exact = countryStates.find(s =>
|
|
213
|
+
s.Name.toLowerCase() === normalized ||
|
|
214
|
+
s.Code.toLowerCase() === normalized ||
|
|
215
|
+
s.ISO3166_2.toLowerCase() === normalized
|
|
216
|
+
);
|
|
217
|
+
if (exact) {
|
|
218
|
+
return {
|
|
219
|
+
Success: true,
|
|
220
|
+
CountryID: countryResult.CountryID,
|
|
221
|
+
CountryName: countryResult.CountryName,
|
|
222
|
+
StateProvinceID: exact.ID,
|
|
223
|
+
StateProvinceName: exact.Name,
|
|
224
|
+
Latitude: exact.Latitude ?? undefined,
|
|
225
|
+
Longitude: exact.Longitude ?? undefined
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// 2. CommonAliases search
|
|
230
|
+
const aliasMatch = countryStates.find(s => {
|
|
231
|
+
if (!s.CommonAliases) return false;
|
|
232
|
+
try {
|
|
233
|
+
const aliases: string[] = JSON.parse(s.CommonAliases);
|
|
234
|
+
return aliases.some(a => a.toLowerCase() === normalized);
|
|
235
|
+
} catch {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
if (aliasMatch) {
|
|
240
|
+
return {
|
|
241
|
+
Success: true,
|
|
242
|
+
CountryID: countryResult.CountryID,
|
|
243
|
+
CountryName: countryResult.CountryName,
|
|
244
|
+
StateProvinceID: aliasMatch.ID,
|
|
245
|
+
StateProvinceName: aliasMatch.Name,
|
|
246
|
+
Latitude: aliasMatch.Latitude ?? undefined,
|
|
247
|
+
Longitude: aliasMatch.Longitude ?? undefined
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return { Success: false, ErrorMessage: `No state/province match for "${stateInput}" in ${countryResult.CountryName}` };
|
|
252
|
+
} catch (e: unknown) {
|
|
253
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
254
|
+
LogError(`GeoResolver.ResolveStateProvince error: ${msg}`);
|
|
255
|
+
return { Success: false, ErrorMessage: msg };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|