@memberjunction/server 5.35.0 → 5.37.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.
@@ -0,0 +1,607 @@
1
+ import { LogError, UserInfo } from '@memberjunction/core';
2
+ import { ListOperations, ListSharing } from '@memberjunction/lists';
3
+ import type {
4
+ ApplyResult as CoreApplyResult,
5
+ ListDelta as CoreListDelta,
6
+ ListDeltaWarning as CoreListDeltaWarning,
7
+ ListShareSummary,
8
+ ListSource as CoreListSource,
9
+ MaterializeOptions as CoreMaterializeOptions,
10
+ SetOpKind,
11
+ SharePermissionLevel,
12
+ SharedListSummary,
13
+ ShareTarget,
14
+ } from '@memberjunction/lists-base';
15
+ import { Arg, Ctx, Field, InputType, Int, Mutation, ObjectType, Query, Resolver } from 'type-graphql';
16
+
17
+ import { ResolverBase } from '../generic/ResolverBase.js';
18
+ import { AppContext } from '../types.js';
19
+ import { GetReadWriteProvider } from '../util.js';
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // GraphQL types — mirror the @memberjunction/lists shape one-to-one.
23
+ //
24
+ // Why duplicate: GraphQL types must be decorated with `@ObjectType` /
25
+ // `@InputType` / `@Field`, but @memberjunction/lists is intentionally
26
+ // framework-agnostic and ships zero type-graphql metadata. The duplication
27
+ // stays cheap because there's a single converter at each boundary
28
+ // (`fromCoreDelta`, `toCoreSource`) — drift gets caught at compile time
29
+ // when fields change shape.
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /**
33
+ * Discriminated source descriptor as a flat input. GraphQL does not support
34
+ * tagged unions in inputs, so we encode the discriminator in `Kind` and
35
+ * the variant-specific payload in optional fields. Runtime validation
36
+ * enforces the right field-presence per kind.
37
+ */
38
+ @InputType('ListSourceInput')
39
+ export class ListSourceInput {
40
+ @Field(() => String, { description: 'Discriminator. One of: list | view | adhoc.' })
41
+ Kind!: 'list' | 'view' | 'adhoc';
42
+
43
+ @Field(() => String, { nullable: true, description: 'List ID, required when Kind=list.' })
44
+ ListID?: string;
45
+
46
+ @Field(() => String, { nullable: true, description: 'User View ID, required when Kind=view.' })
47
+ ViewID?: string;
48
+
49
+ @Field(() => String, { nullable: true, description: 'Entity name, required when Kind=adhoc.' })
50
+ EntityName?: string;
51
+
52
+ @Field(() => String, { nullable: true, description: 'WHERE clause expression, required when Kind=adhoc.' })
53
+ ExtraFilter?: string;
54
+ }
55
+
56
+ @InputType('MaterializeOptionsInput')
57
+ export class MaterializeOptionsInput {
58
+ @Field(() => String) ListName!: string;
59
+ @Field(() => String, { nullable: true }) CategoryId?: string;
60
+ @Field(() => String, { nullable: true }) Description?: string;
61
+ @Field(() => Boolean) RememberLineage!: boolean;
62
+ @Field(() => Boolean) UseSnapshot!: boolean;
63
+
64
+ @Field(() => String, { description: 'Refresh mode: Additive | Sync.' })
65
+ RefreshMode!: 'Additive' | 'Sync';
66
+ }
67
+
68
+ @ObjectType('ListDeltaCountsType')
69
+ export class ListDeltaCountsType {
70
+ @Field(() => Int) Add!: number;
71
+ @Field(() => Int) Remove!: number;
72
+ @Field(() => Int) Unchanged!: number;
73
+ @Field(() => Int) SourceTotal!: number;
74
+ @Field(() => Int) TargetTotal!: number;
75
+ }
76
+
77
+ @ObjectType('ListDeltaWarningType')
78
+ export class ListDeltaWarningType {
79
+ @Field(() => String) Code!: string;
80
+ @Field(() => String) Message!: string;
81
+ /** Structured payload, JSON-stringified for GraphQL-over-the-wire portability. */
82
+ @Field(() => String, { nullable: true }) DetailsJSON?: string;
83
+ }
84
+
85
+ @ObjectType('ListDeltaType')
86
+ export class ListDeltaType {
87
+ @Field(() => String, { nullable: true }) TargetListId?: string | null;
88
+ @Field(() => String) EntityName!: string;
89
+ @Field(() => [String]) ToAdd!: string[];
90
+ @Field(() => [String]) ToRemove!: string[];
91
+ @Field(() => [String]) Unchanged!: string[];
92
+ @Field(() => ListDeltaCountsType) Counts!: ListDeltaCountsType;
93
+ @Field(() => [ListDeltaWarningType]) Warnings!: ListDeltaWarningType[];
94
+ @Field(() => String) DeltaToken!: string;
95
+ }
96
+
97
+ @ObjectType('ApplyListResultType')
98
+ export class ApplyListResultType {
99
+ @Field(() => Boolean) Success!: boolean;
100
+ @Field(() => String) ResultCode!: string;
101
+ @Field(() => String) Message!: string;
102
+ @Field(() => String, { nullable: true }) CreatedListId?: string;
103
+ @Field(() => String, { nullable: true }) TargetListId?: string;
104
+ @Field(() => Int, { nullable: true }) AddedCount?: number;
105
+ @Field(() => Int, { nullable: true }) RemovedCount?: number;
106
+ @Field(() => Int, { nullable: true }) FailedCount?: number;
107
+ @Field(() => [String], { nullable: true }) Errors?: string[];
108
+ }
109
+
110
+ @InputType('ComputeDeltaInput')
111
+ export class ComputeDeltaInput {
112
+ @Field(() => String, { description: 'TargetListId, or the literal string "new" to materialize.' })
113
+ Target!: string;
114
+
115
+ @Field(() => ListSourceInput) Source!: ListSourceInput;
116
+
117
+ @Field(() => String, { description: 'Refresh mode: Additive | Sync.' })
118
+ Mode!: 'Additive' | 'Sync';
119
+ }
120
+
121
+ @InputType('ApplyDeltaInput')
122
+ export class ApplyDeltaInput {
123
+ @Field(() => String) TargetListId!: string;
124
+ @Field(() => String) EntityName!: string;
125
+ @Field(() => [String]) ToAdd!: string[];
126
+ @Field(() => [String]) ToRemove!: string[];
127
+ @Field(() => [String]) Unchanged!: string[];
128
+ @Field(() => Int) AddCount!: number;
129
+ @Field(() => Int) RemoveCount!: number;
130
+ @Field(() => Int) UnchangedCount!: number;
131
+ @Field(() => Int) SourceTotal!: number;
132
+ @Field(() => Int) TargetTotal!: number;
133
+ @Field(() => String) DeltaToken!: string;
134
+ @Field(() => Boolean) ConfirmDrops!: boolean;
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Sharing inputs / outputs (Phase 2)
139
+ // ---------------------------------------------------------------------------
140
+
141
+ @InputType('ShareTargetInput')
142
+ export class ShareTargetInput {
143
+ @Field(() => String, { description: 'Target kind: user | role.' })
144
+ Kind!: 'user' | 'role';
145
+
146
+ @Field(() => String, { nullable: true, description: 'User ID, required when Kind=user.' })
147
+ UserID?: string;
148
+
149
+ @Field(() => String, { nullable: true, description: 'Role ID, required when Kind=role.' })
150
+ RoleID?: string;
151
+ }
152
+
153
+ @InputType('ShareListInput')
154
+ export class ShareListInput {
155
+ @Field(() => String) ListID!: string;
156
+ @Field(() => ShareTargetInput) Target!: ShareTargetInput;
157
+ @Field(() => String, { description: 'Permission level: View | Edit | Owner.' })
158
+ PermissionLevel!: SharePermissionLevel;
159
+ }
160
+
161
+ @InputType('InviteToListInput')
162
+ export class InviteToListInput {
163
+ @Field(() => String) ListID!: string;
164
+ @Field(() => String) Email!: string;
165
+ @Field(() => String, { description: 'Role: Editor | Viewer.' })
166
+ Role!: 'Editor' | 'Viewer';
167
+ @Field(() => Int, { nullable: true, description: 'Optional TTL in hours. Defaults to 7 days.' })
168
+ TtlHours?: number;
169
+ }
170
+
171
+ @ObjectType('ShareTargetType')
172
+ export class ShareTargetType {
173
+ @Field(() => String) Kind!: 'user' | 'role';
174
+ @Field(() => String, { nullable: true }) UserID?: string;
175
+ @Field(() => String, { nullable: true }) RoleID?: string;
176
+ }
177
+
178
+ @ObjectType('ListShareSummaryType')
179
+ export class ListShareSummaryType {
180
+ @Field(() => String) PermissionID!: string;
181
+ @Field(() => String) ListID!: string;
182
+ @Field(() => ShareTargetType) Target!: ShareTargetType;
183
+ @Field(() => String) PermissionLevel!: SharePermissionLevel;
184
+ @Field(() => String) Status!: string;
185
+ @Field(() => String, { nullable: true }) SharedByUserID?: string;
186
+ @Field(() => Date) CreatedAt!: Date;
187
+ }
188
+
189
+ @ObjectType('SharedListSummaryType')
190
+ export class SharedListSummaryType {
191
+ @Field(() => String) ListID!: string;
192
+ @Field(() => String) ListName!: string;
193
+ @Field(() => String) PermissionLevel!: SharePermissionLevel;
194
+ @Field(() => String, { nullable: true }) SharedByUserID?: string;
195
+ @Field(() => Date) SharedAt!: Date;
196
+ }
197
+
198
+ @ObjectType('ShareResultType')
199
+ export class ShareResultType {
200
+ @Field(() => Boolean) Success!: boolean;
201
+ @Field(() => String) ResultCode!: string;
202
+ @Field(() => String) Message!: string;
203
+ @Field(() => String, { nullable: true }) PermissionID?: string;
204
+ }
205
+
206
+ @ObjectType('InviteResultType')
207
+ export class InviteResultType {
208
+ @Field(() => Boolean) Success!: boolean;
209
+ @Field(() => String) ResultCode!: string;
210
+ @Field(() => String) Message!: string;
211
+ @Field(() => String, { nullable: true }) InvitationID?: string;
212
+ @Field(() => String, { nullable: true }) Token?: string;
213
+ @Field(() => Date, { nullable: true }) ExpiresAt?: Date;
214
+ }
215
+
216
+ @ObjectType('AcceptInvitationResultType')
217
+ export class AcceptInvitationResultType {
218
+ @Field(() => Boolean) Success!: boolean;
219
+ @Field(() => String) ResultCode!: string;
220
+ @Field(() => String) Message!: string;
221
+ @Field(() => String, { nullable: true }) PermissionID?: string;
222
+ @Field(() => String, { nullable: true }) ListID?: string;
223
+ }
224
+
225
+ @InputType('ComposeListsInput')
226
+ export class ComposeListsInput {
227
+ @Field(() => String, { description: 'Set-op kind: union | intersection | difference.' })
228
+ Op!: SetOpKind;
229
+
230
+ @Field(() => [ListSourceInput]) Inputs!: ListSourceInput[];
231
+
232
+ @Field(() => ListSourceInput, { nullable: true, description: 'Optional target list (for in-place set-op). Omit to preview against a new list.' })
233
+ Target?: ListSourceInput;
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Boundary converters — exported for unit testing. Kept as standalone
238
+ // functions (not methods) so tests don't have to construct a full
239
+ // AppContext / type-graphql runtime.
240
+ // ---------------------------------------------------------------------------
241
+
242
+ export function toCoreSource(input: ListSourceInput): CoreListSource {
243
+ switch (input.Kind) {
244
+ case 'list':
245
+ if (!input.ListID) throw new Error("ListSourceInput.ListID is required when Kind='list'");
246
+ return { kind: 'list', listId: input.ListID };
247
+ case 'view':
248
+ if (!input.ViewID) throw new Error("ListSourceInput.ViewID is required when Kind='view'");
249
+ return { kind: 'view', viewId: input.ViewID };
250
+ case 'adhoc':
251
+ if (!input.EntityName || input.ExtraFilter == null) {
252
+ throw new Error("ListSourceInput.EntityName and ExtraFilter are required when Kind='adhoc'");
253
+ }
254
+ return { kind: 'adhoc', entityName: input.EntityName, extraFilter: input.ExtraFilter };
255
+ default:
256
+ throw new Error(`Unknown ListSourceInput.Kind: ${String(input.Kind)}`);
257
+ }
258
+ }
259
+
260
+ export function fromCoreDelta(delta: CoreListDelta): ListDeltaType {
261
+ return {
262
+ TargetListId: delta.TargetListId,
263
+ EntityName: delta.EntityName,
264
+ ToAdd: delta.ToAdd,
265
+ ToRemove: delta.ToRemove,
266
+ Unchanged: delta.Unchanged,
267
+ Counts: { ...delta.Counts },
268
+ Warnings: delta.Warnings.map(fromCoreWarning),
269
+ DeltaToken: delta.DeltaToken,
270
+ };
271
+ }
272
+
273
+ export function fromCoreWarning(w: CoreListDeltaWarning): ListDeltaWarningType {
274
+ return {
275
+ Code: w.Code,
276
+ Message: w.Message,
277
+ DetailsJSON: w.Details ? JSON.stringify(w.Details) : undefined,
278
+ };
279
+ }
280
+
281
+ export function fromCoreApplyResult(r: CoreApplyResult): ApplyListResultType {
282
+ return {
283
+ Success: r.Success,
284
+ ResultCode: r.ResultCode,
285
+ Message: r.Message,
286
+ CreatedListId: r.CreatedListId,
287
+ TargetListId: r.TargetListId,
288
+ AddedCount: r.Counts?.Added,
289
+ RemovedCount: r.Counts?.Removed,
290
+ FailedCount: r.Counts?.Failed,
291
+ Errors: r.Errors,
292
+ };
293
+ }
294
+
295
+ /** Convert a `ListShareSummary` from the core into the GraphQL DTO. */
296
+ export function fromCoreShareSummary(s: ListShareSummary): ListShareSummaryType {
297
+ return {
298
+ PermissionID: s.PermissionID,
299
+ ListID: s.ListID,
300
+ Target:
301
+ s.Target.kind === 'user'
302
+ ? { Kind: 'user', UserID: s.Target.userId }
303
+ : { Kind: 'role', RoleID: s.Target.roleId },
304
+ PermissionLevel: s.PermissionLevel,
305
+ Status: s.Status,
306
+ SharedByUserID: s.SharedByUserID ?? undefined,
307
+ CreatedAt: s.CreatedAt,
308
+ };
309
+ }
310
+
311
+ /** Guard for required-when-Kind-X fields on discriminated inputs. */
312
+ export function requireField<T>(value: T | undefined, name: string): T {
313
+ if (value == null) throw new Error(`'${name}' is required`);
314
+ return value;
315
+ }
316
+
317
+ /** Three failure helpers — one per output type — keep the try/catch
318
+ * sites in the resolver methods tiny. */
319
+ export function shareUnexpected(e: unknown, op: string): ShareResultType {
320
+ const message = e instanceof Error ? e.message : String(e);
321
+ return { Success: false, ResultCode: 'UNEXPECTED_ERROR', Message: `${op}: ${message}` };
322
+ }
323
+ export function inviteUnexpected(e: unknown, op: string): InviteResultType {
324
+ const message = e instanceof Error ? e.message : String(e);
325
+ return { Success: false, ResultCode: 'UNEXPECTED_ERROR', Message: `${op}: ${message}` };
326
+ }
327
+ export function acceptInvitationUnexpected(e: unknown, op: string): AcceptInvitationResultType {
328
+ const message = e instanceof Error ? e.message : String(e);
329
+ return { Success: false, ResultCode: 'UNEXPECTED_ERROR', Message: `${op}: ${message}` };
330
+ }
331
+
332
+ export function rebuildDeltaFromInput(input: ApplyDeltaInput): CoreListDelta {
333
+ return {
334
+ TargetListId: input.TargetListId,
335
+ EntityName: input.EntityName,
336
+ ToAdd: input.ToAdd,
337
+ ToRemove: input.ToRemove,
338
+ Unchanged: input.Unchanged,
339
+ Counts: {
340
+ Add: input.AddCount,
341
+ Remove: input.RemoveCount,
342
+ Unchanged: input.UnchangedCount,
343
+ SourceTotal: input.SourceTotal,
344
+ TargetTotal: input.TargetTotal,
345
+ },
346
+ Warnings: [],
347
+ DeltaToken: input.DeltaToken,
348
+ };
349
+ }
350
+
351
+ // ---------------------------------------------------------------------------
352
+ // Resolver
353
+ //
354
+ // All public capability flows through one `ListOperations` instance per
355
+ // request, constructed with the per-request read-write provider and the
356
+ // authenticated user. Auth is enforced at two layers: this resolver checks
357
+ // the user is authenticated (via UserPayload), and `ListOperations` itself
358
+ // enforces the drop-guard / token / staleness contract.
359
+ // ---------------------------------------------------------------------------
360
+
361
+ @Resolver()
362
+ export class ListOperationsResolver extends ResolverBase {
363
+ @Query(() => ListDeltaType, {
364
+ description:
365
+ 'Preview a list operation. Returns a signed DeltaToken that ApplyListDelta will accept (subject to TTL + drift checks).',
366
+ })
367
+ async PreviewListDelta(
368
+ @Arg('input', () => ComputeDeltaInput) input: ComputeDeltaInput,
369
+ @Ctx() ctx: AppContext,
370
+ ): Promise<ListDeltaType> {
371
+ const ops = this.buildOps(ctx);
372
+ const source = toCoreSource(input.Source);
373
+ const target = input.Target === 'new' ? 'new' : ({ kind: 'list', listId: input.Target } as const);
374
+ const delta = await ops.ComputeDelta(target, source, input.Mode);
375
+ return fromCoreDelta(delta);
376
+ }
377
+
378
+ @Mutation(() => ApplyListResultType, {
379
+ description: 'Apply a previously-previewed delta. Server enforces the drop-guard regardless of UI.',
380
+ })
381
+ async ApplyListDelta(
382
+ @Arg('input', () => ApplyDeltaInput) input: ApplyDeltaInput,
383
+ @Ctx() ctx: AppContext,
384
+ ): Promise<ApplyListResultType> {
385
+ const ops = this.buildOps(ctx);
386
+ try {
387
+ const delta = rebuildDeltaFromInput(input);
388
+ const result = await ops.ApplyDelta(delta, {
389
+ ConfirmDrops: input.ConfirmDrops,
390
+ DeltaToken: input.DeltaToken,
391
+ });
392
+ return fromCoreApplyResult(result);
393
+ } catch (e) {
394
+ return this.unexpectedFailure(e, 'ApplyListDelta');
395
+ }
396
+ }
397
+
398
+ @Mutation(() => ApplyListResultType)
399
+ async MaterializeListFromView(
400
+ @Arg('viewId', () => String) viewId: string,
401
+ @Arg('options', () => MaterializeOptionsInput) options: MaterializeOptionsInput,
402
+ @Ctx() ctx: AppContext,
403
+ ): Promise<ApplyListResultType> {
404
+ const ops = this.buildOps(ctx);
405
+ try {
406
+ const opts: CoreMaterializeOptions = {
407
+ ListName: options.ListName,
408
+ CategoryId: options.CategoryId,
409
+ Description: options.Description,
410
+ RememberLineage: options.RememberLineage,
411
+ UseSnapshot: options.UseSnapshot,
412
+ RefreshMode: options.RefreshMode,
413
+ };
414
+ const result = await ops.MaterializeFromView(viewId, opts);
415
+ return fromCoreApplyResult(result);
416
+ } catch (e) {
417
+ return this.unexpectedFailure(e, 'MaterializeListFromView');
418
+ }
419
+ }
420
+
421
+ @Mutation(() => ApplyListResultType)
422
+ async AddViewResultsToList(
423
+ @Arg('viewId', () => String) viewId: string,
424
+ @Arg('listId', () => String) listId: string,
425
+ @Ctx() ctx: AppContext,
426
+ ): Promise<ApplyListResultType> {
427
+ const ops = this.buildOps(ctx);
428
+ try {
429
+ const result = await ops.AddViewResultsToList(viewId, listId);
430
+ return fromCoreApplyResult(result);
431
+ } catch (e) {
432
+ return this.unexpectedFailure(e, 'AddViewResultsToList');
433
+ }
434
+ }
435
+
436
+ @Mutation(() => ApplyListResultType)
437
+ async RefreshListFromSource(
438
+ @Arg('listId', () => String) listId: string,
439
+ @Arg('mode', () => String) mode: 'Additive' | 'Sync',
440
+ @Arg('confirmDrops', () => Boolean) confirmDrops: boolean,
441
+ @Ctx() ctx: AppContext,
442
+ ): Promise<ApplyListResultType> {
443
+ const ops = this.buildOps(ctx);
444
+ try {
445
+ const result = await ops.RefreshFromSource(listId, mode, { ConfirmDrops: confirmDrops });
446
+ return fromCoreApplyResult(result);
447
+ } catch (e) {
448
+ return this.unexpectedFailure(e, 'RefreshListFromSource');
449
+ }
450
+ }
451
+
452
+ @Mutation(() => ListDeltaType, {
453
+ description:
454
+ 'Preview a set-op (union / intersection / difference) across N sources, optionally projected into a target list.',
455
+ })
456
+ async ComposeLists(
457
+ @Arg('input', () => ComposeListsInput) input: ComposeListsInput,
458
+ @Ctx() ctx: AppContext,
459
+ ): Promise<ListDeltaType> {
460
+ const ops = this.buildOps(ctx);
461
+ const inputs = input.Inputs.map(toCoreSource);
462
+ const target = input.Target ? toCoreSource(input.Target) : undefined;
463
+ const delta = await ops.ComputeSetOp(input.Op, inputs, target);
464
+ return fromCoreDelta(delta);
465
+ }
466
+
467
+ // -----------------------------------------------------------------------
468
+ // Sharing (Phase 2). Auth is enforced at two layers — this resolver checks
469
+ // the user is authenticated; `ListSharing` itself enforces the audit-log /
470
+ // status-transition contracts. Server-side notification dispatch fires
471
+ // automatically through the registered share-notification handler.
472
+ // -----------------------------------------------------------------------
473
+
474
+ @Mutation(() => ShareResultType)
475
+ async ShareList(
476
+ @Arg('input', () => ShareListInput) input: ShareListInput,
477
+ @Ctx() ctx: AppContext,
478
+ ): Promise<ShareResultType> {
479
+ const sharing = this.buildSharing(ctx);
480
+ try {
481
+ const target: ShareTarget =
482
+ input.Target.Kind === 'user'
483
+ ? { kind: 'user', userId: requireField(input.Target.UserID, 'Target.UserID') }
484
+ : { kind: 'role', roleId: requireField(input.Target.RoleID, 'Target.RoleID') };
485
+ const result = await sharing.Share({
486
+ ListID: input.ListID,
487
+ Target: target,
488
+ PermissionLevel: input.PermissionLevel,
489
+ });
490
+ return result;
491
+ } catch (e) {
492
+ return shareUnexpected(e, 'ShareList');
493
+ }
494
+ }
495
+
496
+ @Mutation(() => ShareResultType)
497
+ async UnshareList(
498
+ @Arg('permissionId', () => String) permissionId: string,
499
+ @Ctx() ctx: AppContext,
500
+ ): Promise<ShareResultType> {
501
+ const sharing = this.buildSharing(ctx);
502
+ try {
503
+ return await sharing.Unshare(permissionId);
504
+ } catch (e) {
505
+ return shareUnexpected(e, 'UnshareList');
506
+ }
507
+ }
508
+
509
+ @Mutation(() => InviteResultType)
510
+ async InviteToList(
511
+ @Arg('input', () => InviteToListInput) input: InviteToListInput,
512
+ @Ctx() ctx: AppContext,
513
+ ): Promise<InviteResultType> {
514
+ const sharing = this.buildSharing(ctx);
515
+ try {
516
+ return await sharing.Invite({
517
+ ListID: input.ListID,
518
+ Email: input.Email,
519
+ Role: input.Role,
520
+ TtlMs: input.TtlHours != null ? input.TtlHours * 60 * 60 * 1000 : undefined,
521
+ });
522
+ } catch (e) {
523
+ return inviteUnexpected(e, 'InviteToList');
524
+ }
525
+ }
526
+
527
+ @Mutation(() => AcceptInvitationResultType)
528
+ async AcceptListInvitation(
529
+ @Arg('token', () => String) token: string,
530
+ @Ctx() ctx: AppContext,
531
+ ): Promise<AcceptInvitationResultType> {
532
+ const sharing = this.buildSharing(ctx);
533
+ try {
534
+ return await sharing.AcceptInvitation(token);
535
+ } catch (e) {
536
+ return acceptInvitationUnexpected(e, 'AcceptListInvitation');
537
+ }
538
+ }
539
+
540
+ @Mutation(() => ShareResultType)
541
+ async RevokeListInvitation(
542
+ @Arg('invitationId', () => String) invitationId: string,
543
+ @Ctx() ctx: AppContext,
544
+ ): Promise<ShareResultType> {
545
+ const sharing = this.buildSharing(ctx);
546
+ try {
547
+ return await sharing.RevokeInvitation(invitationId);
548
+ } catch (e) {
549
+ return shareUnexpected(e, 'RevokeListInvitation');
550
+ }
551
+ }
552
+
553
+ @Query(() => [ListShareSummaryType])
554
+ async ListSharesForList(
555
+ @Arg('listId', () => String) listId: string,
556
+ @Ctx() ctx: AppContext,
557
+ ): Promise<ListShareSummaryType[]> {
558
+ const sharing = this.buildSharing(ctx);
559
+ const shares = await sharing.GetSharesForList(listId);
560
+ return shares.map(fromCoreShareSummary);
561
+ }
562
+
563
+ @Query(() => [SharedListSummaryType])
564
+ async ListsSharedWithMe(@Ctx() ctx: AppContext): Promise<SharedListSummaryType[]> {
565
+ const sharing = this.buildSharing(ctx);
566
+ const summaries = await sharing.GetListsSharedWithUser();
567
+ return summaries.map((s) => ({
568
+ ListID: s.ListID,
569
+ ListName: s.ListName,
570
+ PermissionLevel: s.PermissionLevel,
571
+ SharedByUserID: s.SharedByUserID ?? undefined,
572
+ SharedAt: s.SharedAt,
573
+ }));
574
+ }
575
+
576
+ // --- private helpers ---------------------------------------------------
577
+
578
+ private buildSharing(ctx: AppContext): ListSharing {
579
+ const user = this.requireUser(ctx);
580
+ const provider = GetReadWriteProvider(ctx.providers, { allowFallbackToReadOnly: false });
581
+ return new ListSharing(user, provider);
582
+ }
583
+
584
+ private buildOps(ctx: AppContext): ListOperations {
585
+ const user = this.requireUser(ctx);
586
+ const provider = GetReadWriteProvider(ctx.providers, { allowFallbackToReadOnly: false });
587
+ return new ListOperations(user, provider);
588
+ }
589
+
590
+ private requireUser(ctx: AppContext): UserInfo {
591
+ const user = ctx.userPayload.userRecord as UserInfo | undefined;
592
+ if (!user) throw new Error('User is not authenticated');
593
+ return user;
594
+ }
595
+
596
+ private unexpectedFailure(e: unknown, op: string): ApplyListResultType {
597
+ const message = e instanceof Error ? e.message : String(e);
598
+ LogError(`${op} threw: ${message}`);
599
+ return {
600
+ Success: false,
601
+ ResultCode: 'UNEXPECTED_ERROR',
602
+ Message: message,
603
+ };
604
+ }
605
+ }
606
+
607
+ export default ListOperationsResolver;