@memberjunction/server 3.2.0 → 3.3.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 (53) hide show
  1. package/README.md +47 -1
  2. package/dist/auth/APIKeyScopeAuth.d.ts +51 -0
  3. package/dist/auth/APIKeyScopeAuth.d.ts.map +1 -0
  4. package/dist/auth/APIKeyScopeAuth.js +163 -0
  5. package/dist/auth/APIKeyScopeAuth.js.map +1 -0
  6. package/dist/auth/index.d.ts +1 -0
  7. package/dist/auth/index.d.ts.map +1 -1
  8. package/dist/auth/index.js +1 -0
  9. package/dist/auth/index.js.map +1 -1
  10. package/dist/context.d.ts +8 -1
  11. package/dist/context.d.ts.map +1 -1
  12. package/dist/context.js +44 -7
  13. package/dist/context.js.map +1 -1
  14. package/dist/generated/generated.d.ts +252 -2
  15. package/dist/generated/generated.d.ts.map +1 -1
  16. package/dist/generated/generated.js +1754 -209
  17. package/dist/generated/generated.js.map +1 -1
  18. package/dist/generic/ResolverBase.d.ts +2 -2
  19. package/dist/generic/ResolverBase.d.ts.map +1 -1
  20. package/dist/generic/ResolverBase.js +22 -4
  21. package/dist/generic/ResolverBase.js.map +1 -1
  22. package/dist/generic/RunViewResolver.d.ts +29 -1
  23. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  24. package/dist/generic/RunViewResolver.js +143 -0
  25. package/dist/generic/RunViewResolver.js.map +1 -1
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +2 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/resolvers/APIKeyResolver.d.ts +23 -0
  31. package/dist/resolvers/APIKeyResolver.d.ts.map +1 -0
  32. package/dist/resolvers/APIKeyResolver.js +191 -0
  33. package/dist/resolvers/APIKeyResolver.js.map +1 -0
  34. package/dist/resolvers/RunAIAgentResolver.js +1 -1
  35. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  36. package/dist/resolvers/UserResolver.d.ts.map +1 -1
  37. package/dist/resolvers/UserResolver.js +31 -1
  38. package/dist/resolvers/UserResolver.js.map +1 -1
  39. package/dist/types.d.ts +4 -1
  40. package/dist/types.d.ts.map +1 -1
  41. package/dist/types.js.map +1 -1
  42. package/package.json +46 -45
  43. package/src/auth/APIKeyScopeAuth.ts +366 -0
  44. package/src/auth/index.ts +1 -0
  45. package/src/context.ts +91 -9
  46. package/src/generated/generated.ts +987 -14
  47. package/src/generic/ResolverBase.ts +38 -5
  48. package/src/generic/RunViewResolver.ts +132 -5
  49. package/src/index.ts +2 -0
  50. package/src/resolvers/APIKeyResolver.ts +234 -0
  51. package/src/resolvers/RunAIAgentResolver.ts +1 -1
  52. package/src/resolvers/UserResolver.ts +37 -1
  53. package/src/types.ts +7 -2
@@ -1,4 +1,5 @@
1
1
  import {
2
+ AggregateExpression,
2
3
  BaseEntity,
3
4
  BaseEntityEvent,
4
5
  CompositeKey,
@@ -266,6 +267,11 @@ export class ResolverBase {
266
267
 
267
268
  async RunViewByNameGeneric(viewInput: RunViewByNameInput, provider: DatabaseProviderBase, userPayload: UserPayload, pubSub: PubSubEngine) {
268
269
  try {
270
+ // Log aggregate input for debugging
271
+ if (viewInput.Aggregates?.length) {
272
+ LogStatus(`[ResolverBase] RunViewByNameGeneric received aggregates: viewName=${viewInput.ViewName}, aggregateCount=${viewInput.Aggregates.length}, aggregates=${JSON.stringify(viewInput.Aggregates.map(a => ({ expression: a.expression, alias: a.alias })))}`);
273
+ }
274
+
269
275
  const rv = provider as any as IRunViewProvider;
270
276
  const result = await rv.RunView<UserViewEntityExtended>({
271
277
  EntityName: 'User Views',
@@ -290,7 +296,8 @@ export class ResolverBase {
290
296
  viewInput.ResultType,
291
297
  userPayload,
292
298
  viewInput.MaxRows,
293
- viewInput.StartRow
299
+ viewInput.StartRow,
300
+ viewInput.Aggregates
294
301
  );
295
302
  }
296
303
  else {
@@ -305,6 +312,11 @@ export class ResolverBase {
305
312
 
306
313
  async RunViewByIDGeneric(viewInput: RunViewByIDInput, provider: DatabaseProviderBase, userPayload: UserPayload, pubSub: PubSubEngine) {
307
314
  try {
315
+ // Log aggregate input for debugging
316
+ if (viewInput.Aggregates?.length) {
317
+ LogStatus(`[ResolverBase] RunViewByIDGeneric received aggregates: viewID=${viewInput.ViewID}, aggregateCount=${viewInput.Aggregates.length}, aggregates=${JSON.stringify(viewInput.Aggregates.map(a => ({ expression: a.expression, alias: a.alias })))}`);
318
+ }
319
+
308
320
  const contextUser = this.GetUserFromPayload(userPayload);
309
321
  const viewInfo = await provider.GetEntityObject<UserViewEntityExtended>('User Views', contextUser);
310
322
  await viewInfo.Load(viewInput.ViewID);
@@ -325,7 +337,8 @@ export class ResolverBase {
325
337
  viewInput.ResultType,
326
338
  userPayload,
327
339
  viewInput.MaxRows,
328
- viewInput.StartRow
340
+ viewInput.StartRow,
341
+ viewInput.Aggregates
329
342
  );
330
343
  } catch (err) {
331
344
  console.log(err);
@@ -335,6 +348,11 @@ export class ResolverBase {
335
348
 
336
349
  async RunDynamicViewGeneric(viewInput: RunDynamicViewInput, provider: DatabaseProviderBase, userPayload: UserPayload, pubSub: PubSubEngine) {
337
350
  try {
351
+ // Log aggregate input for debugging
352
+ if (viewInput.Aggregates?.length) {
353
+ LogStatus(`[ResolverBase] RunDynamicViewGeneric received aggregates: entityName=${viewInput.EntityName}, aggregateCount=${viewInput.Aggregates.length}, aggregates=${JSON.stringify(viewInput.Aggregates.map(a => ({ expression: a.expression, alias: a.alias })))}`);
354
+ }
355
+
338
356
  const md = provider;
339
357
  const entity = md.Entities.find((e) => e.Name === viewInput.EntityName);
340
358
  if (!entity) throw new Error(`Entity ${viewInput.EntityName} not found in metadata`);
@@ -363,7 +381,8 @@ export class ResolverBase {
363
381
  viewInput.ResultType,
364
382
  userPayload,
365
383
  viewInput.MaxRows,
366
- viewInput.StartRow
384
+ viewInput.StartRow,
385
+ viewInput.Aggregates
367
386
  );
368
387
  } catch (err) {
369
388
  console.log(err);
@@ -422,7 +441,8 @@ export class ResolverBase {
422
441
  forceAuditLog: viewInput.ForceAuditLog,
423
442
  auditLogDescription: viewInput.AuditLogDescription,
424
443
  resultType: viewInput.ResultType,
425
- userPayload,
444
+ userPayload,
445
+ aggregates: viewInput.Aggregates,
426
446
  });
427
447
  } catch (err) {
428
448
  LogError(err);
@@ -524,7 +544,8 @@ export class ResolverBase {
524
544
  resultType: string | undefined,
525
545
  userPayload: UserPayload | null,
526
546
  maxRows: number | undefined,
527
- startRow: number | undefined
547
+ startRow: number | undefined,
548
+ aggregates?: AggregateExpression[]
528
549
  ) {
529
550
  try {
530
551
  if (!viewInfo || !userPayload) return null;
@@ -559,6 +580,11 @@ export class ResolverBase {
559
580
  }
560
581
  }
561
582
 
583
+ // Log aggregate request for debugging
584
+ if (aggregates?.length) {
585
+ LogStatus(`[ResolverBase] RunViewGenericInternal with aggregates: entityName=${viewInfo.Entity}, viewName=${viewInfo.Name}, aggregateCount=${aggregates.length}, aggregates=${JSON.stringify(aggregates.map(a => ({ expression: a.expression, alias: a.alias })))}`);
586
+ }
587
+
562
588
  const result = await rv.RunView(
563
589
  {
564
590
  ViewID: viewInfo.ID,
@@ -578,10 +604,16 @@ export class ResolverBase {
578
604
  ForceAuditLog: forceAuditLog,
579
605
  AuditLogDescription: auditLogDescription,
580
606
  ResultType: rt,
607
+ Aggregates: aggregates,
581
608
  },
582
609
  user
583
610
  );
584
611
 
612
+ // Log aggregate results for debugging
613
+ if (aggregates?.length) {
614
+ LogStatus(`[ResolverBase] RunView result aggregate info: entityName=${viewInfo.Entity}, hasAggregateResults=${!!result?.AggregateResults}, aggregateResultCount=${result?.AggregateResults?.length || 0}, aggregateExecutionTime=${result?.AggregateExecutionTime}, aggregateResults=${JSON.stringify(result?.AggregateResults)}`);
615
+ }
616
+
585
617
  // Process results for GraphQL transport
586
618
  const mapper = new FieldMapper();
587
619
  if (result?.Success && result.Results?.length) {
@@ -682,6 +714,7 @@ export class ResolverBase {
682
714
  ForceAuditLog: param.forceAuditLog,
683
715
  AuditLogDescription: param.auditLogDescription,
684
716
  ResultType: rt,
717
+ Aggregates: param.aggregates,
685
718
  });
686
719
  }
687
720
 
@@ -1,7 +1,7 @@
1
1
  import { Arg, Ctx, Field, InputType, Int, ObjectType, PubSubEngine, Query, Resolver } from 'type-graphql';
2
2
  import { AppContext } from '../types.js';
3
3
  import { ResolverBase } from './ResolverBase.js';
4
- import { LogError, LogStatus, EntityInfo, RunViewWithCacheCheckResult, RunViewsWithCacheCheckResponse, RunViewWithCacheCheckParams } from '@memberjunction/core';
4
+ import { LogError, LogStatus, EntityInfo, RunViewWithCacheCheckResult, RunViewsWithCacheCheckResponse, RunViewWithCacheCheckParams, AggregateResult } from '@memberjunction/core';
5
5
  import { RequireSystemUser } from '../directives/RequireSystemUser.js';
6
6
  import { GetReadOnlyProvider } from '../util.js';
7
7
  import { UserViewEntityExtended } from '@memberjunction/core-entities';
@@ -15,6 +15,52 @@ import { SQLServerDataProvider } from '@memberjunction/sqlserver-dataprovider';
15
15
  * back the results, and have your own type checking in place, this resolver can be used.
16
16
  *
17
17
  */
18
+
19
+ //****************************************************************************
20
+ // INPUT/OUTPUT TYPE for Aggregates
21
+ //****************************************************************************
22
+
23
+ /**
24
+ * Input type for a single aggregate expression
25
+ */
26
+ @InputType()
27
+ export class AggregateExpressionInput {
28
+ @Field(() => String, {
29
+ description: 'SQL expression for the aggregate (e.g., "SUM(OrderTotal)", "COUNT(*)", "AVG(Price)")'
30
+ })
31
+ expression: string;
32
+
33
+ @Field(() => String, {
34
+ nullable: true,
35
+ description: 'Optional alias for the result (used in error messages and debugging)'
36
+ })
37
+ alias?: string;
38
+ }
39
+
40
+ /**
41
+ * Output type for a single aggregate result
42
+ */
43
+ @ObjectType()
44
+ export class AggregateResultOutput {
45
+ @Field(() => String, { description: 'The expression that was calculated' })
46
+ expression: string;
47
+
48
+ @Field(() => String, { description: 'The alias (or expression if no alias provided)' })
49
+ alias: string;
50
+
51
+ @Field(() => String, {
52
+ nullable: true,
53
+ description: 'The calculated value as a JSON string (preserves type information)'
54
+ })
55
+ value?: string;
56
+
57
+ @Field(() => String, {
58
+ nullable: true,
59
+ description: 'Error message if calculation failed'
60
+ })
61
+ error?: string;
62
+ }
63
+
18
64
  //****************************************************************************
19
65
  // INPUT TYPE for Running Views
20
66
  //****************************************************************************
@@ -111,6 +157,12 @@ export class RunViewByIDInput {
111
157
  description: 'If a value > 0 is provided, this value will be used to offset the rows returned.',
112
158
  })
113
159
  StartRow?: number;
160
+
161
+ @Field(() => [AggregateExpressionInput], {
162
+ nullable: true,
163
+ description: 'Optional aggregate expressions to calculate on the full result set (e.g., SUM, COUNT, AVG). Results are returned in AggregateResults.',
164
+ })
165
+ Aggregates?: AggregateExpressionInput[];
114
166
  }
115
167
 
116
168
  @InputType()
@@ -206,6 +258,12 @@ export class RunViewByNameInput {
206
258
  description: 'If a value > 0 is provided, this value will be used to offset the rows returned.',
207
259
  })
208
260
  StartRow?: number;
261
+
262
+ @Field(() => [AggregateExpressionInput], {
263
+ nullable: true,
264
+ description: 'Optional aggregate expressions to calculate on the full result set (e.g., SUM, COUNT, AVG). Results are returned in AggregateResults.',
265
+ })
266
+ Aggregates?: AggregateExpressionInput[];
209
267
  }
210
268
 
211
269
  @InputType()
@@ -287,6 +345,12 @@ export class RunDynamicViewInput {
287
345
  description: 'If a value > 0 is provided, this value will be used to offset the rows returned.',
288
346
  })
289
347
  StartRow?: number;
348
+
349
+ @Field(() => [AggregateExpressionInput], {
350
+ nullable: true,
351
+ description: 'Optional aggregate expressions to calculate on the full result set (e.g., SUM, COUNT, AVG). Results are returned in AggregateResults.',
352
+ })
353
+ Aggregates?: AggregateExpressionInput[];
290
354
  }
291
355
 
292
356
  @InputType()
@@ -382,6 +446,12 @@ export class RunViewGenericInput {
382
446
  description: 'If a value > 0 is provided, this value will be used to offset the rows returned.',
383
447
  })
384
448
  StartRow?: number;
449
+
450
+ @Field(() => [AggregateExpressionInput], {
451
+ nullable: true,
452
+ description: 'Optional aggregate expressions to calculate on the full result set (e.g., SUM, COUNT, AVG). Results are returned in AggregateResults.',
453
+ })
454
+ Aggregates?: AggregateExpressionInput[];
385
455
  }
386
456
 
387
457
  //****************************************************************************
@@ -518,6 +588,18 @@ export class RunViewResult {
518
588
 
519
589
  @Field(() => Boolean, { nullable: false })
520
590
  Success: boolean;
591
+
592
+ @Field(() => [AggregateResultOutput], {
593
+ nullable: true,
594
+ description: 'Results of aggregate calculations, in same order as input Aggregates array. Only present if Aggregates were requested.'
595
+ })
596
+ AggregateResults?: AggregateResultOutput[];
597
+
598
+ @Field(() => Int, {
599
+ nullable: true,
600
+ description: 'Execution time for aggregate query specifically (in milliseconds). Only present if Aggregates were requested.'
601
+ })
602
+ AggregateExecutionTime?: number;
521
603
  }
522
604
 
523
605
  @ObjectType()
@@ -542,6 +624,18 @@ export class RunViewGenericResult {
542
624
 
543
625
  @Field(() => Boolean, { nullable: false })
544
626
  Success: boolean;
627
+
628
+ @Field(() => [AggregateResultOutput], {
629
+ nullable: true,
630
+ description: 'Results of aggregate calculations, in same order as input Aggregates array. Only present if Aggregates were requested.'
631
+ })
632
+ AggregateResults?: AggregateResultOutput[];
633
+
634
+ @Field(() => Int, {
635
+ nullable: true,
636
+ description: 'Execution time for aggregate query specifically (in milliseconds). Only present if Aggregates were requested.'
637
+ })
638
+ AggregateExecutionTime?: number;
545
639
  }
546
640
 
547
641
  @Resolver(RunViewResultRow)
@@ -567,6 +661,9 @@ export class RunViewResolver extends ResolverBase {
567
661
  RowCount: rawData?.RowCount,
568
662
  TotalRowCount: rawData?.TotalRowCount,
569
663
  ExecutionTime: rawData?.ExecutionTime,
664
+ Success: rawData?.Success,
665
+ AggregateResults: this.processAggregateResults(rawData?.AggregateResults),
666
+ AggregateExecutionTime: rawData?.AggregateExecutionTime,
570
667
  };
571
668
  } catch (err) {
572
669
  console.log(err);
@@ -595,6 +692,9 @@ export class RunViewResolver extends ResolverBase {
595
692
  RowCount: rawData?.RowCount,
596
693
  TotalRowCount: rawData?.TotalRowCount,
597
694
  ExecutionTime: rawData?.ExecutionTime,
695
+ Success: rawData?.Success,
696
+ AggregateResults: this.processAggregateResults(rawData?.AggregateResults),
697
+ AggregateExecutionTime: rawData?.AggregateExecutionTime,
598
698
  };
599
699
  } catch (err) {
600
700
  console.log(err);
@@ -621,6 +721,9 @@ export class RunViewResolver extends ResolverBase {
621
721
  RowCount: rawData?.RowCount,
622
722
  TotalRowCount: rawData?.TotalRowCount,
623
723
  ExecutionTime: rawData?.ExecutionTime,
724
+ Success: rawData?.Success,
725
+ AggregateResults: this.processAggregateResults(rawData?.AggregateResults),
726
+ AggregateExecutionTime: rawData?.AggregateExecutionTime,
624
727
  };
625
728
  } catch (err) {
626
729
  console.log(err);
@@ -636,7 +739,8 @@ export class RunViewResolver extends ResolverBase {
636
739
  ) {
637
740
  try {
638
741
  const provider = GetReadOnlyProvider(providers, { allowFallbackToReadWrite: true });
639
- const rawData: RunViewGenericResult[] = await super.RunViewsGeneric(input, provider, userPayload);
742
+ // Note: RunViewsGeneric returns the core RunViewResult type, not the GraphQL type
743
+ const rawData = await super.RunViewsGeneric(input, provider, userPayload);
640
744
  if (!rawData) {
641
745
  return null;
642
746
  }
@@ -653,6 +757,8 @@ export class RunViewResolver extends ResolverBase {
653
757
  TotalRowCount: data?.TotalRowCount,
654
758
  ExecutionTime: data?.ExecutionTime,
655
759
  Success: data?.Success,
760
+ AggregateResults: this.processAggregateResults(data?.AggregateResults),
761
+ AggregateExecutionTime: data?.AggregateExecutionTime,
656
762
  });
657
763
  }
658
764
 
@@ -824,7 +930,7 @@ export class RunViewResolver extends ResolverBase {
824
930
  ) {
825
931
  try {
826
932
  const provider = GetReadOnlyProvider(providers, { allowFallbackToReadWrite: true });
827
- const rawData: RunViewGenericResult[] = await super.RunViewsGeneric(input, provider, userPayload);
933
+ const rawData = await super.RunViewsGeneric(input, provider, userPayload);
828
934
  if (!rawData) {
829
935
  return null;
830
936
  }
@@ -846,6 +952,8 @@ export class RunViewResolver extends ResolverBase {
846
952
  ExecutionTime: data?.ExecutionTime,
847
953
  Success: data?.Success,
848
954
  ErrorMessage: data?.ErrorMessage,
955
+ AggregateResults: this.processAggregateResults(data?.AggregateResults),
956
+ AggregateExecutionTime: data?.AggregateExecutionTime,
849
957
  });
850
958
  }
851
959
 
@@ -979,13 +1087,13 @@ export class RunViewResolver extends ResolverBase {
979
1087
  const returnResult = [];
980
1088
  for (let i = 0; i < rawData.length; i++) {
981
1089
  const row = rawData[i];
982
-
1090
+
983
1091
  // Build the primary key array from the entity's primary key fields
984
1092
  const primaryKey: KeyValuePairOutputType[] = entityInfo.PrimaryKeys.map(pk => ({
985
1093
  FieldName: pk.Name,
986
1094
  Value: row[pk.Name]?.toString() || ''
987
1095
  }));
988
-
1096
+
989
1097
  returnResult.push({
990
1098
  PrimaryKey: primaryKey,
991
1099
  EntityID: entityId,
@@ -994,4 +1102,23 @@ export class RunViewResolver extends ResolverBase {
994
1102
  }
995
1103
  return returnResult;
996
1104
  }
1105
+
1106
+ /**
1107
+ * Transform core AggregateResult[] to GraphQL AggregateResultOutput[].
1108
+ * Converts the value to a JSON string to preserve type information across GraphQL.
1109
+ */
1110
+ protected processAggregateResults(aggregateResults: AggregateResult[] | undefined): AggregateResultOutput[] | undefined {
1111
+ if (!aggregateResults || aggregateResults.length === 0) {
1112
+ return undefined;
1113
+ }
1114
+
1115
+ return aggregateResults.map(result => ({
1116
+ expression: result.expression,
1117
+ alias: result.alias,
1118
+ value: result.value !== null && result.value !== undefined
1119
+ ? JSON.stringify(result.value)
1120
+ : undefined,
1121
+ error: result.error
1122
+ }));
1123
+ }
997
1124
  }
package/src/index.ts CHANGED
@@ -82,6 +82,7 @@ export * from './directives/index.js';
82
82
  export * from './entitySubclasses/entityPermissions.server.js';
83
83
  export * from './types.js';
84
84
  export { TokenExpiredError, getSystemUser } from './auth/index.js';
85
+ export * from './auth/APIKeyScopeAuth.js';
85
86
 
86
87
  export * from './generic/PushStatusResolver.js';
87
88
  export * from './generic/ResolverBase.js';
@@ -113,6 +114,7 @@ export * from './resolvers/GetDataContextDataResolver.js';
113
114
  export * from './resolvers/TransactionGroupResolver.js';
114
115
  export * from './resolvers/CreateQueryResolver.js';
115
116
  export * from './resolvers/TelemetryResolver.js';
117
+ export * from './resolvers/APIKeyResolver.js';
116
118
  export { GetReadOnlyDataSource, GetReadWriteDataSource, GetReadWriteProvider, GetReadOnlyProvider } from './util.js';
117
119
 
118
120
  export * from './generated/generated.js';
@@ -0,0 +1,234 @@
1
+ import { Resolver, Mutation, Arg, Ctx } from "type-graphql";
2
+ import { Field, InputType, ObjectType } from "type-graphql";
3
+ import { LogError, Metadata } from "@memberjunction/core";
4
+ import { APIKeyScopeEntity } from "@memberjunction/core-entities";
5
+ import { GetAPIKeyEngine } from "@memberjunction/api-keys";
6
+ import { AppContext } from "../types.js";
7
+
8
+ /**
9
+ * Input type for creating a new API key
10
+ */
11
+ @InputType()
12
+ export class CreateAPIKeyInput {
13
+ /**
14
+ * Human-readable label for the API key
15
+ */
16
+ @Field()
17
+ Label: string;
18
+
19
+ /**
20
+ * Optional description of what the key is used for
21
+ */
22
+ @Field(() => String, { nullable: true })
23
+ Description?: string;
24
+
25
+ /**
26
+ * Optional expiration date for the key
27
+ */
28
+ @Field(() => Date, { nullable: true })
29
+ ExpiresAt?: Date;
30
+
31
+ /**
32
+ * Optional array of scope IDs to assign to this key
33
+ */
34
+ @Field(() => [String], { nullable: true })
35
+ ScopeIDs?: string[];
36
+ }
37
+
38
+ /**
39
+ * Result type for API key creation
40
+ * Returns the raw key ONCE - it cannot be recovered after this
41
+ */
42
+ @ObjectType()
43
+ export class CreateAPIKeyResult {
44
+ /**
45
+ * Whether the key was created successfully
46
+ */
47
+ @Field()
48
+ Success: boolean;
49
+
50
+ /**
51
+ * The raw API key - show this to the user ONCE
52
+ * This cannot be recovered after the initial response
53
+ */
54
+ @Field(() => String, { nullable: true })
55
+ RawKey?: string;
56
+
57
+ /**
58
+ * The database ID of the created API key
59
+ */
60
+ @Field(() => String, { nullable: true })
61
+ APIKeyID?: string;
62
+
63
+ /**
64
+ * Error message if creation failed
65
+ */
66
+ @Field(() => String, { nullable: true })
67
+ Error?: string;
68
+ }
69
+
70
+ /**
71
+ * Result type for API key revocation
72
+ */
73
+ @ObjectType()
74
+ export class RevokeAPIKeyResult {
75
+ /**
76
+ * Whether the key was revoked successfully
77
+ */
78
+ @Field()
79
+ Success: boolean;
80
+
81
+ /**
82
+ * Error message if revocation failed
83
+ */
84
+ @Field(() => String, { nullable: true })
85
+ Error?: string;
86
+ }
87
+
88
+ /**
89
+ * Resolver for API key operations
90
+ * Handles secure server-side API key generation
91
+ */
92
+ @Resolver()
93
+ export class APIKeyResolver {
94
+ /**
95
+ * Creates a new API key with proper server-side cryptographic hashing.
96
+ *
97
+ * This mutation:
98
+ * 1. Generates a cryptographically secure API key using APIKeyEngine
99
+ * 2. Stores only the SHA-256 hash in the database (never the raw key)
100
+ * 3. Returns the raw key ONCE - it cannot be recovered after this call
101
+ * 4. Optionally assigns scope permissions to the key
102
+ *
103
+ * @param input The creation parameters
104
+ * @param ctx The GraphQL context with authenticated user
105
+ * @returns The raw key (show once!) and database ID
106
+ */
107
+ @Mutation(() => CreateAPIKeyResult)
108
+ async CreateAPIKey(
109
+ @Arg("input") input: CreateAPIKeyInput,
110
+ @Ctx() ctx: AppContext
111
+ ): Promise<CreateAPIKeyResult> {
112
+ try {
113
+ // Get the authenticated user
114
+ const user = ctx.userPayload.userRecord;
115
+ if (!user) {
116
+ return {
117
+ Success: false,
118
+ Error: "User is not authenticated"
119
+ };
120
+ }
121
+
122
+ // Use APIKeyEngine to create the API key with proper server-side crypto
123
+ const apiKeyEngine = GetAPIKeyEngine();
124
+ const result = await apiKeyEngine.CreateAPIKey(
125
+ {
126
+ UserId: user.ID,
127
+ Label: input.Label,
128
+ Description: input.Description,
129
+ ExpiresAt: input.ExpiresAt
130
+ },
131
+ user
132
+ );
133
+
134
+ if (!result.Success) {
135
+ return {
136
+ Success: false,
137
+ Error: result.Error || "Failed to create API key"
138
+ };
139
+ }
140
+
141
+ // Save scope associations if provided
142
+ if (input.ScopeIDs && input.ScopeIDs.length > 0 && result.APIKeyId) {
143
+ await this.saveScopeAssociations(result.APIKeyId, input.ScopeIDs, user);
144
+ }
145
+
146
+ return {
147
+ Success: true,
148
+ RawKey: result.RawKey,
149
+ APIKeyID: result.APIKeyId
150
+ };
151
+ } catch (e) {
152
+ const error = e as Error;
153
+ LogError(`Error in CreateAPIKey resolver: ${error.message}`);
154
+ return {
155
+ Success: false,
156
+ Error: `Error creating API key: ${error.message}`
157
+ };
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Revokes an API key, permanently disabling it.
163
+ *
164
+ * Once revoked, an API key cannot be reactivated. Users must create a new key.
165
+ * This uses APIKeyEngine.RevokeAPIKey() for consistency.
166
+ *
167
+ * @param apiKeyId The database ID of the API key to revoke
168
+ * @param ctx The GraphQL context with authenticated user
169
+ * @returns Success status
170
+ */
171
+ @Mutation(() => RevokeAPIKeyResult)
172
+ async RevokeAPIKey(
173
+ @Arg("apiKeyId") apiKeyId: string,
174
+ @Ctx() ctx: AppContext
175
+ ): Promise<RevokeAPIKeyResult> {
176
+ try {
177
+ const user = ctx.userPayload.userRecord;
178
+ if (!user) {
179
+ return {
180
+ Success: false,
181
+ Error: "User is not authenticated"
182
+ };
183
+ }
184
+
185
+ const apiKeyEngine = GetAPIKeyEngine();
186
+ const result = await apiKeyEngine.RevokeAPIKey(apiKeyId, user);
187
+
188
+ if (result) {
189
+ return { Success: true };
190
+ } else {
191
+ return {
192
+ Success: false,
193
+ Error: "Failed to revoke API key. It may not exist or you may not have permission."
194
+ };
195
+ }
196
+ } catch (e) {
197
+ const error = e as Error;
198
+ LogError(`Error in RevokeAPIKey resolver: ${error.message}`);
199
+ return {
200
+ Success: false,
201
+ Error: `Error revoking API key: ${error.message}`
202
+ };
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Saves scope associations for the newly created API key
208
+ * @param apiKeyId The ID of the created API key
209
+ * @param scopeIds Array of scope IDs to associate
210
+ * @param user The context user
211
+ */
212
+ private async saveScopeAssociations(
213
+ apiKeyId: string,
214
+ scopeIds: string[],
215
+ user: any
216
+ ): Promise<void> {
217
+ const md = new Metadata();
218
+
219
+ for (const scopeId of scopeIds) {
220
+ try {
221
+ const keyScope = await md.GetEntityObject<APIKeyScopeEntity>(
222
+ 'MJ: API Key Scopes',
223
+ user
224
+ );
225
+ keyScope.APIKeyID = apiKeyId;
226
+ keyScope.ScopeID = scopeId;
227
+ await keyScope.Save();
228
+ } catch (error) {
229
+ LogError(`Error saving scope association for API key ${apiKeyId}, scope ${scopeId}: ${error}`);
230
+ // Continue with other scopes even if one fails
231
+ }
232
+ }
233
+ }
234
+ }
@@ -896,7 +896,7 @@ export class RunAIAgentResolver extends ResolverBase {
896
896
  private mapDetailRoleToMessageRole(role: string): 'user' | 'assistant' | 'system' {
897
897
  const roleLower = (role || '').toLowerCase();
898
898
  if (roleLower === 'user') return 'user';
899
- if (roleLower === 'assistant' || roleLower === 'agent') return 'assistant';
899
+ if (roleLower === 'assistant' || roleLower === 'agent' || roleLower === 'ai') return 'assistant';
900
900
  if (roleLower === 'system') return 'system';
901
901
  return 'user'; // Default to user
902
902
  }
@@ -6,8 +6,44 @@ import { GetReadOnlyProvider } from '../util.js';
6
6
  export class UserResolver extends MJUserResolverBase {
7
7
  @Query(() => MJUser_)
8
8
  async CurrentUser(@Ctx() context: AppContext) {
9
+ // If userRecord is already available (e.g., from API key auth or system auth),
10
+ // use it directly instead of looking up by email again
11
+ if (context.userPayload.userRecord) {
12
+ // Convert UserInfo to the GraphQL response format
13
+ const userRecord = context.userPayload.userRecord;
14
+ const userData = {
15
+ ID: userRecord.ID,
16
+ Name: userRecord.Name,
17
+ FirstName: userRecord.FirstName,
18
+ LastName: userRecord.LastName,
19
+ Title: userRecord.Title,
20
+ Email: userRecord.Email,
21
+ Type: userRecord.Type,
22
+ IsActive: userRecord.IsActive,
23
+ LinkedRecordType: userRecord.LinkedRecordType,
24
+ EmployeeID: userRecord.EmployeeID,
25
+ LinkedEntityID: userRecord.LinkedEntityID,
26
+ LinkedEntityRecordID: userRecord.LinkedEntityRecordID,
27
+ __mj_CreatedAt: userRecord.__mj_CreatedAt,
28
+ __mj_UpdatedAt: userRecord.__mj_UpdatedAt,
29
+ // Include the UserRoles sub-array for the GraphQL response
30
+ UserRoles_UserIDArray: userRecord.UserRoles?.map((r: { ID: string; UserID: string; RoleID: string; RoleName?: string; __mj_CreatedAt?: Date; __mj_UpdatedAt?: Date }) => ({
31
+ ID: r.ID,
32
+ UserID: r.UserID,
33
+ RoleID: r.RoleID,
34
+ User: userRecord.Name,
35
+ Role: r.RoleName,
36
+ __mj_CreatedAt: r.__mj_CreatedAt,
37
+ __mj_UpdatedAt: r.__mj_UpdatedAt,
38
+ })) || [],
39
+ };
40
+ console.log('CurrentUser (from userRecord)', userData.Email);
41
+ return this.MapFieldNamesToCodeNames('Users', userData);
42
+ }
43
+
44
+ // Fall back to email lookup for JWT-based auth
9
45
  const result = await this.UserByEmail(context.userPayload.email, context);
10
- console.log('CurrentUser', result);
46
+ console.log('CurrentUser (from email lookup)', result?.Email);
11
47
  return result;
12
48
  }
13
49