@jgardner04/ghost-mcp-server 1.8.0 → 1.9.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 +117 -0
- package/src/services/__tests__/ghostServiceImproved.members.test.js +261 -0
- package/src/services/__tests__/memberService.test.js +245 -0
- package/src/services/ghostServiceImproved.js +107 -0
- package/src/services/memberService.js +202 -0
package/package.json
CHANGED
|
@@ -903,6 +903,122 @@ server.tool(
|
|
|
903
903
|
}
|
|
904
904
|
);
|
|
905
905
|
|
|
906
|
+
// =============================================================================
|
|
907
|
+
// MEMBER TOOLS
|
|
908
|
+
// Member management for Ghost CMS subscribers
|
|
909
|
+
// =============================================================================
|
|
910
|
+
|
|
911
|
+
// Create Member Tool
|
|
912
|
+
server.tool(
|
|
913
|
+
'ghost_create_member',
|
|
914
|
+
'Creates a new member (subscriber) in Ghost CMS.',
|
|
915
|
+
{
|
|
916
|
+
email: z.string().email().describe('The email address of the member (required).'),
|
|
917
|
+
name: z.string().optional().describe('The name of the member.'),
|
|
918
|
+
note: z.string().optional().describe('A note about the member.'),
|
|
919
|
+
labels: z.array(z.string()).optional().describe('List of label names to assign to the member.'),
|
|
920
|
+
newsletters: z
|
|
921
|
+
.array(z.object({ id: z.string() }))
|
|
922
|
+
.optional()
|
|
923
|
+
.describe('List of newsletter objects with id field to subscribe the member to.'),
|
|
924
|
+
subscribed: z
|
|
925
|
+
.boolean()
|
|
926
|
+
.optional()
|
|
927
|
+
.describe('Whether the member is subscribed to emails. Defaults to true.'),
|
|
928
|
+
},
|
|
929
|
+
async (input) => {
|
|
930
|
+
console.error(`Executing tool: ghost_create_member with email: ${input.email}`);
|
|
931
|
+
try {
|
|
932
|
+
await loadServices();
|
|
933
|
+
|
|
934
|
+
const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
|
|
935
|
+
const createdMember = await ghostServiceImproved.createMember(input);
|
|
936
|
+
console.error(`Member created successfully. Member ID: ${createdMember.id}`);
|
|
937
|
+
|
|
938
|
+
return {
|
|
939
|
+
content: [{ type: 'text', text: JSON.stringify(createdMember, null, 2) }],
|
|
940
|
+
};
|
|
941
|
+
} catch (error) {
|
|
942
|
+
console.error(`Error in ghost_create_member:`, error);
|
|
943
|
+
return {
|
|
944
|
+
content: [{ type: 'text', text: `Error creating member: ${error.message}` }],
|
|
945
|
+
isError: true,
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
);
|
|
950
|
+
|
|
951
|
+
// Update Member Tool
|
|
952
|
+
server.tool(
|
|
953
|
+
'ghost_update_member',
|
|
954
|
+
'Updates an existing member in Ghost CMS. All fields except id are optional.',
|
|
955
|
+
{
|
|
956
|
+
id: z.string().describe('The ID of the member to update.'),
|
|
957
|
+
email: z.string().email().optional().describe('New email address for the member.'),
|
|
958
|
+
name: z.string().optional().describe('New name for the member.'),
|
|
959
|
+
note: z.string().optional().describe('New note about the member.'),
|
|
960
|
+
labels: z
|
|
961
|
+
.array(z.string())
|
|
962
|
+
.optional()
|
|
963
|
+
.describe('New list of label names to assign to the member.'),
|
|
964
|
+
newsletters: z
|
|
965
|
+
.array(z.object({ id: z.string() }))
|
|
966
|
+
.optional()
|
|
967
|
+
.describe('New list of newsletter objects with id field to subscribe the member to.'),
|
|
968
|
+
},
|
|
969
|
+
async (input) => {
|
|
970
|
+
console.error(`Executing tool: ghost_update_member for member ID: ${input.id}`);
|
|
971
|
+
try {
|
|
972
|
+
await loadServices();
|
|
973
|
+
|
|
974
|
+
const { id, ...updateData } = input;
|
|
975
|
+
|
|
976
|
+
const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
|
|
977
|
+
const updatedMember = await ghostServiceImproved.updateMember(id, updateData);
|
|
978
|
+
console.error(`Member updated successfully. Member ID: ${updatedMember.id}`);
|
|
979
|
+
|
|
980
|
+
return {
|
|
981
|
+
content: [{ type: 'text', text: JSON.stringify(updatedMember, null, 2) }],
|
|
982
|
+
};
|
|
983
|
+
} catch (error) {
|
|
984
|
+
console.error(`Error in ghost_update_member:`, error);
|
|
985
|
+
return {
|
|
986
|
+
content: [{ type: 'text', text: `Error updating member: ${error.message}` }],
|
|
987
|
+
isError: true,
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
);
|
|
992
|
+
|
|
993
|
+
// Delete Member Tool
|
|
994
|
+
server.tool(
|
|
995
|
+
'ghost_delete_member',
|
|
996
|
+
'Deletes a member from Ghost CMS by ID. This operation is permanent and cannot be undone.',
|
|
997
|
+
{
|
|
998
|
+
id: z.string().describe('The ID of the member to delete.'),
|
|
999
|
+
},
|
|
1000
|
+
async ({ id }) => {
|
|
1001
|
+
console.error(`Executing tool: ghost_delete_member for member ID: ${id}`);
|
|
1002
|
+
try {
|
|
1003
|
+
await loadServices();
|
|
1004
|
+
|
|
1005
|
+
const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
|
|
1006
|
+
await ghostServiceImproved.deleteMember(id);
|
|
1007
|
+
console.error(`Member deleted successfully. Member ID: ${id}`);
|
|
1008
|
+
|
|
1009
|
+
return {
|
|
1010
|
+
content: [{ type: 'text', text: `Member ${id} has been successfully deleted.` }],
|
|
1011
|
+
};
|
|
1012
|
+
} catch (error) {
|
|
1013
|
+
console.error(`Error in ghost_delete_member:`, error);
|
|
1014
|
+
return {
|
|
1015
|
+
content: [{ type: 'text', text: `Error deleting member: ${error.message}` }],
|
|
1016
|
+
isError: true,
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
);
|
|
1021
|
+
|
|
906
1022
|
// =============================================================================
|
|
907
1023
|
// NEWSLETTER TOOLS
|
|
908
1024
|
// =============================================================================
|
|
@@ -1108,6 +1224,7 @@ async function main() {
|
|
|
1108
1224
|
'Available tools: ghost_get_tags, ghost_create_tag, ghost_get_tag, ghost_update_tag, ghost_delete_tag, ghost_upload_image, ' +
|
|
1109
1225
|
'ghost_create_post, ghost_get_posts, ghost_get_post, ghost_search_posts, ghost_update_post, ghost_delete_post, ' +
|
|
1110
1226
|
'ghost_get_pages, ghost_get_page, ghost_create_page, ghost_update_page, ghost_delete_page, ghost_search_pages, ' +
|
|
1227
|
+
'ghost_create_member, ghost_update_member, ghost_delete_member, ' +
|
|
1111
1228
|
'ghost_get_newsletters, ghost_get_newsletter, ghost_create_newsletter, ghost_update_newsletter, ghost_delete_newsletter'
|
|
1112
1229
|
);
|
|
1113
1230
|
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
|
|
3
|
+
import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
|
|
4
|
+
|
|
5
|
+
// Mock the Ghost Admin API with members support
|
|
6
|
+
vi.mock('@tryghost/admin-api', () => ({
|
|
7
|
+
default: vi.fn(function () {
|
|
8
|
+
return {
|
|
9
|
+
posts: {
|
|
10
|
+
add: vi.fn(),
|
|
11
|
+
browse: vi.fn(),
|
|
12
|
+
read: vi.fn(),
|
|
13
|
+
edit: vi.fn(),
|
|
14
|
+
delete: vi.fn(),
|
|
15
|
+
},
|
|
16
|
+
pages: {
|
|
17
|
+
add: vi.fn(),
|
|
18
|
+
browse: vi.fn(),
|
|
19
|
+
read: vi.fn(),
|
|
20
|
+
edit: vi.fn(),
|
|
21
|
+
delete: vi.fn(),
|
|
22
|
+
},
|
|
23
|
+
tags: {
|
|
24
|
+
add: vi.fn(),
|
|
25
|
+
browse: vi.fn(),
|
|
26
|
+
read: vi.fn(),
|
|
27
|
+
edit: vi.fn(),
|
|
28
|
+
delete: vi.fn(),
|
|
29
|
+
},
|
|
30
|
+
members: {
|
|
31
|
+
add: vi.fn(),
|
|
32
|
+
browse: vi.fn(),
|
|
33
|
+
read: vi.fn(),
|
|
34
|
+
edit: vi.fn(),
|
|
35
|
+
delete: vi.fn(),
|
|
36
|
+
},
|
|
37
|
+
site: {
|
|
38
|
+
read: vi.fn(),
|
|
39
|
+
},
|
|
40
|
+
images: {
|
|
41
|
+
upload: vi.fn(),
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}),
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
// Mock dotenv
|
|
48
|
+
vi.mock('dotenv', () => mockDotenv());
|
|
49
|
+
|
|
50
|
+
// Mock logger
|
|
51
|
+
vi.mock('../../utils/logger.js', () => ({
|
|
52
|
+
createContextLogger: createMockContextLogger(),
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
// Mock fs for validateImagePath
|
|
56
|
+
vi.mock('fs/promises', () => ({
|
|
57
|
+
default: {
|
|
58
|
+
access: vi.fn(),
|
|
59
|
+
},
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
// Import after setting up mocks
|
|
63
|
+
import { createMember, updateMember, deleteMember, api } from '../ghostServiceImproved.js';
|
|
64
|
+
|
|
65
|
+
describe('ghostServiceImproved - Members', () => {
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
// Reset all mocks before each test
|
|
68
|
+
vi.clearAllMocks();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('createMember', () => {
|
|
72
|
+
it('should create a member with required email', async () => {
|
|
73
|
+
const memberData = {
|
|
74
|
+
email: 'test@example.com',
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const mockCreatedMember = {
|
|
78
|
+
id: 'member-1',
|
|
79
|
+
email: 'test@example.com',
|
|
80
|
+
status: 'free',
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
api.members.add.mockResolvedValue(mockCreatedMember);
|
|
84
|
+
|
|
85
|
+
const result = await createMember(memberData);
|
|
86
|
+
|
|
87
|
+
expect(api.members.add).toHaveBeenCalledWith(
|
|
88
|
+
expect.objectContaining({
|
|
89
|
+
email: 'test@example.com',
|
|
90
|
+
}),
|
|
91
|
+
expect.any(Object)
|
|
92
|
+
);
|
|
93
|
+
expect(result).toEqual(mockCreatedMember);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should create a member with optional fields', async () => {
|
|
97
|
+
const memberData = {
|
|
98
|
+
email: 'test@example.com',
|
|
99
|
+
name: 'John Doe',
|
|
100
|
+
note: 'Test member',
|
|
101
|
+
labels: ['premium', 'newsletter'],
|
|
102
|
+
newsletters: [{ id: 'newsletter-1' }],
|
|
103
|
+
subscribed: true,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const mockCreatedMember = {
|
|
107
|
+
id: 'member-1',
|
|
108
|
+
...memberData,
|
|
109
|
+
status: 'free',
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
api.members.add.mockResolvedValue(mockCreatedMember);
|
|
113
|
+
|
|
114
|
+
const result = await createMember(memberData);
|
|
115
|
+
|
|
116
|
+
expect(api.members.add).toHaveBeenCalledWith(
|
|
117
|
+
expect.objectContaining(memberData),
|
|
118
|
+
expect.any(Object)
|
|
119
|
+
);
|
|
120
|
+
expect(result).toEqual(mockCreatedMember);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should throw validation error for missing email', async () => {
|
|
124
|
+
await expect(createMember({})).rejects.toThrow('Member validation failed');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should throw validation error for invalid email', async () => {
|
|
128
|
+
await expect(createMember({ email: 'invalid-email' })).rejects.toThrow(
|
|
129
|
+
'Member validation failed'
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should throw validation error for invalid labels type', async () => {
|
|
134
|
+
await expect(
|
|
135
|
+
createMember({
|
|
136
|
+
email: 'test@example.com',
|
|
137
|
+
labels: 'premium',
|
|
138
|
+
})
|
|
139
|
+
).rejects.toThrow('Member validation failed');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should handle Ghost API errors', async () => {
|
|
143
|
+
const memberData = {
|
|
144
|
+
email: 'test@example.com',
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
api.members.add.mockRejectedValue(new Error('Ghost API Error'));
|
|
148
|
+
|
|
149
|
+
await expect(createMember(memberData)).rejects.toThrow();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('updateMember', () => {
|
|
154
|
+
it('should update a member with valid ID and data', async () => {
|
|
155
|
+
const memberId = 'member-1';
|
|
156
|
+
const updateData = {
|
|
157
|
+
name: 'Jane Doe',
|
|
158
|
+
note: 'Updated note',
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const mockExistingMember = {
|
|
162
|
+
id: memberId,
|
|
163
|
+
email: 'test@example.com',
|
|
164
|
+
name: 'John Doe',
|
|
165
|
+
updated_at: '2023-01-01T00:00:00.000Z',
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const mockUpdatedMember = {
|
|
169
|
+
...mockExistingMember,
|
|
170
|
+
...updateData,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
api.members.read.mockResolvedValue(mockExistingMember);
|
|
174
|
+
api.members.edit.mockResolvedValue(mockUpdatedMember);
|
|
175
|
+
|
|
176
|
+
const result = await updateMember(memberId, updateData);
|
|
177
|
+
|
|
178
|
+
expect(api.members.read).toHaveBeenCalledWith(expect.any(Object), { id: memberId });
|
|
179
|
+
expect(api.members.edit).toHaveBeenCalledWith(
|
|
180
|
+
expect.objectContaining({
|
|
181
|
+
...mockExistingMember,
|
|
182
|
+
...updateData,
|
|
183
|
+
}),
|
|
184
|
+
expect.objectContaining({ id: memberId })
|
|
185
|
+
);
|
|
186
|
+
expect(result).toEqual(mockUpdatedMember);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('should update member email if provided', async () => {
|
|
190
|
+
const memberId = 'member-1';
|
|
191
|
+
const updateData = {
|
|
192
|
+
email: 'newemail@example.com',
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const mockExistingMember = {
|
|
196
|
+
id: memberId,
|
|
197
|
+
email: 'test@example.com',
|
|
198
|
+
updated_at: '2023-01-01T00:00:00.000Z',
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const mockUpdatedMember = {
|
|
202
|
+
...mockExistingMember,
|
|
203
|
+
email: 'newemail@example.com',
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
api.members.read.mockResolvedValue(mockExistingMember);
|
|
207
|
+
api.members.edit.mockResolvedValue(mockUpdatedMember);
|
|
208
|
+
|
|
209
|
+
const result = await updateMember(memberId, updateData);
|
|
210
|
+
|
|
211
|
+
expect(result.email).toBe('newemail@example.com');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should throw validation error for missing member ID', async () => {
|
|
215
|
+
await expect(updateMember(null, { name: 'Test' })).rejects.toThrow(
|
|
216
|
+
'Member ID is required for update'
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('should throw validation error for invalid email in update', async () => {
|
|
221
|
+
await expect(updateMember('member-1', { email: 'invalid-email' })).rejects.toThrow(
|
|
222
|
+
'Member validation failed'
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should throw not found error if member does not exist', async () => {
|
|
227
|
+
api.members.read.mockRejectedValue({
|
|
228
|
+
response: { status: 404 },
|
|
229
|
+
message: 'Member not found',
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
await expect(updateMember('non-existent', { name: 'Test' })).rejects.toThrow();
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe('deleteMember', () => {
|
|
237
|
+
it('should delete a member with valid ID', async () => {
|
|
238
|
+
const memberId = 'member-1';
|
|
239
|
+
|
|
240
|
+
api.members.delete.mockResolvedValue({ deleted: true });
|
|
241
|
+
|
|
242
|
+
const result = await deleteMember(memberId);
|
|
243
|
+
|
|
244
|
+
expect(api.members.delete).toHaveBeenCalledWith(memberId, expect.any(Object));
|
|
245
|
+
expect(result).toEqual({ deleted: true });
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('should throw validation error for missing member ID', async () => {
|
|
249
|
+
await expect(deleteMember(null)).rejects.toThrow('Member ID is required for deletion');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('should throw not found error if member does not exist', async () => {
|
|
253
|
+
api.members.delete.mockRejectedValue({
|
|
254
|
+
response: { status: 404 },
|
|
255
|
+
message: 'Member not found',
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
await expect(deleteMember('non-existent')).rejects.toThrow();
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
});
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { validateMemberData, validateMemberUpdateData } from '../memberService.js';
|
|
3
|
+
|
|
4
|
+
describe('memberService - Validation', () => {
|
|
5
|
+
describe('validateMemberData', () => {
|
|
6
|
+
it('should validate required email field', () => {
|
|
7
|
+
expect(() => validateMemberData({})).toThrow('Member validation failed');
|
|
8
|
+
expect(() => validateMemberData({ email: '' })).toThrow('Member validation failed');
|
|
9
|
+
expect(() => validateMemberData({ email: ' ' })).toThrow('Member validation failed');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('should validate email format', () => {
|
|
13
|
+
expect(() => validateMemberData({ email: 'invalid-email' })).toThrow(
|
|
14
|
+
'Member validation failed'
|
|
15
|
+
);
|
|
16
|
+
expect(() => validateMemberData({ email: 'test@' })).toThrow('Member validation failed');
|
|
17
|
+
expect(() => validateMemberData({ email: '@test.com' })).toThrow('Member validation failed');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should accept valid email', () => {
|
|
21
|
+
expect(() => validateMemberData({ email: 'test@example.com' })).not.toThrow();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should accept optional name field', () => {
|
|
25
|
+
expect(() =>
|
|
26
|
+
validateMemberData({ email: 'test@example.com', name: 'John Doe' })
|
|
27
|
+
).not.toThrow();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should accept optional note field', () => {
|
|
31
|
+
expect(() =>
|
|
32
|
+
validateMemberData({ email: 'test@example.com', note: 'Test note' })
|
|
33
|
+
).not.toThrow();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should accept optional labels array', () => {
|
|
37
|
+
expect(() =>
|
|
38
|
+
validateMemberData({ email: 'test@example.com', labels: ['premium', 'newsletter'] })
|
|
39
|
+
).not.toThrow();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should validate labels is an array', () => {
|
|
43
|
+
expect(() => validateMemberData({ email: 'test@example.com', labels: 'premium' })).toThrow(
|
|
44
|
+
'Member validation failed'
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should accept optional newsletters array', () => {
|
|
49
|
+
expect(() =>
|
|
50
|
+
validateMemberData({
|
|
51
|
+
email: 'test@example.com',
|
|
52
|
+
newsletters: [{ id: 'newsletter-1' }],
|
|
53
|
+
})
|
|
54
|
+
).not.toThrow();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should validate newsletter objects have id field', () => {
|
|
58
|
+
expect(() =>
|
|
59
|
+
validateMemberData({
|
|
60
|
+
email: 'test@example.com',
|
|
61
|
+
newsletters: [{ name: 'Newsletter' }],
|
|
62
|
+
})
|
|
63
|
+
).toThrow('Member validation failed');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should accept optional subscribed boolean', () => {
|
|
67
|
+
expect(() =>
|
|
68
|
+
validateMemberData({ email: 'test@example.com', subscribed: true })
|
|
69
|
+
).not.toThrow();
|
|
70
|
+
expect(() =>
|
|
71
|
+
validateMemberData({ email: 'test@example.com', subscribed: false })
|
|
72
|
+
).not.toThrow();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should validate subscribed is a boolean', () => {
|
|
76
|
+
expect(() => validateMemberData({ email: 'test@example.com', subscribed: 'yes' })).toThrow(
|
|
77
|
+
'Member validation failed'
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should validate name length', () => {
|
|
82
|
+
const longName = 'a'.repeat(192); // Exceeds MAX_NAME_LENGTH (191)
|
|
83
|
+
expect(() => validateMemberData({ email: 'test@example.com', name: longName })).toThrow(
|
|
84
|
+
'Member validation failed'
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should accept name at max length', () => {
|
|
89
|
+
const maxName = 'a'.repeat(191); // At MAX_NAME_LENGTH
|
|
90
|
+
expect(() => validateMemberData({ email: 'test@example.com', name: maxName })).not.toThrow();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should validate note length', () => {
|
|
94
|
+
const longNote = 'a'.repeat(2001); // Exceeds MAX_NOTE_LENGTH (2000)
|
|
95
|
+
expect(() => validateMemberData({ email: 'test@example.com', note: longNote })).toThrow(
|
|
96
|
+
'Member validation failed'
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should accept note at max length', () => {
|
|
101
|
+
const maxNote = 'a'.repeat(2000); // At MAX_NOTE_LENGTH
|
|
102
|
+
expect(() => validateMemberData({ email: 'test@example.com', note: maxNote })).not.toThrow();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should sanitize HTML in note field', () => {
|
|
106
|
+
const memberData = {
|
|
107
|
+
email: 'test@example.com',
|
|
108
|
+
note: '<script>alert("xss")</script>Test note',
|
|
109
|
+
};
|
|
110
|
+
validateMemberData(memberData);
|
|
111
|
+
expect(memberData.note).toBe('Test note'); // HTML should be stripped
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should validate label length', () => {
|
|
115
|
+
const longLabel = 'a'.repeat(192); // Exceeds MAX_LABEL_LENGTH (191)
|
|
116
|
+
expect(() => validateMemberData({ email: 'test@example.com', labels: [longLabel] })).toThrow(
|
|
117
|
+
'Member validation failed'
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should reject empty string labels', () => {
|
|
122
|
+
expect(() => validateMemberData({ email: 'test@example.com', labels: [''] })).toThrow(
|
|
123
|
+
'Member validation failed'
|
|
124
|
+
);
|
|
125
|
+
expect(() => validateMemberData({ email: 'test@example.com', labels: [' '] })).toThrow(
|
|
126
|
+
'Member validation failed'
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should reject non-string labels', () => {
|
|
131
|
+
expect(() => validateMemberData({ email: 'test@example.com', labels: [123] })).toThrow(
|
|
132
|
+
'Member validation failed'
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should reject empty newsletter IDs', () => {
|
|
137
|
+
expect(() =>
|
|
138
|
+
validateMemberData({ email: 'test@example.com', newsletters: [{ id: '' }] })
|
|
139
|
+
).toThrow('Member validation failed');
|
|
140
|
+
expect(() =>
|
|
141
|
+
validateMemberData({ email: 'test@example.com', newsletters: [{ id: ' ' }] })
|
|
142
|
+
).toThrow('Member validation failed');
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('validateMemberUpdateData', () => {
|
|
147
|
+
it('should validate email format if provided', () => {
|
|
148
|
+
expect(() => validateMemberUpdateData({ email: 'invalid-email' })).toThrow(
|
|
149
|
+
'Member validation failed'
|
|
150
|
+
);
|
|
151
|
+
expect(() => validateMemberUpdateData({ email: 'test@example.com' })).not.toThrow();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should accept update with only name', () => {
|
|
155
|
+
expect(() => validateMemberUpdateData({ name: 'John Doe' })).not.toThrow();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should accept update with only note', () => {
|
|
159
|
+
expect(() => validateMemberUpdateData({ note: 'Updated note' })).not.toThrow();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should accept update with only labels', () => {
|
|
163
|
+
expect(() => validateMemberUpdateData({ labels: ['premium'] })).not.toThrow();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should validate labels is an array if provided', () => {
|
|
167
|
+
expect(() => validateMemberUpdateData({ labels: 'premium' })).toThrow(
|
|
168
|
+
'Member validation failed'
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should accept update with only newsletters', () => {
|
|
173
|
+
expect(() =>
|
|
174
|
+
validateMemberUpdateData({ newsletters: [{ id: 'newsletter-1' }] })
|
|
175
|
+
).not.toThrow();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should validate newsletter objects have id field if provided', () => {
|
|
179
|
+
expect(() => validateMemberUpdateData({ newsletters: [{ name: 'Newsletter' }] })).toThrow(
|
|
180
|
+
'Member validation failed'
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should allow empty update object', () => {
|
|
185
|
+
expect(() => validateMemberUpdateData({})).not.toThrow();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should validate name length in updates', () => {
|
|
189
|
+
const longName = 'a'.repeat(192); // Exceeds MAX_NAME_LENGTH (191)
|
|
190
|
+
expect(() => validateMemberUpdateData({ name: longName })).toThrow(
|
|
191
|
+
'Member validation failed'
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should accept name at max length in updates', () => {
|
|
196
|
+
const maxName = 'a'.repeat(191); // At MAX_NAME_LENGTH
|
|
197
|
+
expect(() => validateMemberUpdateData({ name: maxName })).not.toThrow();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should validate note length in updates', () => {
|
|
201
|
+
const longNote = 'a'.repeat(2001); // Exceeds MAX_NOTE_LENGTH (2000)
|
|
202
|
+
expect(() => validateMemberUpdateData({ note: longNote })).toThrow(
|
|
203
|
+
'Member validation failed'
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should accept note at max length in updates', () => {
|
|
208
|
+
const maxNote = 'a'.repeat(2000); // At MAX_NOTE_LENGTH
|
|
209
|
+
expect(() => validateMemberUpdateData({ note: maxNote })).not.toThrow();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should sanitize HTML in note field for updates', () => {
|
|
213
|
+
const updateData = { note: '<script>alert("xss")</script>Updated note' };
|
|
214
|
+
validateMemberUpdateData(updateData);
|
|
215
|
+
expect(updateData.note).toBe('Updated note'); // HTML should be stripped
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('should validate label length in updates', () => {
|
|
219
|
+
const longLabel = 'a'.repeat(192); // Exceeds MAX_LABEL_LENGTH (191)
|
|
220
|
+
expect(() => validateMemberUpdateData({ labels: [longLabel] })).toThrow(
|
|
221
|
+
'Member validation failed'
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should reject empty string labels in updates', () => {
|
|
226
|
+
expect(() => validateMemberUpdateData({ labels: [''] })).toThrow('Member validation failed');
|
|
227
|
+
expect(() => validateMemberUpdateData({ labels: [' '] })).toThrow(
|
|
228
|
+
'Member validation failed'
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('should reject non-string labels in updates', () => {
|
|
233
|
+
expect(() => validateMemberUpdateData({ labels: [123] })).toThrow('Member validation failed');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should reject empty newsletter IDs in updates', () => {
|
|
237
|
+
expect(() => validateMemberUpdateData({ newsletters: [{ id: '' }] })).toThrow(
|
|
238
|
+
'Member validation failed'
|
|
239
|
+
);
|
|
240
|
+
expect(() => validateMemberUpdateData({ newsletters: [{ id: ' ' }] })).toThrow(
|
|
241
|
+
'Member validation failed'
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
});
|
|
@@ -772,6 +772,110 @@ 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
|
+
|
|
775
879
|
/**
|
|
776
880
|
* Newsletter CRUD Operations
|
|
777
881
|
*/
|
|
@@ -920,6 +1024,9 @@ export default {
|
|
|
920
1024
|
getTag,
|
|
921
1025
|
updateTag,
|
|
922
1026
|
deleteTag,
|
|
1027
|
+
createMember,
|
|
1028
|
+
updateMember,
|
|
1029
|
+
deleteMember,
|
|
923
1030
|
getNewsletters,
|
|
924
1031
|
getNewsletter,
|
|
925
1032
|
createNewsletter,
|
|
@@ -0,0 +1,202 @@
|
|
|
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
|
+
* Validates member data for creation
|
|
19
|
+
* @param {Object} memberData - The member data to validate
|
|
20
|
+
* @throws {ValidationError} If validation fails
|
|
21
|
+
*/
|
|
22
|
+
export function validateMemberData(memberData) {
|
|
23
|
+
const errors = [];
|
|
24
|
+
|
|
25
|
+
// Email is required and must be valid
|
|
26
|
+
if (!memberData.email || memberData.email.trim().length === 0) {
|
|
27
|
+
errors.push({ field: 'email', message: 'Email is required' });
|
|
28
|
+
} else if (!EMAIL_REGEX.test(memberData.email)) {
|
|
29
|
+
errors.push({ field: 'email', message: 'Invalid email format' });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Name is optional but must be a string with valid length if provided
|
|
33
|
+
if (memberData.name !== undefined) {
|
|
34
|
+
if (typeof memberData.name !== 'string') {
|
|
35
|
+
errors.push({ field: 'name', message: 'Name must be a string' });
|
|
36
|
+
} else if (memberData.name.length > MAX_NAME_LENGTH) {
|
|
37
|
+
errors.push({ field: 'name', message: `Name must not exceed ${MAX_NAME_LENGTH} characters` });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Note is optional but must be a string with valid length if provided
|
|
42
|
+
// Sanitize HTML to prevent XSS attacks
|
|
43
|
+
if (memberData.note !== undefined) {
|
|
44
|
+
if (typeof memberData.note !== 'string') {
|
|
45
|
+
errors.push({ field: 'note', message: 'Note must be a string' });
|
|
46
|
+
} else {
|
|
47
|
+
if (memberData.note.length > MAX_NOTE_LENGTH) {
|
|
48
|
+
errors.push({
|
|
49
|
+
field: 'note',
|
|
50
|
+
message: `Note must not exceed ${MAX_NOTE_LENGTH} characters`,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
// Sanitize HTML content - strip all HTML tags for notes
|
|
54
|
+
memberData.note = sanitizeHtml(memberData.note, {
|
|
55
|
+
allowedTags: [], // Strip all HTML
|
|
56
|
+
allowedAttributes: {},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Labels is optional but must be an array of valid strings if provided
|
|
62
|
+
if (memberData.labels !== undefined) {
|
|
63
|
+
if (!Array.isArray(memberData.labels)) {
|
|
64
|
+
errors.push({ field: 'labels', message: 'Labels must be an array' });
|
|
65
|
+
} else {
|
|
66
|
+
// Validate each label is a non-empty string within length limit
|
|
67
|
+
const invalidLabels = memberData.labels.filter(
|
|
68
|
+
(label) =>
|
|
69
|
+
typeof label !== 'string' || label.trim().length === 0 || label.length > MAX_LABEL_LENGTH
|
|
70
|
+
);
|
|
71
|
+
if (invalidLabels.length > 0) {
|
|
72
|
+
errors.push({
|
|
73
|
+
field: 'labels',
|
|
74
|
+
message: `Each label must be a non-empty string (max ${MAX_LABEL_LENGTH} characters)`,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Newsletters is optional but must be an array of objects with valid id field
|
|
81
|
+
if (memberData.newsletters !== undefined) {
|
|
82
|
+
if (!Array.isArray(memberData.newsletters)) {
|
|
83
|
+
errors.push({ field: 'newsletters', message: 'Newsletters must be an array' });
|
|
84
|
+
} else {
|
|
85
|
+
// Validate each newsletter has a non-empty string id
|
|
86
|
+
const invalidNewsletters = memberData.newsletters.filter(
|
|
87
|
+
(newsletter) =>
|
|
88
|
+
!newsletter.id || typeof newsletter.id !== 'string' || newsletter.id.trim().length === 0
|
|
89
|
+
);
|
|
90
|
+
if (invalidNewsletters.length > 0) {
|
|
91
|
+
errors.push({
|
|
92
|
+
field: 'newsletters',
|
|
93
|
+
message: 'Each newsletter must have a non-empty id field',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Subscribed is optional but must be a boolean if provided
|
|
100
|
+
if (memberData.subscribed !== undefined && typeof memberData.subscribed !== 'boolean') {
|
|
101
|
+
errors.push({ field: 'subscribed', message: 'Subscribed must be a boolean' });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (errors.length > 0) {
|
|
105
|
+
throw new ValidationError('Member validation failed', errors);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Validates member data for update
|
|
111
|
+
* All fields are optional for updates, but if provided they must be valid
|
|
112
|
+
* @param {Object} updateData - The member update data to validate
|
|
113
|
+
* @throws {ValidationError} If validation fails
|
|
114
|
+
*/
|
|
115
|
+
export function validateMemberUpdateData(updateData) {
|
|
116
|
+
const errors = [];
|
|
117
|
+
|
|
118
|
+
// Email is optional for update but must be valid if provided
|
|
119
|
+
if (updateData.email !== undefined) {
|
|
120
|
+
if (typeof updateData.email !== 'string' || updateData.email.trim().length === 0) {
|
|
121
|
+
errors.push({ field: 'email', message: 'Email must be a non-empty string' });
|
|
122
|
+
} else if (!EMAIL_REGEX.test(updateData.email)) {
|
|
123
|
+
errors.push({ field: 'email', message: 'Invalid email format' });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Name is optional but must be a string with valid length if provided
|
|
128
|
+
if (updateData.name !== undefined) {
|
|
129
|
+
if (typeof updateData.name !== 'string') {
|
|
130
|
+
errors.push({ field: 'name', message: 'Name must be a string' });
|
|
131
|
+
} else if (updateData.name.length > MAX_NAME_LENGTH) {
|
|
132
|
+
errors.push({ field: 'name', message: `Name must not exceed ${MAX_NAME_LENGTH} characters` });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Note is optional but must be a string with valid length if provided
|
|
137
|
+
// Sanitize HTML to prevent XSS attacks
|
|
138
|
+
if (updateData.note !== undefined) {
|
|
139
|
+
if (typeof updateData.note !== 'string') {
|
|
140
|
+
errors.push({ field: 'note', message: 'Note must be a string' });
|
|
141
|
+
} else {
|
|
142
|
+
if (updateData.note.length > MAX_NOTE_LENGTH) {
|
|
143
|
+
errors.push({
|
|
144
|
+
field: 'note',
|
|
145
|
+
message: `Note must not exceed ${MAX_NOTE_LENGTH} characters`,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
// Sanitize HTML content - strip all HTML tags for notes
|
|
149
|
+
updateData.note = sanitizeHtml(updateData.note, {
|
|
150
|
+
allowedTags: [], // Strip all HTML
|
|
151
|
+
allowedAttributes: {},
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Labels is optional but must be an array of valid strings if provided
|
|
157
|
+
if (updateData.labels !== undefined) {
|
|
158
|
+
if (!Array.isArray(updateData.labels)) {
|
|
159
|
+
errors.push({ field: 'labels', message: 'Labels must be an array' });
|
|
160
|
+
} else {
|
|
161
|
+
// Validate each label is a non-empty string within length limit
|
|
162
|
+
const invalidLabels = updateData.labels.filter(
|
|
163
|
+
(label) =>
|
|
164
|
+
typeof label !== 'string' || label.trim().length === 0 || label.length > MAX_LABEL_LENGTH
|
|
165
|
+
);
|
|
166
|
+
if (invalidLabels.length > 0) {
|
|
167
|
+
errors.push({
|
|
168
|
+
field: 'labels',
|
|
169
|
+
message: `Each label must be a non-empty string (max ${MAX_LABEL_LENGTH} characters)`,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Newsletters is optional but must be an array of objects with valid id field
|
|
176
|
+
if (updateData.newsletters !== undefined) {
|
|
177
|
+
if (!Array.isArray(updateData.newsletters)) {
|
|
178
|
+
errors.push({ field: 'newsletters', message: 'Newsletters must be an array' });
|
|
179
|
+
} else {
|
|
180
|
+
// Validate each newsletter has a non-empty string id
|
|
181
|
+
const invalidNewsletters = updateData.newsletters.filter(
|
|
182
|
+
(newsletter) =>
|
|
183
|
+
!newsletter.id || typeof newsletter.id !== 'string' || newsletter.id.trim().length === 0
|
|
184
|
+
);
|
|
185
|
+
if (invalidNewsletters.length > 0) {
|
|
186
|
+
errors.push({
|
|
187
|
+
field: 'newsletters',
|
|
188
|
+
message: 'Each newsletter must have a non-empty id field',
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (errors.length > 0) {
|
|
195
|
+
throw new ValidationError('Member validation failed', errors);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export default {
|
|
200
|
+
validateMemberData,
|
|
201
|
+
validateMemberUpdateData,
|
|
202
|
+
};
|