@jgardner04/ghost-mcp-server 1.13.2 → 1.13.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/package.json +5 -13
  2. package/src/__tests__/helpers/mockGhostApi.js +36 -0
  3. package/src/__tests__/mcp_server.test.js +204 -117
  4. package/src/__tests__/mcp_server_pages.test.js +32 -18
  5. package/src/config/mcp-config.js +1 -1
  6. package/src/controllers/__tests__/tagController.test.js +12 -8
  7. package/src/controllers/tagController.js +2 -2
  8. package/src/errors/__tests__/index.test.js +3 -3
  9. package/src/errors/index.js +1 -1
  10. package/src/index.js +1 -1
  11. package/src/mcp_server.js +35 -31
  12. package/src/schemas/__tests__/postSchemas.test.js +19 -0
  13. package/src/schemas/__tests__/tagSchemas.test.js +1 -1
  14. package/src/schemas/common.js +2 -2
  15. package/src/schemas/memberSchemas.js +20 -8
  16. package/src/schemas/newsletterSchemas.js +10 -10
  17. package/src/schemas/pageSchemas.js +16 -11
  18. package/src/schemas/postSchemas.js +22 -15
  19. package/src/schemas/tagSchemas.js +12 -7
  20. package/src/schemas/tierSchemas.js +17 -8
  21. package/src/services/__tests__/ghostServiceImproved.members.test.js +31 -62
  22. package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +66 -69
  23. package/src/services/__tests__/ghostServiceImproved.pages.test.js +77 -48
  24. package/src/services/__tests__/ghostServiceImproved.posts.test.js +69 -55
  25. package/src/services/__tests__/ghostServiceImproved.tags.test.js +29 -66
  26. package/src/services/__tests__/ghostServiceImproved.tiers.test.js +12 -62
  27. package/src/services/__tests__/memberService.test.js +0 -28
  28. package/src/services/__tests__/tierService.test.js +0 -28
  29. package/src/services/ghostServiceImproved.js +117 -299
  30. package/src/services/imageProcessingService.js +1 -1
  31. package/src/services/memberService.js +0 -13
  32. package/src/services/tierService.js +0 -13
  33. package/src/utils/__tests__/nqlSanitizer.test.js +38 -0
  34. package/src/utils/nqlSanitizer.js +11 -0
@@ -11,6 +11,7 @@ import {
11
11
  retryWithBackoff,
12
12
  } from '../errors/index.js';
13
13
  import { createContextLogger } from '../utils/logger.js';
14
+ import { sanitizeNqlValue } from '../utils/nqlSanitizer.js';
14
15
 
15
16
  dotenv.config();
16
17
 
@@ -56,7 +57,7 @@ const handleApiRequest = async (resource, action, data = {}, options = {}, confi
56
57
  // Main execution function
57
58
  const executeRequest = async () => {
58
59
  try {
59
- console.error(`Executing Ghost API request: ${operation}`);
60
+ logger.info('Executing Ghost API request', { operation });
60
61
 
61
62
  let result;
62
63
 
@@ -80,7 +81,7 @@ const handleApiRequest = async (resource, action, data = {}, options = {}, confi
80
81
  result = await api[resource][action](data);
81
82
  }
82
83
 
83
- console.error(`Successfully executed Ghost API request: ${operation}`);
84
+ logger.info('Successfully executed Ghost API request', { operation });
84
85
  return result;
85
86
  } catch (error) {
86
87
  // Transform Ghost API errors into our error types
@@ -98,17 +99,21 @@ const handleApiRequest = async (resource, action, data = {}, options = {}, confi
98
99
  return await retryWithBackoff(wrappedExecute, {
99
100
  maxAttempts: maxRetries,
100
101
  onRetry: (attempt, _error) => {
101
- console.error(`Retrying ${operation} (attempt ${attempt}/${maxRetries})`);
102
+ logger.info('Retrying Ghost API request', { operation, attempt, maxRetries });
102
103
 
103
104
  // Log circuit breaker state if relevant
104
105
  if (useCircuitBreaker) {
105
106
  const state = ghostCircuitBreaker.getState();
106
- console.error(`Circuit breaker state:`, state);
107
+ logger.info('Circuit breaker state', { operation, state });
107
108
  }
108
109
  },
109
110
  });
110
111
  } catch (error) {
111
- console.error(`Failed to execute ${operation} after ${maxRetries} attempts:`, error.message);
112
+ logger.error('Failed to execute Ghost API request', {
113
+ operation,
114
+ maxRetries,
115
+ error: error.message,
116
+ });
112
117
  throw error;
113
118
  }
114
119
  };
@@ -258,18 +263,60 @@ const validators = {
258
263
  };
259
264
 
260
265
  /**
261
- * Service functions with enhanced error handling
266
+ * Shared CRUD helper: read a single resource by ID with 404→NotFoundError handling
262
267
  */
268
+ async function readResource(resource, id, label, options = {}) {
269
+ if (!id) throw new ValidationError(`${label} ID is required`);
270
+ try {
271
+ return await handleApiRequest(resource, 'read', { id }, options);
272
+ } catch (error) {
273
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
274
+ throw new NotFoundError(label, id);
275
+ }
276
+ throw error;
277
+ }
278
+ }
263
279
 
264
- export async function getSiteInfo() {
280
+ /**
281
+ * Shared CRUD helper: update a resource with optimistic concurrency control (OCC)
282
+ * Reads the current version, merges updated_at, then edits.
283
+ */
284
+ async function updateWithOCC(resource, id, updateData, options = {}, label = resource) {
285
+ const existing = await readResource(resource, id, label);
286
+ const editData = { ...updateData, updated_at: existing.updated_at };
265
287
  try {
266
- return await handleApiRequest('site', 'read');
288
+ return await handleApiRequest(resource, 'edit', { id, ...editData }, options);
267
289
  } catch (error) {
268
- console.error('Failed to get site info:', error);
290
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
291
+ throw new NotFoundError(label, id);
292
+ }
269
293
  throw error;
270
294
  }
271
295
  }
272
296
 
297
+ /**
298
+ * Shared CRUD helper: delete a resource by ID with 404→NotFoundError handling
299
+ */
300
+ async function deleteResource(resource, id, label) {
301
+ if (!id) throw new ValidationError(`${label} ID is required for deletion`);
302
+ try {
303
+ return await handleApiRequest(resource, 'delete', { id });
304
+ } catch (error) {
305
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
306
+ throw new NotFoundError(label, id);
307
+ }
308
+ throw error;
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Service functions with enhanced error handling
314
+ */
315
+
316
+ export async function getSiteInfo() {
317
+ return handleApiRequest('site', 'read');
318
+ }
319
+
273
320
  export async function createPost(postData, options = { source: 'html' }) {
274
321
  // Validate input
275
322
  validators.validatePostData(postData);
@@ -300,58 +347,26 @@ export async function updatePost(postId, updateData, options = {}) {
300
347
  throw new ValidationError('Post ID is required for update');
301
348
  }
302
349
 
303
- // Validate scheduled status if status is being updated
304
- if (updateData.status) {
305
- validators.validateScheduledStatus(updateData, 'Post');
306
- }
307
-
308
- // Get the current post first to ensure it exists
309
- try {
310
- const existingPost = await handleApiRequest('posts', 'read', { id: postId });
311
-
312
- // Send only changed fields + updated_at for OCC (optimistic concurrency control)
313
- const editData = {
314
- ...updateData,
315
- updated_at: existingPost.updated_at,
316
- };
317
-
318
- return await handleApiRequest('posts', 'edit', editData, { id: postId, ...options });
319
- } catch (error) {
320
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
321
- throw new NotFoundError('Post', postId);
350
+ // Validate scheduled status when status or published_at is being updated
351
+ if (updateData.status || updateData.published_at) {
352
+ let validationData = updateData;
353
+ // When only published_at changes, fetch existing status to check if post is scheduled
354
+ if (!updateData.status && updateData.published_at) {
355
+ const existing = await readResource('posts', postId, 'Post');
356
+ validationData = { ...updateData, status: existing.status };
322
357
  }
323
- throw error;
358
+ validators.validateScheduledStatus(validationData, 'Post');
324
359
  }
360
+
361
+ return updateWithOCC('posts', postId, updateData, options, 'Post');
325
362
  }
326
363
 
327
364
  export async function deletePost(postId) {
328
- if (!postId) {
329
- throw new ValidationError('Post ID is required for deletion');
330
- }
331
-
332
- try {
333
- return await handleApiRequest('posts', 'delete', { id: postId });
334
- } catch (error) {
335
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
336
- throw new NotFoundError('Post', postId);
337
- }
338
- throw error;
339
- }
365
+ return deleteResource('posts', postId, 'Post');
340
366
  }
341
367
 
342
368
  export async function getPost(postId, options = {}) {
343
- if (!postId) {
344
- throw new ValidationError('Post ID is required');
345
- }
346
-
347
- try {
348
- return await handleApiRequest('posts', 'read', { id: postId }, options);
349
- } catch (error) {
350
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
351
- throw new NotFoundError('Post', postId);
352
- }
353
- throw error;
354
- }
369
+ return readResource('posts', postId, 'Post', options);
355
370
  }
356
371
 
357
372
  export async function getPosts(options = {}) {
@@ -361,12 +376,7 @@ export async function getPosts(options = {}) {
361
376
  ...options,
362
377
  };
363
378
 
364
- try {
365
- return await handleApiRequest('posts', 'browse', {}, defaultOptions);
366
- } catch (error) {
367
- console.error('Failed to get posts:', error);
368
- throw error;
369
- }
379
+ return handleApiRequest('posts', 'browse', {}, defaultOptions);
370
380
  }
371
381
 
372
382
  export async function searchPosts(query, options = {}) {
@@ -376,7 +386,7 @@ export async function searchPosts(query, options = {}) {
376
386
  }
377
387
 
378
388
  // Sanitize query - escape special NQL characters to prevent injection
379
- const sanitizedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
389
+ const sanitizedQuery = sanitizeNqlValue(query);
380
390
 
381
391
  // Build filter with fuzzy title match using Ghost NQL
382
392
  const filterParts = [`title:~'${sanitizedQuery}'`];
@@ -392,12 +402,7 @@ export async function searchPosts(query, options = {}) {
392
402
  filter: filterParts.join('+'),
393
403
  };
394
404
 
395
- try {
396
- return await handleApiRequest('posts', 'browse', {}, searchOptions);
397
- } catch (error) {
398
- console.error('Failed to search posts:', error);
399
- throw error;
400
- }
405
+ return handleApiRequest('posts', 'browse', {}, searchOptions);
401
406
  }
402
407
 
403
408
  /**
@@ -436,58 +441,26 @@ export async function updatePage(pageId, updateData, options = {}) {
436
441
 
437
442
  // SECURITY: HTML must be sanitized before reaching this function. See htmlContentSchema in schemas/common.js
438
443
 
439
- // Validate scheduled status if status is being updated
440
- if (updateData.status) {
441
- validators.validateScheduledStatus(updateData, 'Page');
442
- }
443
-
444
- try {
445
- // Get existing page to retrieve updated_at for conflict resolution
446
- const existingPage = await handleApiRequest('pages', 'read', { id: pageId });
447
-
448
- // Send only changed fields + updated_at for OCC (optimistic concurrency control)
449
- const editData = {
450
- ...updateData,
451
- updated_at: existingPage.updated_at,
452
- };
453
-
454
- return await handleApiRequest('pages', 'edit', editData, { id: pageId, ...options });
455
- } catch (error) {
456
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
457
- throw new NotFoundError('Page', pageId);
444
+ // Validate scheduled status when status or published_at is being updated
445
+ if (updateData.status || updateData.published_at) {
446
+ let validationData = updateData;
447
+ // When only published_at changes, fetch existing status to check if page is scheduled
448
+ if (!updateData.status && updateData.published_at) {
449
+ const existing = await readResource('pages', pageId, 'Page');
450
+ validationData = { ...updateData, status: existing.status };
458
451
  }
459
- throw error;
452
+ validators.validateScheduledStatus(validationData, 'Page');
460
453
  }
454
+
455
+ return updateWithOCC('pages', pageId, updateData, options, 'Page');
461
456
  }
462
457
 
463
458
  export async function deletePage(pageId) {
464
- if (!pageId) {
465
- throw new ValidationError('Page ID is required for delete');
466
- }
467
-
468
- try {
469
- return await handleApiRequest('pages', 'delete', { id: pageId });
470
- } catch (error) {
471
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
472
- throw new NotFoundError('Page', pageId);
473
- }
474
- throw error;
475
- }
459
+ return deleteResource('pages', pageId, 'Page');
476
460
  }
477
461
 
478
462
  export async function getPage(pageId, options = {}) {
479
- if (!pageId) {
480
- throw new ValidationError('Page ID is required');
481
- }
482
-
483
- try {
484
- return await handleApiRequest('pages', 'read', { id: pageId }, options);
485
- } catch (error) {
486
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
487
- throw new NotFoundError('Page', pageId);
488
- }
489
- throw error;
490
- }
463
+ return readResource('pages', pageId, 'Page', options);
491
464
  }
492
465
 
493
466
  export async function getPages(options = {}) {
@@ -497,12 +470,7 @@ export async function getPages(options = {}) {
497
470
  ...options,
498
471
  };
499
472
 
500
- try {
501
- return await handleApiRequest('pages', 'browse', {}, defaultOptions);
502
- } catch (error) {
503
- console.error('Failed to get pages:', error);
504
- throw error;
505
- }
473
+ return handleApiRequest('pages', 'browse', {}, defaultOptions);
506
474
  }
507
475
 
508
476
  export async function searchPages(query, options = {}) {
@@ -512,7 +480,7 @@ export async function searchPages(query, options = {}) {
512
480
  }
513
481
 
514
482
  // Sanitize query - escape special NQL characters to prevent injection
515
- const sanitizedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
483
+ const sanitizedQuery = sanitizeNqlValue(query);
516
484
 
517
485
  // Build filter with fuzzy title match using Ghost NQL
518
486
  const filterParts = [`title:~'${sanitizedQuery}'`];
@@ -528,12 +496,7 @@ export async function searchPages(query, options = {}) {
528
496
  filter: filterParts.join('+'),
529
497
  };
530
498
 
531
- try {
532
- return await handleApiRequest('pages', 'browse', {}, searchOptions);
533
- } catch (error) {
534
- console.error('Failed to search pages:', error);
535
- throw error;
536
- }
499
+ return handleApiRequest('pages', 'browse', {}, searchOptions);
537
500
  }
538
501
 
539
502
  export async function uploadImage(imagePath) {
@@ -585,36 +548,20 @@ export async function createTag(tagData) {
585
548
  }
586
549
 
587
550
  export async function getTags(options = {}) {
588
- try {
589
- const tags = await handleApiRequest(
590
- 'tags',
591
- 'browse',
592
- {},
593
- {
594
- limit: 15,
595
- ...options,
596
- }
597
- );
598
- return tags || [];
599
- } catch (error) {
600
- logger.error('Failed to get tags', { error: error.message });
601
- throw error;
602
- }
551
+ const tags = await handleApiRequest(
552
+ 'tags',
553
+ 'browse',
554
+ {},
555
+ {
556
+ limit: 15,
557
+ ...options,
558
+ }
559
+ );
560
+ return tags || [];
603
561
  }
604
562
 
605
563
  export async function getTag(tagId, options = {}) {
606
- if (!tagId) {
607
- throw new ValidationError('Tag ID is required');
608
- }
609
-
610
- try {
611
- return await handleApiRequest('tags', 'read', { id: tagId }, options);
612
- } catch (error) {
613
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
614
- throw new NotFoundError('Tag', tagId);
615
- }
616
- throw error;
617
- }
564
+ return readResource('tags', tagId, 'Tag', options);
618
565
  }
619
566
 
620
567
  export async function updateTag(tagId, updateData) {
@@ -622,14 +569,11 @@ export async function updateTag(tagId, updateData) {
622
569
  throw new ValidationError('Tag ID is required for update');
623
570
  }
624
571
 
625
- validators.validateTagUpdateData(updateData); // Validate update data
572
+ validators.validateTagUpdateData(updateData);
626
573
 
627
574
  try {
628
- // Verify tag exists before updating
629
- await getTag(tagId);
630
-
631
- // Send only changed fields (tags don't use updated_at for OCC)
632
- return await handleApiRequest('tags', 'edit', { ...updateData }, { id: tagId });
575
+ await readResource('tags', tagId, 'Tag');
576
+ return await handleApiRequest('tags', 'edit', { id: tagId, ...updateData });
633
577
  } catch (error) {
634
578
  if (error instanceof NotFoundError) {
635
579
  throw error;
@@ -644,18 +588,7 @@ export async function updateTag(tagId, updateData) {
644
588
  }
645
589
 
646
590
  export async function deleteTag(tagId) {
647
- if (!tagId) {
648
- throw new ValidationError('Tag ID is required for deletion');
649
- }
650
-
651
- try {
652
- return await handleApiRequest('tags', 'delete', { id: tagId });
653
- } catch (error) {
654
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
655
- throw new NotFoundError('Tag', tagId);
656
- }
657
- throw error;
658
- }
591
+ return deleteResource('tags', tagId, 'Tag');
659
592
  }
660
593
 
661
594
  /**
@@ -713,23 +646,7 @@ export async function updateMember(memberId, updateData, options = {}) {
713
646
  throw new ValidationError('Member ID is required for update');
714
647
  }
715
648
 
716
- try {
717
- // Get existing member to retrieve updated_at for conflict resolution
718
- const existingMember = await handleApiRequest('members', 'read', { id: memberId });
719
-
720
- // Send only changed fields + updated_at for OCC (optimistic concurrency control)
721
- const editData = {
722
- ...updateData,
723
- updated_at: existingMember.updated_at,
724
- };
725
-
726
- return await handleApiRequest('members', 'edit', editData, { id: memberId, ...options });
727
- } catch (error) {
728
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
729
- throw new NotFoundError('Member', memberId);
730
- }
731
- throw error;
732
- }
649
+ return updateWithOCC('members', memberId, updateData, options, 'Member');
733
650
  }
734
651
 
735
652
  /**
@@ -741,18 +658,7 @@ export async function updateMember(memberId, updateData, options = {}) {
741
658
  * @throws {GhostAPIError} If the API request fails
742
659
  */
743
660
  export async function deleteMember(memberId) {
744
- if (!memberId) {
745
- throw new ValidationError('Member ID is required for deletion');
746
- }
747
-
748
- try {
749
- return await handleApiRequest('members', 'delete', { id: memberId });
750
- } catch (error) {
751
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
752
- throw new NotFoundError('Member', memberId);
753
- }
754
- throw error;
755
- }
661
+ return deleteResource('members', memberId, 'Member');
756
662
  }
757
663
 
758
664
  /**
@@ -774,13 +680,8 @@ export async function getMembers(options = {}) {
774
680
  ...options,
775
681
  };
776
682
 
777
- try {
778
- const members = await handleApiRequest('members', 'browse', {}, defaultOptions);
779
- return members || [];
780
- } catch (error) {
781
- console.error('Failed to get members:', error);
782
- throw error;
783
- }
683
+ const members = await handleApiRequest('members', 'browse', {}, defaultOptions);
684
+ return members || [];
784
685
  }
785
686
 
786
687
  /**
@@ -795,7 +696,6 @@ export async function getMembers(options = {}) {
795
696
  */
796
697
  export async function getMember(params) {
797
698
  // Input validation is performed at the MCP tool layer using Zod schemas
798
- const { sanitizeNqlValue } = await import('./memberService.js');
799
699
  const { id, email } = params;
800
700
 
801
701
  try {
@@ -837,7 +737,6 @@ export async function getMember(params) {
837
737
  */
838
738
  export async function searchMembers(query, options = {}) {
839
739
  // Input validation is performed at the MCP tool layer using Zod schemas
840
- const { sanitizeNqlValue } = await import('./memberService.js');
841
740
  const sanitizedQuery = sanitizeNqlValue(query.trim());
842
741
 
843
742
  const limit = options.limit || 15;
@@ -846,13 +745,8 @@ export async function searchMembers(query, options = {}) {
846
745
  // Ghost uses ~ for contains/like matching
847
746
  const filter = `name:~'${sanitizedQuery}',email:~'${sanitizedQuery}'`;
848
747
 
849
- try {
850
- const members = await handleApiRequest('members', 'browse', {}, { filter, limit });
851
- return members || [];
852
- } catch (error) {
853
- console.error('Failed to search members:', error);
854
- throw error;
855
- }
748
+ const members = await handleApiRequest('members', 'browse', {}, { filter, limit });
749
+ return members || [];
856
750
  }
857
751
 
858
752
  /**
@@ -865,28 +759,12 @@ export async function getNewsletters(options = {}) {
865
759
  ...options,
866
760
  };
867
761
 
868
- try {
869
- const newsletters = await handleApiRequest('newsletters', 'browse', {}, defaultOptions);
870
- return newsletters || [];
871
- } catch (error) {
872
- console.error('Failed to get newsletters:', error);
873
- throw error;
874
- }
762
+ const newsletters = await handleApiRequest('newsletters', 'browse', {}, defaultOptions);
763
+ return newsletters || [];
875
764
  }
876
765
 
877
766
  export async function getNewsletter(newsletterId) {
878
- if (!newsletterId) {
879
- throw new ValidationError('Newsletter ID is required');
880
- }
881
-
882
- try {
883
- return await handleApiRequest('newsletters', 'read', { id: newsletterId });
884
- } catch (error) {
885
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
886
- throw new NotFoundError('Newsletter', newsletterId);
887
- }
888
- throw error;
889
- }
767
+ return readResource('newsletters', newsletterId, 'Newsletter');
890
768
  }
891
769
 
892
770
  export async function createNewsletter(newsletterData) {
@@ -911,22 +789,8 @@ export async function updateNewsletter(newsletterId, updateData) {
911
789
  }
912
790
 
913
791
  try {
914
- // Get existing newsletter to retrieve updated_at for conflict resolution
915
- const existingNewsletter = await handleApiRequest('newsletters', 'read', {
916
- id: newsletterId,
917
- });
918
-
919
- // Send only changed fields + updated_at for OCC (optimistic concurrency control)
920
- const editData = {
921
- ...updateData,
922
- updated_at: existingNewsletter.updated_at,
923
- };
924
-
925
- return await handleApiRequest('newsletters', 'edit', editData, { id: newsletterId });
792
+ return await updateWithOCC('newsletters', newsletterId, updateData, {}, 'Newsletter');
926
793
  } catch (error) {
927
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
928
- throw new NotFoundError('Newsletter', newsletterId);
929
- }
930
794
  if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
931
795
  throw new ValidationError('Newsletter update failed', [
932
796
  { field: 'newsletter', message: error.originalError },
@@ -937,18 +801,7 @@ export async function updateNewsletter(newsletterId, updateData) {
937
801
  }
938
802
 
939
803
  export async function deleteNewsletter(newsletterId) {
940
- if (!newsletterId) {
941
- throw new ValidationError('Newsletter ID is required for deletion');
942
- }
943
-
944
- try {
945
- return await handleApiRequest('newsletters', 'delete', newsletterId);
946
- } catch (error) {
947
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
948
- throw new NotFoundError('Newsletter', newsletterId);
949
- }
950
- throw error;
951
- }
804
+ return deleteResource('newsletters', newsletterId, 'Newsletter');
952
805
  }
953
806
 
954
807
  /**
@@ -988,23 +841,7 @@ export async function updateTier(id, updateData, options = {}) {
988
841
  const { validateTierUpdateData } = await import('./tierService.js');
989
842
  validateTierUpdateData(updateData);
990
843
 
991
- try {
992
- // Get existing tier to retrieve updated_at for conflict resolution
993
- const existingTier = await handleApiRequest('tiers', 'read', { id }, { id });
994
-
995
- // Send only changed fields + updated_at for OCC (optimistic concurrency control)
996
- const editData = {
997
- ...updateData,
998
- updated_at: existingTier.updated_at,
999
- };
1000
-
1001
- return await handleApiRequest('tiers', 'edit', editData, { id, ...options });
1002
- } catch (error) {
1003
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
1004
- throw new NotFoundError('Tier', id);
1005
- }
1006
- throw error;
1007
- }
844
+ return updateWithOCC('tiers', id, updateData, options, 'Tier');
1008
845
  }
1009
846
 
1010
847
  /**
@@ -1017,14 +854,7 @@ export async function deleteTier(id) {
1017
854
  throw new ValidationError('Tier ID is required for deletion');
1018
855
  }
1019
856
 
1020
- try {
1021
- return await handleApiRequest('tiers', 'delete', { id });
1022
- } catch (error) {
1023
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
1024
- throw new NotFoundError('Tier', id);
1025
- }
1026
- throw error;
1027
- }
857
+ return deleteResource('tiers', id, 'Tier');
1028
858
  }
1029
859
 
1030
860
  /**
@@ -1046,13 +876,8 @@ export async function getTiers(options = {}) {
1046
876
  ...options,
1047
877
  };
1048
878
 
1049
- try {
1050
- const tiers = await handleApiRequest('tiers', 'browse', {}, defaultOptions);
1051
- return tiers || [];
1052
- } catch (error) {
1053
- console.error('Failed to get tiers:', error);
1054
- throw error;
1055
- }
879
+ const tiers = await handleApiRequest('tiers', 'browse', {}, defaultOptions);
880
+ return tiers || [];
1056
881
  }
1057
882
 
1058
883
  /**
@@ -1065,14 +890,7 @@ export async function getTier(id) {
1065
890
  throw new ValidationError('Tier ID is required and must be a non-empty string');
1066
891
  }
1067
892
 
1068
- try {
1069
- return await handleApiRequest('tiers', 'read', { id }, { id });
1070
- } catch (error) {
1071
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
1072
- throw new NotFoundError('Tier', id);
1073
- }
1074
- throw error;
1075
- }
893
+ return readResource('tiers', id, 'Tier');
1076
894
  }
1077
895
 
1078
896
  /**
@@ -89,7 +89,7 @@ const processImage = async (inputPath, outputDir) => {
89
89
  });
90
90
  // If processing fails, maybe fall back to using the original?
91
91
  // Or throw the error to fail the upload.
92
- throw new Error('Image processing failed: ' + error.message);
92
+ throw new Error('Image processing failed: ' + error.message, { cause: error });
93
93
  }
94
94
  };
95
95
 
@@ -204,18 +204,6 @@ export function validateMemberUpdateData(updateData) {
204
204
  }
205
205
  }
206
206
 
207
- /**
208
- * Sanitizes a value for use in NQL filters to prevent injection
209
- * Escapes backslashes, single quotes, and double quotes
210
- * @param {string} value - The value to sanitize
211
- * @returns {string} The sanitized value
212
- */
213
- export function sanitizeNqlValue(value) {
214
- if (!value) return value;
215
- // Escape backslashes first, then quotes
216
- return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');
217
- }
218
-
219
207
  /**
220
208
  * Validates query options for member browsing
221
209
  * @param {Object} options - The query options to validate
@@ -388,5 +376,4 @@ export default {
388
376
  validateMemberLookup,
389
377
  validateSearchQuery,
390
378
  validateSearchOptions,
391
- sanitizeNqlValue,
392
379
  };