@memberjunction/server 5.15.0 → 5.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +66 -3
  2. package/dist/auth/index.d.ts +0 -3
  3. package/dist/auth/index.d.ts.map +1 -1
  4. package/dist/auth/index.js +5 -7
  5. package/dist/auth/index.js.map +1 -1
  6. package/dist/auth/initializeProviders.js +2 -2
  7. package/dist/auth/initializeProviders.js.map +1 -1
  8. package/dist/config.d.ts +51 -0
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/config.js +7 -0
  11. package/dist/config.js.map +1 -1
  12. package/dist/context.d.ts.map +1 -1
  13. package/dist/context.js +3 -3
  14. package/dist/context.js.map +1 -1
  15. package/dist/generated/generated.d.ts +46 -46
  16. package/dist/generated/generated.d.ts.map +1 -1
  17. package/dist/generated/generated.js +332 -332
  18. package/dist/generated/generated.js.map +1 -1
  19. package/dist/index.d.ts +4 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +327 -2
  22. package/dist/index.js.map +1 -1
  23. package/dist/resolvers/DatasetResolver.d.ts +5 -0
  24. package/dist/resolvers/DatasetResolver.d.ts.map +1 -1
  25. package/dist/resolvers/DatasetResolver.js +35 -0
  26. package/dist/resolvers/DatasetResolver.js.map +1 -1
  27. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +484 -0
  28. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  29. package/dist/resolvers/IntegrationDiscoveryResolver.js +3867 -328
  30. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  31. package/dist/resolvers/RSUResolver.d.ts +89 -0
  32. package/dist/resolvers/RSUResolver.d.ts.map +1 -0
  33. package/dist/resolvers/RSUResolver.js +424 -0
  34. package/dist/resolvers/RSUResolver.js.map +1 -0
  35. package/package.json +63 -60
  36. package/src/__tests__/unifiedAuth.test.ts +3 -2
  37. package/src/auth/__tests__/backward-compatibility.test.ts +2 -3
  38. package/src/auth/index.ts +5 -8
  39. package/src/auth/initializeProviders.ts +2 -2
  40. package/src/config.ts +9 -0
  41. package/src/context.ts +3 -3
  42. package/src/generated/generated.ts +269 -269
  43. package/src/index.ts +371 -4
  44. package/src/resolvers/DatasetResolver.ts +36 -0
  45. package/src/resolvers/IntegrationDiscoveryResolver.ts +2970 -39
  46. package/src/resolvers/RSUResolver.ts +351 -0
  47. package/dist/auth/AuthProviderFactory.d.ts +0 -68
  48. package/dist/auth/AuthProviderFactory.d.ts.map +0 -1
  49. package/dist/auth/AuthProviderFactory.js +0 -155
  50. package/dist/auth/AuthProviderFactory.js.map +0 -1
  51. package/dist/auth/BaseAuthProvider.d.ts +0 -41
  52. package/dist/auth/BaseAuthProvider.d.ts.map +0 -1
  53. package/dist/auth/BaseAuthProvider.js +0 -102
  54. package/dist/auth/BaseAuthProvider.js.map +0 -1
  55. package/dist/auth/IAuthProvider.d.ts +0 -46
  56. package/dist/auth/IAuthProvider.d.ts.map +0 -1
  57. package/dist/auth/IAuthProvider.js +0 -2
  58. package/dist/auth/IAuthProvider.js.map +0 -1
  59. package/dist/auth/providers/Auth0Provider.d.ts +0 -18
  60. package/dist/auth/providers/Auth0Provider.d.ts.map +0 -1
  61. package/dist/auth/providers/Auth0Provider.js +0 -52
  62. package/dist/auth/providers/Auth0Provider.js.map +0 -1
  63. package/dist/auth/providers/CognitoProvider.d.ts +0 -18
  64. package/dist/auth/providers/CognitoProvider.d.ts.map +0 -1
  65. package/dist/auth/providers/CognitoProvider.js +0 -56
  66. package/dist/auth/providers/CognitoProvider.js.map +0 -1
  67. package/dist/auth/providers/GoogleProvider.d.ts +0 -18
  68. package/dist/auth/providers/GoogleProvider.d.ts.map +0 -1
  69. package/dist/auth/providers/GoogleProvider.js +0 -51
  70. package/dist/auth/providers/GoogleProvider.js.map +0 -1
  71. package/dist/auth/providers/MSALProvider.d.ts +0 -18
  72. package/dist/auth/providers/MSALProvider.d.ts.map +0 -1
  73. package/dist/auth/providers/MSALProvider.js +0 -52
  74. package/dist/auth/providers/MSALProvider.js.map +0 -1
  75. package/dist/auth/providers/OktaProvider.d.ts +0 -18
  76. package/dist/auth/providers/OktaProvider.d.ts.map +0 -1
  77. package/dist/auth/providers/OktaProvider.js +0 -52
  78. package/dist/auth/providers/OktaProvider.js.map +0 -1
  79. package/dist/auth/tokenExpiredError.d.ts +0 -5
  80. package/dist/auth/tokenExpiredError.d.ts.map +0 -1
  81. package/dist/auth/tokenExpiredError.js +0 -12
  82. package/dist/auth/tokenExpiredError.js.map +0 -1
  83. package/src/auth/AuthProviderFactory.ts +0 -182
  84. package/src/auth/BaseAuthProvider.ts +0 -137
  85. package/src/auth/IAuthProvider.ts +0 -54
  86. package/src/auth/providers/Auth0Provider.ts +0 -45
  87. package/src/auth/providers/CognitoProvider.ts +0 -50
  88. package/src/auth/providers/GoogleProvider.ts +0 -45
  89. package/src/auth/providers/MSALProvider.ts +0 -45
  90. package/src/auth/providers/OktaProvider.ts +0 -46
  91. package/src/auth/tokenExpiredError.ts +0 -12
@@ -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 { MJCompanyIntegrationEntity, MJIntegrationEntity } from "@memberjunction/core-entities";
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
- const error = e as Error;
309
- LogError(`IntegrationTestConnection error: ${error}`);
819
+ LogError(`IntegrationTestConnection error: ${this.formatError(e)}`);
310
820
  return {
311
821
  Success: false,
312
- Message: `Error: ${error.message}`
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
- const error = e as Error;
356
- LogError(`IntegrationGetDefaultConfig error: ${error}`);
865
+ LogError(`IntegrationGetDefaultConfig error: ${this.formatError(e)}`);
357
866
  return {
358
867
  Success: false,
359
- Message: `Error: ${error.message}`
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
- // Filter to only requested objects
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
- // Build target configs from user input + source schema
391
- const targetConfigs = this.buildTargetConfigs(objects, filteredSchema, platform as 'sqlserver' | 'postgresql');
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: platform as 'sqlserver' | 'postgresql',
398
- MJVersion: '5.7.0',
907
+ Platform: validatedPlatform,
908
+ MJVersion: process.env.MJ_VERSION ?? '5.11.0',
399
909
  SourceType: companyIntegration.Integration,
400
- AdditionalSchemaInfoPath: 'additionalSchemaInfo.json',
401
- MigrationsDir: 'migrations/v2',
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
- const error = e as Error;
436
- LogError(`IntegrationSchemaPreview error: ${error}`);
945
+ LogError(`IntegrationSchemaPreview error: ${this.formatError(e)}`);
437
946
  return {
438
947
  Success: false,
439
- Message: `Error: ${error.message}`
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} preview records`,
473
- Records: result.Records.map(r => ({
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
- const error = e as Error;
479
- LogError(`IntegrationPreviewData error: ${error}`);
988
+ LogError(`IntegrationPreviewData error: ${this.formatError(e)}`);
480
989
  return {
481
990
  Success: false,
482
- Message: `Error: ${error.message}`
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 columns: TargetColumnConfig[] = (sourceObj?.Fields ?? []).map(f => ({
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 its own copy of core-entities types cast through unknown
569
- const connector = ConnectorFactory.Resolve(
570
- integration as unknown as Parameters<typeof ConnectorFactory.Resolve>[0]
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
- const error = e as Error;
578
- LogError(`Integration discovery error: ${error}`);
1226
+ LogError(`Integration discovery error: ${this.formatError(e)}`);
579
1227
  return {
580
1228
  Success: false,
581
- Message: `Error: ${error.message}`
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
  }