@jgardner04/ghost-mcp-server 1.9.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 +120 -1
- package/src/services/__tests__/ghostServiceImproved.members.test.js +288 -1
- package/src/services/__tests__/memberService.test.js +233 -1
- package/src/services/ghostServiceImproved.js +108 -0
- package/src/services/memberService.js +190 -0
package/package.json
CHANGED
|
@@ -1019,6 +1019,125 @@ server.tool(
|
|
|
1019
1019
|
}
|
|
1020
1020
|
);
|
|
1021
1021
|
|
|
1022
|
+
// Get Members Tool
|
|
1023
|
+
server.tool(
|
|
1024
|
+
'ghost_get_members',
|
|
1025
|
+
'Retrieves a list of members (subscribers) from Ghost CMS with optional filtering, pagination, and includes.',
|
|
1026
|
+
{
|
|
1027
|
+
limit: z
|
|
1028
|
+
.number()
|
|
1029
|
+
.min(1)
|
|
1030
|
+
.max(100)
|
|
1031
|
+
.optional()
|
|
1032
|
+
.describe('Number of members to retrieve (1-100). Default is 15.'),
|
|
1033
|
+
page: z.number().min(1).optional().describe('Page number for pagination (starts at 1).'),
|
|
1034
|
+
filter: z
|
|
1035
|
+
.string()
|
|
1036
|
+
.optional()
|
|
1037
|
+
.describe('Ghost NQL filter string (e.g., "status:free", "status:paid", "subscribed:true").'),
|
|
1038
|
+
order: z.string().optional().describe('Order string (e.g., "created_at desc", "email asc").'),
|
|
1039
|
+
include: z
|
|
1040
|
+
.string()
|
|
1041
|
+
.optional()
|
|
1042
|
+
.describe('Comma-separated list of related data to include (e.g., "labels,newsletters").'),
|
|
1043
|
+
},
|
|
1044
|
+
async (input) => {
|
|
1045
|
+
console.error(`Executing tool: ghost_get_members`);
|
|
1046
|
+
try {
|
|
1047
|
+
await loadServices();
|
|
1048
|
+
|
|
1049
|
+
const options = {};
|
|
1050
|
+
if (input.limit !== undefined) options.limit = input.limit;
|
|
1051
|
+
if (input.page !== undefined) options.page = input.page;
|
|
1052
|
+
if (input.filter !== undefined) options.filter = input.filter;
|
|
1053
|
+
if (input.order !== undefined) options.order = input.order;
|
|
1054
|
+
if (input.include !== undefined) options.include = input.include;
|
|
1055
|
+
|
|
1056
|
+
const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
|
|
1057
|
+
const members = await ghostServiceImproved.getMembers(options);
|
|
1058
|
+
console.error(`Retrieved ${members.length} members from Ghost.`);
|
|
1059
|
+
|
|
1060
|
+
return {
|
|
1061
|
+
content: [{ type: 'text', text: JSON.stringify(members, null, 2) }],
|
|
1062
|
+
};
|
|
1063
|
+
} catch (error) {
|
|
1064
|
+
console.error(`Error in ghost_get_members:`, error);
|
|
1065
|
+
return {
|
|
1066
|
+
content: [{ type: 'text', text: `Error retrieving members: ${error.message}` }],
|
|
1067
|
+
isError: true,
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
);
|
|
1072
|
+
|
|
1073
|
+
// Get Member Tool
|
|
1074
|
+
server.tool(
|
|
1075
|
+
'ghost_get_member',
|
|
1076
|
+
'Retrieves a single member from Ghost CMS by ID or email. Provide either id OR email.',
|
|
1077
|
+
{
|
|
1078
|
+
id: z.string().optional().describe('The ID of the member to retrieve.'),
|
|
1079
|
+
email: z.string().email().optional().describe('The email of the member to retrieve.'),
|
|
1080
|
+
},
|
|
1081
|
+
async ({ id, email }) => {
|
|
1082
|
+
console.error(`Executing tool: ghost_get_member for ${id ? `ID: ${id}` : `email: ${email}`}`);
|
|
1083
|
+
try {
|
|
1084
|
+
await loadServices();
|
|
1085
|
+
|
|
1086
|
+
const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
|
|
1087
|
+
const member = await ghostServiceImproved.getMember({ id, email });
|
|
1088
|
+
console.error(`Retrieved member: ${member.email} (ID: ${member.id})`);
|
|
1089
|
+
|
|
1090
|
+
return {
|
|
1091
|
+
content: [{ type: 'text', text: JSON.stringify(member, null, 2) }],
|
|
1092
|
+
};
|
|
1093
|
+
} catch (error) {
|
|
1094
|
+
console.error(`Error in ghost_get_member:`, error);
|
|
1095
|
+
return {
|
|
1096
|
+
content: [{ type: 'text', text: `Error retrieving member: ${error.message}` }],
|
|
1097
|
+
isError: true,
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
);
|
|
1102
|
+
|
|
1103
|
+
// Search Members Tool
|
|
1104
|
+
server.tool(
|
|
1105
|
+
'ghost_search_members',
|
|
1106
|
+
'Searches for members by name or email in Ghost CMS.',
|
|
1107
|
+
{
|
|
1108
|
+
query: z.string().min(1).describe('Search query to match against member name or email.'),
|
|
1109
|
+
limit: z
|
|
1110
|
+
.number()
|
|
1111
|
+
.min(1)
|
|
1112
|
+
.max(50)
|
|
1113
|
+
.optional()
|
|
1114
|
+
.describe('Maximum number of results to return (1-50). Default is 15.'),
|
|
1115
|
+
},
|
|
1116
|
+
async ({ query, limit }) => {
|
|
1117
|
+
console.error(`Executing tool: ghost_search_members with query: ${query}`);
|
|
1118
|
+
try {
|
|
1119
|
+
await loadServices();
|
|
1120
|
+
|
|
1121
|
+
const options = {};
|
|
1122
|
+
if (limit !== undefined) options.limit = limit;
|
|
1123
|
+
|
|
1124
|
+
const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
|
|
1125
|
+
const members = await ghostServiceImproved.searchMembers(query, options);
|
|
1126
|
+
console.error(`Found ${members.length} members matching "${query}".`);
|
|
1127
|
+
|
|
1128
|
+
return {
|
|
1129
|
+
content: [{ type: 'text', text: JSON.stringify(members, null, 2) }],
|
|
1130
|
+
};
|
|
1131
|
+
} catch (error) {
|
|
1132
|
+
console.error(`Error in ghost_search_members:`, error);
|
|
1133
|
+
return {
|
|
1134
|
+
content: [{ type: 'text', text: `Error searching members: ${error.message}` }],
|
|
1135
|
+
isError: true,
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
);
|
|
1140
|
+
|
|
1022
1141
|
// =============================================================================
|
|
1023
1142
|
// NEWSLETTER TOOLS
|
|
1024
1143
|
// =============================================================================
|
|
@@ -1224,7 +1343,7 @@ async function main() {
|
|
|
1224
1343
|
'Available tools: ghost_get_tags, ghost_create_tag, ghost_get_tag, ghost_update_tag, ghost_delete_tag, ghost_upload_image, ' +
|
|
1225
1344
|
'ghost_create_post, ghost_get_posts, ghost_get_post, ghost_search_posts, ghost_update_post, ghost_delete_post, ' +
|
|
1226
1345
|
'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, ' +
|
|
1346
|
+
'ghost_create_member, ghost_update_member, ghost_delete_member, ghost_get_members, ghost_get_member, ghost_search_members, ' +
|
|
1228
1347
|
'ghost_get_newsletters, ghost_get_newsletter, ghost_create_newsletter, ghost_update_newsletter, ghost_delete_newsletter'
|
|
1229
1348
|
);
|
|
1230
1349
|
}
|
|
@@ -60,7 +60,15 @@ vi.mock('fs/promises', () => ({
|
|
|
60
60
|
}));
|
|
61
61
|
|
|
62
62
|
// Import after setting up mocks
|
|
63
|
-
import {
|
|
63
|
+
import {
|
|
64
|
+
createMember,
|
|
65
|
+
updateMember,
|
|
66
|
+
deleteMember,
|
|
67
|
+
getMembers,
|
|
68
|
+
getMember,
|
|
69
|
+
searchMembers,
|
|
70
|
+
api,
|
|
71
|
+
} from '../ghostServiceImproved.js';
|
|
64
72
|
|
|
65
73
|
describe('ghostServiceImproved - Members', () => {
|
|
66
74
|
beforeEach(() => {
|
|
@@ -258,4 +266,283 @@ describe('ghostServiceImproved - Members', () => {
|
|
|
258
266
|
await expect(deleteMember('non-existent')).rejects.toThrow();
|
|
259
267
|
});
|
|
260
268
|
});
|
|
269
|
+
|
|
270
|
+
describe('getMembers', () => {
|
|
271
|
+
it('should return all members with default options', async () => {
|
|
272
|
+
const mockMembers = [
|
|
273
|
+
{ id: 'member-1', email: 'test1@example.com', status: 'free' },
|
|
274
|
+
{ id: 'member-2', email: 'test2@example.com', status: 'paid' },
|
|
275
|
+
];
|
|
276
|
+
|
|
277
|
+
api.members.browse.mockResolvedValue(mockMembers);
|
|
278
|
+
|
|
279
|
+
const result = await getMembers();
|
|
280
|
+
|
|
281
|
+
expect(api.members.browse).toHaveBeenCalled();
|
|
282
|
+
expect(result).toEqual(mockMembers);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('should accept pagination options', async () => {
|
|
286
|
+
const mockMembers = [{ id: 'member-1', email: 'test1@example.com' }];
|
|
287
|
+
|
|
288
|
+
api.members.browse.mockResolvedValue(mockMembers);
|
|
289
|
+
|
|
290
|
+
await getMembers({ limit: 50, page: 2 });
|
|
291
|
+
|
|
292
|
+
expect(api.members.browse).toHaveBeenCalledWith(
|
|
293
|
+
expect.objectContaining({
|
|
294
|
+
limit: 50,
|
|
295
|
+
page: 2,
|
|
296
|
+
}),
|
|
297
|
+
expect.any(Object)
|
|
298
|
+
);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('should accept filter options', async () => {
|
|
302
|
+
const mockMembers = [{ id: 'member-1', email: 'test1@example.com', status: 'paid' }];
|
|
303
|
+
|
|
304
|
+
api.members.browse.mockResolvedValue(mockMembers);
|
|
305
|
+
|
|
306
|
+
await getMembers({ filter: 'status:paid' });
|
|
307
|
+
|
|
308
|
+
expect(api.members.browse).toHaveBeenCalledWith(
|
|
309
|
+
expect.objectContaining({
|
|
310
|
+
filter: 'status:paid',
|
|
311
|
+
}),
|
|
312
|
+
expect.any(Object)
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('should accept order options', async () => {
|
|
317
|
+
const mockMembers = [{ id: 'member-1', email: 'test1@example.com' }];
|
|
318
|
+
|
|
319
|
+
api.members.browse.mockResolvedValue(mockMembers);
|
|
320
|
+
|
|
321
|
+
await getMembers({ order: 'created_at desc' });
|
|
322
|
+
|
|
323
|
+
expect(api.members.browse).toHaveBeenCalledWith(
|
|
324
|
+
expect.objectContaining({
|
|
325
|
+
order: 'created_at desc',
|
|
326
|
+
}),
|
|
327
|
+
expect.any(Object)
|
|
328
|
+
);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('should accept include options', async () => {
|
|
332
|
+
const mockMembers = [
|
|
333
|
+
{ id: 'member-1', email: 'test1@example.com', labels: [], newsletters: [] },
|
|
334
|
+
];
|
|
335
|
+
|
|
336
|
+
api.members.browse.mockResolvedValue(mockMembers);
|
|
337
|
+
|
|
338
|
+
await getMembers({ include: 'labels,newsletters' });
|
|
339
|
+
|
|
340
|
+
expect(api.members.browse).toHaveBeenCalledWith(
|
|
341
|
+
expect.objectContaining({
|
|
342
|
+
include: 'labels,newsletters',
|
|
343
|
+
}),
|
|
344
|
+
expect.any(Object)
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('should throw validation error for invalid limit', async () => {
|
|
349
|
+
await expect(getMembers({ limit: 0 })).rejects.toThrow('Member query validation failed');
|
|
350
|
+
await expect(getMembers({ limit: 101 })).rejects.toThrow('Member query validation failed');
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('should throw validation error for invalid page', async () => {
|
|
354
|
+
await expect(getMembers({ page: 0 })).rejects.toThrow('Member query validation failed');
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('should return empty array when no members found', async () => {
|
|
358
|
+
api.members.browse.mockResolvedValue([]);
|
|
359
|
+
|
|
360
|
+
const result = await getMembers();
|
|
361
|
+
|
|
362
|
+
expect(result).toEqual([]);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('should handle Ghost API errors', async () => {
|
|
366
|
+
api.members.browse.mockRejectedValue(new Error('Ghost API Error'));
|
|
367
|
+
|
|
368
|
+
await expect(getMembers()).rejects.toThrow();
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
describe('getMember', () => {
|
|
373
|
+
it('should get member by ID', async () => {
|
|
374
|
+
const mockMember = {
|
|
375
|
+
id: 'member-1',
|
|
376
|
+
email: 'test@example.com',
|
|
377
|
+
name: 'John Doe',
|
|
378
|
+
status: 'free',
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
api.members.read.mockResolvedValue(mockMember);
|
|
382
|
+
|
|
383
|
+
const result = await getMember({ id: 'member-1' });
|
|
384
|
+
|
|
385
|
+
expect(api.members.read).toHaveBeenCalledWith(expect.any(Object), { id: 'member-1' });
|
|
386
|
+
expect(result).toEqual(mockMember);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('should get member by email', async () => {
|
|
390
|
+
const mockMember = {
|
|
391
|
+
id: 'member-1',
|
|
392
|
+
email: 'test@example.com',
|
|
393
|
+
name: 'John Doe',
|
|
394
|
+
status: 'free',
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
api.members.browse.mockResolvedValue([mockMember]);
|
|
398
|
+
|
|
399
|
+
const result = await getMember({ email: 'test@example.com' });
|
|
400
|
+
|
|
401
|
+
expect(api.members.browse).toHaveBeenCalledWith(
|
|
402
|
+
expect.objectContaining({
|
|
403
|
+
filter: expect.stringContaining('test@example.com'),
|
|
404
|
+
}),
|
|
405
|
+
expect.any(Object)
|
|
406
|
+
);
|
|
407
|
+
expect(result).toEqual(mockMember);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it('should throw validation error when neither id nor email provided', async () => {
|
|
411
|
+
await expect(getMember({})).rejects.toThrow('Member lookup validation failed');
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('should throw validation error for invalid email format', async () => {
|
|
415
|
+
await expect(getMember({ email: 'invalid-email' })).rejects.toThrow(
|
|
416
|
+
'Member lookup validation failed'
|
|
417
|
+
);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('should throw not found error when member not found by ID', async () => {
|
|
421
|
+
api.members.read.mockRejectedValue({
|
|
422
|
+
response: { status: 404 },
|
|
423
|
+
message: 'Member not found',
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
await expect(getMember({ id: 'non-existent' })).rejects.toThrow();
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('should throw not found error when member not found by email', async () => {
|
|
430
|
+
api.members.browse.mockResolvedValue([]);
|
|
431
|
+
|
|
432
|
+
await expect(getMember({ email: 'notfound@example.com' })).rejects.toThrow(
|
|
433
|
+
'Member not found'
|
|
434
|
+
);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('should prioritize ID over email when both provided', async () => {
|
|
438
|
+
const mockMember = {
|
|
439
|
+
id: 'member-1',
|
|
440
|
+
email: 'test@example.com',
|
|
441
|
+
status: 'free',
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
api.members.read.mockResolvedValue(mockMember);
|
|
445
|
+
|
|
446
|
+
await getMember({ id: 'member-1', email: 'test@example.com' });
|
|
447
|
+
|
|
448
|
+
// Should use read (ID) instead of browse (email)
|
|
449
|
+
expect(api.members.read).toHaveBeenCalled();
|
|
450
|
+
expect(api.members.browse).not.toHaveBeenCalled();
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
describe('searchMembers', () => {
|
|
455
|
+
it('should search members by query', async () => {
|
|
456
|
+
const mockMembers = [{ id: 'member-1', email: 'john@example.com', name: 'John Doe' }];
|
|
457
|
+
|
|
458
|
+
api.members.browse.mockResolvedValue(mockMembers);
|
|
459
|
+
|
|
460
|
+
const result = await searchMembers('john');
|
|
461
|
+
|
|
462
|
+
expect(api.members.browse).toHaveBeenCalled();
|
|
463
|
+
expect(result).toEqual(mockMembers);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it('should apply default limit of 15', async () => {
|
|
467
|
+
const mockMembers = [];
|
|
468
|
+
|
|
469
|
+
api.members.browse.mockResolvedValue(mockMembers);
|
|
470
|
+
|
|
471
|
+
await searchMembers('test');
|
|
472
|
+
|
|
473
|
+
expect(api.members.browse).toHaveBeenCalledWith(
|
|
474
|
+
expect.objectContaining({
|
|
475
|
+
limit: 15,
|
|
476
|
+
}),
|
|
477
|
+
expect.any(Object)
|
|
478
|
+
);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it('should accept custom limit', async () => {
|
|
482
|
+
const mockMembers = [];
|
|
483
|
+
|
|
484
|
+
api.members.browse.mockResolvedValue(mockMembers);
|
|
485
|
+
|
|
486
|
+
await searchMembers('test', { limit: 25 });
|
|
487
|
+
|
|
488
|
+
expect(api.members.browse).toHaveBeenCalledWith(
|
|
489
|
+
expect.objectContaining({
|
|
490
|
+
limit: 25,
|
|
491
|
+
}),
|
|
492
|
+
expect.any(Object)
|
|
493
|
+
);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('should throw validation error for empty query', async () => {
|
|
497
|
+
await expect(searchMembers('')).rejects.toThrow('Search query validation failed');
|
|
498
|
+
await expect(searchMembers(' ')).rejects.toThrow('Search query validation failed');
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('should throw validation error for non-string query', async () => {
|
|
502
|
+
await expect(searchMembers(123)).rejects.toThrow('Search query validation failed');
|
|
503
|
+
await expect(searchMembers(null)).rejects.toThrow('Search query validation failed');
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it('should throw validation error for invalid limit', async () => {
|
|
507
|
+
await expect(searchMembers('test', { limit: 0 })).rejects.toThrow(
|
|
508
|
+
'Search options validation failed'
|
|
509
|
+
);
|
|
510
|
+
await expect(searchMembers('test', { limit: 51 })).rejects.toThrow(
|
|
511
|
+
'Search options validation failed'
|
|
512
|
+
);
|
|
513
|
+
await expect(searchMembers('test', { limit: 100 })).rejects.toThrow(
|
|
514
|
+
'Search options validation failed'
|
|
515
|
+
);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('should sanitize query to prevent NQL injection', async () => {
|
|
519
|
+
const mockMembers = [];
|
|
520
|
+
|
|
521
|
+
api.members.browse.mockResolvedValue(mockMembers);
|
|
522
|
+
|
|
523
|
+
// Query with special NQL characters
|
|
524
|
+
await searchMembers("test'value");
|
|
525
|
+
|
|
526
|
+
expect(api.members.browse).toHaveBeenCalledWith(
|
|
527
|
+
expect.objectContaining({
|
|
528
|
+
filter: expect.stringContaining("\\'"),
|
|
529
|
+
}),
|
|
530
|
+
expect.any(Object)
|
|
531
|
+
);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it('should return empty array when no matches found', async () => {
|
|
535
|
+
api.members.browse.mockResolvedValue([]);
|
|
536
|
+
|
|
537
|
+
const result = await searchMembers('nonexistent');
|
|
538
|
+
|
|
539
|
+
expect(result).toEqual([]);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it('should handle Ghost API errors', async () => {
|
|
543
|
+
api.members.browse.mockRejectedValue(new Error('Ghost API Error'));
|
|
544
|
+
|
|
545
|
+
await expect(searchMembers('test')).rejects.toThrow();
|
|
546
|
+
});
|
|
547
|
+
});
|
|
261
548
|
});
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
validateMemberData,
|
|
4
|
+
validateMemberUpdateData,
|
|
5
|
+
validateMemberQueryOptions,
|
|
6
|
+
validateMemberLookup,
|
|
7
|
+
validateSearchQuery,
|
|
8
|
+
validateSearchOptions,
|
|
9
|
+
sanitizeNqlValue,
|
|
10
|
+
} from '../memberService.js';
|
|
3
11
|
|
|
4
12
|
describe('memberService - Validation', () => {
|
|
5
13
|
describe('validateMemberData', () => {
|
|
@@ -242,4 +250,228 @@ describe('memberService - Validation', () => {
|
|
|
242
250
|
);
|
|
243
251
|
});
|
|
244
252
|
});
|
|
253
|
+
|
|
254
|
+
describe('validateMemberQueryOptions', () => {
|
|
255
|
+
it('should accept empty options', () => {
|
|
256
|
+
expect(() => validateMemberQueryOptions({})).not.toThrow();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('should accept valid limit within bounds', () => {
|
|
260
|
+
expect(() => validateMemberQueryOptions({ limit: 1 })).not.toThrow();
|
|
261
|
+
expect(() => validateMemberQueryOptions({ limit: 50 })).not.toThrow();
|
|
262
|
+
expect(() => validateMemberQueryOptions({ limit: 100 })).not.toThrow();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('should reject limit below minimum', () => {
|
|
266
|
+
expect(() => validateMemberQueryOptions({ limit: 0 })).toThrow(
|
|
267
|
+
'Member query validation failed'
|
|
268
|
+
);
|
|
269
|
+
expect(() => validateMemberQueryOptions({ limit: -1 })).toThrow(
|
|
270
|
+
'Member query validation failed'
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('should reject limit above maximum', () => {
|
|
275
|
+
expect(() => validateMemberQueryOptions({ limit: 101 })).toThrow(
|
|
276
|
+
'Member query validation failed'
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('should accept valid page number', () => {
|
|
281
|
+
expect(() => validateMemberQueryOptions({ page: 1 })).not.toThrow();
|
|
282
|
+
expect(() => validateMemberQueryOptions({ page: 100 })).not.toThrow();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('should reject page below minimum', () => {
|
|
286
|
+
expect(() => validateMemberQueryOptions({ page: 0 })).toThrow(
|
|
287
|
+
'Member query validation failed'
|
|
288
|
+
);
|
|
289
|
+
expect(() => validateMemberQueryOptions({ page: -1 })).toThrow(
|
|
290
|
+
'Member query validation failed'
|
|
291
|
+
);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('should accept valid filter strings', () => {
|
|
295
|
+
expect(() => validateMemberQueryOptions({ filter: 'status:free' })).not.toThrow();
|
|
296
|
+
expect(() => validateMemberQueryOptions({ filter: 'status:paid' })).not.toThrow();
|
|
297
|
+
expect(() => validateMemberQueryOptions({ filter: 'subscribed:true' })).not.toThrow();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('should reject empty filter string', () => {
|
|
301
|
+
expect(() => validateMemberQueryOptions({ filter: '' })).toThrow(
|
|
302
|
+
'Member query validation failed'
|
|
303
|
+
);
|
|
304
|
+
expect(() => validateMemberQueryOptions({ filter: ' ' })).toThrow(
|
|
305
|
+
'Member query validation failed'
|
|
306
|
+
);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('should accept valid order strings', () => {
|
|
310
|
+
expect(() => validateMemberQueryOptions({ order: 'created_at desc' })).not.toThrow();
|
|
311
|
+
expect(() => validateMemberQueryOptions({ order: 'email asc' })).not.toThrow();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('should reject empty order string', () => {
|
|
315
|
+
expect(() => validateMemberQueryOptions({ order: '' })).toThrow(
|
|
316
|
+
'Member query validation failed'
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('should accept valid include strings', () => {
|
|
321
|
+
expect(() => validateMemberQueryOptions({ include: 'labels' })).not.toThrow();
|
|
322
|
+
expect(() => validateMemberQueryOptions({ include: 'newsletters' })).not.toThrow();
|
|
323
|
+
expect(() => validateMemberQueryOptions({ include: 'labels,newsletters' })).not.toThrow();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('should reject empty include string', () => {
|
|
327
|
+
expect(() => validateMemberQueryOptions({ include: '' })).toThrow(
|
|
328
|
+
'Member query validation failed'
|
|
329
|
+
);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('should validate multiple options together', () => {
|
|
333
|
+
expect(() =>
|
|
334
|
+
validateMemberQueryOptions({
|
|
335
|
+
limit: 50,
|
|
336
|
+
page: 2,
|
|
337
|
+
filter: 'status:paid',
|
|
338
|
+
order: 'created_at desc',
|
|
339
|
+
include: 'labels,newsletters',
|
|
340
|
+
})
|
|
341
|
+
).not.toThrow();
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
describe('validateMemberLookup', () => {
|
|
346
|
+
it('should accept valid id', () => {
|
|
347
|
+
expect(() => validateMemberLookup({ id: '12345' })).not.toThrow();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('should accept valid email', () => {
|
|
351
|
+
expect(() => validateMemberLookup({ email: 'test@example.com' })).not.toThrow();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('should reject when both id and email are missing', () => {
|
|
355
|
+
expect(() => validateMemberLookup({})).toThrow('Member lookup validation failed');
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('should reject empty id', () => {
|
|
359
|
+
expect(() => validateMemberLookup({ id: '' })).toThrow('Member lookup validation failed');
|
|
360
|
+
expect(() => validateMemberLookup({ id: ' ' })).toThrow('Member lookup validation failed');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('should reject invalid email format', () => {
|
|
364
|
+
expect(() => validateMemberLookup({ email: 'invalid-email' })).toThrow(
|
|
365
|
+
'Member lookup validation failed'
|
|
366
|
+
);
|
|
367
|
+
expect(() => validateMemberLookup({ email: 'test@' })).toThrow(
|
|
368
|
+
'Member lookup validation failed'
|
|
369
|
+
);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('should accept when both id and email provided (id takes precedence)', () => {
|
|
373
|
+
expect(() => validateMemberLookup({ id: '12345', email: 'test@example.com' })).not.toThrow();
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('should return normalized params with lookupType', () => {
|
|
377
|
+
const resultId = validateMemberLookup({ id: '12345' });
|
|
378
|
+
expect(resultId).toEqual({ id: '12345', lookupType: 'id' });
|
|
379
|
+
|
|
380
|
+
const resultEmail = validateMemberLookup({ email: 'test@example.com' });
|
|
381
|
+
expect(resultEmail).toEqual({ email: 'test@example.com', lookupType: 'email' });
|
|
382
|
+
|
|
383
|
+
// ID takes precedence when both provided
|
|
384
|
+
const resultBoth = validateMemberLookup({ id: '12345', email: 'test@example.com' });
|
|
385
|
+
expect(resultBoth).toEqual({ id: '12345', lookupType: 'id' });
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
describe('validateSearchQuery', () => {
|
|
390
|
+
it('should accept valid search query', () => {
|
|
391
|
+
expect(() => validateSearchQuery('john')).not.toThrow();
|
|
392
|
+
expect(() => validateSearchQuery('john@example.com')).not.toThrow();
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('should reject empty search query', () => {
|
|
396
|
+
expect(() => validateSearchQuery('')).toThrow('Search query validation failed');
|
|
397
|
+
expect(() => validateSearchQuery(' ')).toThrow('Search query validation failed');
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('should reject non-string search query', () => {
|
|
401
|
+
expect(() => validateSearchQuery(123)).toThrow('Search query validation failed');
|
|
402
|
+
expect(() => validateSearchQuery(null)).toThrow('Search query validation failed');
|
|
403
|
+
expect(() => validateSearchQuery(undefined)).toThrow('Search query validation failed');
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('should return sanitized query', () => {
|
|
407
|
+
const result = validateSearchQuery('john');
|
|
408
|
+
expect(result).toBe('john');
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('should trim whitespace from query', () => {
|
|
412
|
+
const result = validateSearchQuery(' john ');
|
|
413
|
+
expect(result).toBe('john');
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
describe('validateSearchOptions', () => {
|
|
418
|
+
it('should accept empty options', () => {
|
|
419
|
+
expect(() => validateSearchOptions({})).not.toThrow();
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('should accept valid limit within bounds (1-50)', () => {
|
|
423
|
+
expect(() => validateSearchOptions({ limit: 1 })).not.toThrow();
|
|
424
|
+
expect(() => validateSearchOptions({ limit: 25 })).not.toThrow();
|
|
425
|
+
expect(() => validateSearchOptions({ limit: 50 })).not.toThrow();
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('should reject limit below minimum', () => {
|
|
429
|
+
expect(() => validateSearchOptions({ limit: 0 })).toThrow('Search options validation failed');
|
|
430
|
+
expect(() => validateSearchOptions({ limit: -1 })).toThrow(
|
|
431
|
+
'Search options validation failed'
|
|
432
|
+
);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('should reject limit above maximum (50)', () => {
|
|
436
|
+
expect(() => validateSearchOptions({ limit: 51 })).toThrow(
|
|
437
|
+
'Search options validation failed'
|
|
438
|
+
);
|
|
439
|
+
expect(() => validateSearchOptions({ limit: 100 })).toThrow(
|
|
440
|
+
'Search options validation failed'
|
|
441
|
+
);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('should reject non-number limit', () => {
|
|
445
|
+
expect(() => validateSearchOptions({ limit: 'ten' })).toThrow(
|
|
446
|
+
'Search options validation failed'
|
|
447
|
+
);
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
describe('sanitizeNqlValue', () => {
|
|
452
|
+
it('should escape backslashes', () => {
|
|
453
|
+
expect(sanitizeNqlValue('test\\value')).toBe('test\\\\value');
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('should escape single quotes', () => {
|
|
457
|
+
expect(sanitizeNqlValue("test'value")).toBe("test\\'value");
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('should escape double quotes', () => {
|
|
461
|
+
expect(sanitizeNqlValue('test"value')).toBe('test\\"value');
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('should handle multiple special characters', () => {
|
|
465
|
+
expect(sanitizeNqlValue('test\'value"with\\chars')).toBe('test\\\'value\\"with\\\\chars');
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('should not modify strings without special characters', () => {
|
|
469
|
+
expect(sanitizeNqlValue('normalvalue')).toBe('normalvalue');
|
|
470
|
+
expect(sanitizeNqlValue('test@example.com')).toBe('test@example.com');
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('should handle empty string', () => {
|
|
474
|
+
expect(sanitizeNqlValue('')).toBe('');
|
|
475
|
+
});
|
|
476
|
+
});
|
|
245
477
|
});
|
|
@@ -876,6 +876,111 @@ export async function deleteMember(memberId) {
|
|
|
876
876
|
}
|
|
877
877
|
}
|
|
878
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
|
+
|
|
879
984
|
/**
|
|
880
985
|
* Newsletter CRUD Operations
|
|
881
986
|
*/
|
|
@@ -1027,6 +1132,9 @@ export default {
|
|
|
1027
1132
|
createMember,
|
|
1028
1133
|
updateMember,
|
|
1029
1134
|
deleteMember,
|
|
1135
|
+
getMembers,
|
|
1136
|
+
getMember,
|
|
1137
|
+
searchMembers,
|
|
1030
1138
|
getNewsletters,
|
|
1031
1139
|
getNewsletter,
|
|
1032
1140
|
createNewsletter,
|
|
@@ -14,6 +14,14 @@ const MAX_NAME_LENGTH = 191; // Ghost's typical varchar limit
|
|
|
14
14
|
const MAX_NOTE_LENGTH = 2000; // Reasonable limit for notes
|
|
15
15
|
const MAX_LABEL_LENGTH = 191; // Label name limit
|
|
16
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
|
+
|
|
17
25
|
/**
|
|
18
26
|
* Validates member data for creation
|
|
19
27
|
* @param {Object} memberData - The member data to validate
|
|
@@ -196,7 +204,189 @@ export function validateMemberUpdateData(updateData) {
|
|
|
196
204
|
}
|
|
197
205
|
}
|
|
198
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
|
+
|
|
199
384
|
export default {
|
|
200
385
|
validateMemberData,
|
|
201
386
|
validateMemberUpdateData,
|
|
387
|
+
validateMemberQueryOptions,
|
|
388
|
+
validateMemberLookup,
|
|
389
|
+
validateSearchQuery,
|
|
390
|
+
validateSearchOptions,
|
|
391
|
+
sanitizeNqlValue,
|
|
202
392
|
};
|