@memberjunction/server 5.43.0 → 5.44.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 (82) hide show
  1. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  2. package/dist/agentSessions/SessionManager.js +3 -0
  3. package/dist/agentSessions/SessionManager.js.map +1 -1
  4. package/dist/agentSessions/index.d.ts.map +1 -1
  5. package/dist/agentSessions/index.js +3 -1
  6. package/dist/agentSessions/index.js.map +1 -1
  7. package/dist/generated/generated.d.ts +1159 -51
  8. package/dist/generated/generated.d.ts.map +1 -1
  9. package/dist/generated/generated.js +35284 -29068
  10. package/dist/generated/generated.js.map +1 -1
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +9 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/resolvers/FileResolver.d.ts +74 -0
  16. package/dist/resolvers/FileResolver.d.ts.map +1 -1
  17. package/dist/resolvers/FileResolver.js +211 -2
  18. package/dist/resolvers/FileResolver.js.map +1 -1
  19. package/dist/resolvers/QueryResolver.d.ts +6 -4
  20. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  21. package/dist/resolvers/QueryResolver.js +29 -14
  22. package/dist/resolvers/QueryResolver.js.map +1 -1
  23. package/dist/resolvers/RealtimeBridgeResolver.d.ts +8 -1
  24. package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
  25. package/dist/resolvers/RealtimeBridgeResolver.js +28 -4
  26. package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
  27. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +81 -2
  28. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  29. package/dist/resolvers/RealtimeClientSessionResolver.js +293 -17
  30. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  31. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  32. package/dist/resolvers/RemoteBrowserActionResolver.js +17 -2
  33. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  34. package/dist/resolvers/RunAIAgentResolver.d.ts +12 -3
  35. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  36. package/dist/resolvers/RunAIAgentResolver.js +37 -15
  37. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  38. package/dist/resolvers/meetingRecordingRegistration.d.ts +124 -0
  39. package/dist/resolvers/meetingRecordingRegistration.d.ts.map +1 -0
  40. package/dist/resolvers/meetingRecordingRegistration.js +311 -0
  41. package/dist/resolvers/meetingRecordingRegistration.js.map +1 -0
  42. package/dist/resolvers/peaksSidecar.d.ts +30 -0
  43. package/dist/resolvers/peaksSidecar.d.ts.map +1 -0
  44. package/dist/resolvers/peaksSidecar.js +51 -0
  45. package/dist/resolvers/peaksSidecar.js.map +1 -0
  46. package/dist/rest/MediaAccessKeys.d.ts +68 -0
  47. package/dist/rest/MediaAccessKeys.d.ts.map +1 -0
  48. package/dist/rest/MediaAccessKeys.js +96 -0
  49. package/dist/rest/MediaAccessKeys.js.map +1 -0
  50. package/dist/rest/MediaStreamHandler.d.ts +26 -0
  51. package/dist/rest/MediaStreamHandler.d.ts.map +1 -0
  52. package/dist/rest/MediaStreamHandler.js +192 -0
  53. package/dist/rest/MediaStreamHandler.js.map +1 -0
  54. package/dist/rest/mediaRange.d.ts +41 -0
  55. package/dist/rest/mediaRange.d.ts.map +1 -0
  56. package/dist/rest/mediaRange.js +60 -0
  57. package/dist/rest/mediaRange.js.map +1 -0
  58. package/package.json +83 -82
  59. package/src/__tests__/FileResolverPeaks.test.ts +64 -0
  60. package/src/__tests__/MediaAccessKeys.test.ts +68 -0
  61. package/src/__tests__/MediaStreamHandler.test.ts +91 -0
  62. package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -1
  63. package/src/__tests__/RealtimeClientSessionResolver.test.ts +69 -0
  64. package/src/__tests__/RealtimeCoAgentAppAwareness.integration.test.ts +191 -0
  65. package/src/__tests__/RemoteBrowserSnapshot.test.ts +123 -0
  66. package/src/__tests__/SessionManager.test.ts +2 -0
  67. package/src/__tests__/meetingRecordingRegistration.test.ts +248 -0
  68. package/src/agentSessions/SessionManager.ts +3 -0
  69. package/src/agentSessions/index.ts +3 -1
  70. package/src/generated/generated.ts +17839 -13567
  71. package/src/index.ts +10 -0
  72. package/src/resolvers/FileResolver.ts +199 -1
  73. package/src/resolvers/QueryResolver.ts +33 -19
  74. package/src/resolvers/RealtimeBridgeResolver.ts +36 -4
  75. package/src/resolvers/RealtimeClientSessionResolver.ts +326 -9
  76. package/src/resolvers/RemoteBrowserActionResolver.ts +18 -2
  77. package/src/resolvers/RunAIAgentResolver.ts +44 -11
  78. package/src/resolvers/meetingRecordingRegistration.ts +432 -0
  79. package/src/resolvers/peaksSidecar.ts +52 -0
  80. package/src/rest/MediaAccessKeys.ts +121 -0
  81. package/src/rest/MediaStreamHandler.ts +220 -0
  82. package/src/rest/mediaRange.ts +76 -0
package/src/index.ts CHANGED
@@ -40,6 +40,7 @@ import createMSSQLConfig from './orm.js';
40
40
  import { setupRESTEndpoints } from './rest/setupRESTEndpoints.js';
41
41
  import { createOAuthCallbackHandler } from './rest/OAuthCallbackHandler.js';
42
42
  import { createSignatureWebhookHandler } from './rest/SignatureWebhookHandler.js';
43
+ import { createMediaStreamRouter } from './rest/MediaStreamHandler.js';
43
44
  import { createMagicLinkHandler, registerMagicLinkAuthProvider, MAGIC_LINK_MOUNT_PATH } from './auth/magicLink/index.js';
44
45
 
45
46
  import { resolve } from 'node:path';
@@ -162,6 +163,8 @@ export * from './resolvers/ISAEntityResolver.js';
162
163
  export * from './resolvers/ArtifactFileResolver.js';
163
164
  export * from './resolvers/FileCategoryResolver.js';
164
165
  export * from './resolvers/FileResolver.js';
166
+ export * from './rest/MediaAccessKeys.js';
167
+ export * from './rest/MediaStreamHandler.js';
165
168
  export * from './resolvers/InfoResolver.js';
166
169
  export * from './resolvers/PotentialDuplicateRecordResolver.js';
167
170
  export * from './resolvers/RunTestResolver.js';
@@ -991,6 +994,13 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
991
994
  app.use('/esignature', cors<cors.CorsRequest>(), createSignatureWebhookHandler());
992
995
  startupLog.LogIf('verbose', '[eSignature] Webhook route registered at /esignature/webhook/:driverKey');
993
996
 
997
+ // ─── Authenticated media streaming (token-gated, registered BEFORE auth) ─────
998
+ // The `?token=` query param (minted by CreateMediaAccessToken after a per-user permission
999
+ // check) is the capability — the route verifies it itself, so it does NOT use the MJ bearer
1000
+ // auth middleware (an <audio>/<video> element can't send Authorization headers).
1001
+ app.use('/media', cors<cors.CorsRequest>(), createMediaStreamRouter());
1002
+ startupLog.LogIf('verbose', '[Media] Streaming route registered at /media/:fileId');
1003
+
994
1004
  // ─── Magic-link routes (MJ-issued, app-scoped external access) ───────────
995
1005
  // Public router (JWKS + redeem) mounts BEFORE the auth middleware; the
996
1006
  // authenticated invite-creation router mounts AFTER it (see below).
@@ -1,6 +1,7 @@
1
- import { EntityPermissionType, FieldValueCollection, EntitySaveOptions } from '@memberjunction/core';
1
+ import { EntityPermissionType, FieldValueCollection, EntitySaveOptions, LogError, UserInfo } from '@memberjunction/core';
2
2
  import { NormalizeUUID } from '@memberjunction/global';
3
3
  import { MJFileEntity, MJFileStorageProviderEntity, MJFileStorageAccountEntity } from '@memberjunction/core-entities';
4
+ import { readRealtimeRecordingFile } from '@memberjunction/ai-agents';
4
5
  import {
5
6
  AppContext,
6
7
  Arg,
@@ -8,6 +9,7 @@ import {
8
9
  DeleteOptionsInput,
9
10
  Field,
10
11
  FieldResolver,
12
+ Float,
11
13
  InputType,
12
14
  Int,
13
15
  Mutation,
@@ -37,6 +39,9 @@ import {
37
39
  import { CreateMJFileInput, MJFileResolver as FileResolverBase, MJFile_, UpdateMJFileInput } from '../generated/generated.js';
38
40
  import { FieldMapper } from '@memberjunction/graphql-dataprovider';
39
41
  import { GetReadOnlyProvider } from '../util.js';
42
+ import { configInfo } from '../config.js';
43
+ import { MediaAccessKeyManager } from '../rest/MediaAccessKeys.js';
44
+ import { deriveSidecarPath, parsePeaksSidecar } from './peaksSidecar.js';
40
45
 
41
46
  @InputType()
42
47
  export class CreateUploadURLInput {
@@ -312,8 +317,161 @@ export class SearchAcrossAccountsPayload {
312
317
  failedAccounts: number;
313
318
  }
314
319
 
320
+ /**
321
+ * Result of {@link FileResolver.GetFileContents} — the bytes of an `MJ: Files` record returned as
322
+ * base64, read server-side through authenticated MJStorage (never a public pre-signed link).
323
+ */
324
+ @ObjectType()
325
+ export class FileContentsResult {
326
+ @Field(() => Boolean)
327
+ Success: boolean;
328
+
329
+ @Field(() => String, { nullable: true })
330
+ Base64?: string;
331
+
332
+ @Field(() => String, { nullable: true })
333
+ MimeType?: string;
334
+
335
+ @Field(() => String, { nullable: true })
336
+ ErrorMessage?: string;
337
+ }
338
+
339
+ /**
340
+ * Result of {@link FileResolver.CreateMediaAccessToken} — a short-lived signed token plus the
341
+ * authenticated streaming URL the browser hands to a `<audio>`/`<video>` element. The token is
342
+ * minted only after a per-user permission check on the `MJ: Files` row; the `/media/:fileId`
343
+ * route re-verifies it (signature + expiry + file match) on each Range request.
344
+ */
345
+ @ObjectType()
346
+ export class MediaAccessTokenResult {
347
+ @Field(() => Boolean)
348
+ Success: boolean;
349
+
350
+ @Field(() => String, { nullable: true })
351
+ Token?: string;
352
+
353
+ @Field(() => String, { nullable: true })
354
+ Url?: string;
355
+
356
+ @Field(() => Date, { nullable: true })
357
+ ExpiresAt?: Date;
358
+
359
+ /**
360
+ * The file's MIME type (from the already-loaded `MJ: Files` row). Lets the wrapper choose
361
+ * `<audio>` vs `<video>` WITHOUT re-downloading any bytes; the stream itself sets Content-Type.
362
+ */
363
+ @Field(() => String, { nullable: true })
364
+ MimeType?: string;
365
+
366
+ /**
367
+ * Optional precomputed waveform peaks (normalized `0..1`, one per rendered bar) read from a
368
+ * `peaks.json` sidecar that sits beside the file in storage. When present, the player renders the
369
+ * real waveform instantly with NO client-side fetch/decode of the audio. Best-effort: a missing or
370
+ * malformed sidecar simply omits this field — it never blocks token minting or fails the mutation.
371
+ */
372
+ @Field(() => [Float], { nullable: true })
373
+ Peaks?: number[];
374
+
375
+ @Field(() => String, { nullable: true })
376
+ ErrorMessage?: string;
377
+ }
378
+
315
379
  @Resolver(MJFile_)
316
380
  export class FileResolver extends FileResolverBase {
381
+ /**
382
+ * Resolves the server's public base URL the same way callbacks/magic-link do:
383
+ * `publicUrl` if configured (e.g. an ngrok URL), else `baseUrl:graphqlPort`. Trailing
384
+ * slash stripped so callers can append `/media/...`.
385
+ */
386
+ private resolvePublicBaseUrl(): string {
387
+ const base = configInfo.publicUrl || `${configInfo.baseUrl}:${configInfo.graphqlPort}${configInfo.graphqlRootPath || ''}`;
388
+ return base.replace(/\/+$/, '');
389
+ }
390
+
391
+ /**
392
+ * Mints a short-lived signed media-access token for an `MJ: Files` record and returns the
393
+ * authenticated streaming URL (`<publicBase>/media/<fileId>?token=<token>`). Permission-gated
394
+ * IDENTICALLY to {@link GetFileContents}: the file is loaded under the CALLING USER's context, so
395
+ * MJ row-level security determines access. The returned token is the capability — the streaming
396
+ * route re-verifies it without re-checking row-level access. Never throws to the client.
397
+ *
398
+ * @param fileId The `MJ: Files` id to grant streaming access to.
399
+ * @returns `{ Success, Token?, Url?, ExpiresAt?, ErrorMessage? }`.
400
+ */
401
+ @Mutation(() => MediaAccessTokenResult)
402
+ async CreateMediaAccessToken(@Arg('fileId', () => String) fileId: string, @Ctx() context: AppContext): Promise<MediaAccessTokenResult> {
403
+ try {
404
+ const provider = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: true });
405
+ const contextUser = this.GetUserFromPayload(context.userPayload);
406
+
407
+ // Permission gate: load the file under the USER's context. A failed load means the file
408
+ // does not exist OR the user lacks read access under MJ row-level security.
409
+ const fileEntity = await provider.GetEntityObject<MJFileEntity>('MJ: Files', contextUser);
410
+ const loaded = await fileEntity.Load(fileId);
411
+ if (!loaded) {
412
+ return { Success: false, ErrorMessage: 'You do not have access to this file or it does not exist.' };
413
+ }
414
+
415
+ // Access authorized — mint the capability token.
416
+ const { Token, ExpiresAt } = MediaAccessKeyManager.Instance.Sign(fileId, contextUser.ID);
417
+ const url = `${this.resolvePublicBaseUrl()}/media/${encodeURIComponent(fileId)}?token=${encodeURIComponent(Token)}`;
418
+
419
+ // Best-effort: surface precomputed waveform peaks from a peaks.json sidecar beside the file, so
420
+ // the player renders the real waveform instantly without fetching/decoding the audio. Never
421
+ // blocks token minting — any failure just omits Peaks.
422
+ const peaks = await this.tryReadPeaksSidecar(fileEntity, contextUser);
423
+
424
+ return { Success: true, Token, Url: url, ExpiresAt, MimeType: fileEntity.ContentType ?? undefined, Peaks: peaks };
425
+ } catch (error) {
426
+ const message = error instanceof Error ? error.message : String(error);
427
+ LogError(`CreateMediaAccessToken failed (file ${fileId}): ${message}`);
428
+ return { Success: false, ErrorMessage: message };
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Best-effort read of a `peaks.json` waveform sidecar that sits in the SAME storage folder as the
434
+ * given file (a JSON array of normalized `0..1` numbers, written at capture time). Derives the
435
+ * folder from the file's `ProviderKey` (strips the final path segment), reads `<folder>/peaks.json`
436
+ * via the file's own storage driver, parses + validates it, and returns sanitized peaks. Returns
437
+ * `undefined` on ANY failure (no ProviderKey, no sidecar, parse error, garbage) — the caller treats
438
+ * peaks as a pure optimization and must never let a sidecar problem affect token minting.
439
+ *
440
+ * @param file The already-loaded (under the user's context) `MJ: Files` row.
441
+ * @param contextUser The calling user — used to resolve the storage driver.
442
+ * @returns Sanitized `0..1` peaks (length-capped), or `undefined`.
443
+ */
444
+ private async tryReadPeaksSidecar(file: MJFileEntity, contextUser: UserInfo): Promise<number[] | undefined> {
445
+ try {
446
+ // The sidecar lives next to the recording: replace the final path segment with peaks.json.
447
+ const sidecarPath = deriveSidecarPath(file.ProviderKey);
448
+ if (!sidecarPath) {
449
+ return undefined;
450
+ }
451
+
452
+ // Resolve the file's storage account → driver (mirror of readRealtimeRecordingFile's pattern).
453
+ await FileStorageEngine.Instance.Config(false, contextUser);
454
+ let accounts = FileStorageEngine.Instance.GetAccountsByProviderID(file.ProviderID);
455
+ if (accounts.length === 0) {
456
+ await FileStorageEngine.Instance.Config(true, contextUser);
457
+ accounts = FileStorageEngine.Instance.GetAccountsByProviderID(file.ProviderID);
458
+ }
459
+ const account = accounts[0];
460
+ if (!account) {
461
+ return undefined;
462
+ }
463
+ const driver = await FileStorageEngine.Instance.GetDriver(account.ID, contextUser);
464
+ const bytes = await driver.GetObject({ fullPath: sidecarPath });
465
+ if (!bytes || bytes.length === 0) {
466
+ return undefined;
467
+ }
468
+ return parsePeaksSidecar(bytes);
469
+ } catch {
470
+ // No sidecar / unreadable / parse failure — peaks are optional, never surface the error.
471
+ return undefined;
472
+ }
473
+ }
474
+
317
475
  /**
318
476
  * Builds UserContextOptions for storage operations that may require OAuth authentication.
319
477
  * This passes the current user's ID and context to allow the storage utilities to
@@ -431,6 +589,46 @@ export class FileResolver extends FileResolverBase {
431
589
  return url;
432
590
  }
433
591
 
592
+ /**
593
+ * Returns an `MJ: Files` record's bytes as base64, read server-side through authenticated MJStorage
594
+ * (`GetObject`) — NOT a public pre-signed link. Permission-gated: the file is first loaded under the
595
+ * calling user's context, so MJ row-level security determines access. Never throws to the client.
596
+ *
597
+ * @param fileId The `MJ: Files` id whose bytes to return.
598
+ * @returns `{ Success, Base64?, MimeType?, ErrorMessage? }`.
599
+ */
600
+ @Query(() => FileContentsResult)
601
+ async GetFileContents(@Arg('fileId', () => String) fileId: string, @Ctx() context: AppContext): Promise<FileContentsResult> {
602
+ try {
603
+ const provider = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: true });
604
+ const contextUser = this.GetUserFromPayload(context.userPayload);
605
+
606
+ // Permission gate FIRST: load the file record under the USER's context. A failed load means the
607
+ // file does not exist OR the user lacks read access under MJ row-level security.
608
+ const fileEntity = await provider.GetEntityObject<MJFileEntity>('MJ: Files', contextUser);
609
+ const loaded = await fileEntity.Load(fileId);
610
+ if (!loaded) {
611
+ return { Success: false, ErrorMessage: 'You do not have access to this file or it does not exist.' };
612
+ }
613
+
614
+ // Read the bytes via authenticated MJStorage (server-side GetObject on the file's own account).
615
+ const result = await readRealtimeRecordingFile(fileId, contextUser, provider);
616
+ if (!result) {
617
+ return { Success: false, ErrorMessage: 'The file could not be read from storage.' };
618
+ }
619
+
620
+ return {
621
+ Success: true,
622
+ Base64: result.Bytes.toString('base64'),
623
+ MimeType: result.MimeType ?? fileEntity.ContentType ?? undefined,
624
+ };
625
+ } catch (error) {
626
+ const message = error instanceof Error ? error.message : String(error);
627
+ LogError(`GetFileContents failed (file ${fileId}): ${message}`);
628
+ return { Success: false, ErrorMessage: message };
629
+ }
630
+ }
631
+
434
632
  @Mutation(() => MJFile_)
435
633
  async UpdateFile(@Arg('input', () => UpdateMJFileInput) input: UpdateMJFileInput, @Ctx() context: AppContext, @PubSub() pubSub: PubSubEngine) {
436
634
  // if the name is changing, rename the target object as well
@@ -1,5 +1,5 @@
1
1
  import { Arg, Ctx, ObjectType, Query, Resolver, Field, Int, InputType } from 'type-graphql';
2
- import { RunQuery, IRunQueryProvider, IMetadataProvider, RunQueryParams, LogError, RunQueryWithCacheCheckParams, RunQueriesWithCacheCheckResponse, RunQueryWithCacheCheckResult } from '@memberjunction/core';
2
+ import { RunQuery, IRunQueryProvider, IMetadataProvider, RunQueryParams, LogError, RunQueryWithCacheCheckParams, RunQueriesWithCacheCheckResponse, RunQueryWithCacheCheckResult, RunQueryEnrichment } from '@memberjunction/core';
3
3
  import { AppContext } from '../types.js';
4
4
  import { RequireSystemUser } from '../directives/RequireSystemUser.js';
5
5
  import { GraphQLJSONObject } from 'graphql-type-json';
@@ -40,6 +40,9 @@ export class RunQueryInput {
40
40
 
41
41
  @Field(() => String, { nullable: true, description: 'Description to use in audit log' })
42
42
  AuditLogDescription?: string;
43
+
44
+ @Field(() => GraphQLJSONObject, { nullable: true, description: 'Optional runtime-only directive to post-process result rows through a registered query result enricher ({ EnricherKey, Config })' })
45
+ Enrichment?: RunQueryEnrichment;
43
46
  }
44
47
 
45
48
  @ObjectType()
@@ -191,7 +194,8 @@ export class RunQueryResolver extends ResolverBase {
191
194
  @Arg('MaxRows', () => Int, {nullable: true}) MaxRows?: number,
192
195
  @Arg('StartRow', () => Int, {nullable: true}) StartRow?: number,
193
196
  @Arg('ForceAuditLog', () => Boolean, {nullable: true}) ForceAuditLog?: boolean,
194
- @Arg('AuditLogDescription', () => String, {nullable: true}) AuditLogDescription?: string): Promise<RunQueryResultType> {
197
+ @Arg('AuditLogDescription', () => String, {nullable: true}) AuditLogDescription?: string,
198
+ @Arg('Enrichment', () => GraphQLJSONObject, {nullable: true}) Enrichment?: RunQueryEnrichment): Promise<RunQueryResultType> {
195
199
  // Check API key scope authorization for query execution
196
200
  await this.CheckAPIKeyScopeAuthorization('query:run', QueryID, context.userPayload);
197
201
 
@@ -200,7 +204,7 @@ export class RunQueryResolver extends ResolverBase {
200
204
  const rq = new RunQuery(provider as unknown as IRunQueryProvider);
201
205
  console.log('GetQueryData called with:', { QueryID, Parameters, MaxRows, StartRow, ForceAuditLog, AuditLogDescription });
202
206
  const result = await rq.RunQuery(
203
- {
207
+ {
204
208
  QueryID: QueryID,
205
209
  CategoryID: CategoryID,
206
210
  CategoryPath: CategoryPath,
@@ -208,8 +212,9 @@ export class RunQueryResolver extends ResolverBase {
208
212
  MaxRows: MaxRows,
209
213
  StartRow: StartRow,
210
214
  ForceAuditLog: ForceAuditLog,
211
- AuditLogDescription: AuditLogDescription
212
- },
215
+ AuditLogDescription: AuditLogDescription,
216
+ Enrichment: Enrichment
217
+ },
213
218
  context.userPayload.userRecord);
214
219
  console.log('RunQuery result:', {
215
220
  Success: result.Success,
@@ -256,22 +261,24 @@ export class RunQueryResolver extends ResolverBase {
256
261
  @Arg('MaxRows', () => Int, {nullable: true}) MaxRows?: number,
257
262
  @Arg('StartRow', () => Int, {nullable: true}) StartRow?: number,
258
263
  @Arg('ForceAuditLog', () => Boolean, {nullable: true}) ForceAuditLog?: boolean,
259
- @Arg('AuditLogDescription', () => String, {nullable: true}) AuditLogDescription?: string): Promise<RunQueryResultType> {
264
+ @Arg('AuditLogDescription', () => String, {nullable: true}) AuditLogDescription?: string,
265
+ @Arg('Enrichment', () => GraphQLJSONObject, {nullable: true}) Enrichment?: RunQueryEnrichment): Promise<RunQueryResultType> {
260
266
  // Check API key scope authorization for query execution
261
267
  await this.CheckAPIKeyScopeAuthorization('query:run', QueryName, context.userPayload);
262
268
 
263
269
  const provider = GetReadOnlyProvider(context.providers, {allowFallbackToReadWrite: true});
264
270
  const rq = new RunQuery(provider as unknown as IRunQueryProvider);
265
271
  const result = await rq.RunQuery(
266
- {
267
- QueryName: QueryName,
272
+ {
273
+ QueryName: QueryName,
268
274
  CategoryID: CategoryID,
269
275
  CategoryPath: CategoryPath,
270
276
  Parameters: Parameters,
271
277
  MaxRows: MaxRows,
272
278
  StartRow: StartRow,
273
279
  ForceAuditLog: ForceAuditLog,
274
- AuditLogDescription: AuditLogDescription
280
+ AuditLogDescription: AuditLogDescription,
281
+ Enrichment: Enrichment
275
282
  },
276
283
  context.userPayload.userRecord);
277
284
 
@@ -302,13 +309,14 @@ export class RunQueryResolver extends ResolverBase {
302
309
  @Arg('MaxRows', () => Int, {nullable: true}) MaxRows?: number,
303
310
  @Arg('StartRow', () => Int, {nullable: true}) StartRow?: number,
304
311
  @Arg('ForceAuditLog', () => Boolean, {nullable: true}) ForceAuditLog?: boolean,
305
- @Arg('AuditLogDescription', () => String, {nullable: true}) AuditLogDescription?: string): Promise<RunQueryResultType> {
312
+ @Arg('AuditLogDescription', () => String, {nullable: true}) AuditLogDescription?: string,
313
+ @Arg('Enrichment', () => GraphQLJSONObject, {nullable: true}) Enrichment?: RunQueryEnrichment): Promise<RunQueryResultType> {
306
314
  const provider = GetReadOnlyProvider(context.providers, {allowFallbackToReadWrite: true});
307
315
  const md = provider as unknown as IMetadataProvider;
308
316
  const rq = new RunQuery(provider as unknown as IRunQueryProvider);
309
317
 
310
318
  const result = await rq.RunQuery(
311
- {
319
+ {
312
320
  QueryID: QueryID,
313
321
  CategoryID: CategoryID,
314
322
  CategoryPath: CategoryPath,
@@ -316,8 +324,9 @@ export class RunQueryResolver extends ResolverBase {
316
324
  MaxRows: MaxRows,
317
325
  StartRow: StartRow,
318
326
  ForceAuditLog: ForceAuditLog,
319
- AuditLogDescription: AuditLogDescription
320
- },
327
+ AuditLogDescription: AuditLogDescription,
328
+ Enrichment: Enrichment
329
+ },
321
330
  context.userPayload.userRecord);
322
331
 
323
332
  // If QueryName is not populated by the provider, use efficient lookup
@@ -360,12 +369,13 @@ export class RunQueryResolver extends ResolverBase {
360
369
  @Arg('MaxRows', () => Int, {nullable: true}) MaxRows?: number,
361
370
  @Arg('StartRow', () => Int, {nullable: true}) StartRow?: number,
362
371
  @Arg('ForceAuditLog', () => Boolean, {nullable: true}) ForceAuditLog?: boolean,
363
- @Arg('AuditLogDescription', () => String, {nullable: true}) AuditLogDescription?: string): Promise<RunQueryResultType> {
372
+ @Arg('AuditLogDescription', () => String, {nullable: true}) AuditLogDescription?: string,
373
+ @Arg('Enrichment', () => GraphQLJSONObject, {nullable: true}) Enrichment?: RunQueryEnrichment): Promise<RunQueryResultType> {
364
374
  const provider = GetReadOnlyProvider(context.providers, {allowFallbackToReadWrite: true});
365
375
  const rq = new RunQuery(provider as unknown as IRunQueryProvider);
366
376
 
367
377
  const result = await rq.RunQuery(
368
- {
378
+ {
369
379
  QueryName: QueryName,
370
380
  CategoryID: CategoryID,
371
381
  CategoryPath: CategoryPath,
@@ -373,8 +383,9 @@ export class RunQueryResolver extends ResolverBase {
373
383
  MaxRows: MaxRows,
374
384
  StartRow: StartRow,
375
385
  ForceAuditLog: ForceAuditLog,
376
- AuditLogDescription: AuditLogDescription
377
- },
386
+ AuditLogDescription: AuditLogDescription,
387
+ Enrichment: Enrichment
388
+ },
378
389
  context.userPayload.userRecord);
379
390
 
380
391
  return {
@@ -420,7 +431,8 @@ export class RunQueryResolver extends ResolverBase {
420
431
  MaxRows: i.MaxRows,
421
432
  StartRow: i.StartRow,
422
433
  ForceAuditLog: i.ForceAuditLog,
423
- AuditLogDescription: i.AuditLogDescription
434
+ AuditLogDescription: i.AuditLogDescription,
435
+ Enrichment: i.Enrichment
424
436
  }));
425
437
 
426
438
  // Execute all queries in parallel using the batch method
@@ -469,7 +481,8 @@ export class RunQueryResolver extends ResolverBase {
469
481
  MaxRows: i.MaxRows,
470
482
  StartRow: i.StartRow,
471
483
  ForceAuditLog: i.ForceAuditLog,
472
- AuditLogDescription: i.AuditLogDescription
484
+ AuditLogDescription: i.AuditLogDescription,
485
+ Enrichment: i.Enrichment
473
486
  }));
474
487
 
475
488
  // Execute all queries in parallel using the batch method
@@ -532,6 +545,7 @@ export class RunQueryResolver extends ResolverBase {
532
545
  StartRow: item.params.StartRow,
533
546
  ForceAuditLog: item.params.ForceAuditLog,
534
547
  AuditLogDescription: item.params.AuditLogDescription,
548
+ Enrichment: item.params.Enrichment,
535
549
  },
536
550
  cacheStatus: item.cacheStatus ? {
537
551
  maxUpdatedAt: item.cacheStatus.maxUpdatedAt,
@@ -9,6 +9,7 @@ import { CreateBridgeRealtimeSession, FinalizeBridgeCoAgentRuns, GetRealtimeMode
9
9
  import { AIBridgeEngine } from '@memberjunction/ai-bridge-server';
10
10
  import { SessionManager } from '../agentSessions/SessionManager.js';
11
11
  import { NotificationEngine } from '@memberjunction/notifications';
12
+ import { registerMeetingRecordingFile, correlateRecordingStart } from './meetingRecordingRegistration.js';
12
13
 
13
14
  /**
14
15
  * Binds the agent realtime-session factory onto the LiveKit room coordinator's model-session creation seam.
@@ -174,6 +175,10 @@ export class LiveKitRecordingResult {
174
175
 
175
176
  @Field(() => String)
176
177
  Status: string;
178
+
179
+ /** The `MJ: Files` row id of the registered recording (set on stop, once the egress MP4 is registered). */
180
+ @Field(() => String, { nullable: true })
181
+ RecordingFileID?: string;
177
182
  }
178
183
 
179
184
  /** A selectable provider-native voice for the dev voice picker. */
@@ -444,10 +449,17 @@ export class RealtimeBridgeResolver extends ResolverBase {
444
449
  @Ctx() context: AppContext = {} as AppContext,
445
450
  ): Promise<LiveKitRecordingResult> {
446
451
  try {
447
- if (!this.GetUserFromPayload(context.userPayload)) {
452
+ const user = this.GetUserFromPayload(context.userPayload);
453
+ if (!user) {
448
454
  return { Success: false, ErrorMessage: 'Unable to determine current user.', EgressID: '', Status: '' };
449
455
  }
450
456
  const info = await new LiveKitEgressService().StartRoomRecording({ RoomName: input.RoomName, Layout: input.Layout });
457
+
458
+ // Best-effort: correlate the live recording with the room's Meeting-Room Conversation (if it exists
459
+ // yet) by stamping its EgressID. Never fail the start on this — the stop-flow resolves/creates it.
460
+ const provider = GetReadWriteProvider(context.providers) as unknown as IMetadataProvider;
461
+ void correlateRecordingStart(input.RoomName, info.EgressID, user, provider);
462
+
451
463
  return { Success: true, EgressID: info.EgressID, Status: info.Status };
452
464
  } catch (error) {
453
465
  const msg = error instanceof Error ? error.message : String(error);
@@ -456,15 +468,35 @@ export class RealtimeBridgeResolver extends ResolverBase {
456
468
  }
457
469
  }
458
470
 
459
- /** Stops a recording by egress id. */
471
+ /**
472
+ * Stops a recording by egress id, then REGISTERS the completed egress MP4 into MJStorage as an
473
+ * `MJ: Files` row linked to the room's Meeting-Room Conversation (`Conversation.RecordingFileID`).
474
+ * Registration is best-effort: a missing storage config or any failure returns the stop result with no
475
+ * `RecordingFileID` (the recording still stopped) — it never throws.
476
+ */
460
477
  @Mutation(() => LiveKitRecordingResult)
461
478
  async StopLiveKitRecording(@Arg('egressID', () => String) egressID: string, @Ctx() context: AppContext = {} as AppContext): Promise<LiveKitRecordingResult> {
462
479
  try {
463
- if (!this.GetUserFromPayload(context.userPayload)) {
480
+ const user = this.GetUserFromPayload(context.userPayload);
481
+ if (!user) {
464
482
  return { Success: false, ErrorMessage: 'Unable to determine current user.', EgressID: egressID, Status: '' };
465
483
  }
466
484
  const info = await new LiveKitEgressService().StopRecording(egressID);
467
- return { Success: true, EgressID: info.EgressID, Status: info.Status };
485
+ const provider = GetReadWriteProvider(context.providers) as unknown as IMetadataProvider;
486
+
487
+ // Register the completed egress MP4 as a Files row on the Meeting-Room Conversation. Best-effort:
488
+ // any failure (e.g. storage provider not configured) leaves RecordingFileID unset but still
489
+ // returns the successful stop result.
490
+ const registration = await registerMeetingRecordingFile(
491
+ { EgressID: info.EgressID, RoomName: info.RoomName, OutputLocation: info.OutputLocation, OutputSizeBytes: info.OutputSizeBytes },
492
+ user,
493
+ provider,
494
+ );
495
+ if (!registration.Success) {
496
+ LogError(`StopLiveKitRecording: recording stopped but registration did not complete: ${registration.ErrorMessage ?? 'unknown'}`);
497
+ }
498
+
499
+ return { Success: true, EgressID: info.EgressID, Status: info.Status, RecordingFileID: registration.RecordingFileID };
468
500
  } catch (error) {
469
501
  const msg = error instanceof Error ? error.message : String(error);
470
502
  LogError(`StopLiveKitRecording failed: ${msg}`);