@memberjunction/server 5.35.0 → 5.36.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.
@@ -18,6 +18,12 @@ class AdhocQueryInput {
18
18
 
19
19
  @Field(() => Int, { nullable: true, description: 'Query timeout in seconds. Defaults to 30.' })
20
20
  TimeoutSeconds?: number;
21
+
22
+ @Field(() => Int, { nullable: true, description: 'Maximum number of rows to return. Applied in-memory after SQL execution; SQL still runs unbounded server-side.' })
23
+ MaxRows?: number;
24
+
25
+ @Field(() => Int, { nullable: true, description: 'Zero-based offset for pagination. Used in conjunction with MaxRows.' })
26
+ StartRow?: number;
21
27
  }
22
28
 
23
29
  /**
@@ -56,28 +62,55 @@ export class AdhocQueryResolver extends ResolverBase {
56
62
  return this.buildErrorResult('No read-only data source available for ad-hoc query execution');
57
63
  }
58
64
 
59
- // 3. Execute with timeout
65
+ // 3. Build executable SQL. When MaxRows is provided, wrap in a derived table
66
+ // with outer TOP so the engine can short-circuit at the source instead of
67
+ // scanning the full result. Skipped for SQL that begins with WITH/CTE — those
68
+ // can't be nested in a derived table on SQL Server and fall through to the
69
+ // in-memory slice below.
70
+ const startRow = input.StartRow ?? 0;
71
+ const maxRows = input.MaxRows;
72
+ const canWrap =
73
+ maxRows != null &&
74
+ Number.isInteger(maxRows) &&
75
+ maxRows > 0 &&
76
+ Number.isInteger(startRow) &&
77
+ startRow >= 0 &&
78
+ !/^\s*WITH\b/i.test(input.SQL);
79
+ const executableSql = canWrap
80
+ ? `SELECT TOP ${startRow + maxRows} * FROM (\n${input.SQL}\n) AS _adhoc_capped`
81
+ : input.SQL;
82
+
83
+ // 4. Execute with timeout
60
84
  const timeoutMs = (input.TimeoutSeconds ?? 30) * 1000;
61
85
  const request = new sql.Request(readOnlyDS);
62
86
 
63
87
  const result = await Promise.race([
64
- request.query(input.SQL),
88
+ request.query(executableSql),
65
89
  new Promise<never>((_, reject) =>
66
90
  setTimeout(() => reject(new Error('Query timeout exceeded')), timeoutMs)
67
91
  )
68
92
  ]);
69
93
  const executionTimeMs = Date.now() - startTime;
70
94
 
71
- // 4. Return as RunQueryResultType
95
+ // 5. Apply in-memory pagination. With the wrap applied this is a no-op for
96
+ // first-page reads; for StartRow > 0 (or CTE-headed SQL where the wrap was
97
+ // skipped) it carves out the requested page.
98
+ const fullRecordset = result.recordset ?? [];
99
+ const totalRowCount = fullRecordset.length;
100
+ let paginated = fullRecordset;
101
+ if (startRow > 0) paginated = paginated.slice(startRow);
102
+ if (maxRows != null && maxRows > 0) paginated = paginated.slice(0, maxRows);
103
+
104
+ // 6. Return as RunQueryResultType
72
105
  return {
73
106
  QueryID: '',
74
107
  QueryName: 'Ad-Hoc Query',
75
108
  Success: true,
76
- Results: JSON.stringify(result.recordset ?? []),
77
- RowCount: result.recordset?.length ?? 0,
78
- TotalRowCount: result.recordset?.length ?? 0,
79
- PageNumber: undefined,
80
- PageSize: undefined,
109
+ Results: JSON.stringify(paginated),
110
+ RowCount: paginated.length,
111
+ TotalRowCount: totalRowCount,
112
+ PageNumber: maxRows != null && maxRows > 0 ? Math.floor(startRow / maxRows) + 1 : undefined,
113
+ PageSize: maxRows ?? undefined,
81
114
  ExecutionTime: executionTimeMs,
82
115
  ErrorMessage: ''
83
116
  };
@@ -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;