@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.
- package/dist/agentSessions/SessionManager.d.ts.map +1 -1
- package/dist/agentSessions/SessionManager.js +3 -0
- package/dist/agentSessions/SessionManager.js.map +1 -1
- package/dist/agentSessions/index.d.ts.map +1 -1
- package/dist/agentSessions/index.js +3 -1
- package/dist/agentSessions/index.js.map +1 -1
- package/dist/generated/generated.d.ts +1159 -51
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +35284 -29068
- package/dist/generated/generated.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/resolvers/FileResolver.d.ts +74 -0
- package/dist/resolvers/FileResolver.d.ts.map +1 -1
- package/dist/resolvers/FileResolver.js +211 -2
- package/dist/resolvers/FileResolver.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts +6 -4
- package/dist/resolvers/QueryResolver.d.ts.map +1 -1
- package/dist/resolvers/QueryResolver.js +29 -14
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/RealtimeBridgeResolver.d.ts +8 -1
- package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeBridgeResolver.js +28 -4
- package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +81 -2
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +293 -17
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.js +17 -2
- package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts +12 -3
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +37 -15
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/meetingRecordingRegistration.d.ts +124 -0
- package/dist/resolvers/meetingRecordingRegistration.d.ts.map +1 -0
- package/dist/resolvers/meetingRecordingRegistration.js +311 -0
- package/dist/resolvers/meetingRecordingRegistration.js.map +1 -0
- package/dist/resolvers/peaksSidecar.d.ts +30 -0
- package/dist/resolvers/peaksSidecar.d.ts.map +1 -0
- package/dist/resolvers/peaksSidecar.js +51 -0
- package/dist/resolvers/peaksSidecar.js.map +1 -0
- package/dist/rest/MediaAccessKeys.d.ts +68 -0
- package/dist/rest/MediaAccessKeys.d.ts.map +1 -0
- package/dist/rest/MediaAccessKeys.js +96 -0
- package/dist/rest/MediaAccessKeys.js.map +1 -0
- package/dist/rest/MediaStreamHandler.d.ts +26 -0
- package/dist/rest/MediaStreamHandler.d.ts.map +1 -0
- package/dist/rest/MediaStreamHandler.js +192 -0
- package/dist/rest/MediaStreamHandler.js.map +1 -0
- package/dist/rest/mediaRange.d.ts +41 -0
- package/dist/rest/mediaRange.d.ts.map +1 -0
- package/dist/rest/mediaRange.js +60 -0
- package/dist/rest/mediaRange.js.map +1 -0
- package/package.json +83 -82
- package/src/__tests__/FileResolverPeaks.test.ts +64 -0
- package/src/__tests__/MediaAccessKeys.test.ts +68 -0
- package/src/__tests__/MediaStreamHandler.test.ts +91 -0
- package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -1
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +69 -0
- package/src/__tests__/RealtimeCoAgentAppAwareness.integration.test.ts +191 -0
- package/src/__tests__/RemoteBrowserSnapshot.test.ts +123 -0
- package/src/__tests__/SessionManager.test.ts +2 -0
- package/src/__tests__/meetingRecordingRegistration.test.ts +248 -0
- package/src/agentSessions/SessionManager.ts +3 -0
- package/src/agentSessions/index.ts +3 -1
- package/src/generated/generated.ts +17839 -13567
- package/src/index.ts +10 -0
- package/src/resolvers/FileResolver.ts +199 -1
- package/src/resolvers/QueryResolver.ts +33 -19
- package/src/resolvers/RealtimeBridgeResolver.ts +36 -4
- package/src/resolvers/RealtimeClientSessionResolver.ts +326 -9
- package/src/resolvers/RemoteBrowserActionResolver.ts +18 -2
- package/src/resolvers/RunAIAgentResolver.ts +44 -11
- package/src/resolvers/meetingRecordingRegistration.ts +432 -0
- package/src/resolvers/peaksSidecar.ts +52 -0
- package/src/rest/MediaAccessKeys.ts +121 -0
- package/src/rest/MediaStreamHandler.ts +220 -0
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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}`);
|