@jgardner04/ghost-mcp-server 1.13.3 → 1.13.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -0
- package/package.json +7 -3
- package/src/__tests__/helpers/mockGhostApi.js +36 -0
- package/src/__tests__/helpers/testUtils.js +15 -1
- package/src/__tests__/mcp_server.test.js +69 -1
- package/src/__tests__/mcp_server_pages.test.js +23 -6
- package/src/mcp_server.js +393 -1143
- package/src/services/__tests__/createResourceService.test.js +468 -0
- package/src/services/__tests__/ghostServiceImproved.members.test.js +21 -60
- package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +58 -65
- package/src/services/__tests__/ghostServiceImproved.pages.test.js +77 -51
- package/src/services/__tests__/ghostServiceImproved.posts.test.js +65 -52
- package/src/services/__tests__/ghostServiceImproved.tags.test.js +24 -64
- package/src/services/__tests__/ghostServiceImproved.tiers.test.js +6 -53
- package/src/services/createResourceService.js +138 -0
- package/src/services/ghostApiClient.js +240 -0
- package/src/services/ghostServiceImproved.js +76 -949
- package/src/services/images.js +27 -0
- package/src/services/members.js +127 -0
- package/src/services/newsletters.js +63 -0
- package/src/services/pages.js +116 -0
- package/src/services/posts.js +116 -0
- package/src/services/tags.js +118 -0
- package/src/services/tiers.js +72 -0
- package/src/services/validators.js +218 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { GhostAPIError, ValidationError } from '../errors/index.js';
|
|
2
|
+
import { handleApiRequest } from './ghostApiClient.js';
|
|
3
|
+
import { validators } from './validators.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Uploads an image to Ghost CMS from a local file path.
|
|
7
|
+
* @param {string} imagePath - Absolute path to the image file
|
|
8
|
+
* @returns {Promise<Object>} The uploaded image object with URL
|
|
9
|
+
* @throws {ValidationError} If the path is invalid or upload fails
|
|
10
|
+
* @throws {NotFoundError} If the file does not exist
|
|
11
|
+
* @throws {GhostAPIError} If the API request fails
|
|
12
|
+
*/
|
|
13
|
+
export async function uploadImage(imagePath) {
|
|
14
|
+
// Validate input
|
|
15
|
+
await validators.validateImagePath(imagePath);
|
|
16
|
+
|
|
17
|
+
const imageData = { file: imagePath };
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
return await handleApiRequest('images', 'upload', imageData);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (error instanceof GhostAPIError) {
|
|
23
|
+
throw new ValidationError(`Image upload failed: ${error.originalError}`);
|
|
24
|
+
}
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { GhostAPIError, NotFoundError } from '../errors/index.js';
|
|
2
|
+
import { sanitizeNqlValue } from '../utils/nqlSanitizer.js';
|
|
3
|
+
import { handleApiRequest } from './ghostApiClient.js';
|
|
4
|
+
import { createResourceService } from './createResourceService.js';
|
|
5
|
+
|
|
6
|
+
const service = createResourceService({
|
|
7
|
+
resource: 'members',
|
|
8
|
+
label: 'Member',
|
|
9
|
+
listDefaults: { limit: 15 },
|
|
10
|
+
// Input validation is performed at the MCP tool layer using Zod schemas
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Creates a new member (subscriber) in Ghost CMS
|
|
15
|
+
* @param {Object} memberData - The member data
|
|
16
|
+
* @param {string} memberData.email - Member email (required)
|
|
17
|
+
* @param {string} [memberData.name] - Member name
|
|
18
|
+
* @param {string} [memberData.note] - Notes about the member (HTML will be sanitized)
|
|
19
|
+
* @param {string[]} [memberData.labels] - Array of label names
|
|
20
|
+
* @param {Object[]} [memberData.newsletters] - Array of newsletter objects with id
|
|
21
|
+
* @param {boolean} [memberData.subscribed] - Email subscription status
|
|
22
|
+
* @param {Object} [options] - Additional options for the API request
|
|
23
|
+
* @returns {Promise<Object>} The created member object
|
|
24
|
+
* @throws {ValidationError} If validation fails
|
|
25
|
+
* @throws {GhostAPIError} If the API request fails
|
|
26
|
+
*/
|
|
27
|
+
export const createMember = service.create;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Updates an existing member in Ghost CMS
|
|
31
|
+
* @param {string} memberId - The member ID to update
|
|
32
|
+
* @param {Object} updateData - The member update data
|
|
33
|
+
* @param {string} [updateData.email] - Member email
|
|
34
|
+
* @param {string} [updateData.name] - Member name
|
|
35
|
+
* @param {string} [updateData.note] - Notes about the member (HTML will be sanitized)
|
|
36
|
+
* @param {string[]} [updateData.labels] - Array of label names
|
|
37
|
+
* @param {Object[]} [updateData.newsletters] - Array of newsletter objects with id
|
|
38
|
+
* @param {boolean} [updateData.subscribed] - Email subscription status
|
|
39
|
+
* @param {Object} [options] - Additional options for the API request
|
|
40
|
+
* @returns {Promise<Object>} The updated member object
|
|
41
|
+
* @throws {ValidationError} If validation fails
|
|
42
|
+
* @throws {NotFoundError} If the member is not found
|
|
43
|
+
* @throws {GhostAPIError} If the API request fails
|
|
44
|
+
*/
|
|
45
|
+
export const updateMember = service.update;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Deletes a member from Ghost CMS
|
|
49
|
+
* @param {string} memberId - The member ID to delete
|
|
50
|
+
* @returns {Promise<Object>} Deletion confirmation object
|
|
51
|
+
* @throws {ValidationError} If member ID is not provided
|
|
52
|
+
* @throws {NotFoundError} If the member is not found
|
|
53
|
+
* @throws {GhostAPIError} If the API request fails
|
|
54
|
+
*/
|
|
55
|
+
export const deleteMember = service.remove;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* List members from Ghost CMS with optional filtering and pagination
|
|
59
|
+
* @param {Object} [options] - Query options
|
|
60
|
+
* @param {number} [options.limit] - Number of members to return (1-100)
|
|
61
|
+
* @param {number} [options.page] - Page number (1+)
|
|
62
|
+
* @param {string} [options.filter] - NQL filter string (e.g., 'status:paid')
|
|
63
|
+
* @param {string} [options.order] - Order string (e.g., 'created_at desc')
|
|
64
|
+
* @param {string} [options.include] - Include string (e.g., 'labels,newsletters')
|
|
65
|
+
* @returns {Promise<Array>} Array of member objects
|
|
66
|
+
* @throws {ValidationError} If validation fails
|
|
67
|
+
* @throws {GhostAPIError} If the API request fails
|
|
68
|
+
*/
|
|
69
|
+
export const getMembers = service.getList;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get a single member from Ghost CMS by ID or email
|
|
73
|
+
* @param {Object} params - Lookup parameters (id OR email required)
|
|
74
|
+
* @param {string} [params.id] - Member ID
|
|
75
|
+
* @param {string} [params.email] - Member email
|
|
76
|
+
* @returns {Promise<Object>} The member object
|
|
77
|
+
* @throws {ValidationError} If validation fails
|
|
78
|
+
* @throws {NotFoundError} If the member is not found
|
|
79
|
+
* @throws {GhostAPIError} If the API request fails
|
|
80
|
+
*/
|
|
81
|
+
export async function getMember(params) {
|
|
82
|
+
const { id, email } = params;
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
if (id) {
|
|
86
|
+
return await handleApiRequest('members', 'read', { id }, { id });
|
|
87
|
+
} else {
|
|
88
|
+
const sanitizedEmail = sanitizeNqlValue(email);
|
|
89
|
+
const members = await handleApiRequest(
|
|
90
|
+
'members',
|
|
91
|
+
'browse',
|
|
92
|
+
{},
|
|
93
|
+
{ filter: `email:'${sanitizedEmail}'`, limit: 1 }
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
if (!members || members.length === 0) {
|
|
97
|
+
throw new NotFoundError('Member', email);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return members[0];
|
|
101
|
+
}
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
104
|
+
throw new NotFoundError('Member', id || email);
|
|
105
|
+
}
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Search members by name or email
|
|
112
|
+
* @param {string} query - Search query (searches name and email fields)
|
|
113
|
+
* @param {Object} [options] - Additional options
|
|
114
|
+
* @param {number} [options.limit] - Maximum number of results (default: 15)
|
|
115
|
+
* @returns {Promise<Array>} Array of matching member objects
|
|
116
|
+
* @throws {ValidationError} If validation fails
|
|
117
|
+
* @throws {GhostAPIError} If the API request fails
|
|
118
|
+
*/
|
|
119
|
+
export async function searchMembers(query, options = {}) {
|
|
120
|
+
const sanitizedQuery = sanitizeNqlValue(query.trim());
|
|
121
|
+
const limit = options.limit || 15;
|
|
122
|
+
|
|
123
|
+
const filter = `name:~'${sanitizedQuery}',email:~'${sanitizedQuery}'`;
|
|
124
|
+
|
|
125
|
+
const members = await handleApiRequest('members', 'browse', {}, { filter, limit });
|
|
126
|
+
return members || [];
|
|
127
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createResourceService } from './createResourceService.js';
|
|
2
|
+
import { validators } from './validators.js';
|
|
3
|
+
|
|
4
|
+
const service = createResourceService({
|
|
5
|
+
resource: 'newsletters',
|
|
6
|
+
label: 'Newsletter',
|
|
7
|
+
listDefaults: { limit: 'all' },
|
|
8
|
+
validateCreate: (data) => validators.validateNewsletterData(data),
|
|
9
|
+
catch422OnUpdate: true,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Lists all newsletters with optional filtering and pagination.
|
|
14
|
+
* @param {Object} [options={}] - Query options
|
|
15
|
+
* @param {string|number} [options.limit='all'] - Number of newsletters to return (default: 'all')
|
|
16
|
+
* @returns {Promise<Array>} Array of newsletter objects (empty array if none found)
|
|
17
|
+
* @throws {GhostAPIError} If the API request fails
|
|
18
|
+
*/
|
|
19
|
+
export const getNewsletters = service.getList;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Retrieves a single newsletter by ID.
|
|
23
|
+
* @param {string} newsletterId - The newsletter ID to retrieve
|
|
24
|
+
* @returns {Promise<Object>} The newsletter object
|
|
25
|
+
* @throws {ValidationError} If the newsletter ID is missing
|
|
26
|
+
* @throws {NotFoundError} If the newsletter is not found
|
|
27
|
+
* @throws {GhostAPIError} If the API request fails
|
|
28
|
+
*/
|
|
29
|
+
export const getNewsletter = service.getOne;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Creates a new newsletter in Ghost CMS.
|
|
33
|
+
* @param {Object} newsletterData - The newsletter data
|
|
34
|
+
* @param {string} newsletterData.name - Newsletter name (required)
|
|
35
|
+
* @param {string} [newsletterData.description] - Newsletter description
|
|
36
|
+
* @param {string} [newsletterData.sender_name] - Sender name
|
|
37
|
+
* @param {string} [newsletterData.sender_email] - Sender email
|
|
38
|
+
* @returns {Promise<Object>} The created newsletter object
|
|
39
|
+
* @throws {ValidationError} If validation fails or Ghost returns a 422
|
|
40
|
+
* @throws {GhostAPIError} If the API request fails
|
|
41
|
+
*/
|
|
42
|
+
export const createNewsletter = service.create;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Updates an existing newsletter with optimistic concurrency control.
|
|
46
|
+
* @param {string} newsletterId - The newsletter ID to update
|
|
47
|
+
* @param {Object} updateData - Fields to update on the newsletter
|
|
48
|
+
* @returns {Promise<Object>} The updated newsletter object
|
|
49
|
+
* @throws {ValidationError} If the newsletter ID is missing or Ghost returns a 422
|
|
50
|
+
* @throws {NotFoundError} If the newsletter is not found
|
|
51
|
+
* @throws {GhostAPIError} If the API request fails
|
|
52
|
+
*/
|
|
53
|
+
export const updateNewsletter = service.update;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Deletes a newsletter by ID.
|
|
57
|
+
* @param {string} newsletterId - The newsletter ID to delete
|
|
58
|
+
* @returns {Promise<Object>} Deletion confirmation
|
|
59
|
+
* @throws {ValidationError} If the newsletter ID is missing
|
|
60
|
+
* @throws {NotFoundError} If the newsletter is not found
|
|
61
|
+
* @throws {GhostAPIError} If the API request fails
|
|
62
|
+
*/
|
|
63
|
+
export const deleteNewsletter = service.remove;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { ValidationError } from '../errors/index.js';
|
|
2
|
+
import { sanitizeNqlValue } from '../utils/nqlSanitizer.js';
|
|
3
|
+
import { handleApiRequest, readResource } from './ghostApiClient.js';
|
|
4
|
+
import { createResourceService } from './createResourceService.js';
|
|
5
|
+
import { validators } from './validators.js';
|
|
6
|
+
|
|
7
|
+
const service = createResourceService({
|
|
8
|
+
resource: 'pages',
|
|
9
|
+
label: 'Page',
|
|
10
|
+
createDefaults: { status: 'draft' },
|
|
11
|
+
createOptions: { source: 'html' },
|
|
12
|
+
listDefaults: { limit: 15, include: 'authors' },
|
|
13
|
+
validateCreate: (data) => validators.validatePageData(data),
|
|
14
|
+
validateUpdate: async (id, updateData) => {
|
|
15
|
+
if (updateData.status || updateData.published_at) {
|
|
16
|
+
let validationData = updateData;
|
|
17
|
+
if (!updateData.status && updateData.published_at) {
|
|
18
|
+
const existing = await readResource('pages', id, 'Page');
|
|
19
|
+
validationData = { ...updateData, status: existing.status };
|
|
20
|
+
}
|
|
21
|
+
validators.validateScheduledStatus(validationData, 'Page');
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Creates a new page in Ghost CMS.
|
|
28
|
+
* @param {Object} pageData - The page data
|
|
29
|
+
* @param {string} pageData.title - Page title (required)
|
|
30
|
+
* @param {string} [pageData.html] - HTML content (required if mobiledoc not provided)
|
|
31
|
+
* @param {string} [pageData.mobiledoc] - Mobiledoc content (required if html not provided)
|
|
32
|
+
* @param {string} [pageData.status='draft'] - Page status ('draft', 'published', 'scheduled')
|
|
33
|
+
* @param {string} [pageData.published_at] - ISO 8601 date for scheduled pages
|
|
34
|
+
* @param {Object} [options={ source: 'html' }] - API request options
|
|
35
|
+
* @returns {Promise<Object>} The created page object
|
|
36
|
+
* @throws {ValidationError} If validation fails or Ghost returns a 422
|
|
37
|
+
* @throws {GhostAPIError} If the API request fails
|
|
38
|
+
*/
|
|
39
|
+
export const createPage = service.create;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Updates an existing page with optimistic concurrency control.
|
|
43
|
+
* Validates scheduling fields when status or published_at is being changed.
|
|
44
|
+
* @param {string} pageId - The page ID to update
|
|
45
|
+
* @param {Object} updateData - Fields to update on the page
|
|
46
|
+
* @param {Object} [options={}] - API request options
|
|
47
|
+
* @returns {Promise<Object>} The updated page object
|
|
48
|
+
* @throws {ValidationError} If the page ID is missing or scheduling validation fails
|
|
49
|
+
* @throws {NotFoundError} If the page is not found
|
|
50
|
+
* @throws {GhostAPIError} If the API request fails
|
|
51
|
+
*/
|
|
52
|
+
export const updatePage = service.update;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Deletes a page by ID.
|
|
56
|
+
* @param {string} pageId - The page ID to delete
|
|
57
|
+
* @returns {Promise<Object>} Deletion confirmation
|
|
58
|
+
* @throws {ValidationError} If the page ID is missing
|
|
59
|
+
* @throws {NotFoundError} If the page is not found
|
|
60
|
+
* @throws {GhostAPIError} If the API request fails
|
|
61
|
+
*/
|
|
62
|
+
export const deletePage = service.remove;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Retrieves a single page by ID.
|
|
66
|
+
* @param {string} pageId - The page ID to retrieve
|
|
67
|
+
* @param {Object} [options={}] - API request options (e.g., includes)
|
|
68
|
+
* @returns {Promise<Object>} The page object
|
|
69
|
+
* @throws {ValidationError} If the page ID is missing
|
|
70
|
+
* @throws {NotFoundError} If the page is not found
|
|
71
|
+
* @throws {GhostAPIError} If the API request fails
|
|
72
|
+
*/
|
|
73
|
+
export const getPage = service.getOne;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Lists pages with optional filtering and pagination.
|
|
77
|
+
* @param {Object} [options={}] - Query options
|
|
78
|
+
* @param {number} [options.limit=15] - Number of pages to return
|
|
79
|
+
* @param {string} [options.include='authors'] - Related resources to include
|
|
80
|
+
* @param {string} [options.filter] - NQL filter string
|
|
81
|
+
* @param {string} [options.order] - Order string
|
|
82
|
+
* @returns {Promise<Array>} Array of page objects
|
|
83
|
+
* @throws {GhostAPIError} If the API request fails
|
|
84
|
+
*/
|
|
85
|
+
export const getPages = service.getList;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Searches pages by title using Ghost NQL fuzzy matching.
|
|
89
|
+
* @param {string} query - Search query string (required, non-empty)
|
|
90
|
+
* @param {Object} [options={}] - Additional search options
|
|
91
|
+
* @param {string} [options.status] - Filter by status ('draft', 'published', 'scheduled', or 'all')
|
|
92
|
+
* @param {number} [options.limit=15] - Maximum number of results
|
|
93
|
+
* @returns {Promise<Array>} Array of matching page objects
|
|
94
|
+
* @throws {ValidationError} If the query is empty
|
|
95
|
+
* @throws {GhostAPIError} If the API request fails
|
|
96
|
+
*/
|
|
97
|
+
export async function searchPages(query, options = {}) {
|
|
98
|
+
if (!query || query.trim().length === 0) {
|
|
99
|
+
throw new ValidationError('Search query is required');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const sanitizedQuery = sanitizeNqlValue(query);
|
|
103
|
+
|
|
104
|
+
const filterParts = [`title:~'${sanitizedQuery}'`];
|
|
105
|
+
if (options.status && options.status !== 'all') {
|
|
106
|
+
filterParts.push(`status:${options.status}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const searchOptions = {
|
|
110
|
+
limit: options.limit || 15,
|
|
111
|
+
include: 'authors',
|
|
112
|
+
filter: filterParts.join('+'),
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return handleApiRequest('pages', 'browse', {}, searchOptions);
|
|
116
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { ValidationError } from '../errors/index.js';
|
|
2
|
+
import { sanitizeNqlValue } from '../utils/nqlSanitizer.js';
|
|
3
|
+
import { handleApiRequest, readResource } from './ghostApiClient.js';
|
|
4
|
+
import { createResourceService } from './createResourceService.js';
|
|
5
|
+
import { validators } from './validators.js';
|
|
6
|
+
|
|
7
|
+
const service = createResourceService({
|
|
8
|
+
resource: 'posts',
|
|
9
|
+
label: 'Post',
|
|
10
|
+
createDefaults: { status: 'draft' },
|
|
11
|
+
createOptions: { source: 'html' },
|
|
12
|
+
listDefaults: { limit: 15, include: 'tags,authors' },
|
|
13
|
+
validateCreate: (data) => validators.validatePostData(data),
|
|
14
|
+
validateUpdate: async (id, updateData) => {
|
|
15
|
+
if (updateData.status || updateData.published_at) {
|
|
16
|
+
let validationData = updateData;
|
|
17
|
+
if (!updateData.status && updateData.published_at) {
|
|
18
|
+
const existing = await readResource('posts', id, 'Post');
|
|
19
|
+
validationData = { ...updateData, status: existing.status };
|
|
20
|
+
}
|
|
21
|
+
validators.validateScheduledStatus(validationData, 'Post');
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Creates a new post in Ghost CMS.
|
|
28
|
+
* @param {Object} postData - The post data
|
|
29
|
+
* @param {string} postData.title - Post title (required)
|
|
30
|
+
* @param {string} [postData.html] - HTML content (required if mobiledoc not provided)
|
|
31
|
+
* @param {string} [postData.mobiledoc] - Mobiledoc content (required if html not provided)
|
|
32
|
+
* @param {string} [postData.status='draft'] - Post status ('draft', 'published', 'scheduled')
|
|
33
|
+
* @param {string} [postData.published_at] - ISO 8601 date for scheduled posts
|
|
34
|
+
* @param {Object} [options={ source: 'html' }] - API request options
|
|
35
|
+
* @returns {Promise<Object>} The created post object
|
|
36
|
+
* @throws {ValidationError} If validation fails or Ghost returns a 422
|
|
37
|
+
* @throws {GhostAPIError} If the API request fails
|
|
38
|
+
*/
|
|
39
|
+
export const createPost = service.create;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Updates an existing post with optimistic concurrency control.
|
|
43
|
+
* Validates scheduling fields when status or published_at is being changed.
|
|
44
|
+
* @param {string} postId - The post ID to update
|
|
45
|
+
* @param {Object} updateData - Fields to update on the post
|
|
46
|
+
* @param {Object} [options={}] - API request options
|
|
47
|
+
* @returns {Promise<Object>} The updated post object
|
|
48
|
+
* @throws {ValidationError} If the post ID is missing or scheduling validation fails
|
|
49
|
+
* @throws {NotFoundError} If the post is not found
|
|
50
|
+
* @throws {GhostAPIError} If the API request fails
|
|
51
|
+
*/
|
|
52
|
+
export const updatePost = service.update;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Deletes a post by ID.
|
|
56
|
+
* @param {string} postId - The post ID to delete
|
|
57
|
+
* @returns {Promise<Object>} Deletion confirmation
|
|
58
|
+
* @throws {ValidationError} If the post ID is missing
|
|
59
|
+
* @throws {NotFoundError} If the post is not found
|
|
60
|
+
* @throws {GhostAPIError} If the API request fails
|
|
61
|
+
*/
|
|
62
|
+
export const deletePost = service.remove;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Retrieves a single post by ID.
|
|
66
|
+
* @param {string} postId - The post ID to retrieve
|
|
67
|
+
* @param {Object} [options={}] - API request options (e.g., includes)
|
|
68
|
+
* @returns {Promise<Object>} The post object
|
|
69
|
+
* @throws {ValidationError} If the post ID is missing
|
|
70
|
+
* @throws {NotFoundError} If the post is not found
|
|
71
|
+
* @throws {GhostAPIError} If the API request fails
|
|
72
|
+
*/
|
|
73
|
+
export const getPost = service.getOne;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Lists posts with optional filtering and pagination.
|
|
77
|
+
* @param {Object} [options={}] - Query options
|
|
78
|
+
* @param {number} [options.limit=15] - Number of posts to return
|
|
79
|
+
* @param {string} [options.include='tags,authors'] - Related resources to include
|
|
80
|
+
* @param {string} [options.filter] - NQL filter string
|
|
81
|
+
* @param {string} [options.order] - Order string (e.g., 'published_at desc')
|
|
82
|
+
* @returns {Promise<Array>} Array of post objects
|
|
83
|
+
* @throws {GhostAPIError} If the API request fails
|
|
84
|
+
*/
|
|
85
|
+
export const getPosts = service.getList;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Searches posts by title using Ghost NQL fuzzy matching.
|
|
89
|
+
* @param {string} query - Search query string (required, non-empty)
|
|
90
|
+
* @param {Object} [options={}] - Additional search options
|
|
91
|
+
* @param {string} [options.status] - Filter by status ('draft', 'published', 'scheduled', or 'all')
|
|
92
|
+
* @param {number} [options.limit=15] - Maximum number of results
|
|
93
|
+
* @returns {Promise<Array>} Array of matching post objects
|
|
94
|
+
* @throws {ValidationError} If the query is empty
|
|
95
|
+
* @throws {GhostAPIError} If the API request fails
|
|
96
|
+
*/
|
|
97
|
+
export async function searchPosts(query, options = {}) {
|
|
98
|
+
if (!query || query.trim().length === 0) {
|
|
99
|
+
throw new ValidationError('Search query is required');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const sanitizedQuery = sanitizeNqlValue(query);
|
|
103
|
+
|
|
104
|
+
const filterParts = [`title:~'${sanitizedQuery}'`];
|
|
105
|
+
if (options.status && options.status !== 'all') {
|
|
106
|
+
filterParts.push(`status:${options.status}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const searchOptions = {
|
|
110
|
+
limit: options.limit || 15,
|
|
111
|
+
include: 'tags,authors',
|
|
112
|
+
filter: filterParts.join('+'),
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return handleApiRequest('posts', 'browse', {}, searchOptions);
|
|
116
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { GhostAPIError, ValidationError, NotFoundError } from '../errors/index.js';
|
|
2
|
+
import { sanitizeNqlValue } from '../utils/nqlSanitizer.js';
|
|
3
|
+
import { handleApiRequest, readResource } from './ghostApiClient.js';
|
|
4
|
+
import { createResourceService } from './createResourceService.js';
|
|
5
|
+
import { validators } from './validators.js';
|
|
6
|
+
|
|
7
|
+
const service = createResourceService({
|
|
8
|
+
resource: 'tags',
|
|
9
|
+
label: 'Tag',
|
|
10
|
+
listDefaults: { limit: 15 },
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Creates a new tag in Ghost CMS. Auto-generates a slug from the name if not provided.
|
|
15
|
+
* If a tag with the same name already exists, returns the existing tag instead of failing.
|
|
16
|
+
* @param {Object} tagData - The tag data
|
|
17
|
+
* @param {string} tagData.name - Tag name (required)
|
|
18
|
+
* @param {string} [tagData.slug] - Tag slug (auto-generated from name if omitted)
|
|
19
|
+
* @param {string} [tagData.description] - Tag description
|
|
20
|
+
* @returns {Promise<Object>} The created (or existing) tag object
|
|
21
|
+
* @throws {ValidationError} If validation fails or Ghost returns a 422 (non-duplicate)
|
|
22
|
+
* @throws {GhostAPIError} If the API request fails
|
|
23
|
+
*/
|
|
24
|
+
export async function createTag(tagData) {
|
|
25
|
+
validators.validateTagData(tagData);
|
|
26
|
+
|
|
27
|
+
const dataToCreate = tagData.slug
|
|
28
|
+
? { ...tagData }
|
|
29
|
+
: {
|
|
30
|
+
...tagData,
|
|
31
|
+
slug: tagData.name
|
|
32
|
+
.toLowerCase()
|
|
33
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
34
|
+
.replace(/^-+|-+$/g, ''),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
return await handleApiRequest('tags', 'add', dataToCreate);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
|
|
41
|
+
if (error.originalError.includes('already exists')) {
|
|
42
|
+
const existingTags = await getTags({ filter: `name:'${sanitizeNqlValue(tagData.name)}'` });
|
|
43
|
+
if (existingTags.length > 0) {
|
|
44
|
+
return existingTags[0];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
throw new ValidationError('Tag creation failed', [
|
|
48
|
+
{ field: 'tag', message: error.originalError },
|
|
49
|
+
]);
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Lists tags with optional filtering and pagination.
|
|
57
|
+
* @param {Object} [options={}] - Query options
|
|
58
|
+
* @param {number} [options.limit=15] - Number of tags to return
|
|
59
|
+
* @param {string} [options.filter] - NQL filter string
|
|
60
|
+
* @param {string} [options.order] - Order string
|
|
61
|
+
* @returns {Promise<Array>} Array of tag objects (empty array if none found)
|
|
62
|
+
* @throws {GhostAPIError} If the API request fails
|
|
63
|
+
*/
|
|
64
|
+
export const getTags = service.getList;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Retrieves a single tag by ID.
|
|
68
|
+
* @param {string} tagId - The tag ID to retrieve
|
|
69
|
+
* @param {Object} [options={}] - API request options
|
|
70
|
+
* @returns {Promise<Object>} The tag object
|
|
71
|
+
* @throws {ValidationError} If the tag ID is missing
|
|
72
|
+
* @throws {NotFoundError} If the tag is not found
|
|
73
|
+
* @throws {GhostAPIError} If the API request fails
|
|
74
|
+
*/
|
|
75
|
+
export const getTag = service.getOne;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Updates an existing tag. Validates update data and checks that the tag exists first.
|
|
79
|
+
* Tags use direct edit (not OCC) since they don't have concurrent editing concerns.
|
|
80
|
+
* @param {string} tagId - The tag ID to update
|
|
81
|
+
* @param {Object} updateData - Fields to update on the tag
|
|
82
|
+
* @param {string} [updateData.name] - Updated tag name
|
|
83
|
+
* @param {string} [updateData.slug] - Updated tag slug
|
|
84
|
+
* @param {string} [updateData.description] - Updated tag description
|
|
85
|
+
* @returns {Promise<Object>} The updated tag object
|
|
86
|
+
* @throws {ValidationError} If the tag ID is missing or update data is invalid
|
|
87
|
+
* @throws {NotFoundError} If the tag is not found
|
|
88
|
+
* @throws {GhostAPIError} If the API request fails
|
|
89
|
+
*/
|
|
90
|
+
export async function updateTag(tagId, updateData) {
|
|
91
|
+
validators.requireId(tagId, 'Tag');
|
|
92
|
+
validators.validateTagUpdateData(updateData);
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
await readResource('tags', tagId, 'Tag');
|
|
96
|
+
return await handleApiRequest('tags', 'edit', { id: tagId, ...updateData });
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error instanceof NotFoundError) {
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
|
|
102
|
+
throw new ValidationError('Tag update failed', [
|
|
103
|
+
{ field: 'tag', message: error.originalError },
|
|
104
|
+
]);
|
|
105
|
+
}
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Deletes a tag by ID.
|
|
112
|
+
* @param {string} tagId - The tag ID to delete
|
|
113
|
+
* @returns {Promise<Object>} Deletion confirmation
|
|
114
|
+
* @throws {ValidationError} If the tag ID is missing
|
|
115
|
+
* @throws {NotFoundError} If the tag is not found
|
|
116
|
+
* @throws {GhostAPIError} If the API request fails
|
|
117
|
+
*/
|
|
118
|
+
export const deleteTag = service.remove;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createResourceService } from './createResourceService.js';
|
|
2
|
+
import {
|
|
3
|
+
validateTierData,
|
|
4
|
+
validateTierUpdateData,
|
|
5
|
+
validateTierQueryOptions,
|
|
6
|
+
} from './tierService.js';
|
|
7
|
+
|
|
8
|
+
const service = createResourceService({
|
|
9
|
+
resource: 'tiers',
|
|
10
|
+
label: 'Tier',
|
|
11
|
+
listDefaults: { limit: 15 },
|
|
12
|
+
validateCreate: (data) => validateTierData(data),
|
|
13
|
+
validateUpdate: (_id, data) => validateTierUpdateData(data),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create a new tier (membership level)
|
|
18
|
+
* @param {Object} tierData - Tier data
|
|
19
|
+
* @param {Object} [options={}] - Options for the API request
|
|
20
|
+
* @returns {Promise<Object>} Created tier
|
|
21
|
+
* @throws {ValidationError} If validation fails or Ghost returns a 422
|
|
22
|
+
* @throws {GhostAPIError} If the API request fails
|
|
23
|
+
*/
|
|
24
|
+
export const createTier = service.create;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Update an existing tier with optimistic concurrency control.
|
|
28
|
+
* @param {string} id - Tier ID
|
|
29
|
+
* @param {Object} updateData - Tier update data
|
|
30
|
+
* @param {Object} [options={}] - Options for the API request
|
|
31
|
+
* @returns {Promise<Object>} Updated tier
|
|
32
|
+
* @throws {ValidationError} If the tier ID is missing or update data is invalid
|
|
33
|
+
* @throws {NotFoundError} If the tier is not found
|
|
34
|
+
* @throws {GhostAPIError} If the API request fails
|
|
35
|
+
*/
|
|
36
|
+
export const updateTier = service.update;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Delete a tier by ID.
|
|
40
|
+
* @param {string} id - Tier ID
|
|
41
|
+
* @returns {Promise<Object>} Deletion result
|
|
42
|
+
* @throws {ValidationError} If the tier ID is missing
|
|
43
|
+
* @throws {NotFoundError} If the tier is not found
|
|
44
|
+
* @throws {GhostAPIError} If the API request fails
|
|
45
|
+
*/
|
|
46
|
+
export const deleteTier = service.remove;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get all tiers with optional filtering
|
|
50
|
+
* @param {Object} [options={}] - Query options
|
|
51
|
+
* @param {number} [options.limit] - Number of tiers to return (1-100, default 15)
|
|
52
|
+
* @param {number} [options.page] - Page number
|
|
53
|
+
* @param {string} [options.filter] - NQL filter string (e.g., "type:paid", "type:free")
|
|
54
|
+
* @param {string} [options.order] - Order string
|
|
55
|
+
* @param {string} [options.include] - Include string
|
|
56
|
+
* @returns {Promise<Array>} Array of tiers
|
|
57
|
+
* @throws {GhostAPIError} If the API request fails
|
|
58
|
+
*/
|
|
59
|
+
export async function getTiers(options = {}) {
|
|
60
|
+
validateTierQueryOptions(options);
|
|
61
|
+
return service.getList(options);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get a single tier by ID.
|
|
66
|
+
* @param {string} id - Tier ID
|
|
67
|
+
* @returns {Promise<Object>} Tier object
|
|
68
|
+
* @throws {ValidationError} If the tier ID is missing
|
|
69
|
+
* @throws {NotFoundError} If the tier is not found
|
|
70
|
+
* @throws {GhostAPIError} If the API request fails
|
|
71
|
+
*/
|
|
72
|
+
export const getTier = service.getOne;
|