@memberjunction/server 3.1.0 → 3.2.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.
@@ -1,5 +1,5 @@
1
- import { EntityPermissionType, Metadata, FieldValueCollection, EntitySaveOptions } from '@memberjunction/core';
2
- import { FileEntity, FileStorageProviderEntity } from '@memberjunction/core-entities';
1
+ import { EntityPermissionType, Metadata, FieldValueCollection, EntitySaveOptions, RunView } from '@memberjunction/core';
2
+ import { FileEntity, FileStorageProviderEntity, FileStorageAccountEntity } from '@memberjunction/core-entities';
3
3
  import {
4
4
  AppContext,
5
5
  Arg,
@@ -13,10 +13,26 @@ import {
13
13
  ObjectType,
14
14
  PubSub,
15
15
  PubSubEngine,
16
+ Query,
16
17
  Resolver,
17
18
  Root,
18
19
  } from '@memberjunction/server';
19
- import { createDownloadUrl, createUploadUrl, deleteObject, moveObject } from '@memberjunction/storage';
20
+ import {
21
+ createDownloadUrl,
22
+ createUploadUrl,
23
+ deleteObject,
24
+ moveObject,
25
+ copyObject,
26
+ listObjects,
27
+ copyObjectBetweenProviders,
28
+ searchAcrossAccounts,
29
+ AccountSearchResult,
30
+ AccountSearchInput,
31
+ FileSearchResult,
32
+ UserContextOptions,
33
+ ExtendedUserContextOptions,
34
+ initializeDriverWithAccountCredentials,
35
+ } from '@memberjunction/storage';
20
36
  import { CreateMJFileInput, MJFileResolver as FileResolverBase, MJFile_, UpdateMJFileInput } from '../generated/generated.js';
21
37
  import { FieldMapper } from '@memberjunction/graphql-dataprovider';
22
38
  import { GetReadOnlyProvider } from '../util.js';
@@ -43,27 +59,324 @@ export class FileExt extends MJFile_ {
43
59
  DownloadUrl: string;
44
60
  }
45
61
 
62
+ @ObjectType()
63
+ export class StorageObjectMetadata {
64
+ @Field(() => String)
65
+ name: string;
66
+
67
+ @Field(() => String)
68
+ path: string;
69
+
70
+ @Field(() => String)
71
+ fullPath: string;
72
+
73
+ @Field(() => Int)
74
+ size: number;
75
+
76
+ @Field(() => String)
77
+ contentType: string;
78
+
79
+ @Field(() => String)
80
+ lastModified: string;
81
+
82
+ @Field(() => Boolean)
83
+ isDirectory: boolean;
84
+
85
+ @Field(() => String, { nullable: true })
86
+ etag?: string;
87
+
88
+ @Field(() => String, { nullable: true })
89
+ cacheControl?: string;
90
+ }
91
+
92
+ @ObjectType()
93
+ export class StorageListResult {
94
+ @Field(() => [StorageObjectMetadata])
95
+ objects: StorageObjectMetadata[];
96
+
97
+ @Field(() => [String])
98
+ prefixes: string[];
99
+ }
100
+
101
+ @InputType()
102
+ export class ListStorageObjectsInput {
103
+ @Field(() => String)
104
+ AccountID: string;
105
+
106
+ @Field(() => String)
107
+ Prefix: string;
108
+
109
+ @Field(() => String, { nullable: true })
110
+ Delimiter?: string;
111
+ }
112
+
113
+ @InputType()
114
+ export class CreatePreAuthDownloadUrlInput {
115
+ @Field(() => String)
116
+ AccountID: string;
117
+
118
+ @Field(() => String)
119
+ ObjectName: string;
120
+ }
121
+
122
+ @InputType()
123
+ export class CreatePreAuthUploadUrlInput {
124
+ @Field(() => String)
125
+ AccountID: string;
126
+
127
+ @Field(() => String)
128
+ ObjectName: string;
129
+
130
+ @Field(() => String, { nullable: true })
131
+ ContentType?: string;
132
+ }
133
+
134
+ @ObjectType()
135
+ export class CreatePreAuthUploadUrlPayload {
136
+ @Field(() => String)
137
+ UploadUrl: string;
138
+
139
+ @Field(() => String, { nullable: true })
140
+ ProviderKey?: string;
141
+ }
142
+
143
+ @InputType()
144
+ export class DeleteStorageObjectInput {
145
+ @Field(() => String)
146
+ AccountID: string;
147
+
148
+ @Field(() => String)
149
+ ObjectName: string;
150
+ }
151
+
152
+ @InputType()
153
+ export class MoveStorageObjectInput {
154
+ @Field(() => String)
155
+ AccountID: string;
156
+
157
+ @Field(() => String)
158
+ OldName: string;
159
+
160
+ @Field(() => String)
161
+ NewName: string;
162
+ }
163
+
164
+ @InputType()
165
+ export class CopyStorageObjectInput {
166
+ @Field(() => String)
167
+ AccountID: string;
168
+
169
+ @Field(() => String)
170
+ SourceName: string;
171
+
172
+ @Field(() => String)
173
+ DestinationName: string;
174
+ }
175
+
176
+ @InputType()
177
+ export class CreateDirectoryInput {
178
+ @Field(() => String)
179
+ AccountID: string;
180
+
181
+ @Field(() => String)
182
+ Path: string;
183
+ }
184
+
185
+ @InputType()
186
+ export class CopyObjectBetweenAccountsInput {
187
+ @Field(() => String)
188
+ SourceAccountID: string;
189
+
190
+ @Field(() => String)
191
+ DestinationAccountID: string;
192
+
193
+ @Field(() => String)
194
+ SourcePath: string;
195
+
196
+ @Field(() => String)
197
+ DestinationPath: string;
198
+ }
199
+
200
+ @ObjectType()
201
+ export class CopyObjectBetweenAccountsPayload {
202
+ @Field(() => Boolean)
203
+ success: boolean;
204
+
205
+ @Field(() => String)
206
+ message: string;
207
+
208
+ @Field(() => Int, { nullable: true })
209
+ bytesTransferred?: number;
210
+
211
+ @Field(() => String)
212
+ sourceAccount: string;
213
+
214
+ @Field(() => String)
215
+ destinationAccount: string;
216
+
217
+ @Field(() => String)
218
+ sourcePath: string;
219
+
220
+ @Field(() => String)
221
+ destinationPath: string;
222
+ }
223
+
224
+ @InputType()
225
+ export class SearchAcrossAccountsInput {
226
+ @Field(() => [String])
227
+ AccountIDs: string[];
228
+
229
+ @Field(() => String)
230
+ Query: string;
231
+
232
+ @Field(() => Int, { nullable: true })
233
+ MaxResultsPerAccount?: number;
234
+
235
+ @Field(() => [String], { nullable: true })
236
+ FileTypes?: string[];
237
+
238
+ @Field(() => Boolean, { nullable: true })
239
+ SearchContent?: boolean;
240
+ }
241
+
242
+ @ObjectType()
243
+ export class FileSearchResultPayload {
244
+ @Field(() => String)
245
+ path: string;
246
+
247
+ @Field(() => String)
248
+ name: string;
249
+
250
+ @Field(() => Int)
251
+ size: number;
252
+
253
+ @Field(() => String)
254
+ contentType: string;
255
+
256
+ @Field(() => String)
257
+ lastModified: string;
258
+
259
+ @Field(() => Number, { nullable: true })
260
+ relevance?: number;
261
+
262
+ @Field(() => String, { nullable: true })
263
+ excerpt?: string;
264
+
265
+ @Field(() => Boolean, { nullable: true })
266
+ matchInFilename?: boolean;
267
+
268
+ @Field(() => String, { nullable: true })
269
+ objectId?: string;
270
+ }
271
+
272
+ @ObjectType()
273
+ export class AccountSearchResultPayload {
274
+ @Field(() => String)
275
+ accountID: string;
276
+
277
+ @Field(() => String)
278
+ accountName: string;
279
+
280
+ @Field(() => Boolean)
281
+ success: boolean;
282
+
283
+ @Field(() => String, { nullable: true })
284
+ errorMessage?: string;
285
+
286
+ @Field(() => [FileSearchResultPayload])
287
+ results: FileSearchResultPayload[];
288
+
289
+ @Field(() => Int, { nullable: true })
290
+ totalMatches?: number;
291
+
292
+ @Field(() => Boolean)
293
+ hasMore: boolean;
294
+
295
+ @Field(() => String, { nullable: true })
296
+ nextPageToken?: string;
297
+ }
298
+
299
+ @ObjectType()
300
+ export class SearchAcrossAccountsPayload {
301
+ @Field(() => [AccountSearchResultPayload])
302
+ accountResults: AccountSearchResultPayload[];
303
+
304
+ @Field(() => Int)
305
+ totalResultsReturned: number;
306
+
307
+ @Field(() => Int)
308
+ successfulAccounts: number;
309
+
310
+ @Field(() => Int)
311
+ failedAccounts: number;
312
+ }
313
+
46
314
  @Resolver(MJFile_)
47
315
  export class FileResolver extends FileResolverBase {
316
+ /**
317
+ * Builds UserContextOptions for storage operations that may require OAuth authentication.
318
+ * This passes the current user's ID and context to allow the storage utilities to
319
+ * load user-specific OAuth tokens for providers like Google Drive.
320
+ */
321
+ private buildUserContext(context: AppContext): UserContextOptions {
322
+ const user = this.GetUserFromPayload(context.userPayload);
323
+ return {
324
+ userID: user.ID,
325
+ contextUser: user,
326
+ };
327
+ }
328
+
329
+ /**
330
+ * Builds ExtendedUserContextOptions that includes the account entity for enterprise model.
331
+ * This is required for OAuth providers using the Credential Engine to decrypt credentials.
332
+ */
333
+ private buildExtendedUserContext(context: AppContext, accountEntity: FileStorageAccountEntity): ExtendedUserContextOptions {
334
+ const user = this.GetUserFromPayload(context.userPayload);
335
+ return {
336
+ userID: user.ID,
337
+ contextUser: user,
338
+ accountEntity,
339
+ };
340
+ }
341
+
342
+ /**
343
+ * Loads a FileStorageAccount and its associated FileStorageProvider.
344
+ * This is the standard way to get provider information in the enterprise model.
345
+ * @param accountId The ID of the FileStorageAccount
346
+ * @param context The AppContext containing provider info
347
+ * @returns Object containing both the account and provider entities
348
+ */
349
+ private async loadAccountAndProvider(
350
+ accountId: string,
351
+ context: AppContext,
352
+ ): Promise<{ account: FileStorageAccountEntity; provider: FileStorageProviderEntity }> {
353
+ const md = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: true });
354
+ const user = this.GetUserFromPayload(context.userPayload);
355
+
356
+ // Load the account entity
357
+ const account = await md.GetEntityObject<FileStorageAccountEntity>('MJ: File Storage Accounts', user);
358
+ const loaded = await account.Load(accountId);
359
+ if (!loaded) {
360
+ throw new Error(`Storage account with ID ${accountId} not found`);
361
+ }
362
+
363
+ // Load the provider entity from the account's ProviderID
364
+ const provider = await md.GetEntityObject<FileStorageProviderEntity>('File Storage Providers', user);
365
+ await provider.Load(account.ProviderID);
366
+
367
+ return { account, provider };
368
+ }
369
+
48
370
  @Mutation(() => CreateFilePayload)
49
- async CreateFile(
50
- @Arg('input', () => CreateMJFileInput) input: CreateMJFileInput,
51
- @Ctx() context: AppContext,
52
- @PubSub() pubSub: PubSubEngine
53
- ) {
371
+ async CreateFile(@Arg('input', () => CreateMJFileInput) input: CreateMJFileInput, @Ctx() context: AppContext, @PubSub() pubSub: PubSubEngine) {
54
372
  // Check to see if there's already an object with that name
55
- const provider = GetReadOnlyProvider(context.providers, {allowFallbackToReadWrite: true})
373
+ const provider = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: true });
56
374
  const user = this.GetUserFromPayload(context.userPayload);
57
375
  const fileEntity = await provider.GetEntityObject<FileEntity>('Files', user);
58
376
  const providerEntity = await provider.GetEntityObject<FileStorageProviderEntity>('File Storage Providers', user);
59
377
  fileEntity.CheckPermissions(EntityPermissionType.Create, true);
60
378
 
61
- const [sameName] = await this.findBy(
62
- provider,
63
- 'Files',
64
- { Name: input.Name, ProviderID: input.ProviderID },
65
- context.userPayload.userRecord
66
- );
379
+ const [sameName] = await this.findBy(provider, 'Files', { Name: input.Name, ProviderID: input.ProviderID }, context.userPayload.userRecord);
67
380
  const NameExists = Boolean(sameName);
68
381
 
69
382
  const success = fileEntity.NewRecord(FieldValueCollection.FromObject({ ...input, Status: 'Pending' }));
@@ -74,7 +387,8 @@ export class FileResolver extends FileResolverBase {
74
387
  }
75
388
 
76
389
  // Create the upload URL and get the record updates (provider key, content type, etc)
77
- const { updatedInput, UploadUrl } = await createUploadUrl(providerEntity, fileEntity);
390
+ const userContext = this.buildUserContext(context);
391
+ const { updatedInput, UploadUrl } = await createUploadUrl(providerEntity, fileEntity, userContext);
78
392
 
79
393
  // Save the file record with the updated input
80
394
  const mapper = new FieldMapper();
@@ -86,28 +400,25 @@ export class FileResolver extends FileResolverBase {
86
400
  }
87
401
 
88
402
  @FieldResolver(() => String)
89
- async DownloadUrl(@Root() file: MJFile_, @Ctx() { userPayload }: AppContext) {
403
+ async DownloadUrl(@Root() file: MJFile_, @Ctx() context: AppContext) {
90
404
  const md = new Metadata();
91
- const user = this.GetUserFromPayload(userPayload);
405
+ const user = this.GetUserFromPayload(context.userPayload);
92
406
  const fileEntity = await md.GetEntityObject<FileEntity>('Files', user);
93
407
  fileEntity.CheckPermissions(EntityPermissionType.Read, true);
94
408
 
95
409
  const providerEntity = await md.GetEntityObject<FileStorageProviderEntity>('File Storage Providers', user);
96
410
  await providerEntity.Load(file.ProviderID);
97
411
 
98
- const url = await createDownloadUrl(providerEntity, file.ProviderKey ?? file.Name);
412
+ const userContext = this.buildUserContext(context);
413
+ const url = await createDownloadUrl(providerEntity, file.ProviderKey ?? file.Name, userContext);
99
414
 
100
415
  return url;
101
416
  }
102
417
 
103
418
  @Mutation(() => MJFile_)
104
- async UpdateFile(
105
- @Arg('input', () => UpdateMJFileInput) input: UpdateMJFileInput,
106
- @Ctx() context: AppContext,
107
- @PubSub() pubSub: PubSubEngine
108
- ) {
419
+ async UpdateFile(@Arg('input', () => UpdateMJFileInput) input: UpdateMJFileInput, @Ctx() context: AppContext, @PubSub() pubSub: PubSubEngine) {
109
420
  // if the name is changing, rename the target object as well
110
- const md = GetReadOnlyProvider(context.providers, {allowFallbackToReadWrite: true});
421
+ const md = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: true });
111
422
  const user = this.GetUserFromPayload(context.userPayload);
112
423
  const fileEntity = await md.GetEntityObject<FileEntity>('Files', user);
113
424
  fileEntity.CheckPermissions(EntityPermissionType.Update, true);
@@ -118,7 +429,8 @@ export class FileResolver extends FileResolverBase {
118
429
  const providerEntity = await md.GetEntityObject<FileStorageProviderEntity>('File Storage Providers', user);
119
430
  await providerEntity.Load(fileEntity.ProviderID);
120
431
 
121
- const success = await moveObject(providerEntity, fileEntity.Name, input.Name);
432
+ const userContext = this.buildUserContext(context);
433
+ const success = await moveObject(providerEntity, fileEntity.Name, input.Name, userContext);
122
434
  if (!success) {
123
435
  throw new Error('Error updating object name');
124
436
  }
@@ -133,9 +445,9 @@ export class FileResolver extends FileResolverBase {
133
445
  @Arg('ID', () => String) ID: string,
134
446
  @Arg('options___', () => DeleteOptionsInput) options: DeleteOptionsInput,
135
447
  @Ctx() context: AppContext,
136
- @PubSub() pubSub: PubSubEngine
448
+ @PubSub() pubSub: PubSubEngine,
137
449
  ) {
138
- const md = GetReadOnlyProvider(context.providers, {allowFallbackToReadWrite: true});
450
+ const md = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: true });
139
451
  const userInfo = this.GetUserFromPayload(context.userPayload);
140
452
 
141
453
  const fileEntity = await md.GetEntityObject<FileEntity>('Files', userInfo);
@@ -149,9 +461,369 @@ export class FileResolver extends FileResolverBase {
149
461
  if (fileEntity.Status === 'Uploaded') {
150
462
  const providerEntity = await md.GetEntityObject<FileStorageProviderEntity>('File Storage Providers', userInfo);
151
463
  await providerEntity.Load(fileEntity.ProviderID);
152
- await deleteObject(providerEntity, fileEntity.ProviderKey ?? fileEntity.Name);
464
+ const userContext = this.buildUserContext(context);
465
+ await deleteObject(providerEntity, fileEntity.ProviderKey ?? fileEntity.Name, userContext);
153
466
  }
154
467
 
155
468
  return super.DeleteMJFile(ID, options, context, pubSub);
156
469
  }
470
+
471
+ @Query(() => StorageListResult)
472
+ async ListStorageObjects(@Arg('input', () => ListStorageObjectsInput) input: ListStorageObjectsInput, @Ctx() context: AppContext) {
473
+ console.log('[FileResolver] ListStorageObjects called with:', {
474
+ AccountID: input.AccountID,
475
+ Prefix: input.Prefix,
476
+ Delimiter: input.Delimiter,
477
+ });
478
+
479
+ const md = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: true });
480
+ const user = this.GetUserFromPayload(context.userPayload);
481
+
482
+ // Load the account and its provider
483
+ const { account, provider: providerEntity } = await this.loadAccountAndProvider(input.AccountID, context);
484
+
485
+ console.log('[FileResolver] Provider loaded:', {
486
+ Name: providerEntity.Name,
487
+ ServerDriverKey: providerEntity.ServerDriverKey,
488
+ HasConfiguration: !!providerEntity.Get('Configuration'),
489
+ });
490
+
491
+ // Check permissions - user must have read access to Files entity
492
+ const fileEntity = await md.GetEntityObject<FileEntity>('Files', user);
493
+ fileEntity.CheckPermissions(EntityPermissionType.Read, true);
494
+
495
+ // Call the storage provider to list objects with extended user context (includes account for credential lookup)
496
+ const userContext = this.buildExtendedUserContext(context, account);
497
+ const result = await listObjects(providerEntity, input.Prefix, input.Delimiter || '/', userContext);
498
+
499
+ console.log('[FileResolver] listObjects result:', {
500
+ objectsCount: result.objects.length,
501
+ prefixesCount: result.prefixes.length,
502
+ objects: result.objects.map((o) => ({ name: o.name, isDirectory: o.isDirectory })),
503
+ prefixes: result.prefixes,
504
+ });
505
+
506
+ // Convert Date objects to ISO strings for GraphQL
507
+ const objects = result.objects.map((obj) => ({
508
+ ...obj,
509
+ lastModified: obj.lastModified.toISOString(),
510
+ }));
511
+
512
+ return {
513
+ objects,
514
+ prefixes: result.prefixes,
515
+ };
516
+ }
517
+
518
+ @Query(() => String)
519
+ async CreatePreAuthDownloadUrl(@Arg('input', () => CreatePreAuthDownloadUrlInput) input: CreatePreAuthDownloadUrlInput, @Ctx() context: AppContext) {
520
+ const md = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: true });
521
+ const user = this.GetUserFromPayload(context.userPayload);
522
+
523
+ // Load the account and its provider
524
+ const { account, provider: providerEntity } = await this.loadAccountAndProvider(input.AccountID, context);
525
+
526
+ // Check permissions
527
+ const fileEntity = await md.GetEntityObject<FileEntity>('Files', user);
528
+ fileEntity.CheckPermissions(EntityPermissionType.Read, true);
529
+
530
+ // Create download URL with extended user context (includes account for credential lookup)
531
+ const userContext = this.buildExtendedUserContext(context, account);
532
+ const downloadUrl = await createDownloadUrl(providerEntity, input.ObjectName, userContext);
533
+ return downloadUrl;
534
+ }
535
+
536
+ @Mutation(() => CreatePreAuthUploadUrlPayload)
537
+ async CreatePreAuthUploadUrl(@Arg('input', () => CreatePreAuthUploadUrlInput) input: CreatePreAuthUploadUrlInput, @Ctx() context: AppContext) {
538
+ const md = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: true });
539
+ const user = this.GetUserFromPayload(context.userPayload);
540
+
541
+ // Load the account and its provider
542
+ const { account, provider: providerEntity } = await this.loadAccountAndProvider(input.AccountID, context);
543
+
544
+ // Check permissions
545
+ const fileEntity = await md.GetEntityObject<FileEntity>('Files', user);
546
+ fileEntity.CheckPermissions(EntityPermissionType.Create, true);
547
+
548
+ // Create upload URL with extended user context (includes account for credential lookup)
549
+ const userContext = this.buildExtendedUserContext(context, account);
550
+ const { UploadUrl, updatedInput } = await createUploadUrl(
551
+ providerEntity,
552
+ {
553
+ ID: '', // Not needed for direct upload
554
+ Name: input.ObjectName,
555
+ ProviderID: providerEntity.ID,
556
+ ContentType: input.ContentType,
557
+ },
558
+ userContext,
559
+ );
560
+
561
+ // Extract ProviderKey if it exists (spread into updatedInput by createUploadUrl)
562
+ const providerKey = (updatedInput as { ProviderKey?: string }).ProviderKey;
563
+
564
+ return {
565
+ UploadUrl,
566
+ ProviderKey: providerKey,
567
+ };
568
+ }
569
+
570
+ @Mutation(() => Boolean)
571
+ async DeleteStorageObject(@Arg('input', () => DeleteStorageObjectInput) input: DeleteStorageObjectInput, @Ctx() context: AppContext) {
572
+ console.log('[FileResolver] DeleteStorageObject called:', input);
573
+
574
+ const md = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: true });
575
+ const user = this.GetUserFromPayload(context.userPayload);
576
+
577
+ // Load the account and its provider
578
+ const { account, provider: providerEntity } = await this.loadAccountAndProvider(input.AccountID, context);
579
+
580
+ console.log('[FileResolver] Provider loaded:', {
581
+ providerID: providerEntity.ID,
582
+ providerName: providerEntity.Name,
583
+ serverDriverKey: providerEntity.ServerDriverKey,
584
+ });
585
+
586
+ // Check permissions
587
+ const fileEntity = await md.GetEntityObject<FileEntity>('Files', user);
588
+ fileEntity.CheckPermissions(EntityPermissionType.Delete, true);
589
+
590
+ console.log('[FileResolver] Permissions checked, calling deleteObject...');
591
+
592
+ // Delete the object with extended user context (includes account for credential lookup)
593
+ const userContext = this.buildExtendedUserContext(context, account);
594
+ const success = await deleteObject(providerEntity, input.ObjectName, userContext);
595
+
596
+ console.log('[FileResolver] deleteObject returned:', success);
597
+
598
+ return success;
599
+ }
600
+
601
+ @Mutation(() => Boolean)
602
+ async MoveStorageObject(@Arg('input', () => MoveStorageObjectInput) input: MoveStorageObjectInput, @Ctx() context: AppContext) {
603
+ const md = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: true });
604
+ const user = this.GetUserFromPayload(context.userPayload);
605
+
606
+ // Load the account and its provider
607
+ const { account, provider: providerEntity } = await this.loadAccountAndProvider(input.AccountID, context);
608
+
609
+ // Check permissions
610
+ const fileEntity = await md.GetEntityObject<FileEntity>('Files', user);
611
+ fileEntity.CheckPermissions(EntityPermissionType.Update, true);
612
+
613
+ // Move the object with extended user context (includes account for credential lookup)
614
+ const userContext = this.buildExtendedUserContext(context, account);
615
+ const success = await moveObject(providerEntity, input.OldName, input.NewName, userContext);
616
+ return success;
617
+ }
618
+
619
+ @Mutation(() => Boolean)
620
+ async CopyStorageObject(@Arg('input', () => CopyStorageObjectInput) input: CopyStorageObjectInput, @Ctx() context: AppContext) {
621
+ const md = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: true });
622
+ const user = this.GetUserFromPayload(context.userPayload);
623
+
624
+ // Load the account and its provider
625
+ const { account, provider: providerEntity } = await this.loadAccountAndProvider(input.AccountID, context);
626
+
627
+ // Check permissions - copying requires both read (source) and create (destination)
628
+ const fileEntity = await md.GetEntityObject<FileEntity>('Files', user);
629
+ fileEntity.CheckPermissions(EntityPermissionType.Read, true);
630
+ fileEntity.CheckPermissions(EntityPermissionType.Create, true);
631
+
632
+ // Copy the object with extended user context (includes account for credential lookup)
633
+ const userContext = this.buildExtendedUserContext(context, account);
634
+ const success = await copyObject(providerEntity, input.SourceName, input.DestinationName, userContext);
635
+ return success;
636
+ }
637
+
638
+ @Mutation(() => Boolean)
639
+ async CreateDirectory(@Arg('input', () => CreateDirectoryInput) input: CreateDirectoryInput, @Ctx() context: AppContext) {
640
+ const md = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: true });
641
+ const user = this.GetUserFromPayload(context.userPayload);
642
+
643
+ // Load the account and its provider
644
+ const { account: accountEntity, provider: providerEntity } = await this.loadAccountAndProvider(input.AccountID, context);
645
+
646
+ // Check permissions
647
+ const fileEntity = await md.GetEntityObject<FileEntity>('Files', user);
648
+ fileEntity.CheckPermissions(EntityPermissionType.Create, true);
649
+
650
+ // Initialize driver with account-based credentials from Credential Engine
651
+ const driver = await initializeDriverWithAccountCredentials({
652
+ accountEntity,
653
+ providerEntity,
654
+ contextUser: user,
655
+ });
656
+
657
+ const success = await driver.CreateDirectory(input.Path);
658
+ return success;
659
+ }
660
+
661
+ @Mutation(() => CopyObjectBetweenAccountsPayload)
662
+ async CopyObjectBetweenAccounts(
663
+ @Arg('input', () => CopyObjectBetweenAccountsInput) input: CopyObjectBetweenAccountsInput,
664
+ @Ctx() context: AppContext,
665
+ ): Promise<CopyObjectBetweenAccountsPayload> {
666
+ console.log('[FileResolver] CopyObjectBetweenAccounts called:', {
667
+ sourceAccountID: input.SourceAccountID,
668
+ destinationAccountID: input.DestinationAccountID,
669
+ sourcePath: input.SourcePath,
670
+ destinationPath: input.DestinationPath,
671
+ });
672
+
673
+ const md = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: true });
674
+ const user = this.GetUserFromPayload(context.userPayload);
675
+
676
+ // Check permissions - copying requires both read (source) and create (destination)
677
+ const fileEntity = await md.GetEntityObject<FileEntity>('Files', user);
678
+ fileEntity.CheckPermissions(EntityPermissionType.Read, true);
679
+ fileEntity.CheckPermissions(EntityPermissionType.Create, true);
680
+
681
+ // Load the source account and its provider
682
+ const { account: sourceAccount, provider: sourceProviderEntity } = await this.loadAccountAndProvider(input.SourceAccountID, context);
683
+
684
+ // Load the destination account and its provider
685
+ const { account: destAccount, provider: destProviderEntity } = await this.loadAccountAndProvider(input.DestinationAccountID, context);
686
+
687
+ // Perform the cross-provider copy with extended user context (includes account for credential lookup)
688
+ const sourceUserContext = this.buildExtendedUserContext(context, sourceAccount);
689
+ const destUserContext = this.buildExtendedUserContext(context, destAccount);
690
+ const result = await copyObjectBetweenProviders(sourceProviderEntity, destProviderEntity, input.SourcePath, input.DestinationPath, {
691
+ sourceUserContext,
692
+ destinationUserContext: destUserContext,
693
+ });
694
+
695
+ console.log('[FileResolver] CopyObjectBetweenAccounts result:', result);
696
+
697
+ return {
698
+ success: result.success,
699
+ message: result.message,
700
+ bytesTransferred: result.bytesTransferred,
701
+ sourceAccount: sourceAccount.Name,
702
+ destinationAccount: destAccount.Name,
703
+ sourcePath: result.sourcePath,
704
+ destinationPath: result.destinationPath,
705
+ };
706
+ }
707
+
708
+ @Query(() => SearchAcrossAccountsPayload)
709
+ async SearchAcrossAccounts(
710
+ @Arg('input', () => SearchAcrossAccountsInput) input: SearchAcrossAccountsInput,
711
+ @Ctx() context: AppContext,
712
+ ): Promise<SearchAcrossAccountsPayload> {
713
+ console.log('[FileResolver] SearchAcrossAccounts called:', {
714
+ accountIDs: input.AccountIDs,
715
+ query: input.Query,
716
+ maxResultsPerAccount: input.MaxResultsPerAccount,
717
+ fileTypes: input.FileTypes,
718
+ searchContent: input.SearchContent,
719
+ });
720
+
721
+ const md = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: true });
722
+ const user = this.GetUserFromPayload(context.userPayload);
723
+
724
+ // Check permissions - searching requires read access
725
+ const fileEntity = await md.GetEntityObject<FileEntity>('Files', user);
726
+ fileEntity.CheckPermissions(EntityPermissionType.Read, true);
727
+
728
+ // Load all requested account entities in a single query
729
+ const rv = new RunView();
730
+ const quotedIDs = input.AccountIDs.map((id) => `'${id}'`).join(', ');
731
+ const accountResult = await rv.RunView<FileStorageAccountEntity>(
732
+ {
733
+ EntityName: 'MJ: File Storage Accounts',
734
+ ExtraFilter: `ID IN (${quotedIDs})`,
735
+ ResultType: 'entity_object',
736
+ },
737
+ user,
738
+ );
739
+
740
+ if (!accountResult.Success) {
741
+ throw new Error(`Failed to load storage accounts: ${accountResult.ErrorMessage}`);
742
+ }
743
+
744
+ const accountEntities = accountResult.Results;
745
+ if (accountEntities.length === 0) {
746
+ throw new Error('No valid storage accounts found for the provided IDs');
747
+ }
748
+
749
+ // Log any accounts that weren't found
750
+ if (accountEntities.length < input.AccountIDs.length) {
751
+ const foundIDs = new Set(accountEntities.map((a) => a.ID));
752
+ const missingIDs = input.AccountIDs.filter((id) => !foundIDs.has(id));
753
+ console.warn(`[FileResolver] Accounts not found: ${missingIDs.join(', ')}`);
754
+ }
755
+
756
+ // Load providers for all accounts
757
+ const providerIDs = [...new Set(accountEntities.map((a) => a.ProviderID))];
758
+ const quotedProviderIDs = providerIDs.map((id) => `'${id}'`).join(', ');
759
+ const providerResult = await rv.RunView<FileStorageProviderEntity>(
760
+ {
761
+ EntityName: 'File Storage Providers',
762
+ ExtraFilter: `ID IN (${quotedProviderIDs})`,
763
+ ResultType: 'entity_object',
764
+ },
765
+ user,
766
+ );
767
+
768
+ if (!providerResult.Success) {
769
+ throw new Error(`Failed to load storage providers: ${providerResult.ErrorMessage}`);
770
+ }
771
+
772
+ const providerMap = new Map<string, FileStorageProviderEntity>();
773
+ for (const provider of providerResult.Results) {
774
+ providerMap.set(provider.ID, provider);
775
+ }
776
+
777
+ // Build account/provider pairs for the search
778
+ const accountInputs: AccountSearchInput[] = [];
779
+ for (const account of accountEntities) {
780
+ const provider = providerMap.get(account.ProviderID);
781
+ if (provider) {
782
+ accountInputs.push({ accountEntity: account, providerEntity: provider });
783
+ }
784
+ }
785
+
786
+ // Execute the search across all accounts with account-based credentials
787
+ const result = await searchAcrossAccounts(accountInputs, input.Query, {
788
+ maxResultsPerAccount: input.MaxResultsPerAccount,
789
+ fileTypes: input.FileTypes,
790
+ searchContent: input.SearchContent,
791
+ contextUser: user,
792
+ });
793
+
794
+ console.log('[FileResolver] SearchAcrossAccounts result:', {
795
+ totalResultsReturned: result.totalResultsReturned,
796
+ successfulAccounts: result.successfulAccounts,
797
+ failedAccounts: result.failedAccounts,
798
+ });
799
+
800
+ // Convert results to GraphQL payload format
801
+ const accountResults: AccountSearchResultPayload[] = result.accountResults.map((ar: AccountSearchResult) => ({
802
+ accountID: ar.accountID,
803
+ accountName: ar.accountName,
804
+ success: ar.success,
805
+ errorMessage: ar.errorMessage,
806
+ results: ar.results.map((r: FileSearchResult) => ({
807
+ path: r.path,
808
+ name: r.name,
809
+ size: r.size,
810
+ contentType: r.contentType,
811
+ lastModified: r.lastModified.toISOString(),
812
+ relevance: r.relevance,
813
+ excerpt: r.excerpt,
814
+ matchInFilename: r.matchInFilename,
815
+ objectId: r.objectId,
816
+ })),
817
+ totalMatches: ar.totalMatches,
818
+ hasMore: ar.hasMore,
819
+ nextPageToken: ar.nextPageToken,
820
+ }));
821
+
822
+ return {
823
+ accountResults,
824
+ totalResultsReturned: result.totalResultsReturned,
825
+ successfulAccounts: result.successfulAccounts,
826
+ failedAccounts: result.failedAccounts,
827
+ };
828
+ }
157
829
  }