@memberjunction/server 5.16.0 → 5.18.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 +66 -3
- package/dist/agents/skip-sdk.d.ts.map +1 -1
- package/dist/agents/skip-sdk.js +3 -1
- package/dist/agents/skip-sdk.js.map +1 -1
- package/dist/config.d.ts +51 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +7 -0
- package/dist/config.js.map +1 -1
- package/dist/generated/generated.d.ts +44 -44
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +316 -316
- package/dist/generated/generated.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +311 -1
- package/dist/index.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +484 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +3867 -328
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/RSUResolver.d.ts +89 -0
- package/dist/resolvers/RSUResolver.d.ts.map +1 -0
- package/dist/resolvers/RSUResolver.js +424 -0
- package/dist/resolvers/RSUResolver.js.map +1 -0
- package/package.json +63 -61
- package/src/agents/skip-sdk.ts +3 -1
- package/src/config.ts +9 -0
- package/src/generated/generated.ts +256 -256
- package/src/index.ts +350 -1
- package/src/resolvers/IntegrationDiscoveryResolver.ts +2970 -39
- package/src/resolvers/RSUResolver.ts +351 -0
|
@@ -1,12 +1,29 @@
|
|
|
1
|
-
import { Resolver, Query, Arg, Ctx, ObjectType, Field, InputType } from "type-graphql";
|
|
2
|
-
import { Metadata, UserInfo, LogError } from "@memberjunction/core";
|
|
3
|
-
import {
|
|
1
|
+
import { Resolver, Query, Mutation, Arg, Ctx, ObjectType, Field, InputType } from "type-graphql";
|
|
2
|
+
import { CompositeKey, Metadata, RunView, UserInfo, LogError } from "@memberjunction/core";
|
|
3
|
+
import { CronExpressionHelper } from "@memberjunction/scheduling-engine";
|
|
4
|
+
import {
|
|
5
|
+
MJCompanyIntegrationEntity,
|
|
6
|
+
MJIntegrationEntity,
|
|
7
|
+
MJCredentialEntity,
|
|
8
|
+
MJCompanyIntegrationEntityMapEntity,
|
|
9
|
+
MJCompanyIntegrationFieldMapEntity,
|
|
10
|
+
MJCompanyIntegrationRunEntity,
|
|
11
|
+
MJCompanyIntegrationSyncWatermarkEntity,
|
|
12
|
+
MJCompanyIntegrationRecordMapEntity,
|
|
13
|
+
MJScheduledJobEntity,
|
|
14
|
+
MJScheduledJobTypeEntity,
|
|
15
|
+
MJScheduledJobRunEntity,
|
|
16
|
+
MJCompanyIntegrationRunDetailEntity,
|
|
17
|
+
MJEmployeeCompanyIntegrationEntity
|
|
18
|
+
} from "@memberjunction/core-entities";
|
|
4
19
|
import {
|
|
5
20
|
BaseIntegrationConnector,
|
|
6
21
|
ConnectorFactory,
|
|
7
22
|
ExternalObjectSchema,
|
|
8
23
|
ExternalFieldSchema,
|
|
9
24
|
ConnectionTestResult,
|
|
25
|
+
IntegrationEngine,
|
|
26
|
+
IntegrationSyncOptions,
|
|
10
27
|
SourceSchemaInfo
|
|
11
28
|
} from "@memberjunction/integration-engine";
|
|
12
29
|
import {
|
|
@@ -14,15 +31,228 @@ import {
|
|
|
14
31
|
TypeMapper,
|
|
15
32
|
SchemaBuilderInput,
|
|
16
33
|
TargetTableConfig,
|
|
17
|
-
TargetColumnConfig
|
|
34
|
+
TargetColumnConfig,
|
|
35
|
+
ExistingTableInfo,
|
|
36
|
+
SchemaEvolution
|
|
18
37
|
} from "@memberjunction/integration-schema-builder";
|
|
38
|
+
import { RuntimeSchemaManager, type RSUPipelineStep, type RSUPipelineInput } from "@memberjunction/schema-engine";
|
|
39
|
+
import type { SchemaBuilderOutput } from "@memberjunction/integration-schema-builder";
|
|
19
40
|
import { ResolverBase } from "../generic/ResolverBase.js";
|
|
20
41
|
import { AppContext } from "../types.js";
|
|
42
|
+
import { RequireSystemUser } from "../directives/RequireSystemUser.js";
|
|
43
|
+
import { UserCache } from "@memberjunction/sqlserver-dataprovider";
|
|
44
|
+
|
|
45
|
+
// ─── RSU Pipeline Output Types ──────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
@ObjectType()
|
|
48
|
+
class RSUStepOutput {
|
|
49
|
+
@Field() Name: string;
|
|
50
|
+
@Field() Status: string;
|
|
51
|
+
@Field() DurationMs: number;
|
|
52
|
+
@Field() Message: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@ObjectType()
|
|
56
|
+
class ApplySchemaOutput {
|
|
57
|
+
@Field() Success: boolean;
|
|
58
|
+
@Field() Message: string;
|
|
59
|
+
@Field(() => [RSUStepOutput], { nullable: true }) Steps?: RSUStepOutput[];
|
|
60
|
+
@Field({ nullable: true }) MigrationFilePath?: string;
|
|
61
|
+
@Field({ nullable: true }) EntitiesProcessed?: number;
|
|
62
|
+
@Field({ nullable: true }) GitCommitSuccess?: boolean;
|
|
63
|
+
@Field({ nullable: true }) APIRestarted?: boolean;
|
|
64
|
+
@Field(() => [String], { nullable: true }) Warnings?: string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@InputType()
|
|
68
|
+
class ApplySchemaBatchItemInput {
|
|
69
|
+
@Field() CompanyIntegrationID: string;
|
|
70
|
+
@Field(() => [SchemaPreviewObjectInput]) Objects: SchemaPreviewObjectInput[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Apply All Input/Output Types ───────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
@InputType()
|
|
76
|
+
class SourceObjectInput {
|
|
77
|
+
@Field({ description: 'Source object ID (IntegrationObject.ID)' }) SourceObjectID: string;
|
|
78
|
+
@Field(() => [String], { nullable: true, description: 'Optional field selection. Empty/null = all fields (including any new ones). Only specified fields get field maps.' }) Fields?: string[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@InputType()
|
|
82
|
+
class ApplyAllInput {
|
|
83
|
+
@Field() CompanyIntegrationID: string;
|
|
84
|
+
@Field(() => [SourceObjectInput]) SourceObjects: SourceObjectInput[];
|
|
85
|
+
@Field({ nullable: true }) CronExpression?: string;
|
|
86
|
+
@Field({ nullable: true }) ScheduleTimezone?: string;
|
|
87
|
+
@Field(() => Boolean, { nullable: true, defaultValue: true, description: 'If false, skips the sync step after schema + entity maps are created' }) StartSync?: boolean;
|
|
88
|
+
@Field(() => Boolean, { nullable: true, defaultValue: false, description: 'If true, ignores watermarks and does a full re-fetch' }) FullSync?: boolean;
|
|
89
|
+
@Field({ nullable: true, defaultValue: 'created', description: 'Sync scope: "created" = only newly created entity maps, "all" = all maps for the connector' }) SyncScope?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@ObjectType()
|
|
93
|
+
class ApplyAllEntityMapCreated {
|
|
94
|
+
@Field() SourceObjectName: string;
|
|
95
|
+
@Field() EntityName: string;
|
|
96
|
+
@Field() EntityMapID: string;
|
|
97
|
+
@Field() FieldMapCount: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@ObjectType()
|
|
101
|
+
class ApplyAllOutput {
|
|
102
|
+
@Field() Success: boolean;
|
|
103
|
+
@Field() Message: string;
|
|
104
|
+
@Field(() => [RSUStepOutput], { nullable: true }) Steps?: RSUStepOutput[];
|
|
105
|
+
@Field(() => [ApplyAllEntityMapCreated], { nullable: true }) EntityMapsCreated?: ApplyAllEntityMapCreated[];
|
|
106
|
+
@Field({ nullable: true }) SyncRunID?: string;
|
|
107
|
+
@Field({ nullable: true }) ScheduledJobID?: string;
|
|
108
|
+
@Field({ nullable: true }) GitCommitSuccess?: boolean;
|
|
109
|
+
@Field({ nullable: true }) APIRestarted?: boolean;
|
|
110
|
+
@Field(() => [String], { nullable: true }) Warnings?: string[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@ObjectType()
|
|
114
|
+
class ApplySchemaBatchItemOutput {
|
|
115
|
+
@Field() CompanyIntegrationID: string;
|
|
116
|
+
@Field() Success: boolean;
|
|
117
|
+
@Field() Message: string;
|
|
118
|
+
@Field(() => [String], { nullable: true }) Warnings?: string[];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
@ObjectType()
|
|
122
|
+
class ApplySchemaBatchOutput {
|
|
123
|
+
@Field() Success: boolean;
|
|
124
|
+
@Field() Message: string;
|
|
125
|
+
@Field(() => [ApplySchemaBatchItemOutput]) Items: ApplySchemaBatchItemOutput[];
|
|
126
|
+
@Field(() => [RSUStepOutput], { nullable: true }) Steps?: RSUStepOutput[];
|
|
127
|
+
@Field({ nullable: true }) GitCommitSuccess?: boolean;
|
|
128
|
+
@Field({ nullable: true }) APIRestarted?: boolean;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── Apply All Batch Input/Output Types ──────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
@InputType()
|
|
134
|
+
class ApplyAllBatchConnectorInput {
|
|
135
|
+
@Field() CompanyIntegrationID: string;
|
|
136
|
+
@Field(() => [SourceObjectInput]) SourceObjects: SourceObjectInput[];
|
|
137
|
+
/** Optional per-connector schedule. Applied on success. */
|
|
138
|
+
@Field({ nullable: true }) CronExpression?: string;
|
|
139
|
+
@Field({ nullable: true }) ScheduleTimezone?: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
@InputType()
|
|
143
|
+
class ApplyAllBatchInput {
|
|
144
|
+
@Field(() => [ApplyAllBatchConnectorInput]) Connectors: ApplyAllBatchConnectorInput[];
|
|
145
|
+
@Field(() => Boolean, { nullable: true, defaultValue: true, description: 'If false, skips sync after schema + entity maps' }) StartSync?: boolean;
|
|
146
|
+
@Field(() => Boolean, { nullable: true, defaultValue: false, description: 'If true, ignores watermarks and does a full re-fetch' }) FullSync?: boolean;
|
|
147
|
+
@Field({ nullable: true, defaultValue: 'created', description: 'Sync scope: "created" = only newly created entity maps, "all" = all maps for the connector' }) SyncScope?: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
@ObjectType()
|
|
151
|
+
class ApplyAllBatchConnectorResult {
|
|
152
|
+
@Field() CompanyIntegrationID: string;
|
|
153
|
+
@Field() IntegrationName: string;
|
|
154
|
+
@Field() Success: boolean;
|
|
155
|
+
@Field() Message: string;
|
|
156
|
+
@Field(() => [ApplyAllEntityMapCreated], { nullable: true }) EntityMapsCreated?: ApplyAllEntityMapCreated[];
|
|
157
|
+
@Field({ nullable: true }) SyncRunID?: string;
|
|
158
|
+
@Field({ nullable: true }) ScheduledJobID?: string;
|
|
159
|
+
@Field(() => [String], { nullable: true }) Warnings?: string[];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
@ObjectType()
|
|
163
|
+
class ApplyAllBatchOutput {
|
|
164
|
+
@Field() Success: boolean;
|
|
165
|
+
@Field() Message: string;
|
|
166
|
+
@Field(() => [ApplyAllBatchConnectorResult]) ConnectorResults: ApplyAllBatchConnectorResult[];
|
|
167
|
+
@Field(() => [RSUStepOutput], { nullable: true }) PipelineSteps?: RSUStepOutput[];
|
|
168
|
+
@Field({ nullable: true }) GitCommitSuccess?: boolean;
|
|
169
|
+
@Field({ nullable: true }) APIRestarted?: boolean;
|
|
170
|
+
@Field() SuccessCount: number;
|
|
171
|
+
@Field() FailureCount: number;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ─── Delete Connection Output ────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
@ObjectType()
|
|
177
|
+
class DeleteConnectionOutput {
|
|
178
|
+
@Field() Success: boolean;
|
|
179
|
+
@Field() Message: string;
|
|
180
|
+
@Field({ nullable: true }) EntityMapsDeleted?: number;
|
|
181
|
+
@Field({ nullable: true }) FieldMapsDeleted?: number;
|
|
182
|
+
@Field({ nullable: true }) SchedulesDeleted?: number;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ─── Schema Evolution Output ─────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
@ObjectType()
|
|
188
|
+
class SchemaEvolutionOutput {
|
|
189
|
+
@Field() Success: boolean;
|
|
190
|
+
@Field() Message: string;
|
|
191
|
+
@Field() HasChanges: boolean;
|
|
192
|
+
@Field({ nullable: true }) AddedColumns?: number;
|
|
193
|
+
@Field({ nullable: true }) ModifiedColumns?: number;
|
|
194
|
+
@Field(() => [RSUStepOutput], { nullable: true }) Steps?: RSUStepOutput[];
|
|
195
|
+
@Field({ nullable: true }) GitCommitSuccess?: boolean;
|
|
196
|
+
@Field({ nullable: true }) APIRestarted?: boolean;
|
|
197
|
+
@Field(() => [String], { nullable: true }) Warnings?: string[];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ─── Connector Capabilities Output Type ─────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
@ObjectType()
|
|
203
|
+
class ConnectorCapabilitiesOutput {
|
|
204
|
+
@Field() Success: boolean;
|
|
205
|
+
@Field() Message: string;
|
|
206
|
+
@Field({ nullable: true }) SupportsGet?: boolean;
|
|
207
|
+
@Field({ nullable: true }) SupportsCreate?: boolean;
|
|
208
|
+
@Field({ nullable: true }) SupportsUpdate?: boolean;
|
|
209
|
+
@Field({ nullable: true }) SupportsDelete?: boolean;
|
|
210
|
+
@Field({ nullable: true }) SupportsSearch?: boolean;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// --- Connector Discovery Output Types ---
|
|
214
|
+
|
|
215
|
+
@ObjectType()
|
|
216
|
+
class ConnectorInfoOutput {
|
|
217
|
+
@Field() IntegrationID: string;
|
|
218
|
+
@Field() Name: string;
|
|
219
|
+
@Field({ nullable: true }) Description?: string;
|
|
220
|
+
@Field({ nullable: true }) ClassName?: string;
|
|
221
|
+
@Field() IsActive: boolean;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
@ObjectType()
|
|
225
|
+
class DiscoverConnectorsOutput {
|
|
226
|
+
@Field() Success: boolean;
|
|
227
|
+
@Field() Message: string;
|
|
228
|
+
@Field(() => [ConnectorInfoOutput], { nullable: true }) Connectors?: ConnectorInfoOutput[];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// --- Field Map List Output Types ---
|
|
232
|
+
|
|
233
|
+
@ObjectType()
|
|
234
|
+
class FieldMapSummaryOutput {
|
|
235
|
+
@Field() ID: string;
|
|
236
|
+
@Field() EntityMapID: string;
|
|
237
|
+
@Field({ nullable: true }) SourceFieldName?: string;
|
|
238
|
+
@Field({ nullable: true }) DestinationFieldName?: string;
|
|
239
|
+
@Field({ nullable: true }) Status?: string;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
@ObjectType()
|
|
243
|
+
class ListFieldMapsOutput {
|
|
244
|
+
@Field() Success: boolean;
|
|
245
|
+
@Field() Message: string;
|
|
246
|
+
@Field(() => [FieldMapSummaryOutput], { nullable: true }) FieldMaps?: FieldMapSummaryOutput[];
|
|
247
|
+
}
|
|
21
248
|
|
|
22
249
|
// --- Output Types ---
|
|
23
250
|
|
|
24
251
|
@ObjectType()
|
|
25
252
|
class ExternalObjectOutput {
|
|
253
|
+
@Field({ nullable: true })
|
|
254
|
+
ID?: string;
|
|
255
|
+
|
|
26
256
|
@Field()
|
|
27
257
|
Name: string;
|
|
28
258
|
|
|
@@ -117,9 +347,12 @@ class PreviewDataOutput {
|
|
|
117
347
|
|
|
118
348
|
@InputType()
|
|
119
349
|
class SchemaPreviewObjectInput {
|
|
120
|
-
@Field()
|
|
350
|
+
@Field({ nullable: true, description: 'Source object name. Required if SourceObjectID is not provided.' })
|
|
121
351
|
SourceObjectName: string;
|
|
122
352
|
|
|
353
|
+
@Field({ nullable: true, description: 'Source object ID (IntegrationObject.ID). Takes priority over SourceObjectName when provided.' })
|
|
354
|
+
SourceObjectID?: string;
|
|
355
|
+
|
|
123
356
|
@Field()
|
|
124
357
|
SchemaName: string;
|
|
125
358
|
|
|
@@ -128,6 +361,9 @@ class SchemaPreviewObjectInput {
|
|
|
128
361
|
|
|
129
362
|
@Field()
|
|
130
363
|
EntityName: string;
|
|
364
|
+
|
|
365
|
+
@Field(() => [String], { nullable: true, description: 'Optional field selection. If provided, only these source fields are included. Default = all fields.' })
|
|
366
|
+
Fields?: string[];
|
|
131
367
|
}
|
|
132
368
|
|
|
133
369
|
@ObjectType()
|
|
@@ -204,8 +440,243 @@ class DefaultConfigOutput {
|
|
|
204
440
|
DefaultObjects?: DefaultObjectConfigOutput[];
|
|
205
441
|
}
|
|
206
442
|
|
|
443
|
+
// ─── Integration Management Input/Output Types ─────────────────────────────
|
|
444
|
+
|
|
445
|
+
@InputType()
|
|
446
|
+
class CreateConnectionInput {
|
|
447
|
+
@Field() IntegrationID: string;
|
|
448
|
+
@Field() CompanyID: string;
|
|
449
|
+
@Field() CredentialTypeID: string;
|
|
450
|
+
@Field() CredentialName: string;
|
|
451
|
+
@Field() CredentialValues: string;
|
|
452
|
+
@Field({ nullable: true }) ExternalSystemID?: string;
|
|
453
|
+
@Field({ nullable: true }) Configuration?: string;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
@ObjectType()
|
|
457
|
+
class CreateConnectionOutput {
|
|
458
|
+
@Field() Success: boolean;
|
|
459
|
+
@Field() Message: string;
|
|
460
|
+
@Field({ nullable: true }) CompanyIntegrationID?: string;
|
|
461
|
+
@Field({ nullable: true }) CredentialID?: string;
|
|
462
|
+
@Field({ nullable: true }) ConnectionTestSuccess?: boolean;
|
|
463
|
+
@Field({ nullable: true }) ConnectionTestMessage?: string;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
@ObjectType()
|
|
467
|
+
class MutationResultOutput {
|
|
468
|
+
@Field() Success: boolean;
|
|
469
|
+
@Field() Message: string;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
@InputType()
|
|
473
|
+
class FieldMapInput {
|
|
474
|
+
@Field() SourceFieldName: string;
|
|
475
|
+
@Field() DestinationFieldName: string;
|
|
476
|
+
@Field({ nullable: true, defaultValue: false }) IsKeyField?: boolean;
|
|
477
|
+
@Field({ nullable: true, defaultValue: false }) IsRequired?: boolean;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
@InputType()
|
|
481
|
+
class EntityMapInput {
|
|
482
|
+
@Field() ExternalObjectName: string;
|
|
483
|
+
@Field({ nullable: true }) EntityName?: string;
|
|
484
|
+
@Field({ nullable: true }) EntityID?: string;
|
|
485
|
+
@Field({ nullable: true, defaultValue: 'Pull' }) SyncDirection?: string;
|
|
486
|
+
@Field({ nullable: true, defaultValue: 0 }) Priority?: number;
|
|
487
|
+
@Field(() => [FieldMapInput], { nullable: true }) FieldMaps?: FieldMapInput[];
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
@ObjectType()
|
|
491
|
+
class EntityMapCreatedOutput {
|
|
492
|
+
@Field() EntityMapID: string;
|
|
493
|
+
@Field() ExternalObjectName: string;
|
|
494
|
+
@Field() FieldMapCount: number;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
@ObjectType()
|
|
498
|
+
class CreateEntityMapsOutput {
|
|
499
|
+
@Field() Success: boolean;
|
|
500
|
+
@Field() Message: string;
|
|
501
|
+
@Field(() => [EntityMapCreatedOutput], { nullable: true }) Created?: EntityMapCreatedOutput[];
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
@ObjectType()
|
|
505
|
+
class StartSyncOutput {
|
|
506
|
+
@Field() Success: boolean;
|
|
507
|
+
@Field() Message: string;
|
|
508
|
+
@Field({ nullable: true }) RunID?: string;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
@InputType()
|
|
512
|
+
class CreateScheduleInput {
|
|
513
|
+
@Field() CompanyIntegrationID: string;
|
|
514
|
+
@Field() Name: string;
|
|
515
|
+
@Field() CronExpression: string;
|
|
516
|
+
@Field({ nullable: true }) Timezone?: string;
|
|
517
|
+
@Field({ nullable: true }) Description?: string;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
@ObjectType()
|
|
521
|
+
class CreateScheduleOutput {
|
|
522
|
+
@Field() Success: boolean;
|
|
523
|
+
@Field() Message: string;
|
|
524
|
+
@Field({ nullable: true }) ScheduledJobID?: string;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
@ObjectType()
|
|
528
|
+
class ScheduleSummaryOutput {
|
|
529
|
+
@Field() ID: string;
|
|
530
|
+
@Field() Name: string;
|
|
531
|
+
@Field({ nullable: true }) Status?: string;
|
|
532
|
+
@Field({ nullable: true }) CronExpression?: string;
|
|
533
|
+
@Field({ nullable: true }) Timezone?: string;
|
|
534
|
+
@Field({ nullable: true }) NextRunAt?: string;
|
|
535
|
+
@Field({ nullable: true }) LastRunAt?: string;
|
|
536
|
+
@Field({ nullable: true }) RunCount?: number;
|
|
537
|
+
@Field({ nullable: true }) SuccessCount?: number;
|
|
538
|
+
@Field({ nullable: true }) FailureCount?: number;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
@ObjectType()
|
|
542
|
+
class ListSchedulesOutput {
|
|
543
|
+
@Field() Success: boolean;
|
|
544
|
+
@Field() Message: string;
|
|
545
|
+
@Field(() => [ScheduleSummaryOutput], { nullable: true }) Schedules?: ScheduleSummaryOutput[];
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
@InputType()
|
|
549
|
+
class EntityMapUpdateInput {
|
|
550
|
+
@Field() EntityMapID: string;
|
|
551
|
+
@Field({ nullable: true }) SyncDirection?: string;
|
|
552
|
+
@Field({ nullable: true }) Priority?: number;
|
|
553
|
+
@Field({ nullable: true }) Status?: string;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
@ObjectType()
|
|
557
|
+
class EntityMapSummaryOutput {
|
|
558
|
+
@Field() ID: string;
|
|
559
|
+
@Field({ nullable: true }) EntityID?: string;
|
|
560
|
+
@Field({ nullable: true }) Entity?: string;
|
|
561
|
+
@Field({ nullable: true }) ExternalObjectName?: string;
|
|
562
|
+
@Field({ nullable: true }) SyncDirection?: string;
|
|
563
|
+
@Field({ nullable: true }) Priority?: number;
|
|
564
|
+
@Field({ nullable: true }) Status?: string;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
@ObjectType()
|
|
568
|
+
class ListEntityMapsOutput {
|
|
569
|
+
@Field() Success: boolean;
|
|
570
|
+
@Field() Message: string;
|
|
571
|
+
@Field(() => [EntityMapSummaryOutput], { nullable: true }) EntityMaps?: EntityMapSummaryOutput[];
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
@ObjectType()
|
|
575
|
+
class IntegrationStatusOutput {
|
|
576
|
+
@Field() Success: boolean;
|
|
577
|
+
@Field() Message: string;
|
|
578
|
+
@Field({ nullable: true }) IsActive?: boolean;
|
|
579
|
+
@Field({ nullable: true }) IntegrationName?: string;
|
|
580
|
+
@Field({ nullable: true }) CompanyIntegrationID?: string;
|
|
581
|
+
@Field({ nullable: true }) TotalEntityMaps?: number;
|
|
582
|
+
@Field({ nullable: true }) ActiveEntityMaps?: number;
|
|
583
|
+
@Field({ nullable: true }) LastRunStatus?: string;
|
|
584
|
+
@Field({ nullable: true }) LastRunStartedAt?: string;
|
|
585
|
+
@Field({ nullable: true }) LastRunEndedAt?: string;
|
|
586
|
+
@Field({ nullable: true }) ScheduleEnabled?: boolean;
|
|
587
|
+
// RSU pipeline state
|
|
588
|
+
@Field({ nullable: true }) RSUEnabled?: boolean;
|
|
589
|
+
@Field({ nullable: true }) RSURunning?: boolean;
|
|
590
|
+
@Field({ nullable: true }) RSUOutOfSync?: boolean;
|
|
591
|
+
@Field({ nullable: true }) RSULastRunAt?: string;
|
|
592
|
+
@Field({ nullable: true }) RSULastRunResult?: string;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
@ObjectType()
|
|
596
|
+
class SyncRunEntityDetail {
|
|
597
|
+
@Field() EntityName: string;
|
|
598
|
+
@Field() InsertCount: number;
|
|
599
|
+
@Field() UpdateCount: number;
|
|
600
|
+
@Field() SkipCount: number;
|
|
601
|
+
@Field() ErrorCount: number;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
@ObjectType()
|
|
605
|
+
class SyncRunSummaryOutput {
|
|
606
|
+
@Field() ID: string;
|
|
607
|
+
@Field({ nullable: true }) Status?: string;
|
|
608
|
+
@Field({ nullable: true }) StartedAt?: string;
|
|
609
|
+
@Field({ nullable: true }) EndedAt?: string;
|
|
610
|
+
@Field({ nullable: true }) TotalRecords?: number;
|
|
611
|
+
@Field({ nullable: true }) RunByUserID?: string;
|
|
612
|
+
@Field(() => [SyncRunEntityDetail], { nullable: true }) EntityDetails?: SyncRunEntityDetail[];
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
@ObjectType()
|
|
616
|
+
class SyncHistoryOutput {
|
|
617
|
+
@Field() Success: boolean;
|
|
618
|
+
@Field() Message: string;
|
|
619
|
+
@Field(() => [SyncRunSummaryOutput], { nullable: true }) Runs?: SyncRunSummaryOutput[];
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
@ObjectType()
|
|
623
|
+
class OperationProgressOutput {
|
|
624
|
+
@Field() Success: boolean;
|
|
625
|
+
@Field() Message: string;
|
|
626
|
+
@Field({ nullable: true }) OperationType?: string; // 'sync' | 'rsu' | 'none'
|
|
627
|
+
@Field({ nullable: true }) IsRunning?: boolean;
|
|
628
|
+
@Field({ nullable: true }) CurrentEntity?: string;
|
|
629
|
+
@Field({ nullable: true }) EntityMapsTotal?: number;
|
|
630
|
+
@Field({ nullable: true }) EntityMapsCompleted?: number;
|
|
631
|
+
@Field({ nullable: true }) RecordsProcessed?: number;
|
|
632
|
+
@Field({ nullable: true }) RecordsCreated?: number;
|
|
633
|
+
@Field({ nullable: true }) RecordsUpdated?: number;
|
|
634
|
+
@Field({ nullable: true }) RecordsErrored?: number;
|
|
635
|
+
@Field({ nullable: true }) RSUStep?: string;
|
|
636
|
+
@Field({ nullable: true }) RSURunning?: boolean;
|
|
637
|
+
@Field({ nullable: true }) ElapsedMs?: number;
|
|
638
|
+
@Field({ nullable: true }) StartedAt?: string;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Sync progress is now tracked inside IntegrationEngine itself via IntegrationEngine.GetSyncProgress()
|
|
642
|
+
|
|
643
|
+
@ObjectType()
|
|
644
|
+
class ConnectionSummaryOutput {
|
|
645
|
+
@Field() ID: string;
|
|
646
|
+
@Field() IntegrationName: string;
|
|
647
|
+
@Field() IntegrationID: string;
|
|
648
|
+
@Field() CompanyID: string;
|
|
649
|
+
@Field({ nullable: true }) Company?: string;
|
|
650
|
+
@Field() IsActive: boolean;
|
|
651
|
+
@Field() ScheduleEnabled: boolean;
|
|
652
|
+
@Field({ nullable: true }) CronExpression?: string;
|
|
653
|
+
@Field() CreatedAt: string;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
@ObjectType()
|
|
657
|
+
class ListConnectionsOutput {
|
|
658
|
+
@Field() Success: boolean;
|
|
659
|
+
@Field() Message: string;
|
|
660
|
+
@Field(() => [ConnectionSummaryOutput], { nullable: true }) Connections?: ConnectionSummaryOutput[];
|
|
661
|
+
}
|
|
662
|
+
|
|
207
663
|
// --- Resolver ---
|
|
208
664
|
|
|
665
|
+
// ── Validation helpers for entity map union types ──────────────────────
|
|
666
|
+
const VALID_SYNC_DIRECTIONS = ['Bidirectional', 'Pull', 'Push'] as const;
|
|
667
|
+
type SyncDirection = typeof VALID_SYNC_DIRECTIONS[number];
|
|
668
|
+
|
|
669
|
+
function isValidSyncDirection(value: string): value is SyncDirection {
|
|
670
|
+
return (VALID_SYNC_DIRECTIONS as readonly string[]).includes(value);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const VALID_ENTITY_MAP_STATUSES = ['Active', 'Inactive'] as const;
|
|
674
|
+
type EntityMapStatus = typeof VALID_ENTITY_MAP_STATUSES[number];
|
|
675
|
+
|
|
676
|
+
function isValidEntityMapStatus(value: string): value is EntityMapStatus {
|
|
677
|
+
return (VALID_ENTITY_MAP_STATUSES as readonly string[]).includes(value);
|
|
678
|
+
}
|
|
679
|
+
|
|
209
680
|
/**
|
|
210
681
|
* GraphQL resolver for integration discovery operations.
|
|
211
682
|
* Provides endpoints to test connections, discover objects, and discover fields
|
|
@@ -214,6 +685,45 @@ class DefaultConfigOutput {
|
|
|
214
685
|
@Resolver()
|
|
215
686
|
export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
216
687
|
|
|
688
|
+
/**
|
|
689
|
+
* Lists all registered integration connectors (active by default).
|
|
690
|
+
* Useful for populating connector selection UIs.
|
|
691
|
+
*/
|
|
692
|
+
@Query(() => DiscoverConnectorsOutput)
|
|
693
|
+
async IntegrationDiscoverConnectors(
|
|
694
|
+
@Arg("companyID", { nullable: true }) companyID: string,
|
|
695
|
+
@Ctx() ctx: AppContext
|
|
696
|
+
): Promise<DiscoverConnectorsOutput> {
|
|
697
|
+
try {
|
|
698
|
+
const user = this.getAuthenticatedUser(ctx);
|
|
699
|
+
const rv = new RunView();
|
|
700
|
+
const result = await rv.RunView<MJIntegrationEntity>({
|
|
701
|
+
EntityName: 'MJ: Integrations',
|
|
702
|
+
ExtraFilter: '',
|
|
703
|
+
OrderBy: 'Name',
|
|
704
|
+
ResultType: 'entity_object'
|
|
705
|
+
}, user);
|
|
706
|
+
|
|
707
|
+
if (!result.Success) return { Success: false, Message: result.ErrorMessage || 'Query failed' };
|
|
708
|
+
|
|
709
|
+
return {
|
|
710
|
+
Success: true,
|
|
711
|
+
Message: `${result.Results.length} connectors available`,
|
|
712
|
+
Connectors: result.Results
|
|
713
|
+
.map(r => ({
|
|
714
|
+
IntegrationID: r.ID,
|
|
715
|
+
Name: r.Name,
|
|
716
|
+
Description: r.Description,
|
|
717
|
+
ClassName: r.ClassName,
|
|
718
|
+
IsActive: true
|
|
719
|
+
}))
|
|
720
|
+
};
|
|
721
|
+
} catch (e) {
|
|
722
|
+
LogError(`IntegrationDiscoverConnectors error: ${e}`);
|
|
723
|
+
return { Success: false, Message: this.formatError(e) };
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
217
727
|
/**
|
|
218
728
|
* Discovers available objects/tables in the external system.
|
|
219
729
|
*/
|
|
@@ -236,6 +746,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
236
746
|
Success: true,
|
|
237
747
|
Message: `Discovered ${objects.length} objects`,
|
|
238
748
|
Objects: objects.map(o => ({
|
|
749
|
+
ID: o.ID,
|
|
239
750
|
Name: o.Name,
|
|
240
751
|
Label: o.Label,
|
|
241
752
|
SupportsIncrementalSync: o.SupportsIncrementalSync,
|
|
@@ -305,11 +816,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
305
816
|
ServerVersion: result.ServerVersion
|
|
306
817
|
};
|
|
307
818
|
} catch (e) {
|
|
308
|
-
|
|
309
|
-
LogError(`IntegrationTestConnection error: ${error}`);
|
|
819
|
+
LogError(`IntegrationTestConnection error: ${this.formatError(e)}`);
|
|
310
820
|
return {
|
|
311
821
|
Success: false,
|
|
312
|
-
Message: `Error: ${
|
|
822
|
+
Message: `Error: ${this.formatError(e)}`
|
|
313
823
|
};
|
|
314
824
|
}
|
|
315
825
|
}
|
|
@@ -352,11 +862,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
352
862
|
}))
|
|
353
863
|
};
|
|
354
864
|
} catch (e) {
|
|
355
|
-
|
|
356
|
-
LogError(`IntegrationGetDefaultConfig error: ${error}`);
|
|
865
|
+
LogError(`IntegrationGetDefaultConfig error: ${this.formatError(e)}`);
|
|
357
866
|
return {
|
|
358
867
|
Success: false,
|
|
359
|
-
Message: `Error: ${
|
|
868
|
+
Message: `Error: ${this.formatError(e)}`
|
|
360
869
|
};
|
|
361
870
|
}
|
|
362
871
|
}
|
|
@@ -381,26 +890,27 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
381
890
|
(ci: unknown, u: unknown) => Promise<SourceSchemaInfo>;
|
|
382
891
|
const sourceSchema = await introspect(companyIntegration, user);
|
|
383
892
|
|
|
384
|
-
|
|
893
|
+
await this.resolveObjectInputs(objects, sourceSchema, user);
|
|
894
|
+
|
|
385
895
|
const requestedNames = new Set(objects.map(o => o.SourceObjectName));
|
|
386
896
|
const filteredSchema: SourceSchemaInfo = {
|
|
387
897
|
Objects: sourceSchema.Objects.filter(o => requestedNames.has(o.ExternalName))
|
|
388
898
|
};
|
|
389
899
|
|
|
390
|
-
|
|
391
|
-
const targetConfigs = this.buildTargetConfigs(objects, filteredSchema,
|
|
900
|
+
const validatedPlatform = this.validatePlatform(platform);
|
|
901
|
+
const targetConfigs = this.buildTargetConfigs(objects, filteredSchema, validatedPlatform, connector);
|
|
392
902
|
|
|
393
903
|
// Run SchemaBuilder
|
|
394
904
|
const input: SchemaBuilderInput = {
|
|
395
905
|
SourceSchema: filteredSchema,
|
|
396
906
|
TargetConfigs: targetConfigs,
|
|
397
|
-
Platform:
|
|
398
|
-
MJVersion: '5.
|
|
907
|
+
Platform: validatedPlatform,
|
|
908
|
+
MJVersion: process.env.MJ_VERSION ?? '5.11.0',
|
|
399
909
|
SourceType: companyIntegration.Integration,
|
|
400
|
-
AdditionalSchemaInfoPath: 'additionalSchemaInfo.json',
|
|
401
|
-
MigrationsDir: 'migrations/
|
|
402
|
-
MetadataDir: 'metadata',
|
|
403
|
-
ExistingTables:
|
|
910
|
+
AdditionalSchemaInfoPath: process.env.RSU_ADDITIONAL_SCHEMA_INFO_PATH ?? 'additionalSchemaInfo.json',
|
|
911
|
+
MigrationsDir: process.env.RSU_MIGRATIONS_PATH ?? 'migrations/rsu',
|
|
912
|
+
MetadataDir: process.env.RSU_METADATA_DIR ?? 'metadata',
|
|
913
|
+
ExistingTables: this.buildExistingTables(targetConfigs),
|
|
404
914
|
EntitySettingsForTargets: {}
|
|
405
915
|
};
|
|
406
916
|
|
|
@@ -432,11 +942,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
432
942
|
Warnings: output.Warnings.length > 0 ? output.Warnings : undefined
|
|
433
943
|
};
|
|
434
944
|
} catch (e) {
|
|
435
|
-
|
|
436
|
-
LogError(`IntegrationSchemaPreview error: ${error}`);
|
|
945
|
+
LogError(`IntegrationSchemaPreview error: ${this.formatError(e)}`);
|
|
437
946
|
return {
|
|
438
947
|
Success: false,
|
|
439
|
-
Message: `Error: ${
|
|
948
|
+
Message: `Error: ${this.formatError(e)}`
|
|
440
949
|
};
|
|
441
950
|
}
|
|
442
951
|
}
|
|
@@ -467,19 +976,19 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
467
976
|
ContextUser: user
|
|
468
977
|
});
|
|
469
978
|
|
|
979
|
+
const truncated = result.Records.slice(0, limit);
|
|
470
980
|
return {
|
|
471
981
|
Success: true,
|
|
472
|
-
Message: `Fetched ${result.Records.length}
|
|
473
|
-
Records:
|
|
982
|
+
Message: `Fetched ${truncated.length} preview records${result.Records.length > limit ? ` (truncated from ${result.Records.length})` : ''}`,
|
|
983
|
+
Records: truncated.map(r => ({
|
|
474
984
|
Data: JSON.stringify(r.Fields)
|
|
475
985
|
}))
|
|
476
986
|
};
|
|
477
987
|
} catch (e) {
|
|
478
|
-
|
|
479
|
-
LogError(`IntegrationPreviewData error: ${error}`);
|
|
988
|
+
LogError(`IntegrationPreviewData error: ${this.formatError(e)}`);
|
|
480
989
|
return {
|
|
481
990
|
Success: false,
|
|
482
|
-
Message: `Error: ${
|
|
991
|
+
Message: `Error: ${this.formatError(e)}`
|
|
483
992
|
};
|
|
484
993
|
}
|
|
485
994
|
}
|
|
@@ -489,13 +998,29 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
489
998
|
private buildTargetConfigs(
|
|
490
999
|
objects: SchemaPreviewObjectInput[],
|
|
491
1000
|
sourceSchema: SourceSchemaInfo,
|
|
492
|
-
platform: 'sqlserver' | 'postgresql'
|
|
1001
|
+
platform: 'sqlserver' | 'postgresql',
|
|
1002
|
+
connector?: BaseIntegrationConnector
|
|
493
1003
|
): TargetTableConfig[] {
|
|
494
1004
|
const mapper = new TypeMapper();
|
|
495
1005
|
|
|
1006
|
+
// Build a lookup from the connector's static metadata for descriptions.
|
|
1007
|
+
// DB entities may not have Description populated yet on first run,
|
|
1008
|
+
// but the connector's GetIntegrationObjects() always has them.
|
|
1009
|
+
const connectorDescriptions = this.buildDescriptionLookup(connector);
|
|
1010
|
+
|
|
496
1011
|
return objects.map(obj => {
|
|
497
|
-
const sourceObj = sourceSchema.Objects.find(o => o.ExternalName === obj.SourceObjectName);
|
|
498
|
-
const
|
|
1012
|
+
const sourceObj = sourceSchema.Objects.find(o => o.ExternalName.toLowerCase() === obj.SourceObjectName.toLowerCase());
|
|
1013
|
+
const objDescriptions = connectorDescriptions.get(obj.SourceObjectName.toLowerCase());
|
|
1014
|
+
|
|
1015
|
+
// Filter fields if caller specified a subset
|
|
1016
|
+
const selectedFieldSet = obj.Fields?.length
|
|
1017
|
+
? new Set(obj.Fields.map(f => f.toLowerCase()))
|
|
1018
|
+
: null;
|
|
1019
|
+
const sourceFields = (sourceObj?.Fields ?? []).filter(f =>
|
|
1020
|
+
!selectedFieldSet || selectedFieldSet.has(f.Name.toLowerCase()) || f.IsPrimaryKey
|
|
1021
|
+
);
|
|
1022
|
+
|
|
1023
|
+
const columns: TargetColumnConfig[] = sourceFields.map(f => ({
|
|
499
1024
|
SourceFieldName: f.Name,
|
|
500
1025
|
TargetColumnName: f.Name.replace(/[^A-Za-z0-9_]/g, '_'),
|
|
501
1026
|
TargetSqlType: mapper.MapSourceType(f.SourceType, platform, f),
|
|
@@ -503,7 +1028,8 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
503
1028
|
MaxLength: f.MaxLength,
|
|
504
1029
|
Precision: f.Precision,
|
|
505
1030
|
Scale: f.Scale,
|
|
506
|
-
DefaultValue: f.DefaultValue
|
|
1031
|
+
DefaultValue: f.DefaultValue,
|
|
1032
|
+
Description: f.Description ?? objDescriptions?.fields.get(f.Name.toLowerCase()),
|
|
507
1033
|
}));
|
|
508
1034
|
|
|
509
1035
|
const primaryKeyFields = (sourceObj?.Fields ?? [])
|
|
@@ -515,6 +1041,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
515
1041
|
SchemaName: obj.SchemaName,
|
|
516
1042
|
TableName: obj.TableName,
|
|
517
1043
|
EntityName: obj.EntityName,
|
|
1044
|
+
Description: sourceObj?.Description ?? objDescriptions?.objectDescription,
|
|
518
1045
|
Columns: columns,
|
|
519
1046
|
PrimaryKeyFields: primaryKeyFields.length > 0 ? primaryKeyFields : ['ID'],
|
|
520
1047
|
SoftForeignKeys: []
|
|
@@ -522,6 +1049,122 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
522
1049
|
});
|
|
523
1050
|
}
|
|
524
1051
|
|
|
1052
|
+
/** Builds a lookup of object name → { objectDescription, fields: fieldName → description } from the connector's static metadata. */
|
|
1053
|
+
/** Build ExistingTableInfo[] from MJ Metadata for tables that already exist in the target schemas. */
|
|
1054
|
+
private buildExistingTables(targetConfigs: TargetTableConfig[]): ExistingTableInfo[] {
|
|
1055
|
+
const md = new Metadata();
|
|
1056
|
+
const result: ExistingTableInfo[] = [];
|
|
1057
|
+
for (const config of targetConfigs) {
|
|
1058
|
+
const entity = md.Entities.find(e =>
|
|
1059
|
+
e.SchemaName.toLowerCase() === config.SchemaName.toLowerCase() &&
|
|
1060
|
+
e.BaseTable.toLowerCase() === config.TableName.toLowerCase()
|
|
1061
|
+
);
|
|
1062
|
+
if (entity) {
|
|
1063
|
+
result.push({
|
|
1064
|
+
SchemaName: config.SchemaName,
|
|
1065
|
+
TableName: config.TableName,
|
|
1066
|
+
Columns: entity.Fields.map(f => ({
|
|
1067
|
+
Name: f.Name,
|
|
1068
|
+
SqlType: f.SQLFullType || 'NVARCHAR(MAX)',
|
|
1069
|
+
IsNullable: f.AllowsNull,
|
|
1070
|
+
MaxLength: f.MaxLength,
|
|
1071
|
+
Precision: f.Precision,
|
|
1072
|
+
Scale: f.Scale,
|
|
1073
|
+
}))
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
return result;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
private buildDescriptionLookup(connector?: BaseIntegrationConnector): Map<string, { objectDescription?: string; fields: Map<string, string> }> {
|
|
1081
|
+
const result = new Map<string, { objectDescription?: string; fields: Map<string, string> }>();
|
|
1082
|
+
if (!connector) return result;
|
|
1083
|
+
|
|
1084
|
+
const staticObjects = connector.GetIntegrationObjects();
|
|
1085
|
+
for (const obj of staticObjects) {
|
|
1086
|
+
const fieldMap = new Map<string, string>();
|
|
1087
|
+
for (const f of obj.Fields) {
|
|
1088
|
+
if (f.Description) fieldMap.set(f.Name.toLowerCase(), f.Description);
|
|
1089
|
+
}
|
|
1090
|
+
result.set(obj.Name.toLowerCase(), { objectDescription: obj.Description, fields: fieldMap });
|
|
1091
|
+
}
|
|
1092
|
+
return result;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/**
|
|
1096
|
+
* Resolves source object IDs to exact names from the DB, and normalizes names
|
|
1097
|
+
* to match the source schema's ExternalName casing. Call once at each entry point.
|
|
1098
|
+
*/
|
|
1099
|
+
private async resolveSourceObjectNames(
|
|
1100
|
+
ids: string[] | undefined,
|
|
1101
|
+
names: string[] | undefined,
|
|
1102
|
+
sourceSchema: SourceSchemaInfo,
|
|
1103
|
+
integrationID: string,
|
|
1104
|
+
user: UserInfo
|
|
1105
|
+
): Promise<string[]> {
|
|
1106
|
+
// If IDs provided, resolve them to names from IntegrationObject records
|
|
1107
|
+
if (ids && ids.length > 0) {
|
|
1108
|
+
const rv = new RunView();
|
|
1109
|
+
const result = await rv.RunView<{ ID: string; Name: string }>({
|
|
1110
|
+
EntityName: 'MJ: Integration Objects',
|
|
1111
|
+
ExtraFilter: ids.map(id => `ID='${id}'`).join(' OR '),
|
|
1112
|
+
ResultType: 'simple',
|
|
1113
|
+
Fields: ['ID', 'Name'],
|
|
1114
|
+
}, user);
|
|
1115
|
+
if (result.Success) {
|
|
1116
|
+
return result.Results.map(r => r.Name);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// Otherwise normalize provided names against source schema casing
|
|
1121
|
+
if (names && names.length > 0) {
|
|
1122
|
+
const nameMap = new Map(sourceSchema.Objects.map(o => [o.ExternalName.toLowerCase(), o.ExternalName]));
|
|
1123
|
+
return names.map(n => nameMap.get(n.toLowerCase()) ?? n);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
return [];
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* Resolves SourceObjectID/SourceObjectName on SchemaPreviewObjectInput array.
|
|
1131
|
+
* Mutates the objects in place — sets SourceObjectName from ID if provided.
|
|
1132
|
+
*/
|
|
1133
|
+
private async resolveObjectInputs(
|
|
1134
|
+
objects: SchemaPreviewObjectInput[],
|
|
1135
|
+
sourceSchema: SourceSchemaInfo,
|
|
1136
|
+
user: UserInfo
|
|
1137
|
+
): Promise<void> {
|
|
1138
|
+
const idsToResolve = objects.filter(o => o.SourceObjectID && !o.SourceObjectName).map(o => o.SourceObjectID!);
|
|
1139
|
+
if (idsToResolve.length > 0) {
|
|
1140
|
+
const rv = new RunView();
|
|
1141
|
+
const result = await rv.RunView<{ ID: string; Name: string }>({
|
|
1142
|
+
EntityName: 'MJ: Integration Objects',
|
|
1143
|
+
ExtraFilter: idsToResolve.map(id => `ID='${id}'`).join(' OR '),
|
|
1144
|
+
ResultType: 'simple',
|
|
1145
|
+
Fields: ['ID', 'Name'],
|
|
1146
|
+
}, user);
|
|
1147
|
+
if (result.Success) {
|
|
1148
|
+
const idToName = new Map(result.Results.map(r => [r.ID.toUpperCase(), r.Name]));
|
|
1149
|
+
for (const obj of objects) {
|
|
1150
|
+
if (obj.SourceObjectID) {
|
|
1151
|
+
const resolved = idToName.get(obj.SourceObjectID.toUpperCase());
|
|
1152
|
+
if (resolved) obj.SourceObjectName = resolved;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// Normalize remaining names to match source schema casing
|
|
1159
|
+
const nameMap = new Map(sourceSchema.Objects.map(o => [o.ExternalName.toLowerCase(), o.ExternalName]));
|
|
1160
|
+
for (const obj of objects) {
|
|
1161
|
+
if (obj.SourceObjectName) {
|
|
1162
|
+
const exact = nameMap.get(obj.SourceObjectName.toLowerCase());
|
|
1163
|
+
if (exact) obj.SourceObjectName = exact;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
525
1168
|
private getAuthenticatedUser(ctx: AppContext): UserInfo {
|
|
526
1169
|
const user = ctx.userPayload.userRecord;
|
|
527
1170
|
if (!user) {
|
|
@@ -530,6 +1173,13 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
530
1173
|
return user;
|
|
531
1174
|
}
|
|
532
1175
|
|
|
1176
|
+
/** Get system user for server-side operations that need elevated permissions (cascade deletes, etc.) */
|
|
1177
|
+
private getSystemUser(): UserInfo {
|
|
1178
|
+
const sysUser = UserCache.Instance.GetSystemUser();
|
|
1179
|
+
if (!sysUser) throw new Error('System user not available');
|
|
1180
|
+
return sysUser;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
533
1183
|
/**
|
|
534
1184
|
* Loads the CompanyIntegration + its parent Integration, then resolves the
|
|
535
1185
|
* appropriate connector via ConnectorFactory.
|
|
@@ -565,20 +1215,2301 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
565
1215
|
throw new Error(`Integration with ID "${companyIntegration.IntegrationID}" not found`);
|
|
566
1216
|
}
|
|
567
1217
|
|
|
568
|
-
// ConnectorFactory expects
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
);
|
|
1218
|
+
// ConnectorFactory.Resolve expects MJIntegrationEntity, which is the same type loaded above.
|
|
1219
|
+
// Both this file and ConnectorFactory import from @memberjunction/core-entities.
|
|
1220
|
+
const connector = ConnectorFactory.Resolve(integration);
|
|
572
1221
|
|
|
573
1222
|
return { connector, companyIntegration };
|
|
574
1223
|
}
|
|
575
1224
|
|
|
576
1225
|
private handleDiscoveryError(e: unknown): DiscoverObjectsOutput & DiscoverFieldsOutput {
|
|
577
|
-
|
|
578
|
-
LogError(`Integration discovery error: ${error}`);
|
|
1226
|
+
LogError(`Integration discovery error: ${this.formatError(e)}`);
|
|
579
1227
|
return {
|
|
580
1228
|
Success: false,
|
|
581
|
-
Message: `Error: ${
|
|
1229
|
+
Message: `Error: ${this.formatError(e)}`
|
|
582
1230
|
};
|
|
583
1231
|
}
|
|
1232
|
+
|
|
1233
|
+
private formatError(e: unknown): string {
|
|
1234
|
+
return e instanceof Error ? e.message : String(e);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
private static readonly VALID_PLATFORMS = new Set<string>(['sqlserver', 'postgresql']);
|
|
1238
|
+
|
|
1239
|
+
/** Validates and narrows a platform string to the supported union type. */
|
|
1240
|
+
private validatePlatform(platform: string): 'sqlserver' | 'postgresql' {
|
|
1241
|
+
if (!IntegrationDiscoveryResolver.VALID_PLATFORMS.has(platform)) {
|
|
1242
|
+
throw new Error(`Unsupported platform "${platform}". Must be one of: ${[...IntegrationDiscoveryResolver.VALID_PLATFORMS].join(', ')}`);
|
|
1243
|
+
}
|
|
1244
|
+
return platform as 'sqlserver' | 'postgresql';
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// ── CONNECTION TEST HELPERS ────────────────────────────────────────────
|
|
1248
|
+
|
|
1249
|
+
/**
|
|
1250
|
+
* Tests connectivity for a given CompanyIntegration, reusing the same pattern as IntegrationTestConnection.
|
|
1251
|
+
*/
|
|
1252
|
+
private async testConnectionForCI(
|
|
1253
|
+
companyIntegrationID: string,
|
|
1254
|
+
user: UserInfo
|
|
1255
|
+
): Promise<ConnectionTestResult> {
|
|
1256
|
+
const { connector, companyIntegration } = await this.resolveConnector(companyIntegrationID, user);
|
|
1257
|
+
const testFn = connector.TestConnection.bind(connector) as
|
|
1258
|
+
(ci: unknown, u: unknown) => Promise<ConnectionTestResult>;
|
|
1259
|
+
return testFn(companyIntegration, user);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/**
|
|
1263
|
+
* Rolls back a freshly created connection by deleting both the CompanyIntegration and Credential records.
|
|
1264
|
+
*/
|
|
1265
|
+
private async rollbackCreatedConnection(
|
|
1266
|
+
ci: MJCompanyIntegrationEntity,
|
|
1267
|
+
credential: MJCredentialEntity
|
|
1268
|
+
): Promise<void> {
|
|
1269
|
+
try { await ci.Delete(); } catch (e) { LogError(`Rollback: failed to delete CompanyIntegration: ${this.formatError(e)}`); }
|
|
1270
|
+
try { await credential.Delete(); } catch (e) { LogError(`Rollback: failed to delete Credential: ${this.formatError(e)}`); }
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* Snapshots the current credential Values for a given credential ID so they can be restored on rollback.
|
|
1275
|
+
*/
|
|
1276
|
+
private async snapshotCredentialValues(
|
|
1277
|
+
credentialID: string | undefined,
|
|
1278
|
+
user: UserInfo
|
|
1279
|
+
): Promise<string | undefined> {
|
|
1280
|
+
if (!credentialID) return undefined;
|
|
1281
|
+
const md = new Metadata();
|
|
1282
|
+
const credential = await md.GetEntityObject<MJCredentialEntity>('MJ: Credentials', user);
|
|
1283
|
+
const loaded = await credential.InnerLoad(CompositeKey.FromID(credentialID));
|
|
1284
|
+
return loaded ? credential.Values : undefined;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/**
|
|
1288
|
+
* Reverts an update to a CompanyIntegration by restoring old configuration, externalSystemID,
|
|
1289
|
+
* and credential values.
|
|
1290
|
+
*/
|
|
1291
|
+
private async revertUpdateConnection(
|
|
1292
|
+
ci: MJCompanyIntegrationEntity,
|
|
1293
|
+
oldConfiguration: string | undefined,
|
|
1294
|
+
oldExternalSystemID: string | undefined,
|
|
1295
|
+
oldCredentialValues: string | undefined,
|
|
1296
|
+
user: UserInfo
|
|
1297
|
+
): Promise<void> {
|
|
1298
|
+
try {
|
|
1299
|
+
// Revert CI fields
|
|
1300
|
+
let dirty = false;
|
|
1301
|
+
if (oldConfiguration !== undefined) { ci.Configuration = oldConfiguration; dirty = true; }
|
|
1302
|
+
if (oldExternalSystemID !== undefined) { ci.ExternalSystemID = oldExternalSystemID; dirty = true; }
|
|
1303
|
+
if (dirty) await ci.Save();
|
|
1304
|
+
|
|
1305
|
+
// Revert credential values
|
|
1306
|
+
if (oldCredentialValues !== undefined && ci.CredentialID) {
|
|
1307
|
+
const md = new Metadata();
|
|
1308
|
+
const credential = await md.GetEntityObject<MJCredentialEntity>('MJ: Credentials', user);
|
|
1309
|
+
const loaded = await credential.InnerLoad(CompositeKey.FromID(ci.CredentialID));
|
|
1310
|
+
if (loaded) {
|
|
1311
|
+
credential.Values = oldCredentialValues;
|
|
1312
|
+
await credential.Save();
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
} catch (e) {
|
|
1316
|
+
LogError(`Revert update connection failed: ${this.formatError(e)}`);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// ── CONNECTION LIFECYCLE ─────────────────────────────────────────────
|
|
1321
|
+
|
|
1322
|
+
/**
|
|
1323
|
+
* Lists all CompanyIntegrations (optionally filtered by active status).
|
|
1324
|
+
* Returns key fields for dashboard display without requiring a raw RunView call.
|
|
1325
|
+
*/
|
|
1326
|
+
@Query(() => ListConnectionsOutput)
|
|
1327
|
+
async IntegrationListConnections(
|
|
1328
|
+
@Arg("activeOnly", { defaultValue: true }) activeOnly: boolean,
|
|
1329
|
+
@Arg("companyID", { nullable: true }) companyID: string,
|
|
1330
|
+
@Ctx() ctx: AppContext
|
|
1331
|
+
): Promise<ListConnectionsOutput> {
|
|
1332
|
+
try {
|
|
1333
|
+
const user = this.getAuthenticatedUser(ctx);
|
|
1334
|
+
const rv = new RunView();
|
|
1335
|
+
const filters: string[] = [];
|
|
1336
|
+
if (activeOnly) filters.push('IsActive=1');
|
|
1337
|
+
if (companyID) filters.push(`CompanyID='${companyID}'`);
|
|
1338
|
+
const filter = filters.join(' AND ');
|
|
1339
|
+
const result = await rv.RunView<MJCompanyIntegrationEntity>({
|
|
1340
|
+
EntityName: 'MJ: Company Integrations',
|
|
1341
|
+
ExtraFilter: filter,
|
|
1342
|
+
OrderBy: 'Integration ASC',
|
|
1343
|
+
ResultType: 'simple',
|
|
1344
|
+
Fields: [
|
|
1345
|
+
'ID', 'Integration', 'IntegrationID', 'CompanyID', 'Company',
|
|
1346
|
+
'IsActive', 'ScheduleEnabled', 'CronExpression',
|
|
1347
|
+
'ExternalSystemID', '__mj_CreatedAt'
|
|
1348
|
+
]
|
|
1349
|
+
}, user);
|
|
1350
|
+
|
|
1351
|
+
if (!result.Success) return { Success: false, Message: result.ErrorMessage || 'Query failed' };
|
|
1352
|
+
|
|
1353
|
+
return {
|
|
1354
|
+
Success: true,
|
|
1355
|
+
Message: `${result.Results.length} connections`,
|
|
1356
|
+
Connections: result.Results.map(ci => ({
|
|
1357
|
+
ID: ci.ID,
|
|
1358
|
+
IntegrationName: ci.Integration,
|
|
1359
|
+
IntegrationID: ci.IntegrationID,
|
|
1360
|
+
CompanyID: ci.CompanyID,
|
|
1361
|
+
Company: ci.Company,
|
|
1362
|
+
IsActive: ci.IsActive,
|
|
1363
|
+
ScheduleEnabled: ci.ScheduleEnabled ?? false,
|
|
1364
|
+
CronExpression: ci.CronExpression ?? undefined,
|
|
1365
|
+
CreatedAt: ci.__mj_CreatedAt?.toISOString() ?? '',
|
|
1366
|
+
}))
|
|
1367
|
+
};
|
|
1368
|
+
} catch (e) {
|
|
1369
|
+
LogError(`IntegrationListConnections error: ${e}`);
|
|
1370
|
+
return { Success: false, Message: this.formatError(e) };
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* Creates a CompanyIntegration with a linked Credential entity for encrypted credential storage.
|
|
1376
|
+
*/
|
|
1377
|
+
@Mutation(() => CreateConnectionOutput)
|
|
1378
|
+
async IntegrationCreateConnection(
|
|
1379
|
+
@Arg("input") input: CreateConnectionInput,
|
|
1380
|
+
@Arg("testConnection", () => Boolean, { defaultValue: false }) testConnection: boolean,
|
|
1381
|
+
@Ctx() ctx: AppContext
|
|
1382
|
+
): Promise<CreateConnectionOutput> {
|
|
1383
|
+
try {
|
|
1384
|
+
const user = this.getAuthenticatedUser(ctx);
|
|
1385
|
+
const md = new Metadata();
|
|
1386
|
+
|
|
1387
|
+
// 1. Create Credential record with encrypted values
|
|
1388
|
+
const credential = await md.GetEntityObject<MJCredentialEntity>('MJ: Credentials', user);
|
|
1389
|
+
credential.NewRecord();
|
|
1390
|
+
credential.CredentialTypeID = input.CredentialTypeID;
|
|
1391
|
+
credential.Name = input.CredentialName;
|
|
1392
|
+
credential.Values = input.CredentialValues;
|
|
1393
|
+
credential.IsActive = true;
|
|
1394
|
+
|
|
1395
|
+
const credSaved = await credential.Save();
|
|
1396
|
+
if (!credSaved) {
|
|
1397
|
+
const err = credential.LatestResult?.Message || 'Unknown error';
|
|
1398
|
+
return { Success: false, Message: `Failed to create Credential: ${err}` };
|
|
1399
|
+
}
|
|
1400
|
+
const credentialID = credential.ID;
|
|
1401
|
+
|
|
1402
|
+
// 2. Create CompanyIntegration linked to the Credential
|
|
1403
|
+
const ci = await md.GetEntityObject<MJCompanyIntegrationEntity>('MJ: Company Integrations', user);
|
|
1404
|
+
ci.NewRecord();
|
|
1405
|
+
ci.IntegrationID = input.IntegrationID;
|
|
1406
|
+
ci.CompanyID = input.CompanyID;
|
|
1407
|
+
ci.CredentialID = credentialID;
|
|
1408
|
+
ci.IsActive = true;
|
|
1409
|
+
ci.Name = input.CredentialName; // Name is required on CompanyIntegration
|
|
1410
|
+
if (input.ExternalSystemID) ci.ExternalSystemID = input.ExternalSystemID;
|
|
1411
|
+
if (input.Configuration) ci.Configuration = input.Configuration;
|
|
1412
|
+
|
|
1413
|
+
const saved = await ci.Save();
|
|
1414
|
+
if (!saved) {
|
|
1415
|
+
const validationErrors = ci.LatestResult?.Message || 'Unknown validation error';
|
|
1416
|
+
return { Success: false, Message: `Failed to save CompanyIntegration: ${validationErrors}` };
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// 3. Optionally test the connection; rollback on failure
|
|
1420
|
+
if (testConnection) {
|
|
1421
|
+
const testResult = await this.testConnectionForCI(ci.ID, user);
|
|
1422
|
+
if (!testResult.Success) {
|
|
1423
|
+
await this.rollbackCreatedConnection(ci, credential);
|
|
1424
|
+
return {
|
|
1425
|
+
Success: false,
|
|
1426
|
+
Message: `Connection test failed: ${testResult.Message}. Connection was not saved.`,
|
|
1427
|
+
ConnectionTestSuccess: false,
|
|
1428
|
+
ConnectionTestMessage: testResult.Message
|
|
1429
|
+
};
|
|
1430
|
+
}
|
|
1431
|
+
return {
|
|
1432
|
+
Success: true,
|
|
1433
|
+
Message: 'Connection created and test passed',
|
|
1434
|
+
CompanyIntegrationID: ci.ID,
|
|
1435
|
+
CredentialID: credentialID,
|
|
1436
|
+
ConnectionTestSuccess: true,
|
|
1437
|
+
ConnectionTestMessage: testResult.Message
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
return {
|
|
1442
|
+
Success: true,
|
|
1443
|
+
Message: 'Connection created',
|
|
1444
|
+
CompanyIntegrationID: ci.ID,
|
|
1445
|
+
CredentialID: credentialID
|
|
1446
|
+
};
|
|
1447
|
+
} catch (e) {
|
|
1448
|
+
LogError(`IntegrationCreateConnection error: ${e}`);
|
|
1449
|
+
return { Success: false, Message: this.formatError(e) };
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
/**
|
|
1454
|
+
* Updates credential values and/or configuration on an existing CompanyIntegration.
|
|
1455
|
+
*/
|
|
1456
|
+
@Mutation(() => MutationResultOutput)
|
|
1457
|
+
async IntegrationUpdateConnection(
|
|
1458
|
+
@Arg("companyIntegrationID") companyIntegrationID: string,
|
|
1459
|
+
@Arg("credentialValues", { nullable: true }) credentialValues: string,
|
|
1460
|
+
@Arg("configuration", { nullable: true }) configuration: string,
|
|
1461
|
+
@Arg("externalSystemID", { nullable: true }) externalSystemID: string,
|
|
1462
|
+
@Arg("testConnection", () => Boolean, { defaultValue: false }) testConnection: boolean,
|
|
1463
|
+
@Ctx() ctx: AppContext
|
|
1464
|
+
): Promise<MutationResultOutput> {
|
|
1465
|
+
try {
|
|
1466
|
+
const user = this.getAuthenticatedUser(ctx);
|
|
1467
|
+
const md = new Metadata();
|
|
1468
|
+
const ci = await md.GetEntityObject<MJCompanyIntegrationEntity>('MJ: Company Integrations', user);
|
|
1469
|
+
const loaded = await ci.InnerLoad(CompositeKey.FromID(companyIntegrationID));
|
|
1470
|
+
if (!loaded) return { Success: false, Message: 'CompanyIntegration not found' };
|
|
1471
|
+
|
|
1472
|
+
// Snapshot old values for rollback if testConnection is requested
|
|
1473
|
+
const oldCredentialValues = credentialValues ? await this.snapshotCredentialValues(ci.CredentialID, user) : undefined;
|
|
1474
|
+
const oldConfiguration = ci.Configuration;
|
|
1475
|
+
const oldExternalSystemID = ci.ExternalSystemID;
|
|
1476
|
+
|
|
1477
|
+
// Update linked Credential values if provided
|
|
1478
|
+
if (credentialValues) {
|
|
1479
|
+
const credentialID = ci.CredentialID;
|
|
1480
|
+
if (!credentialID) {
|
|
1481
|
+
return { Success: false, Message: 'No linked Credential — use IntegrationCreateConnection first' };
|
|
1482
|
+
}
|
|
1483
|
+
const credential = await md.GetEntityObject<MJCredentialEntity>('MJ: Credentials', user);
|
|
1484
|
+
const credLoaded = await credential.InnerLoad(CompositeKey.FromID(credentialID));
|
|
1485
|
+
if (!credLoaded) return { Success: false, Message: 'Linked Credential not found' };
|
|
1486
|
+
credential.Values = credentialValues;
|
|
1487
|
+
if (!await credential.Save()) return { Success: false, Message: 'Failed to update Credential' };
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
let dirty = false;
|
|
1491
|
+
if (configuration !== undefined && configuration !== null) { ci.Configuration = configuration; dirty = true; }
|
|
1492
|
+
if (externalSystemID !== undefined && externalSystemID !== null) { ci.ExternalSystemID = externalSystemID; dirty = true; }
|
|
1493
|
+
|
|
1494
|
+
if (dirty && !await ci.Save()) return { Success: false, Message: 'Failed to save CompanyIntegration' };
|
|
1495
|
+
|
|
1496
|
+
// Optionally test the connection; revert on failure
|
|
1497
|
+
if (testConnection) {
|
|
1498
|
+
const testResult = await this.testConnectionForCI(companyIntegrationID, user);
|
|
1499
|
+
if (!testResult.Success) {
|
|
1500
|
+
await this.revertUpdateConnection(ci, oldConfiguration, oldExternalSystemID, oldCredentialValues, user);
|
|
1501
|
+
return { Success: false, Message: `Connection test failed: ${testResult.Message}. Changes have been reverted.` };
|
|
1502
|
+
}
|
|
1503
|
+
return { Success: true, Message: 'Updated and connection test passed' };
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
return { Success: true, Message: 'Updated' };
|
|
1507
|
+
} catch (e) {
|
|
1508
|
+
LogError(`IntegrationUpdateConnection error: ${e}`);
|
|
1509
|
+
return { Success: false, Message: this.formatError(e) };
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
/**
|
|
1514
|
+
* Soft-deletes a CompanyIntegration by setting IsActive=false.
|
|
1515
|
+
*/
|
|
1516
|
+
@Mutation(() => MutationResultOutput)
|
|
1517
|
+
async IntegrationDeactivateConnection(
|
|
1518
|
+
@Arg("companyIntegrationID") companyIntegrationID: string,
|
|
1519
|
+
@Ctx() ctx: AppContext
|
|
1520
|
+
): Promise<MutationResultOutput> {
|
|
1521
|
+
try {
|
|
1522
|
+
const user = this.getAuthenticatedUser(ctx);
|
|
1523
|
+
const md = new Metadata();
|
|
1524
|
+
const ci = await md.GetEntityObject<MJCompanyIntegrationEntity>('MJ: Company Integrations', user);
|
|
1525
|
+
const loaded = await ci.InnerLoad(CompositeKey.FromID(companyIntegrationID));
|
|
1526
|
+
if (!loaded) return { Success: false, Message: 'CompanyIntegration not found' };
|
|
1527
|
+
ci.IsActive = false;
|
|
1528
|
+
if (!await ci.Save()) return { Success: false, Message: 'Failed to deactivate' };
|
|
1529
|
+
return { Success: true, Message: 'Deactivated' };
|
|
1530
|
+
} catch (e) {
|
|
1531
|
+
LogError(`IntegrationDeactivateConnection error: ${e}`);
|
|
1532
|
+
return { Success: false, Message: this.formatError(e) };
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
/**
|
|
1537
|
+
* Reactivates a previously deactivated CompanyIntegration by setting IsActive=true.
|
|
1538
|
+
*/
|
|
1539
|
+
@Mutation(() => MutationResultOutput)
|
|
1540
|
+
async IntegrationReactivateConnection(
|
|
1541
|
+
@Arg("companyIntegrationID") companyIntegrationID: string,
|
|
1542
|
+
@Ctx() ctx: AppContext
|
|
1543
|
+
): Promise<MutationResultOutput> {
|
|
1544
|
+
try {
|
|
1545
|
+
const user = this.getAuthenticatedUser(ctx);
|
|
1546
|
+
const md = new Metadata();
|
|
1547
|
+
const ci = await md.GetEntityObject<MJCompanyIntegrationEntity>('MJ: Company Integrations', user);
|
|
1548
|
+
const loaded = await ci.InnerLoad(CompositeKey.FromID(companyIntegrationID));
|
|
1549
|
+
if (!loaded) return { Success: false, Message: 'CompanyIntegration not found' };
|
|
1550
|
+
ci.IsActive = true;
|
|
1551
|
+
if (!await ci.Save()) return { Success: false, Message: `Failed to reactivate: ${ci.LatestResult?.Message ?? 'Unknown error'}` };
|
|
1552
|
+
return { Success: true, Message: 'Reactivated' };
|
|
1553
|
+
} catch (e) {
|
|
1554
|
+
LogError(`IntegrationReactivateConnection error: ${e}`);
|
|
1555
|
+
return { Success: false, Message: this.formatError(e) };
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
// ── ENTITY MAPS ─────────────────────────────────────────────────────
|
|
1560
|
+
|
|
1561
|
+
/**
|
|
1562
|
+
* Batch creates entity maps by entity name (resolved by lookup).
|
|
1563
|
+
* Call AFTER the schema pipeline has created the target entities.
|
|
1564
|
+
*/
|
|
1565
|
+
@Mutation(() => CreateEntityMapsOutput)
|
|
1566
|
+
async IntegrationCreateEntityMaps(
|
|
1567
|
+
@Arg("companyIntegrationID") companyIntegrationID: string,
|
|
1568
|
+
@Arg("entityMaps", () => [EntityMapInput]) entityMaps: EntityMapInput[],
|
|
1569
|
+
@Ctx() ctx: AppContext
|
|
1570
|
+
): Promise<CreateEntityMapsOutput> {
|
|
1571
|
+
try {
|
|
1572
|
+
const user = this.getAuthenticatedUser(ctx);
|
|
1573
|
+
const md = new Metadata();
|
|
1574
|
+
|
|
1575
|
+
// Batch resolve entity names → IDs using cached Metadata
|
|
1576
|
+
const namesToResolve = entityMaps.filter(m => m.EntityName && !m.EntityID).map(m => m.EntityName as string);
|
|
1577
|
+
const nameToID = new Map<string, string>();
|
|
1578
|
+
|
|
1579
|
+
if (namesToResolve.length > 0) {
|
|
1580
|
+
const uniqueNames = [...new Set(namesToResolve)];
|
|
1581
|
+
for (const name of uniqueNames) {
|
|
1582
|
+
const entity = md.EntityByName(name);
|
|
1583
|
+
if (entity) {
|
|
1584
|
+
nameToID.set(name, entity.ID);
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
const unresolved = uniqueNames.filter(n => !nameToID.has(n));
|
|
1588
|
+
if (unresolved.length > 0) {
|
|
1589
|
+
return {
|
|
1590
|
+
Success: false,
|
|
1591
|
+
Message: `Entities not found: ${unresolved.join(', ')}. Run the schema pipeline first.`
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
const created: EntityMapCreatedOutput[] = [];
|
|
1597
|
+
for (const mapDef of entityMaps) {
|
|
1598
|
+
const entityID = mapDef.EntityID || nameToID.get(mapDef.EntityName || '');
|
|
1599
|
+
if (!entityID) {
|
|
1600
|
+
return { Success: false, Message: `No EntityID or EntityName for "${mapDef.ExternalObjectName}"`, Created: created };
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
const em = await md.GetEntityObject<MJCompanyIntegrationEntityMapEntity>('MJ: Company Integration Entity Maps', user);
|
|
1604
|
+
em.NewRecord();
|
|
1605
|
+
em.CompanyIntegrationID = companyIntegrationID;
|
|
1606
|
+
em.ExternalObjectName = mapDef.ExternalObjectName;
|
|
1607
|
+
em.EntityID = entityID;
|
|
1608
|
+
const syncDir = mapDef.SyncDirection || 'Pull';
|
|
1609
|
+
if (!isValidSyncDirection(syncDir)) {
|
|
1610
|
+
return { Success: false, Message: `Invalid SyncDirection "${syncDir}" for "${mapDef.ExternalObjectName}". Must be one of: ${VALID_SYNC_DIRECTIONS.join(', ')}`, Created: created };
|
|
1611
|
+
}
|
|
1612
|
+
em.SyncDirection = syncDir;
|
|
1613
|
+
em.Priority = mapDef.Priority || 0;
|
|
1614
|
+
em.Status = 'Active';
|
|
1615
|
+
|
|
1616
|
+
if (!await em.Save()) {
|
|
1617
|
+
return { Success: false, Message: `Failed to create map for ${mapDef.ExternalObjectName}`, Created: created };
|
|
1618
|
+
}
|
|
1619
|
+
const entityMapID = em.ID;
|
|
1620
|
+
|
|
1621
|
+
// Create field maps if provided
|
|
1622
|
+
if (mapDef.FieldMaps) {
|
|
1623
|
+
for (const fmDef of mapDef.FieldMaps) {
|
|
1624
|
+
const fm = await md.GetEntityObject<MJCompanyIntegrationFieldMapEntity>('MJ: Company Integration Field Maps', user);
|
|
1625
|
+
fm.NewRecord();
|
|
1626
|
+
fm.EntityMapID = entityMapID;
|
|
1627
|
+
fm.SourceFieldName = fmDef.SourceFieldName;
|
|
1628
|
+
fm.DestinationFieldName = fmDef.DestinationFieldName;
|
|
1629
|
+
fm.IsKeyField = fmDef.IsKeyField || false;
|
|
1630
|
+
fm.IsRequired = fmDef.IsRequired || false;
|
|
1631
|
+
fm.Status = 'Active';
|
|
1632
|
+
await fm.Save();
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
created.push({
|
|
1637
|
+
EntityMapID: entityMapID,
|
|
1638
|
+
ExternalObjectName: mapDef.ExternalObjectName,
|
|
1639
|
+
FieldMapCount: mapDef.FieldMaps?.length || 0
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
return { Success: true, Message: `Created ${created.length} entity maps`, Created: created };
|
|
1644
|
+
} catch (e) {
|
|
1645
|
+
LogError(`IntegrationCreateEntityMaps error: ${e}`);
|
|
1646
|
+
return { Success: false, Message: this.formatError(e) };
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
// ── SCHEMA EXECUTION ────────────────────────────────────────────────
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
* Generates schema artifacts from connector introspection and runs the full
|
|
1654
|
+
* RSU pipeline: write migration file → execute SQL → run CodeGen →
|
|
1655
|
+
* compile TypeScript → restart MJAPI → git commit (if enabled).
|
|
1656
|
+
*
|
|
1657
|
+
* Replaces the old two-step IntegrationSchemaPreview + IntegrationWriteSchemaFiles
|
|
1658
|
+
* pattern. Use IntegrationSchemaPreview to preview generated SQL without applying.
|
|
1659
|
+
*/
|
|
1660
|
+
@Mutation(() => ApplySchemaOutput)
|
|
1661
|
+
async IntegrationApplySchema(
|
|
1662
|
+
@Arg("companyIntegrationID") companyIntegrationID: string,
|
|
1663
|
+
@Arg("objects", () => [SchemaPreviewObjectInput]) objects: SchemaPreviewObjectInput[],
|
|
1664
|
+
@Arg("platform", { defaultValue: "sqlserver" }) platform: string,
|
|
1665
|
+
@Arg("skipGitCommit", { defaultValue: false }) skipGitCommit: boolean,
|
|
1666
|
+
@Arg("skipRestart", { defaultValue: false }) skipRestart: boolean,
|
|
1667
|
+
@Ctx() ctx: AppContext
|
|
1668
|
+
): Promise<ApplySchemaOutput> {
|
|
1669
|
+
try {
|
|
1670
|
+
const user = this.getAuthenticatedUser(ctx);
|
|
1671
|
+
const { connector, companyIntegration } = await this.resolveConnector(companyIntegrationID, user);
|
|
1672
|
+
|
|
1673
|
+
const introspect = connector.IntrospectSchema.bind(connector) as
|
|
1674
|
+
(ci: unknown, u: unknown) => Promise<SourceSchemaInfo>;
|
|
1675
|
+
const sourceSchema = await introspect(companyIntegration, user);
|
|
1676
|
+
|
|
1677
|
+
await this.resolveObjectInputs(objects, sourceSchema, user);
|
|
1678
|
+
|
|
1679
|
+
const requestedNames = new Set(objects.map(o => o.SourceObjectName));
|
|
1680
|
+
const filteredSchema: SourceSchemaInfo = {
|
|
1681
|
+
Objects: sourceSchema.Objects.filter(o => requestedNames.has(o.ExternalName))
|
|
1682
|
+
};
|
|
1683
|
+
|
|
1684
|
+
const validatedPlatform = this.validatePlatform(platform);
|
|
1685
|
+
const targetConfigs = this.buildTargetConfigs(objects, filteredSchema, validatedPlatform, connector);
|
|
1686
|
+
|
|
1687
|
+
const input: SchemaBuilderInput = {
|
|
1688
|
+
SourceSchema: filteredSchema,
|
|
1689
|
+
TargetConfigs: targetConfigs,
|
|
1690
|
+
Platform: validatedPlatform,
|
|
1691
|
+
MJVersion: process.env.MJ_VERSION ?? '5.11.0',
|
|
1692
|
+
SourceType: companyIntegration.Integration,
|
|
1693
|
+
AdditionalSchemaInfoPath: process.env.RSU_ADDITIONAL_SCHEMA_INFO_PATH ?? 'additionalSchemaInfo.json',
|
|
1694
|
+
MigrationsDir: process.env.RSU_MIGRATIONS_PATH ?? 'migrations/rsu',
|
|
1695
|
+
MetadataDir: process.env.RSU_METADATA_DIR ?? 'metadata',
|
|
1696
|
+
ExistingTables: this.buildExistingTables(targetConfigs),
|
|
1697
|
+
EntitySettingsForTargets: {}
|
|
1698
|
+
};
|
|
1699
|
+
|
|
1700
|
+
const builder = new SchemaBuilder();
|
|
1701
|
+
const { SchemaOutput, PipelineResult } = await builder.RunSchemaPipeline(input, {
|
|
1702
|
+
SkipGitCommit: skipGitCommit,
|
|
1703
|
+
SkipRestart: skipRestart,
|
|
1704
|
+
});
|
|
1705
|
+
|
|
1706
|
+
return {
|
|
1707
|
+
Success: PipelineResult.Success,
|
|
1708
|
+
Message: PipelineResult.Success
|
|
1709
|
+
? `Schema applied — ${PipelineResult.EntitiesProcessed ?? 0} entities processed`
|
|
1710
|
+
: `Pipeline failed at '${PipelineResult.ErrorStep}': ${PipelineResult.ErrorMessage}`,
|
|
1711
|
+
Steps: PipelineResult.Steps.map((s: RSUPipelineStep) => ({
|
|
1712
|
+
Name: s.Name,
|
|
1713
|
+
Status: s.Status,
|
|
1714
|
+
DurationMs: s.DurationMs,
|
|
1715
|
+
Message: s.Message,
|
|
1716
|
+
})),
|
|
1717
|
+
MigrationFilePath: PipelineResult.MigrationFilePath,
|
|
1718
|
+
EntitiesProcessed: PipelineResult.EntitiesProcessed,
|
|
1719
|
+
GitCommitSuccess: PipelineResult.GitCommitSuccess,
|
|
1720
|
+
APIRestarted: PipelineResult.APIRestarted,
|
|
1721
|
+
Warnings: SchemaOutput.Warnings.length > 0 ? SchemaOutput.Warnings : undefined,
|
|
1722
|
+
};
|
|
1723
|
+
} catch (e) {
|
|
1724
|
+
LogError(`IntegrationApplySchema error: ${e}`);
|
|
1725
|
+
return { Success: false, Message: this.formatError(e) };
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
/**
|
|
1730
|
+
* Batch apply schema for multiple connectors in one RSU pipeline run.
|
|
1731
|
+
* Each item specifies a companyIntegrationID + source objects to create tables for.
|
|
1732
|
+
* All migrations run sequentially, then ONE CodeGen, ONE compile, ONE git PR, ONE restart.
|
|
1733
|
+
*/
|
|
1734
|
+
@Mutation(() => ApplySchemaBatchOutput)
|
|
1735
|
+
async IntegrationApplySchemaBatch(
|
|
1736
|
+
@Arg("items", () => [ApplySchemaBatchItemInput]) items: ApplySchemaBatchItemInput[],
|
|
1737
|
+
@Arg("platform", { defaultValue: "sqlserver" }) platform: string,
|
|
1738
|
+
@Arg("skipGitCommit", { defaultValue: false }) skipGitCommit: boolean,
|
|
1739
|
+
@Arg("skipRestart", { defaultValue: false }) skipRestart: boolean,
|
|
1740
|
+
@Ctx() ctx: AppContext
|
|
1741
|
+
): Promise<ApplySchemaBatchOutput> {
|
|
1742
|
+
try {
|
|
1743
|
+
const user = this.getAuthenticatedUser(ctx);
|
|
1744
|
+
const validatedPlatform = this.validatePlatform(platform);
|
|
1745
|
+
const pipelineInputs: RSUPipelineInput[] = [];
|
|
1746
|
+
const itemResults: ApplySchemaBatchItemOutput[] = [];
|
|
1747
|
+
|
|
1748
|
+
// Phase 1: Build schema artifacts for each connector's objects
|
|
1749
|
+
for (const item of items) {
|
|
1750
|
+
try {
|
|
1751
|
+
const { schemaOutput, rsuInput } = await this.buildSchemaForConnector(
|
|
1752
|
+
item.CompanyIntegrationID, item.Objects, validatedPlatform, user, skipGitCommit, skipRestart
|
|
1753
|
+
);
|
|
1754
|
+
pipelineInputs.push(rsuInput);
|
|
1755
|
+
itemResults.push({
|
|
1756
|
+
CompanyIntegrationID: item.CompanyIntegrationID,
|
|
1757
|
+
Success: true,
|
|
1758
|
+
Message: `Schema generated for ${item.Objects.length} object(s)`,
|
|
1759
|
+
Warnings: schemaOutput.Warnings.length > 0 ? schemaOutput.Warnings : undefined,
|
|
1760
|
+
});
|
|
1761
|
+
} catch (e) {
|
|
1762
|
+
itemResults.push({
|
|
1763
|
+
CompanyIntegrationID: item.CompanyIntegrationID,
|
|
1764
|
+
Success: false,
|
|
1765
|
+
Message: this.formatError(e),
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
// Phase 2: Run all successful migrations through one pipeline batch
|
|
1771
|
+
if (pipelineInputs.length === 0) {
|
|
1772
|
+
return { Success: false, Message: 'No valid schema inputs to process', Items: itemResults };
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
const rsm = RuntimeSchemaManager.Instance;
|
|
1776
|
+
const batchResult = await rsm.RunPipelineBatch(pipelineInputs);
|
|
1777
|
+
|
|
1778
|
+
return {
|
|
1779
|
+
Success: batchResult.SuccessCount > 0,
|
|
1780
|
+
Message: `Batch complete: ${batchResult.SuccessCount} succeeded, ${batchResult.FailureCount} failed`,
|
|
1781
|
+
Items: itemResults,
|
|
1782
|
+
Steps: batchResult.Results[0]?.Steps.map((s: RSUPipelineStep) => ({
|
|
1783
|
+
Name: s.Name, Status: s.Status, DurationMs: s.DurationMs, Message: s.Message,
|
|
1784
|
+
})),
|
|
1785
|
+
GitCommitSuccess: batchResult.Results[0]?.GitCommitSuccess,
|
|
1786
|
+
APIRestarted: batchResult.Results[0]?.APIRestarted,
|
|
1787
|
+
};
|
|
1788
|
+
} catch (e) {
|
|
1789
|
+
LogError(`IntegrationApplySchemaBatch error: ${e}`);
|
|
1790
|
+
return { Success: false, Message: this.formatError(e), Items: [] };
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
// ── APPLY ALL (Full Automatic Flow) ──────────────────────────────────
|
|
1795
|
+
|
|
1796
|
+
/**
|
|
1797
|
+
* Full automatic "Apply All" flow for MJ integrations.
|
|
1798
|
+
* 1. Auto-generates schema/table names from the integration name + source object names
|
|
1799
|
+
* 2. Builds DDL + additionalSchemaInfo
|
|
1800
|
+
* 3. Runs RSU pipeline (migration → CodeGen → compile → git → restart)
|
|
1801
|
+
* 4. Creates CompanyIntegrationEntityMap records for each object
|
|
1802
|
+
* 5. Creates CompanyIntegrationFieldMap records for each field (1:1 mapping)
|
|
1803
|
+
* 6. Starts sync for the integration
|
|
1804
|
+
*/
|
|
1805
|
+
@Mutation(() => ApplyAllOutput)
|
|
1806
|
+
async IntegrationApplyAll(
|
|
1807
|
+
@Arg("input") input: ApplyAllInput,
|
|
1808
|
+
@Arg("platform", { defaultValue: "sqlserver" }) platform: string,
|
|
1809
|
+
@Arg("skipGitCommit", { defaultValue: false }) skipGitCommit: boolean,
|
|
1810
|
+
@Arg("skipRestart", { defaultValue: false }) skipRestart: boolean,
|
|
1811
|
+
@Ctx() ctx: AppContext
|
|
1812
|
+
): Promise<ApplyAllOutput> {
|
|
1813
|
+
try {
|
|
1814
|
+
const user = this.getAuthenticatedUser(ctx);
|
|
1815
|
+
const validatedPlatform = this.validatePlatform(platform);
|
|
1816
|
+
|
|
1817
|
+
// Step 1: Resolve connector and derive schema name
|
|
1818
|
+
const { connector, companyIntegration } = await this.resolveConnector(input.CompanyIntegrationID, user);
|
|
1819
|
+
const schemaName = this.deriveSchemaName(companyIntegration.Integration);
|
|
1820
|
+
|
|
1821
|
+
// Step 2: Resolve object IDs to names, build inputs with per-object Fields
|
|
1822
|
+
const sourceSchema = await (connector.IntrospectSchema.bind(connector) as
|
|
1823
|
+
(ci: unknown, u: unknown) => Promise<SourceSchemaInfo>)(companyIntegration, user);
|
|
1824
|
+
const objectIDs = input.SourceObjects.map(so => so.SourceObjectID);
|
|
1825
|
+
const resolvedNames = await this.resolveSourceObjectNames(objectIDs, undefined, sourceSchema, companyIntegration.IntegrationID, user);
|
|
1826
|
+
|
|
1827
|
+
// Build SchemaPreviewObjectInput with Fields carried from SourceObjectInput
|
|
1828
|
+
const fieldsByID = new Map(input.SourceObjects.map(so => [so.SourceObjectID, so.Fields]));
|
|
1829
|
+
const objects = resolvedNames.map((name, i) => {
|
|
1830
|
+
const obj = new SchemaPreviewObjectInput();
|
|
1831
|
+
obj.SourceObjectName = name;
|
|
1832
|
+
obj.SchemaName = schemaName;
|
|
1833
|
+
obj.TableName = name.replace(/[^A-Za-z0-9_]/g, '_');
|
|
1834
|
+
obj.EntityName = name.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/_/g, ' ');
|
|
1835
|
+
obj.Fields = fieldsByID.get(objectIDs[i]) ?? undefined;
|
|
1836
|
+
return obj;
|
|
1837
|
+
});
|
|
1838
|
+
|
|
1839
|
+
// Step 3: Build schema and RSU pipeline input
|
|
1840
|
+
const { schemaOutput, rsuInput } = await this.buildSchemaForConnector(
|
|
1841
|
+
input.CompanyIntegrationID, objects, validatedPlatform, user, skipGitCommit, skipRestart
|
|
1842
|
+
);
|
|
1843
|
+
|
|
1844
|
+
// Step 4: Inject integration post-restart payload into RSU input.
|
|
1845
|
+
const { join } = await import('node:path');
|
|
1846
|
+
const rsuWorkDir = process.env.RSU_WORK_DIR || process.cwd();
|
|
1847
|
+
const pendingWorkDir = join(rsuWorkDir, '.rsu_pending');
|
|
1848
|
+
const pendingFilePath = join(pendingWorkDir, `${Date.now()}.json`);
|
|
1849
|
+
|
|
1850
|
+
// Build per-object field map for pending file (null = all fields)
|
|
1851
|
+
const sourceObjectFields: Record<string, string[] | null> = {};
|
|
1852
|
+
for (const so of input.SourceObjects) {
|
|
1853
|
+
const resolvedName = resolvedNames[objectIDs.indexOf(so.SourceObjectID)];
|
|
1854
|
+
if (resolvedName) sourceObjectFields[resolvedName] = so.Fields ?? null;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
const pendingPayload = {
|
|
1858
|
+
CompanyIntegrationID: input.CompanyIntegrationID,
|
|
1859
|
+
SourceObjectNames: resolvedNames,
|
|
1860
|
+
SourceObjectFields: sourceObjectFields,
|
|
1861
|
+
SchemaName: schemaName,
|
|
1862
|
+
CronExpression: input.CronExpression,
|
|
1863
|
+
ScheduleTimezone: input.ScheduleTimezone,
|
|
1864
|
+
StartSync: input.StartSync,
|
|
1865
|
+
FullSync: input.FullSync ?? false,
|
|
1866
|
+
SyncScope: input.SyncScope ?? 'created',
|
|
1867
|
+
CreatedAt: new Date().toISOString(),
|
|
1868
|
+
};
|
|
1869
|
+
rsuInput.PostRestartFiles = [
|
|
1870
|
+
{ Path: pendingFilePath, Content: JSON.stringify(pendingPayload, null, 2) }
|
|
1871
|
+
];
|
|
1872
|
+
|
|
1873
|
+
// Step 5: Run pipeline (restart kills process at the end)
|
|
1874
|
+
const rsm = RuntimeSchemaManager.Instance;
|
|
1875
|
+
const batchResult = await rsm.RunPipelineBatch([rsuInput]);
|
|
1876
|
+
|
|
1877
|
+
const migrationSucceeded = batchResult.SuccessCount > 0;
|
|
1878
|
+
const pipelineSteps = batchResult.Results[0]?.Steps.map((s: RSUPipelineStep) => ({
|
|
1879
|
+
Name: s.Name, Status: s.Status, DurationMs: s.DurationMs, Message: s.Message,
|
|
1880
|
+
}));
|
|
1881
|
+
|
|
1882
|
+
// If pipeline failed, clean up pending file and return error
|
|
1883
|
+
if (!migrationSucceeded) {
|
|
1884
|
+
try { (await import('node:fs')).unlinkSync(pendingFilePath); } catch { /* may not exist */ }
|
|
1885
|
+
return {
|
|
1886
|
+
Success: false,
|
|
1887
|
+
Message: `Pipeline failed: ${batchResult.Results[0]?.ErrorMessage ?? 'unknown error'}`,
|
|
1888
|
+
Steps: pipelineSteps,
|
|
1889
|
+
Warnings: schemaOutput.Warnings.length > 0 ? schemaOutput.Warnings : undefined,
|
|
1890
|
+
};
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
// If we get here, pipeline succeeded but restart may or may not have happened yet.
|
|
1894
|
+
// If restart happened, this code never executes (process died).
|
|
1895
|
+
// If skipRestart=true, we can do entity maps now.
|
|
1896
|
+
if (skipRestart) {
|
|
1897
|
+
await Metadata.Provider.Refresh();
|
|
1898
|
+
const entityMapsCreated = await this.createEntityAndFieldMaps(
|
|
1899
|
+
input.CompanyIntegrationID, objects, connector, companyIntegration, schemaName, user
|
|
1900
|
+
);
|
|
1901
|
+
const createdMapIDs = entityMapsCreated.map(em => em.EntityMapID).filter(Boolean);
|
|
1902
|
+
const scopedMapIDs = input.SyncScope === 'all' ? undefined : createdMapIDs;
|
|
1903
|
+
const syncRunID = input.StartSync !== false
|
|
1904
|
+
? await this.startSyncAfterApply(input.CompanyIntegrationID, user, scopedMapIDs, input.FullSync)
|
|
1905
|
+
: null;
|
|
1906
|
+
|
|
1907
|
+
// Create schedule if requested
|
|
1908
|
+
let scheduledJobID: string | undefined;
|
|
1909
|
+
if (input.CronExpression) {
|
|
1910
|
+
try {
|
|
1911
|
+
scheduledJobID = await this.createScheduleForConnector(
|
|
1912
|
+
input.CompanyIntegrationID,
|
|
1913
|
+
companyIntegration.Integration,
|
|
1914
|
+
input.CronExpression,
|
|
1915
|
+
input.ScheduleTimezone,
|
|
1916
|
+
user
|
|
1917
|
+
) ?? undefined;
|
|
1918
|
+
} catch (schedErr) {
|
|
1919
|
+
console.warn(`[Integration] Schedule creation failed: ${schedErr}`);
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
try { (await import('node:fs')).unlinkSync(pendingFilePath); } catch { /* already consumed */ }
|
|
1924
|
+
|
|
1925
|
+
return {
|
|
1926
|
+
Success: true,
|
|
1927
|
+
Message: `Applied ${objects.length} object(s) — ${entityMapsCreated.length} entity maps created${syncRunID ? ', sync started' : ''}${scheduledJobID ? ', schedule created' : ''}`,
|
|
1928
|
+
Steps: pipelineSteps,
|
|
1929
|
+
EntityMapsCreated: entityMapsCreated,
|
|
1930
|
+
SyncRunID: syncRunID ?? undefined,
|
|
1931
|
+
ScheduledJobID: scheduledJobID,
|
|
1932
|
+
GitCommitSuccess: batchResult.Results[0]?.GitCommitSuccess,
|
|
1933
|
+
APIRestarted: false,
|
|
1934
|
+
Warnings: schemaOutput.Warnings.length > 0 ? schemaOutput.Warnings : undefined,
|
|
1935
|
+
};
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
// If restart is enabled, this return may or may not execute
|
|
1939
|
+
// (depends on whether PM2 kills process before GraphQL response is sent)
|
|
1940
|
+
return {
|
|
1941
|
+
Success: true,
|
|
1942
|
+
Message: `Applied ${objects.length} object(s) — entity maps will be created after restart`,
|
|
1943
|
+
Steps: pipelineSteps,
|
|
1944
|
+
GitCommitSuccess: batchResult.Results[0]?.GitCommitSuccess,
|
|
1945
|
+
APIRestarted: true,
|
|
1946
|
+
Warnings: schemaOutput.Warnings.length > 0 ? schemaOutput.Warnings : undefined,
|
|
1947
|
+
};
|
|
1948
|
+
} catch (e) {
|
|
1949
|
+
LogError(`IntegrationApplyAll error: ${e}`);
|
|
1950
|
+
return { Success: false, Message: this.formatError(e) };
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
/** Derives a SQL-safe schema name from the integration name (e.g., "HubSpot" → "hubspot"). */
|
|
1955
|
+
private deriveSchemaName(integrationName: string): string {
|
|
1956
|
+
return (integrationName || 'integration').toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
/** Builds SchemaPreviewObjectInput[] from source object name strings, using auto-derived schema/table names. */
|
|
1960
|
+
private buildObjectInputsFromNames(sourceObjectNames: string[], schemaName: string): SchemaPreviewObjectInput[] {
|
|
1961
|
+
return sourceObjectNames.map(name => {
|
|
1962
|
+
const obj = new SchemaPreviewObjectInput();
|
|
1963
|
+
obj.SourceObjectName = name;
|
|
1964
|
+
obj.SchemaName = schemaName;
|
|
1965
|
+
obj.TableName = name.replace(/[^A-Za-z0-9_]/g, '_');
|
|
1966
|
+
obj.EntityName = name.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/_/g, ' ');
|
|
1967
|
+
return obj;
|
|
1968
|
+
});
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
/**
|
|
1972
|
+
* After pipeline success, creates CompanyIntegrationEntityMap + CompanyIntegrationFieldMap
|
|
1973
|
+
* records for each source object by matching schema + table name to newly created entities.
|
|
1974
|
+
*/
|
|
1975
|
+
private async createEntityAndFieldMaps(
|
|
1976
|
+
companyIntegrationID: string,
|
|
1977
|
+
objects: SchemaPreviewObjectInput[],
|
|
1978
|
+
connector: BaseIntegrationConnector,
|
|
1979
|
+
companyIntegration: MJCompanyIntegrationEntity,
|
|
1980
|
+
schemaName: string,
|
|
1981
|
+
user: UserInfo
|
|
1982
|
+
): Promise<ApplyAllEntityMapCreated[]> {
|
|
1983
|
+
const md = new Metadata();
|
|
1984
|
+
const results: ApplyAllEntityMapCreated[] = [];
|
|
1985
|
+
|
|
1986
|
+
for (const obj of objects) {
|
|
1987
|
+
const entityMapResult = await this.createSingleEntityMap(
|
|
1988
|
+
companyIntegrationID, obj, connector, companyIntegration, schemaName, user, md
|
|
1989
|
+
);
|
|
1990
|
+
if (entityMapResult) {
|
|
1991
|
+
results.push(entityMapResult);
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
return results;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
/** Creates a single entity map + field maps for one source object. */
|
|
1998
|
+
private async createSingleEntityMap(
|
|
1999
|
+
companyIntegrationID: string,
|
|
2000
|
+
obj: SchemaPreviewObjectInput,
|
|
2001
|
+
connector: BaseIntegrationConnector,
|
|
2002
|
+
companyIntegration: MJCompanyIntegrationEntity,
|
|
2003
|
+
schemaName: string,
|
|
2004
|
+
user: UserInfo,
|
|
2005
|
+
md: Metadata
|
|
2006
|
+
): Promise<ApplyAllEntityMapCreated | null> {
|
|
2007
|
+
// Find the entity by schema + table name
|
|
2008
|
+
const entityInfo = md.Entities.find(
|
|
2009
|
+
e => e.SchemaName.toLowerCase() === schemaName.toLowerCase()
|
|
2010
|
+
&& e.BaseTable.toLowerCase() === obj.TableName.toLowerCase()
|
|
2011
|
+
);
|
|
2012
|
+
if (!entityInfo) {
|
|
2013
|
+
LogError(`IntegrationApplyAll: entity not found for ${schemaName}.${obj.TableName}`);
|
|
2014
|
+
return null;
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
// Create entity map
|
|
2018
|
+
const em = await md.GetEntityObject<MJCompanyIntegrationEntityMapEntity>('MJ: Company Integration Entity Maps', user);
|
|
2019
|
+
em.NewRecord();
|
|
2020
|
+
em.CompanyIntegrationID = companyIntegrationID;
|
|
2021
|
+
em.ExternalObjectName = obj.SourceObjectName;
|
|
2022
|
+
em.EntityID = entityInfo.ID;
|
|
2023
|
+
em.SyncDirection = 'Pull';
|
|
2024
|
+
em.Priority = 0;
|
|
2025
|
+
em.Status = 'Active';
|
|
2026
|
+
em.SyncEnabled = true;
|
|
2027
|
+
|
|
2028
|
+
if (!await em.Save()) {
|
|
2029
|
+
LogError(`IntegrationApplyAll: failed to save entity map for ${obj.SourceObjectName}`);
|
|
2030
|
+
return null;
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
// Discover fields from the source and create 1:1 field maps
|
|
2034
|
+
const fieldMapCount = await this.createFieldMapsForEntityMap(
|
|
2035
|
+
em.ID, obj.SourceObjectName, connector, companyIntegration, user, md
|
|
2036
|
+
);
|
|
2037
|
+
|
|
2038
|
+
return {
|
|
2039
|
+
SourceObjectName: obj.SourceObjectName,
|
|
2040
|
+
EntityName: entityInfo.Name,
|
|
2041
|
+
EntityMapID: em.ID,
|
|
2042
|
+
FieldMapCount: fieldMapCount,
|
|
2043
|
+
};
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
/** Discovers fields from the source object and creates 1:1 field maps. */
|
|
2047
|
+
private async createFieldMapsForEntityMap(
|
|
2048
|
+
entityMapID: string,
|
|
2049
|
+
sourceObjectName: string,
|
|
2050
|
+
connector: BaseIntegrationConnector,
|
|
2051
|
+
companyIntegration: MJCompanyIntegrationEntity,
|
|
2052
|
+
user: UserInfo,
|
|
2053
|
+
md: Metadata
|
|
2054
|
+
): Promise<number> {
|
|
2055
|
+
let fieldCount = 0;
|
|
2056
|
+
try {
|
|
2057
|
+
const discoverFields = connector.DiscoverFields.bind(connector) as
|
|
2058
|
+
(ci: unknown, obj: string, u: unknown) => Promise<ExternalFieldSchema[]>;
|
|
2059
|
+
const fields = await discoverFields(companyIntegration, sourceObjectName, user);
|
|
2060
|
+
|
|
2061
|
+
for (const field of fields) {
|
|
2062
|
+
const fm = await md.GetEntityObject<MJCompanyIntegrationFieldMapEntity>('MJ: Company Integration Field Maps', user);
|
|
2063
|
+
fm.NewRecord();
|
|
2064
|
+
fm.EntityMapID = entityMapID;
|
|
2065
|
+
fm.SourceFieldName = field.Name;
|
|
2066
|
+
fm.DestinationFieldName = field.Name.replace(/[^A-Za-z0-9_]/g, '_');
|
|
2067
|
+
fm.IsKeyField = field.IsUniqueKey;
|
|
2068
|
+
fm.IsRequired = field.IsRequired;
|
|
2069
|
+
fm.Direction = 'SourceToDest';
|
|
2070
|
+
fm.Status = 'Active';
|
|
2071
|
+
fm.Priority = 0;
|
|
2072
|
+
|
|
2073
|
+
if (await fm.Save()) {
|
|
2074
|
+
fieldCount++;
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
} catch (e) {
|
|
2078
|
+
LogError(`IntegrationApplyAll: failed to discover/create field maps for ${sourceObjectName}: ${this.formatError(e)}`);
|
|
2079
|
+
}
|
|
2080
|
+
return fieldCount;
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
/** Starts sync after a successful apply-all pipeline, returning the run ID if available. */
|
|
2084
|
+
private async startSyncAfterApply(companyIntegrationID: string, user: UserInfo, entityMapIDs?: string[], fullSync?: boolean): Promise<string | null> {
|
|
2085
|
+
try {
|
|
2086
|
+
await IntegrationEngine.Instance.Config(false, user);
|
|
2087
|
+
const options: IntegrationSyncOptions = {};
|
|
2088
|
+
if (entityMapIDs?.length) options.EntityMapIDs = entityMapIDs;
|
|
2089
|
+
if (fullSync) options.FullSync = true;
|
|
2090
|
+
const finalOptions = Object.keys(options).length > 0 ? options : undefined;
|
|
2091
|
+
const syncPromise = IntegrationEngine.Instance.RunSync(companyIntegrationID, user, 'Manual', undefined, undefined, finalOptions);
|
|
2092
|
+
|
|
2093
|
+
// Fire and forget — don't block the response
|
|
2094
|
+
syncPromise.catch(err => {
|
|
2095
|
+
LogError(`IntegrationApplyAll: background sync failed for ${companyIntegrationID}: ${err}`);
|
|
2096
|
+
});
|
|
2097
|
+
|
|
2098
|
+
// Small delay to let the run record get created
|
|
2099
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
2100
|
+
|
|
2101
|
+
const rv = new RunView();
|
|
2102
|
+
const runResult = await rv.RunView<{ ID: string }>({
|
|
2103
|
+
EntityName: 'MJ: Company Integration Runs',
|
|
2104
|
+
ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND Status='In Progress'`,
|
|
2105
|
+
OrderBy: '__mj_CreatedAt DESC',
|
|
2106
|
+
MaxRows: 1,
|
|
2107
|
+
ResultType: 'simple',
|
|
2108
|
+
Fields: ['ID']
|
|
2109
|
+
}, user);
|
|
2110
|
+
|
|
2111
|
+
return (runResult.Success && runResult.Results.length > 0) ? runResult.Results[0].ID : null;
|
|
2112
|
+
} catch (e) {
|
|
2113
|
+
LogError(`IntegrationApplyAll: sync start failed: ${this.formatError(e)}`);
|
|
2114
|
+
return null;
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
/**
|
|
2119
|
+
* Build schema artifacts for a single connector's objects.
|
|
2120
|
+
* Shared by IntegrationApplySchema (single) and IntegrationApplySchemaBatch (batch).
|
|
2121
|
+
*/
|
|
2122
|
+
private async buildSchemaForConnector(
|
|
2123
|
+
companyIntegrationID: string,
|
|
2124
|
+
objects: SchemaPreviewObjectInput[],
|
|
2125
|
+
platform: 'sqlserver' | 'postgresql',
|
|
2126
|
+
user: UserInfo,
|
|
2127
|
+
skipGitCommit: boolean,
|
|
2128
|
+
skipRestart: boolean
|
|
2129
|
+
): Promise<{ schemaOutput: SchemaBuilderOutput; rsuInput: RSUPipelineInput }> {
|
|
2130
|
+
const { connector, companyIntegration } = await this.resolveConnector(companyIntegrationID, user);
|
|
2131
|
+
|
|
2132
|
+
const introspect = connector.IntrospectSchema.bind(connector) as
|
|
2133
|
+
(ci: unknown, u: unknown) => Promise<SourceSchemaInfo>;
|
|
2134
|
+
const sourceSchema = await introspect(companyIntegration, user);
|
|
2135
|
+
|
|
2136
|
+
// Normalize names to match source schema casing
|
|
2137
|
+
const nameMap = new Map(sourceSchema.Objects.map(o => [o.ExternalName.toLowerCase(), o.ExternalName]));
|
|
2138
|
+
for (const obj of objects) {
|
|
2139
|
+
const exact = nameMap.get(obj.SourceObjectName.toLowerCase());
|
|
2140
|
+
if (exact) obj.SourceObjectName = exact;
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
const requestedNames = new Set(objects.map(o => o.SourceObjectName));
|
|
2144
|
+
const filteredSchema: SourceSchemaInfo = {
|
|
2145
|
+
Objects: sourceSchema.Objects.filter(o => requestedNames.has(o.ExternalName))
|
|
2146
|
+
};
|
|
2147
|
+
|
|
2148
|
+
const targetConfigs = this.buildTargetConfigs(objects, filteredSchema, platform, connector);
|
|
2149
|
+
|
|
2150
|
+
const input: SchemaBuilderInput = {
|
|
2151
|
+
SourceSchema: filteredSchema,
|
|
2152
|
+
TargetConfigs: targetConfigs,
|
|
2153
|
+
Platform: platform,
|
|
2154
|
+
MJVersion: process.env.MJ_VERSION ?? '5.11.0',
|
|
2155
|
+
SourceType: companyIntegration.Integration,
|
|
2156
|
+
AdditionalSchemaInfoPath: process.env.RSU_ADDITIONAL_SCHEMA_INFO_PATH ?? 'additionalSchemaInfo.json',
|
|
2157
|
+
MigrationsDir: process.env.RSU_MIGRATIONS_PATH ?? 'migrations/rsu',
|
|
2158
|
+
MetadataDir: process.env.RSU_METADATA_DIR ?? 'metadata',
|
|
2159
|
+
ExistingTables: this.buildExistingTables(targetConfigs),
|
|
2160
|
+
EntitySettingsForTargets: {}
|
|
2161
|
+
};
|
|
2162
|
+
|
|
2163
|
+
const builder = new SchemaBuilder();
|
|
2164
|
+
const schemaOutput = builder.BuildSchema(input);
|
|
2165
|
+
|
|
2166
|
+
if (schemaOutput.Errors.length > 0) {
|
|
2167
|
+
throw new Error(`Schema generation failed: ${schemaOutput.Errors.join('; ')}`);
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
const rsuInput = builder.BuildRSUInput(schemaOutput, input, { SkipGitCommit: skipGitCommit, SkipRestart: skipRestart });
|
|
2171
|
+
return { schemaOutput, rsuInput };
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
// ── SYNC ────────────────────────────────────────────────────────────
|
|
2175
|
+
|
|
2176
|
+
/**
|
|
2177
|
+
* Starts an async integration sync. Returns immediately with the run ID.
|
|
2178
|
+
* Sends a webhook to the registered callback when complete.
|
|
2179
|
+
*/
|
|
2180
|
+
@Mutation(() => StartSyncOutput)
|
|
2181
|
+
async IntegrationStartSync(
|
|
2182
|
+
@Arg("companyIntegrationID") companyIntegrationID: string,
|
|
2183
|
+
@Arg("webhookURL", { nullable: true }) webhookURL: string,
|
|
2184
|
+
@Arg("fullSync", () => Boolean, { defaultValue: false, description: 'If true, ignores watermarks and re-fetches all records from the source' }) fullSync: boolean,
|
|
2185
|
+
@Arg("entityMapIDs", () => [String], { nullable: true, description: 'Optional: sync only these entity maps. If omitted, syncs all maps for the connector.' }) entityMapIDs: string[],
|
|
2186
|
+
@Ctx() ctx: AppContext
|
|
2187
|
+
): Promise<StartSyncOutput> {
|
|
2188
|
+
try {
|
|
2189
|
+
const user = this.getAuthenticatedUser(ctx);
|
|
2190
|
+
await IntegrationEngine.Instance.Config(false, user);
|
|
2191
|
+
|
|
2192
|
+
const syncOptions: { FullSync?: boolean; EntityMapIDs?: string[] } = {};
|
|
2193
|
+
if (fullSync) syncOptions.FullSync = true;
|
|
2194
|
+
if (entityMapIDs?.length) syncOptions.EntityMapIDs = entityMapIDs;
|
|
2195
|
+
|
|
2196
|
+
// Fire and forget — progress is tracked inside IntegrationEngine
|
|
2197
|
+
const syncPromise = IntegrationEngine.Instance.RunSync(
|
|
2198
|
+
companyIntegrationID,
|
|
2199
|
+
user,
|
|
2200
|
+
'Manual',
|
|
2201
|
+
undefined,
|
|
2202
|
+
undefined,
|
|
2203
|
+
Object.keys(syncOptions).length > 0 ? syncOptions : undefined
|
|
2204
|
+
);
|
|
2205
|
+
|
|
2206
|
+
syncPromise
|
|
2207
|
+
.then(async (result) => {
|
|
2208
|
+
if (webhookURL) {
|
|
2209
|
+
await this.sendWebhook(webhookURL, {
|
|
2210
|
+
event: result.Success ? 'sync_complete' : 'sync_failed',
|
|
2211
|
+
companyIntegrationID,
|
|
2212
|
+
success: result.Success,
|
|
2213
|
+
recordsProcessed: result.RecordsProcessed,
|
|
2214
|
+
recordsCreated: result.RecordsCreated,
|
|
2215
|
+
recordsUpdated: result.RecordsUpdated,
|
|
2216
|
+
recordsErrored: result.RecordsErrored,
|
|
2217
|
+
errorCount: result.Errors.length
|
|
2218
|
+
});
|
|
2219
|
+
}
|
|
2220
|
+
})
|
|
2221
|
+
.catch(async (err) => {
|
|
2222
|
+
console.error(`[Integration] Background sync failed for ${companyIntegrationID}:`, err);
|
|
2223
|
+
if (webhookURL) {
|
|
2224
|
+
await this.sendWebhook(webhookURL, {
|
|
2225
|
+
event: 'sync_failed',
|
|
2226
|
+
companyIntegrationID,
|
|
2227
|
+
success: false,
|
|
2228
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2229
|
+
});
|
|
2230
|
+
}
|
|
2231
|
+
});
|
|
2232
|
+
|
|
2233
|
+
// Small delay to let the run record get created
|
|
2234
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
2235
|
+
|
|
2236
|
+
const rv = new RunView();
|
|
2237
|
+
const runResult = await rv.RunView<MJCompanyIntegrationRunEntity>({
|
|
2238
|
+
EntityName: 'MJ: Company Integration Runs',
|
|
2239
|
+
ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND Status='In Progress'`,
|
|
2240
|
+
OrderBy: '__mj_CreatedAt DESC',
|
|
2241
|
+
MaxRows: 1,
|
|
2242
|
+
ResultType: 'simple',
|
|
2243
|
+
Fields: ['ID', 'Status', 'StartedAt']
|
|
2244
|
+
}, user);
|
|
2245
|
+
|
|
2246
|
+
const run = runResult.Success && runResult.Results.length > 0 ? runResult.Results[0] : null;
|
|
2247
|
+
|
|
2248
|
+
return {
|
|
2249
|
+
Success: true,
|
|
2250
|
+
Message: 'Sync started',
|
|
2251
|
+
RunID: run?.ID
|
|
2252
|
+
};
|
|
2253
|
+
} catch (e) {
|
|
2254
|
+
LogError(`IntegrationStartSync error: ${e}`);
|
|
2255
|
+
return { Success: false, Message: this.formatError(e) };
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
/**
|
|
2260
|
+
* Cancels a running sync by marking its status as Cancelled.
|
|
2261
|
+
*/
|
|
2262
|
+
@Mutation(() => MutationResultOutput)
|
|
2263
|
+
async IntegrationCancelSync(
|
|
2264
|
+
@Arg("companyIntegrationID") companyIntegrationID: string,
|
|
2265
|
+
@Ctx() ctx: AppContext
|
|
2266
|
+
): Promise<MutationResultOutput> {
|
|
2267
|
+
try {
|
|
2268
|
+
this.getAuthenticatedUser(ctx);
|
|
2269
|
+
|
|
2270
|
+
// Signal the engine to abort the running sync
|
|
2271
|
+
const cancelled = IntegrationEngine.CancelSync(companyIntegrationID);
|
|
2272
|
+
if (!cancelled) {
|
|
2273
|
+
return { Success: false, Message: 'No active sync found for this connector' };
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
return { Success: true, Message: 'Sync cancellation signalled — will stop after current batch completes' };
|
|
2277
|
+
} catch (e) {
|
|
2278
|
+
LogError(`IntegrationCancelSync error: ${e}`);
|
|
2279
|
+
return { Success: false, Message: this.formatError(e) };
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
// ── SCHEDULE ────────────────────────────────────────────────────────
|
|
2284
|
+
|
|
2285
|
+
@Mutation(() => CreateScheduleOutput)
|
|
2286
|
+
async IntegrationCreateSchedule(
|
|
2287
|
+
@Arg("input") input: CreateScheduleInput,
|
|
2288
|
+
@Ctx() ctx: AppContext
|
|
2289
|
+
): Promise<CreateScheduleOutput> {
|
|
2290
|
+
try {
|
|
2291
|
+
const user = this.getAuthenticatedUser(ctx);
|
|
2292
|
+
const md = new Metadata();
|
|
2293
|
+
const rv = new RunView();
|
|
2294
|
+
|
|
2295
|
+
// Find IntegrationSync job type
|
|
2296
|
+
const jobTypeResult = await rv.RunView<MJScheduledJobTypeEntity>({
|
|
2297
|
+
EntityName: 'MJ: Scheduled Job Types',
|
|
2298
|
+
ExtraFilter: `DriverClass='IntegrationSyncScheduledJobDriver'`,
|
|
2299
|
+
MaxRows: 1,
|
|
2300
|
+
ResultType: 'simple',
|
|
2301
|
+
Fields: ['ID']
|
|
2302
|
+
}, user);
|
|
2303
|
+
if (!jobTypeResult.Success || jobTypeResult.Results.length === 0) {
|
|
2304
|
+
return { Success: false, Message: 'IntegrationSync scheduled job type not found' };
|
|
2305
|
+
}
|
|
2306
|
+
const jobTypeID = jobTypeResult.Results[0].ID;
|
|
2307
|
+
|
|
2308
|
+
const job = await md.GetEntityObject<MJScheduledJobEntity>('MJ: Scheduled Jobs', user);
|
|
2309
|
+
job.NewRecord();
|
|
2310
|
+
job.JobTypeID = jobTypeID;
|
|
2311
|
+
job.Name = input.Name;
|
|
2312
|
+
if (input.Description) job.Description = input.Description;
|
|
2313
|
+
job.CronExpression = input.CronExpression;
|
|
2314
|
+
job.Timezone = input.Timezone || 'UTC';
|
|
2315
|
+
job.Status = 'Active';
|
|
2316
|
+
job.OwnerUserID = user.ID;
|
|
2317
|
+
job.Configuration = JSON.stringify({ CompanyIntegrationID: input.CompanyIntegrationID });
|
|
2318
|
+
job.NextRunAt = CronExpressionHelper.GetNextRunTime(input.CronExpression, input.Timezone || 'UTC');
|
|
2319
|
+
|
|
2320
|
+
if (!await job.Save()) return { Success: false, Message: 'Failed to create schedule' };
|
|
2321
|
+
|
|
2322
|
+
// Link to CompanyIntegration
|
|
2323
|
+
const ci = await md.GetEntityObject<MJCompanyIntegrationEntity>('MJ: Company Integrations', user);
|
|
2324
|
+
const ciLoaded = await ci.InnerLoad(CompositeKey.FromID(input.CompanyIntegrationID));
|
|
2325
|
+
if (ciLoaded) {
|
|
2326
|
+
ci.ScheduleEnabled = true;
|
|
2327
|
+
ci.ScheduleType = 'Cron';
|
|
2328
|
+
ci.CronExpression = input.CronExpression;
|
|
2329
|
+
await ci.Save();
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
return { Success: true, Message: 'Schedule created', ScheduledJobID: job.ID };
|
|
2333
|
+
} catch (e) {
|
|
2334
|
+
LogError(`IntegrationCreateSchedule error: ${e}`);
|
|
2335
|
+
return { Success: false, Message: this.formatError(e) };
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
@Mutation(() => MutationResultOutput)
|
|
2340
|
+
async IntegrationUpdateSchedule(
|
|
2341
|
+
@Arg("scheduledJobID") scheduledJobID: string,
|
|
2342
|
+
@Arg("cronExpression", { nullable: true }) cronExpression: string,
|
|
2343
|
+
@Arg("timezone", { nullable: true }) timezone: string,
|
|
2344
|
+
@Arg("name", { nullable: true }) name: string,
|
|
2345
|
+
@Ctx() ctx: AppContext
|
|
2346
|
+
): Promise<MutationResultOutput> {
|
|
2347
|
+
try {
|
|
2348
|
+
const user = this.getAuthenticatedUser(ctx);
|
|
2349
|
+
const md = new Metadata();
|
|
2350
|
+
const job = await md.GetEntityObject<MJScheduledJobEntity>('MJ: Scheduled Jobs', user);
|
|
2351
|
+
const loaded = await job.InnerLoad(CompositeKey.FromID(scheduledJobID));
|
|
2352
|
+
if (!loaded) return { Success: false, Message: 'ScheduledJob not found' };
|
|
2353
|
+
|
|
2354
|
+
if (cronExpression) job.CronExpression = cronExpression;
|
|
2355
|
+
if (timezone) job.Timezone = timezone;
|
|
2356
|
+
if (name) job.Name = name;
|
|
2357
|
+
if (cronExpression || timezone) {
|
|
2358
|
+
job.NextRunAt = CronExpressionHelper.GetNextRunTime(job.CronExpression, job.Timezone || 'UTC');
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
if (!await job.Save()) return { Success: false, Message: 'Failed to update' };
|
|
2362
|
+
return { Success: true, Message: 'Updated' };
|
|
2363
|
+
} catch (e) {
|
|
2364
|
+
LogError(`IntegrationUpdateSchedule error: ${e}`);
|
|
2365
|
+
return { Success: false, Message: this.formatError(e) };
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
@Mutation(() => MutationResultOutput)
|
|
2370
|
+
async IntegrationToggleSchedule(
|
|
2371
|
+
@Arg("scheduledJobID") scheduledJobID: string,
|
|
2372
|
+
@Arg("enabled", () => Boolean) enabled: boolean,
|
|
2373
|
+
@Ctx() ctx: AppContext
|
|
2374
|
+
): Promise<MutationResultOutput> {
|
|
2375
|
+
try {
|
|
2376
|
+
this.getAuthenticatedUser(ctx); // verify caller is authenticated
|
|
2377
|
+
const sysUser = this.getSystemUser();
|
|
2378
|
+
const md = new Metadata();
|
|
2379
|
+
const job = await md.GetEntityObject<MJScheduledJobEntity>('MJ: Scheduled Jobs', sysUser);
|
|
2380
|
+
const loaded = await job.InnerLoad(CompositeKey.FromID(scheduledJobID));
|
|
2381
|
+
if (!loaded) return { Success: false, Message: 'ScheduledJob not found' };
|
|
2382
|
+
job.Status = enabled ? 'Active' : 'Paused';
|
|
2383
|
+
if (!await job.Save()) {
|
|
2384
|
+
const err = job.LatestResult?.Message || 'Unknown error';
|
|
2385
|
+
return { Success: false, Message: `Failed to toggle: ${err}` };
|
|
2386
|
+
}
|
|
2387
|
+
return { Success: true, Message: enabled ? 'Activated' : 'Paused' };
|
|
2388
|
+
} catch (e) {
|
|
2389
|
+
LogError(`IntegrationToggleSchedule error: ${e}`);
|
|
2390
|
+
return { Success: false, Message: this.formatError(e) };
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
@Mutation(() => MutationResultOutput)
|
|
2395
|
+
async IntegrationDeleteSchedule(
|
|
2396
|
+
@Arg("scheduledJobID") scheduledJobID: string,
|
|
2397
|
+
@Arg("companyIntegrationID", { nullable: true }) companyIntegrationID: string,
|
|
2398
|
+
@Ctx() ctx: AppContext
|
|
2399
|
+
): Promise<MutationResultOutput> {
|
|
2400
|
+
try {
|
|
2401
|
+
this.getAuthenticatedUser(ctx); // verify caller is authenticated
|
|
2402
|
+
const sysUser = this.getSystemUser(); // use system user for delete operations
|
|
2403
|
+
const md = new Metadata();
|
|
2404
|
+
|
|
2405
|
+
// Unlink from CI if provided
|
|
2406
|
+
if (companyIntegrationID) {
|
|
2407
|
+
const ci = await md.GetEntityObject<MJCompanyIntegrationEntity>('MJ: Company Integrations', sysUser);
|
|
2408
|
+
const ciLoaded = await ci.InnerLoad(CompositeKey.FromID(companyIntegrationID));
|
|
2409
|
+
if (ciLoaded) {
|
|
2410
|
+
ci.ScheduleEnabled = false;
|
|
2411
|
+
ci.CronExpression = null;
|
|
2412
|
+
await ci.Save();
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
const rv = new RunView();
|
|
2417
|
+
|
|
2418
|
+
// Clear ScheduledJobID on any CompanyIntegration that references this job
|
|
2419
|
+
const ciResult = await rv.RunView<MJCompanyIntegrationEntity>({
|
|
2420
|
+
EntityName: 'MJ: Company Integrations',
|
|
2421
|
+
ExtraFilter: `ScheduledJobID='${scheduledJobID}'`,
|
|
2422
|
+
ResultType: 'entity_object',
|
|
2423
|
+
}, sysUser);
|
|
2424
|
+
if (ciResult.Success) {
|
|
2425
|
+
for (const refCI of ciResult.Results) {
|
|
2426
|
+
refCI.ScheduledJobID = null;
|
|
2427
|
+
refCI.ScheduleEnabled = false;
|
|
2428
|
+
refCI.CronExpression = null;
|
|
2429
|
+
await refCI.Save();
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
// Null out ScheduledJobRunID on CompanyIntegrationRuns that reference this job's runs
|
|
2434
|
+
const jobRunsResult = await rv.RunView<MJScheduledJobRunEntity>({
|
|
2435
|
+
EntityName: 'MJ: Scheduled Job Runs',
|
|
2436
|
+
ExtraFilter: `ScheduledJobID='${scheduledJobID}'`,
|
|
2437
|
+
ResultType: 'entity_object',
|
|
2438
|
+
}, sysUser);
|
|
2439
|
+
if (jobRunsResult.Success) {
|
|
2440
|
+
for (const jr of jobRunsResult.Results) {
|
|
2441
|
+
// Find CompanyIntegrationRuns referencing this job run and null the FK
|
|
2442
|
+
const ciRunsResult = await rv.RunView<MJCompanyIntegrationRunEntity>({
|
|
2443
|
+
EntityName: 'MJ: Company Integration Runs',
|
|
2444
|
+
ExtraFilter: `ScheduledJobRunID='${jr.ID}'`,
|
|
2445
|
+
ResultType: 'entity_object',
|
|
2446
|
+
}, sysUser);
|
|
2447
|
+
if (ciRunsResult.Success) {
|
|
2448
|
+
for (const ciRun of ciRunsResult.Results) {
|
|
2449
|
+
ciRun.ScheduledJobRunID = null;
|
|
2450
|
+
await ciRun.Save();
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
// Now delete job runs + job in a transaction
|
|
2457
|
+
const tg = await Metadata.Provider.CreateTransactionGroup();
|
|
2458
|
+
if (jobRunsResult.Success) {
|
|
2459
|
+
for (const run of jobRunsResult.Results) {
|
|
2460
|
+
run.TransactionGroup = tg;
|
|
2461
|
+
await run.Delete();
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
const job = await md.GetEntityObject<MJScheduledJobEntity>('MJ: Scheduled Jobs', sysUser);
|
|
2466
|
+
const loaded = await job.InnerLoad(CompositeKey.FromID(scheduledJobID));
|
|
2467
|
+
if (!loaded) return { Success: false, Message: 'ScheduledJob not found' };
|
|
2468
|
+
job.TransactionGroup = tg;
|
|
2469
|
+
await job.Delete();
|
|
2470
|
+
const deleted = await tg.Submit();
|
|
2471
|
+
if (!deleted) {
|
|
2472
|
+
const err = job.LatestResult?.Message || 'Unknown error';
|
|
2473
|
+
return { Success: false, Message: `Failed to delete: ${err}` };
|
|
2474
|
+
}
|
|
2475
|
+
return { Success: true, Message: `Deleted (${jobRunsResult.Results?.length ?? 0} runs removed)` };
|
|
2476
|
+
} catch (e) {
|
|
2477
|
+
LogError(`IntegrationDeleteSchedule error: ${e}`);
|
|
2478
|
+
return { Success: false, Message: this.formatError(e) };
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
@Query(() => ListSchedulesOutput)
|
|
2483
|
+
async IntegrationListSchedules(
|
|
2484
|
+
@Arg("companyIntegrationID") companyIntegrationID: string,
|
|
2485
|
+
@Ctx() ctx: AppContext
|
|
2486
|
+
): Promise<ListSchedulesOutput> {
|
|
2487
|
+
try {
|
|
2488
|
+
const user = this.getAuthenticatedUser(ctx);
|
|
2489
|
+
const rv = new RunView();
|
|
2490
|
+
const result = await rv.RunView<{ ID: string; Name: string; Status: string; CronExpression: string; Timezone: string; NextRunAt: string; LastRunAt: string; RunCount: number; SuccessCount: number; FailureCount: number }>({
|
|
2491
|
+
EntityName: 'MJ: Scheduled Jobs',
|
|
2492
|
+
ExtraFilter: `Configuration LIKE '%"CompanyIntegrationID":"${companyIntegrationID}"%'`,
|
|
2493
|
+
OrderBy: '__mj_CreatedAt DESC',
|
|
2494
|
+
ResultType: 'simple',
|
|
2495
|
+
Fields: ['ID', 'Name', 'Status', 'CronExpression', 'Timezone', 'NextRunAt', 'LastRunAt', 'RunCount', 'SuccessCount', 'FailureCount']
|
|
2496
|
+
}, user);
|
|
2497
|
+
|
|
2498
|
+
if (!result.Success) return { Success: false, Message: result.ErrorMessage || 'Query failed' };
|
|
2499
|
+
return {
|
|
2500
|
+
Success: true,
|
|
2501
|
+
Message: `${result.Results.length} schedule(s)`,
|
|
2502
|
+
Schedules: result.Results.map(r => ({
|
|
2503
|
+
ID: r.ID,
|
|
2504
|
+
Name: r.Name,
|
|
2505
|
+
Status: r.Status,
|
|
2506
|
+
CronExpression: r.CronExpression,
|
|
2507
|
+
Timezone: r.Timezone,
|
|
2508
|
+
NextRunAt: r.NextRunAt ?? undefined,
|
|
2509
|
+
LastRunAt: r.LastRunAt ?? undefined,
|
|
2510
|
+
RunCount: r.RunCount,
|
|
2511
|
+
SuccessCount: r.SuccessCount,
|
|
2512
|
+
FailureCount: r.FailureCount,
|
|
2513
|
+
}))
|
|
2514
|
+
};
|
|
2515
|
+
} catch (e) {
|
|
2516
|
+
LogError(`IntegrationListSchedules error: ${e}`);
|
|
2517
|
+
return { Success: false, Message: this.formatError(e) };
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
// ── ENTITY MAP MANAGEMENT ──────────────────────────────────────────
|
|
2522
|
+
|
|
2523
|
+
@Query(() => ListEntityMapsOutput)
|
|
2524
|
+
async IntegrationListEntityMaps(
|
|
2525
|
+
@Arg("companyIntegrationID") companyIntegrationID: string,
|
|
2526
|
+
@Ctx() ctx: AppContext
|
|
2527
|
+
): Promise<ListEntityMapsOutput> {
|
|
2528
|
+
try {
|
|
2529
|
+
const user = this.getAuthenticatedUser(ctx);
|
|
2530
|
+
const rv = new RunView();
|
|
2531
|
+
const result = await rv.RunView<EntityMapSummaryOutput>({
|
|
2532
|
+
EntityName: 'MJ: Company Integration Entity Maps',
|
|
2533
|
+
ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
|
|
2534
|
+
OrderBy: 'Priority ASC',
|
|
2535
|
+
ResultType: 'simple',
|
|
2536
|
+
Fields: ['ID', 'EntityID', 'Entity', 'ExternalObjectName', 'SyncDirection', 'Priority', 'Status']
|
|
2537
|
+
}, user);
|
|
2538
|
+
|
|
2539
|
+
if (!result.Success) return { Success: false, Message: result.ErrorMessage || 'Query failed' };
|
|
2540
|
+
return {
|
|
2541
|
+
Success: true,
|
|
2542
|
+
Message: `${result.Results.length} entity maps`,
|
|
2543
|
+
EntityMaps: result.Results
|
|
2544
|
+
};
|
|
2545
|
+
} catch (e) {
|
|
2546
|
+
LogError(`IntegrationListEntityMaps error: ${e}`);
|
|
2547
|
+
return { Success: false, Message: this.formatError(e) };
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
@Query(() => ListFieldMapsOutput)
|
|
2552
|
+
async IntegrationListFieldMaps(
|
|
2553
|
+
@Arg("entityMapID") entityMapID: string,
|
|
2554
|
+
@Ctx() ctx: AppContext
|
|
2555
|
+
): Promise<ListFieldMapsOutput> {
|
|
2556
|
+
try {
|
|
2557
|
+
const user = this.getAuthenticatedUser(ctx);
|
|
2558
|
+
const rv = new RunView();
|
|
2559
|
+
const result = await rv.RunView<FieldMapSummaryOutput>({
|
|
2560
|
+
EntityName: 'MJ: Company Integration Field Maps',
|
|
2561
|
+
ExtraFilter: `EntityMapID='${entityMapID}'`,
|
|
2562
|
+
OrderBy: 'SourceFieldName',
|
|
2563
|
+
ResultType: 'simple',
|
|
2564
|
+
Fields: ['ID', 'EntityMapID', 'SourceFieldName', 'DestinationFieldName', 'Status']
|
|
2565
|
+
}, user);
|
|
2566
|
+
|
|
2567
|
+
if (!result.Success) return { Success: false, Message: result.ErrorMessage || 'Query failed' };
|
|
2568
|
+
return {
|
|
2569
|
+
Success: true,
|
|
2570
|
+
Message: `${result.Results.length} field maps`,
|
|
2571
|
+
FieldMaps: result.Results
|
|
2572
|
+
};
|
|
2573
|
+
} catch (e) {
|
|
2574
|
+
LogError(`IntegrationListFieldMaps error: ${e}`);
|
|
2575
|
+
return { Success: false, Message: this.formatError(e) };
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
@Mutation(() => MutationResultOutput)
|
|
2580
|
+
async IntegrationUpdateEntityMaps(
|
|
2581
|
+
@Arg("updates", () => [EntityMapUpdateInput]) updates: EntityMapUpdateInput[],
|
|
2582
|
+
@Ctx() ctx: AppContext
|
|
2583
|
+
): Promise<MutationResultOutput> {
|
|
2584
|
+
try {
|
|
2585
|
+
const user = this.getAuthenticatedUser(ctx);
|
|
2586
|
+
const md = new Metadata();
|
|
2587
|
+
const errors: string[] = [];
|
|
2588
|
+
|
|
2589
|
+
for (const update of updates) {
|
|
2590
|
+
const em = await md.GetEntityObject<MJCompanyIntegrationEntityMapEntity>('MJ: Company Integration Entity Maps', user);
|
|
2591
|
+
const loaded = await em.InnerLoad(CompositeKey.FromID(update.EntityMapID));
|
|
2592
|
+
if (!loaded) { errors.push(`${update.EntityMapID}: not found`); continue; }
|
|
2593
|
+
|
|
2594
|
+
if (update.SyncDirection != null) {
|
|
2595
|
+
if (!isValidSyncDirection(update.SyncDirection)) {
|
|
2596
|
+
errors.push(`${update.EntityMapID}: invalid SyncDirection "${update.SyncDirection}"`);
|
|
2597
|
+
continue;
|
|
2598
|
+
}
|
|
2599
|
+
em.SyncDirection = update.SyncDirection;
|
|
2600
|
+
}
|
|
2601
|
+
if (update.Priority != null) em.Priority = update.Priority;
|
|
2602
|
+
if (update.Status != null) {
|
|
2603
|
+
if (!isValidEntityMapStatus(update.Status)) {
|
|
2604
|
+
errors.push(`${update.EntityMapID}: invalid Status "${update.Status}"`);
|
|
2605
|
+
continue;
|
|
2606
|
+
}
|
|
2607
|
+
em.Status = update.Status;
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
if (!await em.Save()) errors.push(`${update.EntityMapID}: failed to save`);
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
if (errors.length > 0) return { Success: false, Message: `Errors: ${errors.join('; ')}` };
|
|
2614
|
+
return { Success: true, Message: `Updated ${updates.length} entity maps` };
|
|
2615
|
+
} catch (e) {
|
|
2616
|
+
LogError(`IntegrationUpdateEntityMaps error: ${e}`);
|
|
2617
|
+
return { Success: false, Message: this.formatError(e) };
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
@Mutation(() => MutationResultOutput)
|
|
2622
|
+
async IntegrationDeleteEntityMaps(
|
|
2623
|
+
@Arg("entityMapIDs", () => [String]) entityMapIDs: string[],
|
|
2624
|
+
@Ctx() ctx: AppContext
|
|
2625
|
+
): Promise<MutationResultOutput> {
|
|
2626
|
+
try {
|
|
2627
|
+
this.getAuthenticatedUser(ctx);
|
|
2628
|
+
const sysUser = this.getSystemUser();
|
|
2629
|
+
const md = new Metadata();
|
|
2630
|
+
const rv = new RunView();
|
|
2631
|
+
const tg = await Metadata.Provider.CreateTransactionGroup();
|
|
2632
|
+
const errors: string[] = [];
|
|
2633
|
+
|
|
2634
|
+
for (const entityMapID of entityMapIDs) {
|
|
2635
|
+
const em = await md.GetEntityObject<MJCompanyIntegrationEntityMapEntity>('MJ: Company Integration Entity Maps', sysUser);
|
|
2636
|
+
const loaded = await em.InnerLoad(CompositeKey.FromID(entityMapID));
|
|
2637
|
+
if (!loaded) { errors.push(`${entityMapID}: not found`); continue; }
|
|
2638
|
+
|
|
2639
|
+
// Delete field maps
|
|
2640
|
+
const fmResult = await rv.RunView<MJCompanyIntegrationFieldMapEntity>({
|
|
2641
|
+
EntityName: 'MJ: Company Integration Field Maps',
|
|
2642
|
+
ExtraFilter: `EntityMapID='${entityMapID}'`,
|
|
2643
|
+
ResultType: 'entity_object'
|
|
2644
|
+
}, sysUser);
|
|
2645
|
+
if (fmResult.Success) {
|
|
2646
|
+
for (const fm of fmResult.Results) { fm.TransactionGroup = tg; await fm.Delete(); }
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
// Delete watermarks
|
|
2650
|
+
const wmResult = await rv.RunView<MJCompanyIntegrationSyncWatermarkEntity>({
|
|
2651
|
+
EntityName: 'MJ: Company Integration Sync Watermarks',
|
|
2652
|
+
ExtraFilter: `EntityMapID='${entityMapID}'`,
|
|
2653
|
+
ResultType: 'entity_object'
|
|
2654
|
+
}, sysUser);
|
|
2655
|
+
if (wmResult.Success) {
|
|
2656
|
+
for (const wm of wmResult.Results) { wm.TransactionGroup = tg; await wm.Delete(); }
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
// Delete record maps for THIS entity only (filter by CI + EntityID)
|
|
2660
|
+
const rmResult = await rv.RunView<MJCompanyIntegrationRecordMapEntity>({
|
|
2661
|
+
EntityName: 'MJ: Company Integration Record Maps',
|
|
2662
|
+
ExtraFilter: `CompanyIntegrationID='${em.CompanyIntegrationID}' AND EntityID='${em.EntityID}'`,
|
|
2663
|
+
ResultType: 'entity_object'
|
|
2664
|
+
}, sysUser);
|
|
2665
|
+
if (rmResult.Success) {
|
|
2666
|
+
for (const rm of rmResult.Results) { rm.TransactionGroup = tg; await rm.Delete(); }
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
em.TransactionGroup = tg;
|
|
2670
|
+
await em.Delete();
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
const submitted = await tg.Submit();
|
|
2674
|
+
if (!submitted) return { Success: false, Message: 'Transaction failed — all deletes rolled back' };
|
|
2675
|
+
if (errors.length > 0) return { Success: false, Message: `Partial: ${errors.join('; ')}` };
|
|
2676
|
+
return { Success: true, Message: `Deleted ${entityMapIDs.length} entity maps (with field maps, watermarks, record maps)` };
|
|
2677
|
+
} catch (e) {
|
|
2678
|
+
LogError(`IntegrationDeleteEntityMaps error: ${e}`);
|
|
2679
|
+
return { Success: false, Message: this.formatError(e) };
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
// ── OPERATION PROGRESS (polling) ──────────────────────────────────
|
|
2684
|
+
|
|
2685
|
+
@Query(() => OperationProgressOutput)
|
|
2686
|
+
async IntegrationGetRSUProgress(
|
|
2687
|
+
@Ctx() ctx: AppContext
|
|
2688
|
+
): Promise<OperationProgressOutput> {
|
|
2689
|
+
try {
|
|
2690
|
+
this.getAuthenticatedUser(ctx);
|
|
2691
|
+
const rsm = RuntimeSchemaManager.Instance;
|
|
2692
|
+
const rsuStatus = rsm.GetStatus();
|
|
2693
|
+
if (rsuStatus.Running) {
|
|
2694
|
+
return {
|
|
2695
|
+
Success: true,
|
|
2696
|
+
Message: 'RSU pipeline in progress',
|
|
2697
|
+
OperationType: 'rsu',
|
|
2698
|
+
IsRunning: true,
|
|
2699
|
+
RSURunning: true,
|
|
2700
|
+
RSUStep: rsuStatus.LastRunResult ?? 'running',
|
|
2701
|
+
StartedAt: rsuStatus.LastRunAt?.toISOString(),
|
|
2702
|
+
ElapsedMs: rsuStatus.LastRunAt ? Date.now() - rsuStatus.LastRunAt.getTime() : undefined,
|
|
2703
|
+
};
|
|
2704
|
+
}
|
|
2705
|
+
return { Success: true, Message: 'No RSU pipeline running', OperationType: 'none', IsRunning: false };
|
|
2706
|
+
} catch (e) {
|
|
2707
|
+
return { Success: false, Message: this.formatError(e) };
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
@Query(() => OperationProgressOutput)
|
|
2712
|
+
async IntegrationGetSyncProgress(
|
|
2713
|
+
@Arg("companyIntegrationID") companyIntegrationID: string,
|
|
2714
|
+
@Ctx() ctx: AppContext
|
|
2715
|
+
): Promise<OperationProgressOutput> {
|
|
2716
|
+
try {
|
|
2717
|
+
this.getAuthenticatedUser(ctx);
|
|
2718
|
+
const syncProgress = IntegrationEngine.GetSyncProgress(companyIntegrationID);
|
|
2719
|
+
if (syncProgress) {
|
|
2720
|
+
return {
|
|
2721
|
+
Success: true,
|
|
2722
|
+
Message: `Sync in progress (${syncProgress.TriggerType})`,
|
|
2723
|
+
OperationType: 'sync',
|
|
2724
|
+
IsRunning: true,
|
|
2725
|
+
CurrentEntity: syncProgress.CurrentEntity,
|
|
2726
|
+
EntityMapsTotal: syncProgress.EntityMapsTotal,
|
|
2727
|
+
EntityMapsCompleted: syncProgress.EntityMapsCompleted,
|
|
2728
|
+
RecordsProcessed: syncProgress.RecordsProcessed,
|
|
2729
|
+
RecordsCreated: syncProgress.RecordsCreated,
|
|
2730
|
+
RecordsUpdated: syncProgress.RecordsUpdated,
|
|
2731
|
+
RecordsErrored: syncProgress.RecordsErrored,
|
|
2732
|
+
StartedAt: syncProgress.StartedAt.toISOString(),
|
|
2733
|
+
ElapsedMs: Date.now() - syncProgress.StartedAt.getTime(),
|
|
2734
|
+
};
|
|
2735
|
+
}
|
|
2736
|
+
return { Success: true, Message: 'No sync running for this connector', OperationType: 'none', IsRunning: false };
|
|
2737
|
+
} catch (e) {
|
|
2738
|
+
return { Success: false, Message: this.formatError(e) };
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
// ── STATUS & HISTORY (not polling — for page loads) ─────────────────
|
|
2743
|
+
|
|
2744
|
+
@Query(() => IntegrationStatusOutput)
|
|
2745
|
+
async IntegrationGetStatus(
|
|
2746
|
+
@Arg("companyIntegrationID") companyIntegrationID: string,
|
|
2747
|
+
@Ctx() ctx: AppContext
|
|
2748
|
+
): Promise<IntegrationStatusOutput> {
|
|
2749
|
+
try {
|
|
2750
|
+
const user = this.getAuthenticatedUser(ctx);
|
|
2751
|
+
const md = new Metadata();
|
|
2752
|
+
const ci = await md.GetEntityObject<MJCompanyIntegrationEntity>('MJ: Company Integrations', user);
|
|
2753
|
+
const loaded = await ci.InnerLoad(CompositeKey.FromID(companyIntegrationID));
|
|
2754
|
+
if (!loaded) return { Success: false, Message: 'Not found' };
|
|
2755
|
+
|
|
2756
|
+
const rv = new RunView();
|
|
2757
|
+
const [mapsResult, runsResult] = await rv.RunViews([
|
|
2758
|
+
{
|
|
2759
|
+
EntityName: 'MJ: Company Integration Entity Maps',
|
|
2760
|
+
ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
|
|
2761
|
+
ResultType: 'simple',
|
|
2762
|
+
Fields: ['ID', 'Status']
|
|
2763
|
+
},
|
|
2764
|
+
{
|
|
2765
|
+
EntityName: 'MJ: Company Integration Runs',
|
|
2766
|
+
ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
|
|
2767
|
+
OrderBy: 'StartedAt DESC',
|
|
2768
|
+
MaxRows: 1,
|
|
2769
|
+
ResultType: 'simple',
|
|
2770
|
+
Fields: ['ID', 'Status', 'StartedAt', 'EndedAt', 'TotalRecords']
|
|
2771
|
+
}
|
|
2772
|
+
], user);
|
|
2773
|
+
|
|
2774
|
+
const maps = mapsResult.Success ? mapsResult.Results as Array<{ ID: string; Status: string }> : [];
|
|
2775
|
+
const lastRun = runsResult.Success && runsResult.Results.length > 0
|
|
2776
|
+
? runsResult.Results[0] as { ID: string; Status: string; StartedAt: string; EndedAt: string; TotalRecords: number }
|
|
2777
|
+
: null;
|
|
2778
|
+
|
|
2779
|
+
// RSU pipeline state
|
|
2780
|
+
const rsuStatus = RuntimeSchemaManager.Instance.GetStatus();
|
|
2781
|
+
|
|
2782
|
+
return {
|
|
2783
|
+
Success: true,
|
|
2784
|
+
Message: 'OK',
|
|
2785
|
+
IsActive: ci.IsActive ?? false,
|
|
2786
|
+
IntegrationName: ci.Integration,
|
|
2787
|
+
CompanyIntegrationID: companyIntegrationID,
|
|
2788
|
+
TotalEntityMaps: maps.length,
|
|
2789
|
+
ActiveEntityMaps: maps.filter(m => m.Status === 'Active').length,
|
|
2790
|
+
LastRunStatus: lastRun?.Status,
|
|
2791
|
+
LastRunStartedAt: lastRun?.StartedAt,
|
|
2792
|
+
LastRunEndedAt: lastRun?.EndedAt,
|
|
2793
|
+
ScheduleEnabled: ci.ScheduleEnabled,
|
|
2794
|
+
RSUEnabled: rsuStatus.Enabled,
|
|
2795
|
+
RSURunning: rsuStatus.Running,
|
|
2796
|
+
RSUOutOfSync: rsuStatus.OutOfSync,
|
|
2797
|
+
RSULastRunAt: rsuStatus.LastRunAt?.toISOString(),
|
|
2798
|
+
RSULastRunResult: rsuStatus.LastRunResult,
|
|
2799
|
+
};
|
|
2800
|
+
} catch (e) {
|
|
2801
|
+
LogError(`IntegrationGetStatus error: ${e}`);
|
|
2802
|
+
return { Success: false, Message: this.formatError(e) };
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
@Query(() => SyncHistoryOutput)
|
|
2807
|
+
async IntegrationGetSyncHistory(
|
|
2808
|
+
@Arg("companyIntegrationID") companyIntegrationID: string,
|
|
2809
|
+
@Arg("limit", { defaultValue: 20 }) limit: number,
|
|
2810
|
+
@Ctx() ctx: AppContext
|
|
2811
|
+
): Promise<SyncHistoryOutput> {
|
|
2812
|
+
try {
|
|
2813
|
+
const user = this.getAuthenticatedUser(ctx);
|
|
2814
|
+
const rv = new RunView();
|
|
2815
|
+
const result = await rv.RunView<SyncRunSummaryOutput>({
|
|
2816
|
+
EntityName: 'MJ: Company Integration Runs',
|
|
2817
|
+
ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
|
|
2818
|
+
OrderBy: 'StartedAt DESC',
|
|
2819
|
+
MaxRows: limit,
|
|
2820
|
+
ResultType: 'simple',
|
|
2821
|
+
Fields: ['ID', 'Status', 'StartedAt', 'EndedAt', 'TotalRecords', 'RunByUserID']
|
|
2822
|
+
}, user);
|
|
2823
|
+
|
|
2824
|
+
if (!result.Success) return { Success: false, Message: result.ErrorMessage || 'Query failed' };
|
|
2825
|
+
return {
|
|
2826
|
+
Success: true,
|
|
2827
|
+
Message: `${result.Results.length} runs`,
|
|
2828
|
+
Runs: result.Results
|
|
2829
|
+
};
|
|
2830
|
+
} catch (e) {
|
|
2831
|
+
LogError(`IntegrationGetSyncHistory error: ${e}`);
|
|
2832
|
+
return { Success: false, Message: this.formatError(e) };
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
// ── CONNECTOR CAPABILITIES ──────────────────────────────────────────
|
|
2837
|
+
|
|
2838
|
+
/**
|
|
2839
|
+
* Returns the CRUD capability flags for the connector bound to a CompanyIntegration.
|
|
2840
|
+
* Use this to determine which operations (Create/Update/Delete/Search) are supported
|
|
2841
|
+
* before attempting point-action calls.
|
|
2842
|
+
*/
|
|
2843
|
+
@Query(() => ConnectorCapabilitiesOutput)
|
|
2844
|
+
async IntegrationGetConnectorCapabilities(
|
|
2845
|
+
@Arg("companyIntegrationID") companyIntegrationID: string,
|
|
2846
|
+
@Ctx() ctx: AppContext
|
|
2847
|
+
): Promise<ConnectorCapabilitiesOutput> {
|
|
2848
|
+
try {
|
|
2849
|
+
const user = this.getAuthenticatedUser(ctx);
|
|
2850
|
+
const { connector } = await this.resolveConnector(companyIntegrationID, user);
|
|
2851
|
+
|
|
2852
|
+
return {
|
|
2853
|
+
Success: true,
|
|
2854
|
+
Message: 'OK',
|
|
2855
|
+
SupportsGet: connector.SupportsGet,
|
|
2856
|
+
SupportsCreate: connector.SupportsCreate,
|
|
2857
|
+
SupportsUpdate: connector.SupportsUpdate,
|
|
2858
|
+
SupportsDelete: connector.SupportsDelete,
|
|
2859
|
+
SupportsSearch: connector.SupportsSearch,
|
|
2860
|
+
};
|
|
2861
|
+
} catch (e) {
|
|
2862
|
+
LogError(`IntegrationGetConnectorCapabilities error: ${e}`);
|
|
2863
|
+
return { Success: false, Message: this.formatError(e) };
|
|
2864
|
+
}
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
// ── APPLY ALL BATCH ─────────────────────────────────────────────────
|
|
2868
|
+
|
|
2869
|
+
/**
|
|
2870
|
+
* Batch "Apply All" for multiple connectors in a single RSU pipeline run.
|
|
2871
|
+
* For each connector: introspect → build schema → collect RSU input.
|
|
2872
|
+
* Then run ONE pipeline batch for all connectors.
|
|
2873
|
+
* Post-pipeline: create entity/field maps and start sync for each success.
|
|
2874
|
+
*/
|
|
2875
|
+
@Mutation(() => ApplyAllBatchOutput)
|
|
2876
|
+
async IntegrationApplyAllBatch(
|
|
2877
|
+
@Arg("input") input: ApplyAllBatchInput,
|
|
2878
|
+
@Arg("platform", { defaultValue: "sqlserver" }) platform: string,
|
|
2879
|
+
@Arg("skipGitCommit", { defaultValue: false }) skipGitCommit: boolean,
|
|
2880
|
+
@Arg("skipRestart", { defaultValue: false }) skipRestart: boolean,
|
|
2881
|
+
@Ctx() ctx: AppContext
|
|
2882
|
+
): Promise<ApplyAllBatchOutput> {
|
|
2883
|
+
try {
|
|
2884
|
+
const user = this.getAuthenticatedUser(ctx);
|
|
2885
|
+
const validatedPlatform = this.validatePlatform(platform);
|
|
2886
|
+
|
|
2887
|
+
// Phase 1: Build schema for each connector in parallel
|
|
2888
|
+
const buildResults = await Promise.allSettled(
|
|
2889
|
+
input.Connectors.map(async (connInput) => {
|
|
2890
|
+
const { connector, companyIntegration } = await this.resolveConnector(connInput.CompanyIntegrationID, user);
|
|
2891
|
+
const schemaName = this.deriveSchemaName(companyIntegration.Integration);
|
|
2892
|
+
|
|
2893
|
+
// Resolve object IDs to names with per-object Fields
|
|
2894
|
+
const sourceSchema = await (connector.IntrospectSchema.bind(connector) as
|
|
2895
|
+
(ci: unknown, u: unknown) => Promise<SourceSchemaInfo>)(companyIntegration, user);
|
|
2896
|
+
const objectIDs = connInput.SourceObjects.map(so => so.SourceObjectID);
|
|
2897
|
+
const resolvedNames = await this.resolveSourceObjectNames(objectIDs, undefined, sourceSchema, companyIntegration.IntegrationID, user);
|
|
2898
|
+
|
|
2899
|
+
const fieldsByID = new Map(connInput.SourceObjects.map(so => [so.SourceObjectID, so.Fields]));
|
|
2900
|
+
const objects = resolvedNames.map((name, i) => {
|
|
2901
|
+
const obj = new SchemaPreviewObjectInput();
|
|
2902
|
+
obj.SourceObjectName = name;
|
|
2903
|
+
obj.SchemaName = schemaName;
|
|
2904
|
+
obj.TableName = name.replace(/[^A-Za-z0-9_]/g, '_');
|
|
2905
|
+
obj.EntityName = name.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/_/g, ' ');
|
|
2906
|
+
obj.Fields = fieldsByID.get(objectIDs[i]) ?? undefined;
|
|
2907
|
+
return obj;
|
|
2908
|
+
});
|
|
2909
|
+
|
|
2910
|
+
const { schemaOutput, rsuInput } = await this.buildSchemaForConnector(
|
|
2911
|
+
connInput.CompanyIntegrationID, objects, validatedPlatform, user, skipGitCommit, skipRestart
|
|
2912
|
+
);
|
|
2913
|
+
|
|
2914
|
+
// Build per-object field map for pending file
|
|
2915
|
+
const sourceObjectFields: Record<string, string[] | null> = {};
|
|
2916
|
+
for (const so of connInput.SourceObjects) {
|
|
2917
|
+
const resolvedName = resolvedNames[objectIDs.indexOf(so.SourceObjectID)];
|
|
2918
|
+
if (resolvedName) sourceObjectFields[resolvedName] = so.Fields ?? null;
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
// Inject post-restart pending work payload
|
|
2922
|
+
const { join } = await import('node:path');
|
|
2923
|
+
const rsuWorkDir = process.env.RSU_WORK_DIR || process.cwd();
|
|
2924
|
+
const pendingWorkDir = join(rsuWorkDir, '.rsu_pending');
|
|
2925
|
+
const pendingFilePath = join(pendingWorkDir, `${Date.now()}_${connInput.CompanyIntegrationID}.json`);
|
|
2926
|
+
const pendingPayload = {
|
|
2927
|
+
CompanyIntegrationID: connInput.CompanyIntegrationID,
|
|
2928
|
+
SourceObjectNames: resolvedNames,
|
|
2929
|
+
SourceObjectFields: sourceObjectFields,
|
|
2930
|
+
SchemaName: schemaName,
|
|
2931
|
+
CronExpression: connInput.CronExpression,
|
|
2932
|
+
ScheduleTimezone: connInput.ScheduleTimezone,
|
|
2933
|
+
StartSync: input.StartSync,
|
|
2934
|
+
FullSync: input.FullSync ?? false,
|
|
2935
|
+
SyncScope: input.SyncScope ?? 'created',
|
|
2936
|
+
CreatedAt: new Date().toISOString(),
|
|
2937
|
+
};
|
|
2938
|
+
rsuInput.PostRestartFiles = [
|
|
2939
|
+
{ Path: pendingFilePath, Content: JSON.stringify(pendingPayload, null, 2) }
|
|
2940
|
+
];
|
|
2941
|
+
|
|
2942
|
+
return {
|
|
2943
|
+
connInput,
|
|
2944
|
+
connector,
|
|
2945
|
+
companyIntegration,
|
|
2946
|
+
schemaName,
|
|
2947
|
+
objects,
|
|
2948
|
+
schemaOutput,
|
|
2949
|
+
rsuInput,
|
|
2950
|
+
pendingFilePath,
|
|
2951
|
+
};
|
|
2952
|
+
})
|
|
2953
|
+
);
|
|
2954
|
+
|
|
2955
|
+
// Separate successes and failures
|
|
2956
|
+
const successfulBuilds: Array<{
|
|
2957
|
+
connInput: ApplyAllBatchConnectorInput;
|
|
2958
|
+
connector: BaseIntegrationConnector;
|
|
2959
|
+
companyIntegration: MJCompanyIntegrationEntity;
|
|
2960
|
+
schemaName: string;
|
|
2961
|
+
objects: SchemaPreviewObjectInput[];
|
|
2962
|
+
schemaOutput: SchemaBuilderOutput;
|
|
2963
|
+
rsuInput: RSUPipelineInput;
|
|
2964
|
+
pendingFilePath: string;
|
|
2965
|
+
}> = [];
|
|
2966
|
+
const connectorResults: ApplyAllBatchConnectorResult[] = [];
|
|
2967
|
+
|
|
2968
|
+
for (let i = 0; i < buildResults.length; i++) {
|
|
2969
|
+
const result = buildResults[i];
|
|
2970
|
+
const connInput = input.Connectors[i];
|
|
2971
|
+
if (result.status === 'fulfilled') {
|
|
2972
|
+
successfulBuilds.push(result.value);
|
|
2973
|
+
} else {
|
|
2974
|
+
LogError(`IntegrationApplyAllBatch: build failed for ${connInput.CompanyIntegrationID}: ${result.reason}`);
|
|
2975
|
+
connectorResults.push({
|
|
2976
|
+
CompanyIntegrationID: connInput.CompanyIntegrationID,
|
|
2977
|
+
IntegrationName: 'Unknown',
|
|
2978
|
+
Success: false,
|
|
2979
|
+
Message: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
|
2980
|
+
});
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
if (successfulBuilds.length === 0) {
|
|
2985
|
+
return {
|
|
2986
|
+
Success: false,
|
|
2987
|
+
Message: 'All connectors failed during schema build phase',
|
|
2988
|
+
ConnectorResults: connectorResults,
|
|
2989
|
+
SuccessCount: 0,
|
|
2990
|
+
FailureCount: connectorResults.length,
|
|
2991
|
+
};
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
// Phase 2: Run all successful RSU inputs through one pipeline batch
|
|
2995
|
+
const pipelineInputs = successfulBuilds.map(b => b.rsuInput);
|
|
2996
|
+
const rsm = RuntimeSchemaManager.Instance;
|
|
2997
|
+
const batchResult = await rsm.RunPipelineBatch(pipelineInputs);
|
|
2998
|
+
|
|
2999
|
+
// Phase 3: Post-pipeline — create entity maps, field maps, schedules for each success
|
|
3000
|
+
for (let i = 0; i < successfulBuilds.length; i++) {
|
|
3001
|
+
const build = successfulBuilds[i];
|
|
3002
|
+
const pipelineResult = batchResult.Results[i];
|
|
3003
|
+
const integrationName = build.companyIntegration.Integration;
|
|
3004
|
+
|
|
3005
|
+
if (!pipelineResult || !pipelineResult.Success) {
|
|
3006
|
+
connectorResults.push({
|
|
3007
|
+
CompanyIntegrationID: build.connInput.CompanyIntegrationID,
|
|
3008
|
+
IntegrationName: integrationName,
|
|
3009
|
+
Success: false,
|
|
3010
|
+
Message: pipelineResult?.ErrorMessage ?? 'Pipeline failed',
|
|
3011
|
+
Warnings: build.schemaOutput.Warnings.length > 0 ? build.schemaOutput.Warnings : undefined,
|
|
3012
|
+
});
|
|
3013
|
+
// Clean up pending file on failure
|
|
3014
|
+
try { (await import('node:fs')).unlinkSync(build.pendingFilePath); } catch { /* may not exist */ }
|
|
3015
|
+
continue;
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
const connResult: ApplyAllBatchConnectorResult = {
|
|
3019
|
+
CompanyIntegrationID: build.connInput.CompanyIntegrationID,
|
|
3020
|
+
IntegrationName: integrationName,
|
|
3021
|
+
Success: true,
|
|
3022
|
+
Message: `Applied ${build.objects.length} object(s)`,
|
|
3023
|
+
Warnings: build.schemaOutput.Warnings.length > 0 ? build.schemaOutput.Warnings : undefined,
|
|
3024
|
+
};
|
|
3025
|
+
|
|
3026
|
+
if (skipRestart) {
|
|
3027
|
+
// Entity maps, field maps, sync
|
|
3028
|
+
await Metadata.Provider.Refresh();
|
|
3029
|
+
const entityMapsCreated = await this.createEntityAndFieldMaps(
|
|
3030
|
+
build.connInput.CompanyIntegrationID, build.objects, build.connector,
|
|
3031
|
+
build.companyIntegration, build.schemaName, user
|
|
3032
|
+
);
|
|
3033
|
+
connResult.EntityMapsCreated = entityMapsCreated;
|
|
3034
|
+
|
|
3035
|
+
const createdMapIDs = entityMapsCreated.map(em => em.EntityMapID).filter(Boolean);
|
|
3036
|
+
const scopedMapIDs = input.SyncScope === 'all' ? undefined : createdMapIDs;
|
|
3037
|
+
const syncRunID = input.StartSync !== false
|
|
3038
|
+
? await this.startSyncAfterApply(build.connInput.CompanyIntegrationID, user, scopedMapIDs, input.FullSync)
|
|
3039
|
+
: null;
|
|
3040
|
+
if (syncRunID) connResult.SyncRunID = syncRunID;
|
|
3041
|
+
|
|
3042
|
+
// Create schedule if CronExpression provided
|
|
3043
|
+
if (build.connInput.CronExpression) {
|
|
3044
|
+
const scheduleResult = await this.createScheduleForConnector(
|
|
3045
|
+
build.connInput.CompanyIntegrationID, integrationName,
|
|
3046
|
+
build.connInput.CronExpression, build.connInput.ScheduleTimezone, user
|
|
3047
|
+
);
|
|
3048
|
+
if (scheduleResult) connResult.ScheduledJobID = scheduleResult;
|
|
3049
|
+
}
|
|
3050
|
+
|
|
3051
|
+
// Clean up pending file
|
|
3052
|
+
try { (await import('node:fs')).unlinkSync(build.pendingFilePath); } catch { /* already consumed */ }
|
|
3053
|
+
|
|
3054
|
+
connResult.Message = `Applied ${build.objects.length} object(s) — ${entityMapsCreated.length} entity maps created${syncRunID ? ', sync started' : ''}`;
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
connectorResults.push(connResult);
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
const pipelineSteps = batchResult.Results[0]?.Steps.map((s: RSUPipelineStep) => ({
|
|
3061
|
+
Name: s.Name, Status: s.Status, DurationMs: s.DurationMs, Message: s.Message,
|
|
3062
|
+
}));
|
|
3063
|
+
|
|
3064
|
+
const successCount = connectorResults.filter(r => r.Success).length;
|
|
3065
|
+
const failureCount = connectorResults.filter(r => !r.Success).length;
|
|
3066
|
+
|
|
3067
|
+
return {
|
|
3068
|
+
Success: successCount > 0,
|
|
3069
|
+
Message: `Batch complete: ${successCount} succeeded, ${failureCount} failed`,
|
|
3070
|
+
ConnectorResults: connectorResults,
|
|
3071
|
+
PipelineSteps: pipelineSteps,
|
|
3072
|
+
GitCommitSuccess: batchResult.Results[0]?.GitCommitSuccess,
|
|
3073
|
+
APIRestarted: batchResult.Results[0]?.APIRestarted,
|
|
3074
|
+
SuccessCount: successCount,
|
|
3075
|
+
FailureCount: failureCount,
|
|
3076
|
+
};
|
|
3077
|
+
} catch (e) {
|
|
3078
|
+
LogError(`IntegrationApplyAllBatch error: ${e}`);
|
|
3079
|
+
return {
|
|
3080
|
+
Success: false, Message: this.formatError(e),
|
|
3081
|
+
ConnectorResults: [], SuccessCount: 0, FailureCount: 0,
|
|
3082
|
+
};
|
|
3083
|
+
}
|
|
3084
|
+
}
|
|
3085
|
+
|
|
3086
|
+
/** Helper: creates a schedule for a connector, returns ScheduledJobID or null. */
|
|
3087
|
+
private async createScheduleForConnector(
|
|
3088
|
+
companyIntegrationID: string,
|
|
3089
|
+
integrationName: string,
|
|
3090
|
+
cronExpression: string,
|
|
3091
|
+
timezone: string | undefined,
|
|
3092
|
+
user: UserInfo
|
|
3093
|
+
): Promise<string | null> {
|
|
3094
|
+
try {
|
|
3095
|
+
const md = new Metadata();
|
|
3096
|
+
const rv = new RunView();
|
|
3097
|
+
|
|
3098
|
+
// Find IntegrationSync job type
|
|
3099
|
+
const jobTypeResult = await rv.RunView<MJScheduledJobTypeEntity>({
|
|
3100
|
+
EntityName: 'MJ: Scheduled Job Types',
|
|
3101
|
+
ExtraFilter: `DriverClass='IntegrationSyncScheduledJobDriver'`,
|
|
3102
|
+
MaxRows: 1,
|
|
3103
|
+
ResultType: 'simple',
|
|
3104
|
+
Fields: ['ID']
|
|
3105
|
+
}, user);
|
|
3106
|
+
if (!jobTypeResult.Success || jobTypeResult.Results.length === 0) {
|
|
3107
|
+
LogError('IntegrationApplyAllBatch: IntegrationSync scheduled job type not found');
|
|
3108
|
+
return null;
|
|
3109
|
+
}
|
|
3110
|
+
const jobTypeID = jobTypeResult.Results[0].ID;
|
|
3111
|
+
|
|
3112
|
+
const job = await md.GetEntityObject<MJScheduledJobEntity>('MJ: Scheduled Jobs', user);
|
|
3113
|
+
job.NewRecord();
|
|
3114
|
+
job.JobTypeID = jobTypeID;
|
|
3115
|
+
job.Name = `${integrationName} Sync`;
|
|
3116
|
+
job.CronExpression = cronExpression;
|
|
3117
|
+
job.Timezone = timezone || 'UTC';
|
|
3118
|
+
job.Status = 'Active';
|
|
3119
|
+
job.OwnerUserID = user.ID;
|
|
3120
|
+
job.Configuration = JSON.stringify({ CompanyIntegrationID: companyIntegrationID });
|
|
3121
|
+
|
|
3122
|
+
if (!await job.Save()) return null;
|
|
3123
|
+
|
|
3124
|
+
// Link to CompanyIntegration
|
|
3125
|
+
const ci = await md.GetEntityObject<MJCompanyIntegrationEntity>('MJ: Company Integrations', user);
|
|
3126
|
+
const ciLoaded = await ci.InnerLoad(CompositeKey.FromID(companyIntegrationID));
|
|
3127
|
+
if (ciLoaded) {
|
|
3128
|
+
ci.ScheduleEnabled = true;
|
|
3129
|
+
ci.ScheduleType = 'Cron';
|
|
3130
|
+
ci.CronExpression = cronExpression;
|
|
3131
|
+
await ci.Save();
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3134
|
+
return job.ID;
|
|
3135
|
+
} catch (e) {
|
|
3136
|
+
LogError(`IntegrationApplyAllBatch: schedule creation failed: ${this.formatError(e)}`);
|
|
3137
|
+
return null;
|
|
3138
|
+
}
|
|
3139
|
+
}
|
|
3140
|
+
|
|
3141
|
+
// ── DELETE CONNECTION ────────────────────────────────────────────────
|
|
3142
|
+
|
|
3143
|
+
/**
|
|
3144
|
+
* Hard-deletes a CompanyIntegration and all associated entity maps, field maps,
|
|
3145
|
+
* and scheduled jobs. Does NOT drop database tables (flagged for future).
|
|
3146
|
+
*/
|
|
3147
|
+
@Mutation(() => DeleteConnectionOutput)
|
|
3148
|
+
async IntegrationDeleteConnection(
|
|
3149
|
+
@Arg("companyIntegrationID") companyIntegrationID: string,
|
|
3150
|
+
@Arg("deleteData", { defaultValue: false }) deleteData: boolean,
|
|
3151
|
+
@Ctx() ctx: AppContext
|
|
3152
|
+
): Promise<DeleteConnectionOutput> {
|
|
3153
|
+
try {
|
|
3154
|
+
this.getAuthenticatedUser(ctx); // verify caller is authenticated
|
|
3155
|
+
const sysUser = this.getSystemUser(); // use system user for cascade delete
|
|
3156
|
+
const md = new Metadata();
|
|
3157
|
+
const rv = new RunView();
|
|
3158
|
+
|
|
3159
|
+
// Step 1: Load CompanyIntegration
|
|
3160
|
+
const ci = await md.GetEntityObject<MJCompanyIntegrationEntity>('MJ: Company Integrations', sysUser);
|
|
3161
|
+
const ciLoaded = await ci.InnerLoad(CompositeKey.FromID(companyIntegrationID));
|
|
3162
|
+
if (!ciLoaded) return { Success: false, Message: 'CompanyIntegration not found' };
|
|
3163
|
+
|
|
3164
|
+
// Cascade delete in FK-safe order using TransactionGroup
|
|
3165
|
+
const tg = await Metadata.Provider.CreateTransactionGroup();
|
|
3166
|
+
let fieldMapsDeleted = 0;
|
|
3167
|
+
let entityMapsDeleted = 0;
|
|
3168
|
+
let schedulesDeleted = 0;
|
|
3169
|
+
|
|
3170
|
+
// Step 2: Null out ScheduledJobRunID on CompanyIntegrationRuns (break FK before deleting job runs)
|
|
3171
|
+
const ciRunsResult = await rv.RunView<MJCompanyIntegrationRunEntity>({
|
|
3172
|
+
EntityName: 'MJ: Company Integration Runs',
|
|
3173
|
+
ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
|
|
3174
|
+
ResultType: 'entity_object'
|
|
3175
|
+
}, sysUser);
|
|
3176
|
+
if (ciRunsResult.Success) {
|
|
3177
|
+
for (const ciRun of ciRunsResult.Results) {
|
|
3178
|
+
if (ciRun.ScheduledJobRunID) {
|
|
3179
|
+
ciRun.ScheduledJobRunID = null;
|
|
3180
|
+
await ciRun.Save();
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3185
|
+
// Step 3: Delete scheduled job runs + jobs (reference CI via Configuration JSON)
|
|
3186
|
+
const jobsResult = await rv.RunView<MJScheduledJobEntity>({
|
|
3187
|
+
EntityName: 'MJ: Scheduled Jobs',
|
|
3188
|
+
ExtraFilter: `Configuration LIKE '%${companyIntegrationID}%'`,
|
|
3189
|
+
ResultType: 'entity_object'
|
|
3190
|
+
}, sysUser);
|
|
3191
|
+
if (jobsResult.Success) {
|
|
3192
|
+
for (const job of jobsResult.Results) {
|
|
3193
|
+
try {
|
|
3194
|
+
const config = JSON.parse(job.Configuration || '{}') as Record<string, unknown>;
|
|
3195
|
+
if (config.CompanyIntegrationID === companyIntegrationID) {
|
|
3196
|
+
const jobRunsResult = await rv.RunView<MJScheduledJobRunEntity>({
|
|
3197
|
+
EntityName: 'MJ: Scheduled Job Runs',
|
|
3198
|
+
ExtraFilter: `ScheduledJobID='${job.ID}'`,
|
|
3199
|
+
ResultType: 'entity_object'
|
|
3200
|
+
}, sysUser);
|
|
3201
|
+
if (jobRunsResult.Success) {
|
|
3202
|
+
for (const jr of jobRunsResult.Results) {
|
|
3203
|
+
jr.TransactionGroup = tg;
|
|
3204
|
+
await jr.Delete();
|
|
3205
|
+
}
|
|
3206
|
+
}
|
|
3207
|
+
job.TransactionGroup = tg;
|
|
3208
|
+
await job.Delete();
|
|
3209
|
+
schedulesDeleted++;
|
|
3210
|
+
}
|
|
3211
|
+
} catch { /* skip invalid config */ }
|
|
3212
|
+
}
|
|
3213
|
+
}
|
|
3214
|
+
|
|
3215
|
+
// Step 4: Delete run details then runs (reuse ciRunsResult from Step 2)
|
|
3216
|
+
if (ciRunsResult.Success) {
|
|
3217
|
+
for (const run of ciRunsResult.Results) {
|
|
3218
|
+
// Delete run details first
|
|
3219
|
+
const detailsResult = await rv.RunView<MJCompanyIntegrationRunDetailEntity>({
|
|
3220
|
+
EntityName: 'MJ: Company Integration Run Details',
|
|
3221
|
+
ExtraFilter: `CompanyIntegrationRunID='${run.ID}'`,
|
|
3222
|
+
ResultType: 'entity_object'
|
|
3223
|
+
}, sysUser);
|
|
3224
|
+
if (detailsResult.Success) {
|
|
3225
|
+
for (const detail of detailsResult.Results) {
|
|
3226
|
+
detail.TransactionGroup = tg;
|
|
3227
|
+
await detail.Delete();
|
|
3228
|
+
}
|
|
3229
|
+
}
|
|
3230
|
+
run.TransactionGroup = tg;
|
|
3231
|
+
await run.Delete();
|
|
3232
|
+
}
|
|
3233
|
+
}
|
|
3234
|
+
|
|
3235
|
+
// Step 4: Delete entity map children (field maps, watermarks, record maps)
|
|
3236
|
+
const entityMapsResult = await rv.RunView<MJCompanyIntegrationEntityMapEntity>({
|
|
3237
|
+
EntityName: 'MJ: Company Integration Entity Maps',
|
|
3238
|
+
ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
|
|
3239
|
+
ResultType: 'entity_object'
|
|
3240
|
+
}, sysUser);
|
|
3241
|
+
const entityMaps = entityMapsResult.Success ? entityMapsResult.Results : [];
|
|
3242
|
+
|
|
3243
|
+
for (const em of entityMaps) {
|
|
3244
|
+
const fmResult = await rv.RunView<MJCompanyIntegrationFieldMapEntity>({
|
|
3245
|
+
EntityName: 'MJ: Company Integration Field Maps',
|
|
3246
|
+
ExtraFilter: `EntityMapID='${em.ID}'`,
|
|
3247
|
+
ResultType: 'entity_object'
|
|
3248
|
+
}, sysUser);
|
|
3249
|
+
if (fmResult.Success) {
|
|
3250
|
+
for (const fm of fmResult.Results) {
|
|
3251
|
+
fm.TransactionGroup = tg;
|
|
3252
|
+
if (await fm.Delete()) fieldMapsDeleted++;
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
|
|
3256
|
+
const wmResult = await rv.RunView<MJCompanyIntegrationSyncWatermarkEntity>({
|
|
3257
|
+
EntityName: 'MJ: Company Integration Sync Watermarks',
|
|
3258
|
+
ExtraFilter: `EntityMapID='${em.ID}'`,
|
|
3259
|
+
ResultType: 'entity_object'
|
|
3260
|
+
}, sysUser);
|
|
3261
|
+
if (wmResult.Success) {
|
|
3262
|
+
for (const wm of wmResult.Results) {
|
|
3263
|
+
wm.TransactionGroup = tg;
|
|
3264
|
+
await wm.Delete();
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
3267
|
+
|
|
3268
|
+
const rmResult = await rv.RunView<MJCompanyIntegrationRecordMapEntity>({
|
|
3269
|
+
EntityName: 'MJ: Company Integration Record Maps',
|
|
3270
|
+
ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
|
|
3271
|
+
ResultType: 'entity_object'
|
|
3272
|
+
}, sysUser);
|
|
3273
|
+
if (rmResult.Success) {
|
|
3274
|
+
for (const rm of rmResult.Results) {
|
|
3275
|
+
rm.TransactionGroup = tg;
|
|
3276
|
+
await rm.Delete();
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
3279
|
+
}
|
|
3280
|
+
|
|
3281
|
+
// Step 5: Delete entity maps
|
|
3282
|
+
for (const em of entityMaps) {
|
|
3283
|
+
em.TransactionGroup = tg;
|
|
3284
|
+
if (await em.Delete()) entityMapsDeleted++;
|
|
3285
|
+
}
|
|
3286
|
+
|
|
3287
|
+
// Step 6: Delete employee-company integration links
|
|
3288
|
+
const empResult = await rv.RunView<MJEmployeeCompanyIntegrationEntity>({
|
|
3289
|
+
EntityName: 'MJ: Employee Company Integrations',
|
|
3290
|
+
ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
|
|
3291
|
+
ResultType: 'entity_object'
|
|
3292
|
+
}, sysUser);
|
|
3293
|
+
if (empResult.Success) {
|
|
3294
|
+
for (const emp of empResult.Results) {
|
|
3295
|
+
emp.TransactionGroup = tg;
|
|
3296
|
+
await emp.Delete();
|
|
3297
|
+
}
|
|
3298
|
+
}
|
|
3299
|
+
|
|
3300
|
+
// Step 7: Delete the CompanyIntegration itself
|
|
3301
|
+
ci.TransactionGroup = tg;
|
|
3302
|
+
await ci.Delete();
|
|
3303
|
+
|
|
3304
|
+
// Submit the transaction
|
|
3305
|
+
const submitted = await tg.Submit();
|
|
3306
|
+
if (!submitted) {
|
|
3307
|
+
return { Success: false, Message: 'Transaction failed — all deletes rolled back' };
|
|
3308
|
+
}
|
|
3309
|
+
|
|
3310
|
+
if (deleteData) {
|
|
3311
|
+
LogError(`IntegrationDeleteConnection: deleteData=true requested but table deletion not yet implemented for ${companyIntegrationID}`);
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3314
|
+
return {
|
|
3315
|
+
Success: true,
|
|
3316
|
+
Message: `Deleted connection and all associated records`,
|
|
3317
|
+
EntityMapsDeleted: entityMapsDeleted,
|
|
3318
|
+
FieldMapsDeleted: fieldMapsDeleted,
|
|
3319
|
+
SchedulesDeleted: schedulesDeleted,
|
|
3320
|
+
};
|
|
3321
|
+
} catch (e) {
|
|
3322
|
+
LogError(`IntegrationDeleteConnection error: ${e}`);
|
|
3323
|
+
return { Success: false, Message: this.formatError(e) };
|
|
3324
|
+
}
|
|
3325
|
+
}
|
|
3326
|
+
|
|
3327
|
+
// ── SCHEMA EVOLUTION ────────────────────────────────────────────────
|
|
3328
|
+
|
|
3329
|
+
/**
|
|
3330
|
+
* Detects schema changes (new/modified columns) in the external system and
|
|
3331
|
+
* applies ALTER TABLE migrations via the RSU pipeline.
|
|
3332
|
+
* Compares the current connector introspection against existing MJ entities.
|
|
3333
|
+
*/
|
|
3334
|
+
@Mutation(() => SchemaEvolutionOutput)
|
|
3335
|
+
async IntegrationSchemaEvolution(
|
|
3336
|
+
@Arg("companyIntegrationID") companyIntegrationID: string,
|
|
3337
|
+
@Arg("platform", { defaultValue: "sqlserver" }) platform: string,
|
|
3338
|
+
@Arg("skipGitCommit", { defaultValue: false }) skipGitCommit: boolean,
|
|
3339
|
+
@Arg("skipRestart", { defaultValue: false }) skipRestart: boolean,
|
|
3340
|
+
@Ctx() ctx: AppContext
|
|
3341
|
+
): Promise<SchemaEvolutionOutput> {
|
|
3342
|
+
try {
|
|
3343
|
+
const user = this.getAuthenticatedUser(ctx);
|
|
3344
|
+
const validatedPlatform = this.validatePlatform(platform);
|
|
3345
|
+
const { connector, companyIntegration } = await this.resolveConnector(companyIntegrationID, user);
|
|
3346
|
+
const schemaName = this.deriveSchemaName(companyIntegration.Integration);
|
|
3347
|
+
const md = new Metadata();
|
|
3348
|
+
const rv = new RunView();
|
|
3349
|
+
|
|
3350
|
+
// Step 1: Get existing entity maps for this CompanyIntegration
|
|
3351
|
+
const entityMapsResult = await rv.RunView<MJCompanyIntegrationEntityMapEntity>({
|
|
3352
|
+
EntityName: 'MJ: Company Integration Entity Maps',
|
|
3353
|
+
ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
|
|
3354
|
+
ResultType: 'simple',
|
|
3355
|
+
Fields: ['ID', 'ExternalObjectName', 'EntityID']
|
|
3356
|
+
}, user);
|
|
3357
|
+
|
|
3358
|
+
if (!entityMapsResult.Success || entityMapsResult.Results.length === 0) {
|
|
3359
|
+
return {
|
|
3360
|
+
Success: false, Message: 'No entity maps found — nothing to evolve',
|
|
3361
|
+
HasChanges: false,
|
|
3362
|
+
};
|
|
3363
|
+
}
|
|
3364
|
+
|
|
3365
|
+
const sourceObjectNames = entityMapsResult.Results.map(em => em.ExternalObjectName);
|
|
3366
|
+
const objects = this.buildObjectInputsFromNames(sourceObjectNames, schemaName);
|
|
3367
|
+
|
|
3368
|
+
// Step 2: Build ExistingTables from Metadata.Entities matching the schema
|
|
3369
|
+
const existingTables: ExistingTableInfo[] = [];
|
|
3370
|
+
for (const obj of objects) {
|
|
3371
|
+
const entityInfo = md.Entities.find(
|
|
3372
|
+
e => e.SchemaName.toLowerCase() === schemaName.toLowerCase()
|
|
3373
|
+
&& e.BaseTable.toLowerCase() === obj.TableName.toLowerCase()
|
|
3374
|
+
);
|
|
3375
|
+
if (entityInfo) {
|
|
3376
|
+
existingTables.push({
|
|
3377
|
+
SchemaName: entityInfo.SchemaName,
|
|
3378
|
+
TableName: entityInfo.BaseTable,
|
|
3379
|
+
Columns: entityInfo.Fields.map(f => ({
|
|
3380
|
+
Name: f.Name,
|
|
3381
|
+
SqlType: f.SQLFullType || f.Type,
|
|
3382
|
+
IsNullable: f.AllowsNull,
|
|
3383
|
+
MaxLength: f.MaxLength > 0 ? f.MaxLength : null,
|
|
3384
|
+
Precision: f.Precision > 0 ? f.Precision : null,
|
|
3385
|
+
Scale: f.Scale > 0 ? f.Scale : null,
|
|
3386
|
+
})),
|
|
3387
|
+
});
|
|
3388
|
+
}
|
|
3389
|
+
}
|
|
3390
|
+
|
|
3391
|
+
// Step 3: Introspect current schema from connector
|
|
3392
|
+
const introspect = connector.IntrospectSchema.bind(connector) as
|
|
3393
|
+
(ci: unknown, u: unknown) => Promise<SourceSchemaInfo>;
|
|
3394
|
+
const sourceSchema = await introspect(companyIntegration, user);
|
|
3395
|
+
|
|
3396
|
+
// Normalize names to match source schema casing
|
|
3397
|
+
const nameMap = new Map(sourceSchema.Objects.map(o => [o.ExternalName.toLowerCase(), o.ExternalName]));
|
|
3398
|
+
const normalizedNames = sourceObjectNames.map(n => nameMap.get(n.toLowerCase()) ?? n);
|
|
3399
|
+
// Also normalize the objects array SourceObjectName
|
|
3400
|
+
for (const obj of objects) {
|
|
3401
|
+
const exact = nameMap.get(obj.SourceObjectName.toLowerCase());
|
|
3402
|
+
if (exact) obj.SourceObjectName = exact;
|
|
3403
|
+
}
|
|
3404
|
+
|
|
3405
|
+
const requestedNames = new Set(normalizedNames);
|
|
3406
|
+
const filteredSchema: SourceSchemaInfo = {
|
|
3407
|
+
Objects: sourceSchema.Objects.filter(o => requestedNames.has(o.ExternalName))
|
|
3408
|
+
};
|
|
3409
|
+
|
|
3410
|
+
// Step 4: Build target configs and SchemaBuilder input with ExistingTables
|
|
3411
|
+
const targetConfigs = this.buildTargetConfigs(objects, filteredSchema, validatedPlatform, connector);
|
|
3412
|
+
|
|
3413
|
+
const schemaInput: SchemaBuilderInput = {
|
|
3414
|
+
SourceSchema: filteredSchema,
|
|
3415
|
+
TargetConfigs: targetConfigs,
|
|
3416
|
+
Platform: validatedPlatform,
|
|
3417
|
+
MJVersion: process.env.MJ_VERSION ?? '5.11.0',
|
|
3418
|
+
SourceType: companyIntegration.Integration,
|
|
3419
|
+
AdditionalSchemaInfoPath: process.env.RSU_ADDITIONAL_SCHEMA_INFO_PATH ?? 'additionalSchemaInfo.json',
|
|
3420
|
+
MigrationsDir: process.env.RSU_MIGRATIONS_PATH ?? 'migrations/rsu',
|
|
3421
|
+
MetadataDir: process.env.RSU_METADATA_DIR ?? 'metadata',
|
|
3422
|
+
ExistingTables: existingTables,
|
|
3423
|
+
EntitySettingsForTargets: {}
|
|
3424
|
+
};
|
|
3425
|
+
|
|
3426
|
+
// Step 5: Build schema — SchemaBuilder handles evolution (ALTER TABLE) internally
|
|
3427
|
+
const builder = new SchemaBuilder();
|
|
3428
|
+
const schemaOutput = builder.BuildSchema(schemaInput);
|
|
3429
|
+
|
|
3430
|
+
if (schemaOutput.Errors.length > 0) {
|
|
3431
|
+
return {
|
|
3432
|
+
Success: false,
|
|
3433
|
+
Message: `Schema evolution failed: ${schemaOutput.Errors.join('; ')}`,
|
|
3434
|
+
HasChanges: false,
|
|
3435
|
+
Warnings: schemaOutput.Warnings.length > 0 ? schemaOutput.Warnings : undefined,
|
|
3436
|
+
};
|
|
3437
|
+
}
|
|
3438
|
+
|
|
3439
|
+
// Step 6: Check if any migration SQL was generated
|
|
3440
|
+
if (schemaOutput.MigrationFiles.length === 0) {
|
|
3441
|
+
return {
|
|
3442
|
+
Success: true,
|
|
3443
|
+
Message: 'No schema changes detected',
|
|
3444
|
+
HasChanges: false,
|
|
3445
|
+
Warnings: schemaOutput.Warnings.length > 0 ? schemaOutput.Warnings : undefined,
|
|
3446
|
+
};
|
|
3447
|
+
}
|
|
3448
|
+
|
|
3449
|
+
// Step 7: Count added/modified columns by re-diffing
|
|
3450
|
+
let addedColumns = 0;
|
|
3451
|
+
let modifiedColumns = 0;
|
|
3452
|
+
const evolution = new SchemaEvolution();
|
|
3453
|
+
for (const existing of existingTables) {
|
|
3454
|
+
const config = targetConfigs.find(
|
|
3455
|
+
c => c.SchemaName.toLowerCase() === existing.SchemaName.toLowerCase()
|
|
3456
|
+
&& c.TableName.toLowerCase() === existing.TableName.toLowerCase()
|
|
3457
|
+
);
|
|
3458
|
+
if (!config) continue;
|
|
3459
|
+
const sourceObj = filteredSchema.Objects.find(o => o.ExternalName.toLowerCase() === config.SourceObjectName.toLowerCase());
|
|
3460
|
+
if (!sourceObj) continue;
|
|
3461
|
+
const diff = evolution.DiffSchema(sourceObj, config, existing, validatedPlatform);
|
|
3462
|
+
addedColumns += diff.AddedColumns.length;
|
|
3463
|
+
modifiedColumns += diff.ModifiedColumns.length;
|
|
3464
|
+
}
|
|
3465
|
+
|
|
3466
|
+
// Step 8: Run RSU pipeline
|
|
3467
|
+
const rsuInput = builder.BuildRSUInput(schemaOutput, schemaInput, {
|
|
3468
|
+
SkipGitCommit: skipGitCommit,
|
|
3469
|
+
SkipRestart: skipRestart,
|
|
3470
|
+
});
|
|
3471
|
+
|
|
3472
|
+
const rsm = RuntimeSchemaManager.Instance;
|
|
3473
|
+
const batchResult = await rsm.RunPipelineBatch([rsuInput]);
|
|
3474
|
+
|
|
3475
|
+
const pipelineResult = batchResult.Results[0];
|
|
3476
|
+
const pipelineSteps = pipelineResult?.Steps.map((s: RSUPipelineStep) => ({
|
|
3477
|
+
Name: s.Name, Status: s.Status, DurationMs: s.DurationMs, Message: s.Message,
|
|
3478
|
+
}));
|
|
3479
|
+
|
|
3480
|
+
return {
|
|
3481
|
+
Success: pipelineResult?.Success ?? false,
|
|
3482
|
+
Message: pipelineResult?.Success
|
|
3483
|
+
? `Schema evolution applied — ${addedColumns} column(s) added, ${modifiedColumns} column(s) modified`
|
|
3484
|
+
: `Pipeline failed: ${pipelineResult?.ErrorMessage ?? 'unknown error'}`,
|
|
3485
|
+
HasChanges: true,
|
|
3486
|
+
AddedColumns: addedColumns,
|
|
3487
|
+
ModifiedColumns: modifiedColumns,
|
|
3488
|
+
Steps: pipelineSteps,
|
|
3489
|
+
GitCommitSuccess: pipelineResult?.GitCommitSuccess,
|
|
3490
|
+
APIRestarted: pipelineResult?.APIRestarted,
|
|
3491
|
+
Warnings: schemaOutput.Warnings.length > 0 ? schemaOutput.Warnings : undefined,
|
|
3492
|
+
};
|
|
3493
|
+
} catch (e) {
|
|
3494
|
+
LogError(`IntegrationSchemaEvolution error: ${e}`);
|
|
3495
|
+
return { Success: false, Message: this.formatError(e), HasChanges: false };
|
|
3496
|
+
}
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3499
|
+
// ── WEBHOOK HELPER ──────────────────────────────────────────────────
|
|
3500
|
+
|
|
3501
|
+
private async sendWebhook(url: string, payload: Record<string, unknown>): Promise<void> {
|
|
3502
|
+
try {
|
|
3503
|
+
const response = await fetch(url, {
|
|
3504
|
+
method: 'POST',
|
|
3505
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3506
|
+
body: JSON.stringify(payload)
|
|
3507
|
+
});
|
|
3508
|
+
if (!response.ok) {
|
|
3509
|
+
console.error(`[Integration] Webhook POST to ${url} returned ${response.status}`);
|
|
3510
|
+
}
|
|
3511
|
+
} catch (e) {
|
|
3512
|
+
console.error(`[Integration] Webhook POST to ${url} failed:`, e);
|
|
3513
|
+
}
|
|
3514
|
+
}
|
|
584
3515
|
}
|