@jgardner04/ghost-mcp-server 1.8.0 → 1.10.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.
- package/package.json +1 -1
- package/src/mcp_server_improved.js +236 -0
- package/src/services/__tests__/ghostServiceImproved.members.test.js +548 -0
- package/src/services/__tests__/memberService.test.js +477 -0
- package/src/services/ghostServiceImproved.js +215 -0
- package/src/services/memberService.js +392 -0
|
@@ -772,6 +772,215 @@ export async function deleteTag(tagId) {
|
|
|
772
772
|
}
|
|
773
773
|
}
|
|
774
774
|
|
|
775
|
+
/**
|
|
776
|
+
* Member CRUD Operations
|
|
777
|
+
* Members represent subscribers/users in Ghost CMS
|
|
778
|
+
*/
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Creates a new member (subscriber) in Ghost CMS
|
|
782
|
+
* @param {Object} memberData - The member data
|
|
783
|
+
* @param {string} memberData.email - Member email (required)
|
|
784
|
+
* @param {string} [memberData.name] - Member name
|
|
785
|
+
* @param {string} [memberData.note] - Notes about the member (HTML will be sanitized)
|
|
786
|
+
* @param {string[]} [memberData.labels] - Array of label names
|
|
787
|
+
* @param {Object[]} [memberData.newsletters] - Array of newsletter objects with id
|
|
788
|
+
* @param {boolean} [memberData.subscribed] - Email subscription status
|
|
789
|
+
* @param {Object} [options] - Additional options for the API request
|
|
790
|
+
* @returns {Promise<Object>} The created member object
|
|
791
|
+
* @throws {ValidationError} If validation fails
|
|
792
|
+
* @throws {GhostAPIError} If the API request fails
|
|
793
|
+
*/
|
|
794
|
+
export async function createMember(memberData, options = {}) {
|
|
795
|
+
// Import and validate input
|
|
796
|
+
const { validateMemberData } = await import('./memberService.js');
|
|
797
|
+
validateMemberData(memberData);
|
|
798
|
+
|
|
799
|
+
try {
|
|
800
|
+
return await handleApiRequest('members', 'add', memberData, options);
|
|
801
|
+
} catch (error) {
|
|
802
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
|
|
803
|
+
throw new ValidationError('Member creation failed due to validation errors', [
|
|
804
|
+
{ field: 'member', message: error.originalError },
|
|
805
|
+
]);
|
|
806
|
+
}
|
|
807
|
+
throw error;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Updates an existing member in Ghost CMS
|
|
813
|
+
* @param {string} memberId - The member ID to update
|
|
814
|
+
* @param {Object} updateData - The member update data
|
|
815
|
+
* @param {string} [updateData.email] - Member email
|
|
816
|
+
* @param {string} [updateData.name] - Member name
|
|
817
|
+
* @param {string} [updateData.note] - Notes about the member (HTML will be sanitized)
|
|
818
|
+
* @param {string[]} [updateData.labels] - Array of label names
|
|
819
|
+
* @param {Object[]} [updateData.newsletters] - Array of newsletter objects with id
|
|
820
|
+
* @param {boolean} [updateData.subscribed] - Email subscription status
|
|
821
|
+
* @param {Object} [options] - Additional options for the API request
|
|
822
|
+
* @returns {Promise<Object>} The updated member object
|
|
823
|
+
* @throws {ValidationError} If validation fails
|
|
824
|
+
* @throws {NotFoundError} If the member is not found
|
|
825
|
+
* @throws {GhostAPIError} If the API request fails
|
|
826
|
+
*/
|
|
827
|
+
export async function updateMember(memberId, updateData, options = {}) {
|
|
828
|
+
if (!memberId) {
|
|
829
|
+
throw new ValidationError('Member ID is required for update');
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// Import and validate update data
|
|
833
|
+
const { validateMemberUpdateData } = await import('./memberService.js');
|
|
834
|
+
validateMemberUpdateData(updateData);
|
|
835
|
+
|
|
836
|
+
try {
|
|
837
|
+
// Get existing member to retrieve updated_at for conflict resolution
|
|
838
|
+
const existingMember = await handleApiRequest('members', 'read', { id: memberId });
|
|
839
|
+
|
|
840
|
+
// Merge existing data with updates, preserving updated_at
|
|
841
|
+
const mergedData = {
|
|
842
|
+
...existingMember,
|
|
843
|
+
...updateData,
|
|
844
|
+
updated_at: existingMember.updated_at,
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
return await handleApiRequest('members', 'edit', mergedData, { id: memberId, ...options });
|
|
848
|
+
} catch (error) {
|
|
849
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
850
|
+
throw new NotFoundError('Member', memberId);
|
|
851
|
+
}
|
|
852
|
+
throw error;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Deletes a member from Ghost CMS
|
|
858
|
+
* @param {string} memberId - The member ID to delete
|
|
859
|
+
* @returns {Promise<Object>} Deletion confirmation object
|
|
860
|
+
* @throws {ValidationError} If member ID is not provided
|
|
861
|
+
* @throws {NotFoundError} If the member is not found
|
|
862
|
+
* @throws {GhostAPIError} If the API request fails
|
|
863
|
+
*/
|
|
864
|
+
export async function deleteMember(memberId) {
|
|
865
|
+
if (!memberId) {
|
|
866
|
+
throw new ValidationError('Member ID is required for deletion');
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
try {
|
|
870
|
+
return await handleApiRequest('members', 'delete', { id: memberId });
|
|
871
|
+
} catch (error) {
|
|
872
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
873
|
+
throw new NotFoundError('Member', memberId);
|
|
874
|
+
}
|
|
875
|
+
throw error;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* List members from Ghost CMS with optional filtering and pagination
|
|
881
|
+
* @param {Object} [options] - Query options
|
|
882
|
+
* @param {number} [options.limit] - Number of members to return (1-100)
|
|
883
|
+
* @param {number} [options.page] - Page number (1+)
|
|
884
|
+
* @param {string} [options.filter] - NQL filter string (e.g., 'status:paid')
|
|
885
|
+
* @param {string} [options.order] - Order string (e.g., 'created_at desc')
|
|
886
|
+
* @param {string} [options.include] - Include string (e.g., 'labels,newsletters')
|
|
887
|
+
* @returns {Promise<Array>} Array of member objects
|
|
888
|
+
* @throws {ValidationError} If validation fails
|
|
889
|
+
* @throws {GhostAPIError} If the API request fails
|
|
890
|
+
*/
|
|
891
|
+
export async function getMembers(options = {}) {
|
|
892
|
+
// Import and validate query options
|
|
893
|
+
const { validateMemberQueryOptions } = await import('./memberService.js');
|
|
894
|
+
validateMemberQueryOptions(options);
|
|
895
|
+
|
|
896
|
+
const defaultOptions = {
|
|
897
|
+
limit: 15,
|
|
898
|
+
...options,
|
|
899
|
+
};
|
|
900
|
+
|
|
901
|
+
try {
|
|
902
|
+
const members = await handleApiRequest('members', 'browse', {}, defaultOptions);
|
|
903
|
+
return members || [];
|
|
904
|
+
} catch (error) {
|
|
905
|
+
console.error('Failed to get members:', error);
|
|
906
|
+
throw error;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Get a single member from Ghost CMS by ID or email
|
|
912
|
+
* @param {Object} params - Lookup parameters (id OR email required)
|
|
913
|
+
* @param {string} [params.id] - Member ID
|
|
914
|
+
* @param {string} [params.email] - Member email
|
|
915
|
+
* @returns {Promise<Object>} The member object
|
|
916
|
+
* @throws {ValidationError} If validation fails
|
|
917
|
+
* @throws {NotFoundError} If the member is not found
|
|
918
|
+
* @throws {GhostAPIError} If the API request fails
|
|
919
|
+
*/
|
|
920
|
+
export async function getMember(params) {
|
|
921
|
+
// Import and validate lookup parameters
|
|
922
|
+
const { validateMemberLookup, sanitizeNqlValue } = await import('./memberService.js');
|
|
923
|
+
const { lookupType, id, email } = validateMemberLookup(params);
|
|
924
|
+
|
|
925
|
+
try {
|
|
926
|
+
if (lookupType === 'id') {
|
|
927
|
+
// Lookup by ID using read endpoint
|
|
928
|
+
return await handleApiRequest('members', 'read', { id }, { id });
|
|
929
|
+
} else {
|
|
930
|
+
// Lookup by email using browse with filter
|
|
931
|
+
const sanitizedEmail = sanitizeNqlValue(email);
|
|
932
|
+
const members = await handleApiRequest(
|
|
933
|
+
'members',
|
|
934
|
+
'browse',
|
|
935
|
+
{},
|
|
936
|
+
{ filter: `email:'${sanitizedEmail}'`, limit: 1 }
|
|
937
|
+
);
|
|
938
|
+
|
|
939
|
+
if (!members || members.length === 0) {
|
|
940
|
+
throw new NotFoundError('Member', email);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
return members[0];
|
|
944
|
+
}
|
|
945
|
+
} catch (error) {
|
|
946
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
947
|
+
throw new NotFoundError('Member', id || email);
|
|
948
|
+
}
|
|
949
|
+
throw error;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Search members by name or email
|
|
955
|
+
* @param {string} query - Search query (searches name and email fields)
|
|
956
|
+
* @param {Object} [options] - Additional options
|
|
957
|
+
* @param {number} [options.limit] - Maximum number of results (default: 15)
|
|
958
|
+
* @returns {Promise<Array>} Array of matching member objects
|
|
959
|
+
* @throws {ValidationError} If validation fails
|
|
960
|
+
* @throws {GhostAPIError} If the API request fails
|
|
961
|
+
*/
|
|
962
|
+
export async function searchMembers(query, options = {}) {
|
|
963
|
+
// Import and validate search query and options
|
|
964
|
+
const { validateSearchQuery, validateSearchOptions, sanitizeNqlValue } =
|
|
965
|
+
await import('./memberService.js');
|
|
966
|
+
validateSearchOptions(options);
|
|
967
|
+
const sanitizedQuery = sanitizeNqlValue(validateSearchQuery(query));
|
|
968
|
+
|
|
969
|
+
const limit = options.limit || 15;
|
|
970
|
+
|
|
971
|
+
// Build NQL filter for name or email containing the query
|
|
972
|
+
// Ghost uses ~ for contains/like matching
|
|
973
|
+
const filter = `name:~'${sanitizedQuery}',email:~'${sanitizedQuery}'`;
|
|
974
|
+
|
|
975
|
+
try {
|
|
976
|
+
const members = await handleApiRequest('members', 'browse', {}, { filter, limit });
|
|
977
|
+
return members || [];
|
|
978
|
+
} catch (error) {
|
|
979
|
+
console.error('Failed to search members:', error);
|
|
980
|
+
throw error;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
775
984
|
/**
|
|
776
985
|
* Newsletter CRUD Operations
|
|
777
986
|
*/
|
|
@@ -920,6 +1129,12 @@ export default {
|
|
|
920
1129
|
getTag,
|
|
921
1130
|
updateTag,
|
|
922
1131
|
deleteTag,
|
|
1132
|
+
createMember,
|
|
1133
|
+
updateMember,
|
|
1134
|
+
deleteMember,
|
|
1135
|
+
getMembers,
|
|
1136
|
+
getMember,
|
|
1137
|
+
searchMembers,
|
|
923
1138
|
getNewsletters,
|
|
924
1139
|
getNewsletter,
|
|
925
1140
|
createNewsletter,
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import sanitizeHtml from 'sanitize-html';
|
|
2
|
+
import { ValidationError } from '../errors/index.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Email validation regex
|
|
6
|
+
* Simple but effective email validation
|
|
7
|
+
*/
|
|
8
|
+
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Maximum length constants (following Ghost's database constraints)
|
|
12
|
+
*/
|
|
13
|
+
const MAX_NAME_LENGTH = 191; // Ghost's typical varchar limit
|
|
14
|
+
const MAX_NOTE_LENGTH = 2000; // Reasonable limit for notes
|
|
15
|
+
const MAX_LABEL_LENGTH = 191; // Label name limit
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Query constraints for member browsing
|
|
19
|
+
*/
|
|
20
|
+
const MIN_LIMIT = 1;
|
|
21
|
+
const MAX_LIMIT = 100;
|
|
22
|
+
const MAX_SEARCH_LIMIT = 50; // Lower limit for search operations
|
|
23
|
+
const MIN_PAGE = 1;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Validates member data for creation
|
|
27
|
+
* @param {Object} memberData - The member data to validate
|
|
28
|
+
* @throws {ValidationError} If validation fails
|
|
29
|
+
*/
|
|
30
|
+
export function validateMemberData(memberData) {
|
|
31
|
+
const errors = [];
|
|
32
|
+
|
|
33
|
+
// Email is required and must be valid
|
|
34
|
+
if (!memberData.email || memberData.email.trim().length === 0) {
|
|
35
|
+
errors.push({ field: 'email', message: 'Email is required' });
|
|
36
|
+
} else if (!EMAIL_REGEX.test(memberData.email)) {
|
|
37
|
+
errors.push({ field: 'email', message: 'Invalid email format' });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Name is optional but must be a string with valid length if provided
|
|
41
|
+
if (memberData.name !== undefined) {
|
|
42
|
+
if (typeof memberData.name !== 'string') {
|
|
43
|
+
errors.push({ field: 'name', message: 'Name must be a string' });
|
|
44
|
+
} else if (memberData.name.length > MAX_NAME_LENGTH) {
|
|
45
|
+
errors.push({ field: 'name', message: `Name must not exceed ${MAX_NAME_LENGTH} characters` });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Note is optional but must be a string with valid length if provided
|
|
50
|
+
// Sanitize HTML to prevent XSS attacks
|
|
51
|
+
if (memberData.note !== undefined) {
|
|
52
|
+
if (typeof memberData.note !== 'string') {
|
|
53
|
+
errors.push({ field: 'note', message: 'Note must be a string' });
|
|
54
|
+
} else {
|
|
55
|
+
if (memberData.note.length > MAX_NOTE_LENGTH) {
|
|
56
|
+
errors.push({
|
|
57
|
+
field: 'note',
|
|
58
|
+
message: `Note must not exceed ${MAX_NOTE_LENGTH} characters`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
// Sanitize HTML content - strip all HTML tags for notes
|
|
62
|
+
memberData.note = sanitizeHtml(memberData.note, {
|
|
63
|
+
allowedTags: [], // Strip all HTML
|
|
64
|
+
allowedAttributes: {},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Labels is optional but must be an array of valid strings if provided
|
|
70
|
+
if (memberData.labels !== undefined) {
|
|
71
|
+
if (!Array.isArray(memberData.labels)) {
|
|
72
|
+
errors.push({ field: 'labels', message: 'Labels must be an array' });
|
|
73
|
+
} else {
|
|
74
|
+
// Validate each label is a non-empty string within length limit
|
|
75
|
+
const invalidLabels = memberData.labels.filter(
|
|
76
|
+
(label) =>
|
|
77
|
+
typeof label !== 'string' || label.trim().length === 0 || label.length > MAX_LABEL_LENGTH
|
|
78
|
+
);
|
|
79
|
+
if (invalidLabels.length > 0) {
|
|
80
|
+
errors.push({
|
|
81
|
+
field: 'labels',
|
|
82
|
+
message: `Each label must be a non-empty string (max ${MAX_LABEL_LENGTH} characters)`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Newsletters is optional but must be an array of objects with valid id field
|
|
89
|
+
if (memberData.newsletters !== undefined) {
|
|
90
|
+
if (!Array.isArray(memberData.newsletters)) {
|
|
91
|
+
errors.push({ field: 'newsletters', message: 'Newsletters must be an array' });
|
|
92
|
+
} else {
|
|
93
|
+
// Validate each newsletter has a non-empty string id
|
|
94
|
+
const invalidNewsletters = memberData.newsletters.filter(
|
|
95
|
+
(newsletter) =>
|
|
96
|
+
!newsletter.id || typeof newsletter.id !== 'string' || newsletter.id.trim().length === 0
|
|
97
|
+
);
|
|
98
|
+
if (invalidNewsletters.length > 0) {
|
|
99
|
+
errors.push({
|
|
100
|
+
field: 'newsletters',
|
|
101
|
+
message: 'Each newsletter must have a non-empty id field',
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Subscribed is optional but must be a boolean if provided
|
|
108
|
+
if (memberData.subscribed !== undefined && typeof memberData.subscribed !== 'boolean') {
|
|
109
|
+
errors.push({ field: 'subscribed', message: 'Subscribed must be a boolean' });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (errors.length > 0) {
|
|
113
|
+
throw new ValidationError('Member validation failed', errors);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Validates member data for update
|
|
119
|
+
* All fields are optional for updates, but if provided they must be valid
|
|
120
|
+
* @param {Object} updateData - The member update data to validate
|
|
121
|
+
* @throws {ValidationError} If validation fails
|
|
122
|
+
*/
|
|
123
|
+
export function validateMemberUpdateData(updateData) {
|
|
124
|
+
const errors = [];
|
|
125
|
+
|
|
126
|
+
// Email is optional for update but must be valid if provided
|
|
127
|
+
if (updateData.email !== undefined) {
|
|
128
|
+
if (typeof updateData.email !== 'string' || updateData.email.trim().length === 0) {
|
|
129
|
+
errors.push({ field: 'email', message: 'Email must be a non-empty string' });
|
|
130
|
+
} else if (!EMAIL_REGEX.test(updateData.email)) {
|
|
131
|
+
errors.push({ field: 'email', message: 'Invalid email format' });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Name is optional but must be a string with valid length if provided
|
|
136
|
+
if (updateData.name !== undefined) {
|
|
137
|
+
if (typeof updateData.name !== 'string') {
|
|
138
|
+
errors.push({ field: 'name', message: 'Name must be a string' });
|
|
139
|
+
} else if (updateData.name.length > MAX_NAME_LENGTH) {
|
|
140
|
+
errors.push({ field: 'name', message: `Name must not exceed ${MAX_NAME_LENGTH} characters` });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Note is optional but must be a string with valid length if provided
|
|
145
|
+
// Sanitize HTML to prevent XSS attacks
|
|
146
|
+
if (updateData.note !== undefined) {
|
|
147
|
+
if (typeof updateData.note !== 'string') {
|
|
148
|
+
errors.push({ field: 'note', message: 'Note must be a string' });
|
|
149
|
+
} else {
|
|
150
|
+
if (updateData.note.length > MAX_NOTE_LENGTH) {
|
|
151
|
+
errors.push({
|
|
152
|
+
field: 'note',
|
|
153
|
+
message: `Note must not exceed ${MAX_NOTE_LENGTH} characters`,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
// Sanitize HTML content - strip all HTML tags for notes
|
|
157
|
+
updateData.note = sanitizeHtml(updateData.note, {
|
|
158
|
+
allowedTags: [], // Strip all HTML
|
|
159
|
+
allowedAttributes: {},
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Labels is optional but must be an array of valid strings if provided
|
|
165
|
+
if (updateData.labels !== undefined) {
|
|
166
|
+
if (!Array.isArray(updateData.labels)) {
|
|
167
|
+
errors.push({ field: 'labels', message: 'Labels must be an array' });
|
|
168
|
+
} else {
|
|
169
|
+
// Validate each label is a non-empty string within length limit
|
|
170
|
+
const invalidLabels = updateData.labels.filter(
|
|
171
|
+
(label) =>
|
|
172
|
+
typeof label !== 'string' || label.trim().length === 0 || label.length > MAX_LABEL_LENGTH
|
|
173
|
+
);
|
|
174
|
+
if (invalidLabels.length > 0) {
|
|
175
|
+
errors.push({
|
|
176
|
+
field: 'labels',
|
|
177
|
+
message: `Each label must be a non-empty string (max ${MAX_LABEL_LENGTH} characters)`,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Newsletters is optional but must be an array of objects with valid id field
|
|
184
|
+
if (updateData.newsletters !== undefined) {
|
|
185
|
+
if (!Array.isArray(updateData.newsletters)) {
|
|
186
|
+
errors.push({ field: 'newsletters', message: 'Newsletters must be an array' });
|
|
187
|
+
} else {
|
|
188
|
+
// Validate each newsletter has a non-empty string id
|
|
189
|
+
const invalidNewsletters = updateData.newsletters.filter(
|
|
190
|
+
(newsletter) =>
|
|
191
|
+
!newsletter.id || typeof newsletter.id !== 'string' || newsletter.id.trim().length === 0
|
|
192
|
+
);
|
|
193
|
+
if (invalidNewsletters.length > 0) {
|
|
194
|
+
errors.push({
|
|
195
|
+
field: 'newsletters',
|
|
196
|
+
message: 'Each newsletter must have a non-empty id field',
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (errors.length > 0) {
|
|
203
|
+
throw new ValidationError('Member validation failed', errors);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
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
|
+
/**
|
|
220
|
+
* Validates query options for member browsing
|
|
221
|
+
* @param {Object} options - The query options to validate
|
|
222
|
+
* @param {number} [options.limit] - Number of members to return (1-100)
|
|
223
|
+
* @param {number} [options.page] - Page number (1+)
|
|
224
|
+
* @param {string} [options.filter] - NQL filter string
|
|
225
|
+
* @param {string} [options.order] - Order string (e.g., 'created_at desc')
|
|
226
|
+
* @param {string} [options.include] - Include string (e.g., 'labels,newsletters')
|
|
227
|
+
* @throws {ValidationError} If validation fails
|
|
228
|
+
*/
|
|
229
|
+
export function validateMemberQueryOptions(options) {
|
|
230
|
+
const errors = [];
|
|
231
|
+
|
|
232
|
+
// Validate limit
|
|
233
|
+
if (options.limit !== undefined) {
|
|
234
|
+
if (
|
|
235
|
+
typeof options.limit !== 'number' ||
|
|
236
|
+
options.limit < MIN_LIMIT ||
|
|
237
|
+
options.limit > MAX_LIMIT
|
|
238
|
+
) {
|
|
239
|
+
errors.push({
|
|
240
|
+
field: 'limit',
|
|
241
|
+
message: `Limit must be a number between ${MIN_LIMIT} and ${MAX_LIMIT}`,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Validate page
|
|
247
|
+
if (options.page !== undefined) {
|
|
248
|
+
if (typeof options.page !== 'number' || options.page < MIN_PAGE) {
|
|
249
|
+
errors.push({
|
|
250
|
+
field: 'page',
|
|
251
|
+
message: `Page must be a number >= ${MIN_PAGE}`,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Validate filter (must be non-empty string if provided)
|
|
257
|
+
if (options.filter !== undefined) {
|
|
258
|
+
if (typeof options.filter !== 'string' || options.filter.trim().length === 0) {
|
|
259
|
+
errors.push({
|
|
260
|
+
field: 'filter',
|
|
261
|
+
message: 'Filter must be a non-empty string',
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Validate order (must be non-empty string if provided)
|
|
267
|
+
if (options.order !== undefined) {
|
|
268
|
+
if (typeof options.order !== 'string' || options.order.trim().length === 0) {
|
|
269
|
+
errors.push({
|
|
270
|
+
field: 'order',
|
|
271
|
+
message: 'Order must be a non-empty string',
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Validate include (must be non-empty string if provided)
|
|
277
|
+
if (options.include !== undefined) {
|
|
278
|
+
if (typeof options.include !== 'string' || options.include.trim().length === 0) {
|
|
279
|
+
errors.push({
|
|
280
|
+
field: 'include',
|
|
281
|
+
message: 'Include must be a non-empty string',
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (errors.length > 0) {
|
|
287
|
+
throw new ValidationError('Member query validation failed', errors);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Validates member lookup parameters (id OR email required)
|
|
293
|
+
* @param {Object} params - The lookup parameters
|
|
294
|
+
* @param {string} [params.id] - Member ID
|
|
295
|
+
* @param {string} [params.email] - Member email
|
|
296
|
+
* @returns {Object} Normalized params with lookupType ('id' or 'email')
|
|
297
|
+
* @throws {ValidationError} If validation fails
|
|
298
|
+
*/
|
|
299
|
+
export function validateMemberLookup(params) {
|
|
300
|
+
const errors = [];
|
|
301
|
+
|
|
302
|
+
// Check if id is provided and valid
|
|
303
|
+
const hasValidId = params.id && typeof params.id === 'string' && params.id.trim().length > 0;
|
|
304
|
+
|
|
305
|
+
// Check if email is provided and valid
|
|
306
|
+
const hasEmail = params.email !== undefined;
|
|
307
|
+
const hasValidEmail =
|
|
308
|
+
hasEmail && typeof params.email === 'string' && EMAIL_REGEX.test(params.email);
|
|
309
|
+
|
|
310
|
+
// Must have at least one valid identifier
|
|
311
|
+
if (!hasValidId && !hasValidEmail) {
|
|
312
|
+
if (params.id !== undefined && !hasValidId) {
|
|
313
|
+
errors.push({ field: 'id', message: 'ID must be a non-empty string' });
|
|
314
|
+
}
|
|
315
|
+
if (hasEmail && !hasValidEmail) {
|
|
316
|
+
errors.push({ field: 'email', message: 'Invalid email format' });
|
|
317
|
+
}
|
|
318
|
+
if (!params.id && !params.email) {
|
|
319
|
+
errors.push({ field: 'id|email', message: 'Either id or email is required' });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
throw new ValidationError('Member lookup validation failed', errors);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Return normalized result - ID takes precedence if both provided
|
|
326
|
+
if (hasValidId) {
|
|
327
|
+
return { id: params.id, lookupType: 'id' };
|
|
328
|
+
}
|
|
329
|
+
return { email: params.email, lookupType: 'email' };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Validates and sanitizes a search query
|
|
334
|
+
* @param {string} query - The search query
|
|
335
|
+
* @returns {string} The sanitized query
|
|
336
|
+
* @throws {ValidationError} If validation fails
|
|
337
|
+
*/
|
|
338
|
+
export function validateSearchQuery(query) {
|
|
339
|
+
const errors = [];
|
|
340
|
+
|
|
341
|
+
if (query === null || query === undefined || typeof query !== 'string') {
|
|
342
|
+
errors.push({ field: 'query', message: 'Query must be a string' });
|
|
343
|
+
throw new ValidationError('Search query validation failed', errors);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const trimmedQuery = query.trim();
|
|
347
|
+
if (trimmedQuery.length === 0) {
|
|
348
|
+
errors.push({ field: 'query', message: 'Query must not be empty' });
|
|
349
|
+
throw new ValidationError('Search query validation failed', errors);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return trimmedQuery;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Validates search options (specifically limit for search operations)
|
|
357
|
+
* Search has a lower max limit (50) than browse operations (100)
|
|
358
|
+
* @param {Object} options - The search options to validate
|
|
359
|
+
* @param {number} [options.limit] - Maximum number of results (1-50)
|
|
360
|
+
* @throws {ValidationError} If validation fails
|
|
361
|
+
*/
|
|
362
|
+
export function validateSearchOptions(options) {
|
|
363
|
+
const errors = [];
|
|
364
|
+
|
|
365
|
+
// Validate limit for search (1-50, lower than browse)
|
|
366
|
+
if (options.limit !== undefined) {
|
|
367
|
+
if (
|
|
368
|
+
typeof options.limit !== 'number' ||
|
|
369
|
+
options.limit < MIN_LIMIT ||
|
|
370
|
+
options.limit > MAX_SEARCH_LIMIT
|
|
371
|
+
) {
|
|
372
|
+
errors.push({
|
|
373
|
+
field: 'limit',
|
|
374
|
+
message: `Limit must be a number between ${MIN_LIMIT} and ${MAX_SEARCH_LIMIT}`,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (errors.length > 0) {
|
|
380
|
+
throw new ValidationError('Search options validation failed', errors);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export default {
|
|
385
|
+
validateMemberData,
|
|
386
|
+
validateMemberUpdateData,
|
|
387
|
+
validateMemberQueryOptions,
|
|
388
|
+
validateMemberLookup,
|
|
389
|
+
validateSearchQuery,
|
|
390
|
+
validateSearchOptions,
|
|
391
|
+
sanitizeNqlValue,
|
|
392
|
+
};
|