@rooguys/js 0.1.0 → 1.0.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.
Files changed (28) hide show
  1. package/README.md +342 -141
  2. package/package.json +1 -1
  3. package/src/__tests__/fixtures/responses.js +249 -0
  4. package/src/__tests__/property/batch-event-validation.property.test.js +225 -0
  5. package/src/__tests__/property/email-validation.property.test.js +272 -0
  6. package/src/__tests__/property/error-mapping.property.test.js +506 -0
  7. package/src/__tests__/property/field-selection.property.test.js +297 -0
  8. package/src/__tests__/property/idempotency-key.property.test.js +350 -0
  9. package/src/__tests__/property/leaderboard-filter.property.test.js +585 -0
  10. package/src/__tests__/property/partial-update.property.test.js +251 -0
  11. package/src/__tests__/property/rate-limit-error.property.test.js +276 -0
  12. package/src/__tests__/property/rate-limit-extraction.property.test.js +193 -0
  13. package/src/__tests__/property/request-construction.property.test.js +20 -28
  14. package/src/__tests__/property/response-format.property.test.js +418 -0
  15. package/src/__tests__/property/response-parsing.property.test.js +16 -21
  16. package/src/__tests__/property/timestamp-validation.property.test.js +345 -0
  17. package/src/__tests__/unit/aha.test.js +57 -26
  18. package/src/__tests__/unit/config.test.js +7 -1
  19. package/src/__tests__/unit/errors.test.js +6 -8
  20. package/src/__tests__/unit/events.test.js +253 -14
  21. package/src/__tests__/unit/leaderboards.test.js +249 -0
  22. package/src/__tests__/unit/questionnaires.test.js +6 -6
  23. package/src/__tests__/unit/users.test.js +275 -12
  24. package/src/__tests__/utils/generators.js +87 -0
  25. package/src/__tests__/utils/mockClient.js +71 -5
  26. package/src/errors.js +156 -0
  27. package/src/http-client.js +276 -0
  28. package/src/index.js +856 -66
package/src/index.js CHANGED
@@ -1,138 +1,928 @@
1
1
  /**
2
2
  * Rooguys Browser SDK
3
+ * Official JavaScript SDK for the Rooguys Gamification Platform
4
+ */
5
+
6
+ import { HttpClient, extractRateLimitInfo, extractRequestId, parseResponseBody } from './http-client.js';
7
+ import {
8
+ RooguysError,
9
+ ValidationError,
10
+ AuthenticationError,
11
+ ForbiddenError,
12
+ NotFoundError,
13
+ ConflictError,
14
+ RateLimitError,
15
+ ServerError,
16
+ mapStatusToError,
17
+ } from './errors.js';
18
+
19
+ /**
20
+ * Main Rooguys SDK class
3
21
  */
4
22
  class Rooguys {
23
+ /**
24
+ * Create a new Rooguys SDK instance
25
+ * @param {string} apiKey - API key for authentication
26
+ * @param {Object} [options] - Configuration options
27
+ * @param {string} [options.baseUrl] - Base URL for API (default: https://api.rooguys.com/v1)
28
+ * @param {number} [options.timeout] - Request timeout in ms (default: 10000)
29
+ * @param {Function} [options.onRateLimitWarning] - Callback when rate limit is 80% consumed
30
+ * @param {boolean} [options.autoRetry] - Enable auto-retry for rate-limited requests (default: false)
31
+ * @param {number} [options.maxRetries] - Maximum retry attempts for rate limits (default: 3)
32
+ */
5
33
  constructor(apiKey, options = {}) {
34
+ if (!apiKey) {
35
+ throw new ValidationError('API key is required', { code: 'MISSING_API_KEY' });
36
+ }
37
+
6
38
  this.apiKey = apiKey;
7
39
  this.baseUrl = options.baseUrl || 'https://api.rooguys.com/v1';
8
40
  this.timeout = options.timeout || 10000;
41
+ this.onRateLimitWarning = options.onRateLimitWarning || null;
42
+ this.autoRetry = options.autoRetry || false;
43
+ this.maxRetries = options.maxRetries || 3;
44
+
45
+ // Initialize HTTP client
46
+ this._httpClient = new HttpClient(apiKey, {
47
+ baseUrl: this.baseUrl,
48
+ timeout: this.timeout,
49
+ onRateLimitWarning: this.onRateLimitWarning,
50
+ autoRetry: this.autoRetry,
51
+ maxRetries: this.maxRetries,
52
+ });
9
53
  }
10
54
 
55
+ /**
56
+ * Legacy request method for backward compatibility
57
+ * @deprecated Use the new module methods instead
58
+ */
11
59
  async _request(endpoint, method = 'GET', data = null, params = {}) {
12
- const url = new URL(`${this.baseUrl}${endpoint}`);
13
- Object.keys(params).forEach(key => {
14
- if (params[key] !== undefined) {
15
- url.searchParams.append(key, params[key]);
16
- }
60
+ const response = await this._httpClient.request({
61
+ method,
62
+ path: endpoint,
63
+ body: data,
64
+ params,
17
65
  });
66
+ return response.data;
67
+ }
18
68
 
19
- const config = {
20
- method,
21
- headers: {
22
- 'x-api-key': this.apiKey,
23
- 'Content-Type': 'application/json',
69
+ /**
70
+ * Events module for tracking user events
71
+ */
72
+ get events() {
73
+ const self = this;
74
+ return {
75
+ /**
76
+ * Track a single event
77
+ * @param {string} eventName - Name of the event
78
+ * @param {string} userId - User ID
79
+ * @param {Object} [properties] - Event properties
80
+ * @param {Object} [options] - Additional options
81
+ * @param {boolean} [options.includeProfile] - Include user profile in response
82
+ * @param {Date} [options.timestamp] - Custom timestamp for historical events (max 7 days old)
83
+ * @param {string} [options.idempotencyKey] - Idempotency key to prevent duplicate processing
84
+ * @returns {Promise<Object>} Event tracking response
85
+ */
86
+ track: async (eventName, userId, properties = {}, options = {}) => {
87
+ const body = {
88
+ event_name: eventName,
89
+ user_id: userId,
90
+ properties,
91
+ };
92
+
93
+ // Add custom timestamp if provided
94
+ if (options.timestamp) {
95
+ const timestamp = options.timestamp instanceof Date ? options.timestamp : new Date(options.timestamp);
96
+ const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
97
+
98
+ if (timestamp < sevenDaysAgo) {
99
+ throw new ValidationError('Custom timestamp cannot be more than 7 days in the past', {
100
+ code: 'TIMESTAMP_TOO_OLD',
101
+ fieldErrors: [{ field: 'timestamp', message: 'Timestamp must be within the last 7 days' }],
102
+ });
103
+ }
104
+
105
+ body.timestamp = timestamp.toISOString();
106
+ }
107
+
108
+ const response = await self._httpClient.post('/events', body, {
109
+ params: {
110
+ include_profile: options.includeProfile,
111
+ },
112
+ idempotencyKey: options.idempotencyKey,
113
+ });
114
+ return response.data;
24
115
  },
25
- };
26
116
 
27
- if (data) {
28
- config.body = JSON.stringify(data);
29
- }
117
+ /**
118
+ * Track multiple events in a single request (batch operation)
119
+ * @param {Array<Object>} events - Array of events to track (max 100)
120
+ * @param {string} events[].eventName - Name of the event
121
+ * @param {string} events[].userId - User ID
122
+ * @param {Object} [events[].properties] - Event properties
123
+ * @param {Date} [events[].timestamp] - Custom timestamp for historical events
124
+ * @param {Object} [options] - Batch options
125
+ * @param {string} [options.idempotencyKey] - Idempotency key for the batch
126
+ * @returns {Promise<Object>} Batch tracking response with individual status for each event
127
+ */
128
+ trackBatch: async (events, options = {}) => {
129
+ // Validate array
130
+ if (!Array.isArray(events)) {
131
+ throw new ValidationError('Events must be an array', {
132
+ code: 'INVALID_EVENTS',
133
+ fieldErrors: [{ field: 'events', message: 'Events must be an array' }],
134
+ });
135
+ }
30
136
 
31
- try {
32
- const controller = new AbortController();
33
- const id = setTimeout(() => controller.abort(), this.timeout);
34
- config.signal = controller.signal;
137
+ // Validate array length
138
+ if (events.length === 0) {
139
+ throw new ValidationError('Events array cannot be empty', {
140
+ code: 'EMPTY_EVENTS',
141
+ fieldErrors: [{ field: 'events', message: 'At least one event is required' }],
142
+ });
143
+ }
35
144
 
36
- const response = await fetch(url.toString(), config);
37
- clearTimeout(id);
145
+ if (events.length > 100) {
146
+ throw new ValidationError('Batch size exceeds maximum of 100 events', {
147
+ code: 'BATCH_TOO_LARGE',
148
+ fieldErrors: [{ field: 'events', message: 'Maximum batch size is 100 events' }],
149
+ });
150
+ }
38
151
 
39
- if (!response.ok) {
40
- const errorData = await response.json().catch(() => ({}));
41
- throw new Error(errorData.message || `API Error: ${response.statusText}`);
42
- }
152
+ const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
43
153
 
44
- return await response.json();
45
- } catch (error) {
46
- throw error;
47
- }
48
- }
154
+ // Transform and validate events
155
+ const transformedEvents = events.map((event, index) => {
156
+ const transformed = {
157
+ event_name: event.eventName,
158
+ user_id: event.userId,
159
+ properties: event.properties || {},
160
+ };
49
161
 
50
- get events() {
51
- return {
52
- track: (eventName, userId, properties = {}, options = {}) => {
53
- return this._request('/event', 'POST', {
162
+ // Validate and add custom timestamp if provided
163
+ if (event.timestamp) {
164
+ const timestamp = event.timestamp instanceof Date ? event.timestamp : new Date(event.timestamp);
165
+
166
+ if (timestamp < sevenDaysAgo) {
167
+ throw new ValidationError(`Event at index ${index}: Custom timestamp cannot be more than 7 days in the past`, {
168
+ code: 'TIMESTAMP_TOO_OLD',
169
+ fieldErrors: [{ field: `events[${index}].timestamp`, message: 'Timestamp must be within the last 7 days' }],
170
+ });
171
+ }
172
+
173
+ transformed.timestamp = timestamp.toISOString();
174
+ }
175
+
176
+ return transformed;
177
+ });
178
+
179
+ const response = await self._httpClient.post('/events/batch', {
180
+ events: transformedEvents,
181
+ }, {
182
+ idempotencyKey: options.idempotencyKey,
183
+ });
184
+ return response.data;
185
+ },
186
+
187
+ /**
188
+ * @deprecated Use track() instead. The /v1/event endpoint is deprecated.
189
+ * Track a single event using the legacy endpoint
190
+ * @param {string} eventName - Name of the event
191
+ * @param {string} userId - User ID
192
+ * @param {Object} [properties] - Event properties
193
+ * @param {Object} [options] - Additional options
194
+ * @returns {Promise<Object>} Event tracking response
195
+ */
196
+ trackLegacy: async (eventName, userId, properties = {}, options = {}) => {
197
+ console.warn('DEPRECATION WARNING: events.trackLegacy() uses the deprecated /v1/event endpoint. Please use events.track() instead which uses /v1/events.');
198
+
199
+ const response = await self._httpClient.post('/event', {
54
200
  event_name: eventName,
55
201
  user_id: userId,
56
202
  properties,
57
203
  }, {
58
- include_profile: options.includeProfile,
204
+ params: {
205
+ include_profile: options.includeProfile,
206
+ },
59
207
  });
208
+ return response.data;
60
209
  },
61
210
  };
62
211
  }
63
212
 
213
+ /**
214
+ * Users module for user management and queries
215
+ */
64
216
  get users() {
65
- return {
66
- get: (userId) => this._request(`/user/${encodeURIComponent(userId)}`),
67
-
68
- getBulk: (userIds) => this._request('/users/bulk', 'POST', { user_ids: userIds }),
217
+ const self = this;
218
+
219
+ /**
220
+ * Validate email format client-side
221
+ * @param {string} email - Email to validate
222
+ * @returns {boolean} True if valid
223
+ */
224
+ const isValidEmail = (email) => {
225
+ if (!email || typeof email !== 'string') return true; // Optional field
226
+ // RFC 5322 compliant email regex (simplified)
227
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
228
+ return emailRegex.test(email);
229
+ };
230
+
231
+ /**
232
+ * Build request body with only provided fields (partial update support)
233
+ * @param {Object} userData - User data object
234
+ * @returns {Object} Cleaned request body
235
+ */
236
+ const buildUserRequestBody = (userData) => {
237
+ const body = {};
69
238
 
70
- getBadges: (userId) => this._request(`/user/${encodeURIComponent(userId)}/badges`),
239
+ if (userData.userId !== undefined) {
240
+ body.user_id = userData.userId;
241
+ }
242
+ if (userData.displayName !== undefined) {
243
+ body.display_name = userData.displayName;
244
+ }
245
+ if (userData.email !== undefined) {
246
+ body.email = userData.email;
247
+ }
248
+ if (userData.metadata !== undefined) {
249
+ body.metadata = userData.metadata;
250
+ }
251
+ if (userData.firstName !== undefined) {
252
+ body.first_name = userData.firstName;
253
+ }
254
+ if (userData.lastName !== undefined) {
255
+ body.last_name = userData.lastName;
256
+ }
71
257
 
72
- getRank: (userId, timeframe = 'all-time') =>
73
- this._request(`/user/${encodeURIComponent(userId)}/rank`, 'GET', null, { timeframe }),
258
+ return body;
259
+ };
260
+
261
+ return {
262
+ /**
263
+ * Create a new user
264
+ * @param {Object} userData - User data
265
+ * @param {string} userData.userId - Unique user ID
266
+ * @param {string} [userData.displayName] - Display name
267
+ * @param {string} [userData.email] - Email address
268
+ * @param {string} [userData.firstName] - First name
269
+ * @param {string} [userData.lastName] - Last name
270
+ * @param {Object} [userData.metadata] - Custom metadata
271
+ * @returns {Promise<Object>} Created user profile
272
+ */
273
+ create: async (userData) => {
274
+ // Validate required fields
275
+ if (!userData || !userData.userId) {
276
+ throw new ValidationError('User ID is required', {
277
+ code: 'MISSING_USER_ID',
278
+ fieldErrors: [{ field: 'userId', message: 'User ID is required' }],
279
+ });
280
+ }
281
+
282
+ // Validate email format if provided
283
+ if (userData.email && !isValidEmail(userData.email)) {
284
+ throw new ValidationError('Invalid email format', {
285
+ code: 'INVALID_EMAIL',
286
+ fieldErrors: [{ field: 'email', message: 'Email must be a valid email address' }],
287
+ });
288
+ }
289
+
290
+ const body = buildUserRequestBody(userData);
291
+ const response = await self._httpClient.post('/users', body);
292
+ return response.data;
293
+ },
294
+
295
+ /**
296
+ * Update an existing user
297
+ * @param {string} userId - User ID to update
298
+ * @param {Object} userData - User data to update (partial update supported)
299
+ * @param {string} [userData.displayName] - Display name
300
+ * @param {string} [userData.email] - Email address
301
+ * @param {string} [userData.firstName] - First name
302
+ * @param {string} [userData.lastName] - Last name
303
+ * @param {Object} [userData.metadata] - Custom metadata
304
+ * @returns {Promise<Object>} Updated user profile
305
+ */
306
+ update: async (userId, userData) => {
307
+ // Validate user ID
308
+ if (!userId) {
309
+ throw new ValidationError('User ID is required', {
310
+ code: 'MISSING_USER_ID',
311
+ fieldErrors: [{ field: 'userId', message: 'User ID is required' }],
312
+ });
313
+ }
314
+
315
+ // Validate email format if provided
316
+ if (userData.email && !isValidEmail(userData.email)) {
317
+ throw new ValidationError('Invalid email format', {
318
+ code: 'INVALID_EMAIL',
319
+ fieldErrors: [{ field: 'email', message: 'Email must be a valid email address' }],
320
+ });
321
+ }
322
+
323
+ const body = buildUserRequestBody(userData);
324
+ const response = await self._httpClient.patch(`/users/${encodeURIComponent(userId)}`, body);
325
+ return response.data;
326
+ },
327
+
328
+ /**
329
+ * Create multiple users in a single request (batch operation)
330
+ * @param {Array<Object>} users - Array of user data objects (max 100)
331
+ * @param {string} users[].userId - Unique user ID
332
+ * @param {string} [users[].displayName] - Display name
333
+ * @param {string} [users[].email] - Email address
334
+ * @param {Object} [users[].metadata] - Custom metadata
335
+ * @returns {Promise<Object>} Batch creation response with individual status for each user
336
+ */
337
+ createBatch: async (users) => {
338
+ // Validate array
339
+ if (!Array.isArray(users)) {
340
+ throw new ValidationError('Users must be an array', {
341
+ code: 'INVALID_USERS',
342
+ fieldErrors: [{ field: 'users', message: 'Users must be an array' }],
343
+ });
344
+ }
345
+
346
+ // Validate array length
347
+ if (users.length === 0) {
348
+ throw new ValidationError('Users array cannot be empty', {
349
+ code: 'EMPTY_USERS',
350
+ fieldErrors: [{ field: 'users', message: 'At least one user is required' }],
351
+ });
352
+ }
353
+
354
+ if (users.length > 100) {
355
+ throw new ValidationError('Batch size exceeds maximum of 100 users', {
356
+ code: 'BATCH_TOO_LARGE',
357
+ fieldErrors: [{ field: 'users', message: 'Maximum batch size is 100 users' }],
358
+ });
359
+ }
360
+
361
+ // Validate and transform each user
362
+ const transformedUsers = users.map((user, index) => {
363
+ // Validate required fields
364
+ if (!user.userId) {
365
+ throw new ValidationError(`User at index ${index}: User ID is required`, {
366
+ code: 'MISSING_USER_ID',
367
+ fieldErrors: [{ field: `users[${index}].userId`, message: 'User ID is required' }],
368
+ });
369
+ }
370
+
371
+ // Validate email format if provided
372
+ if (user.email && !isValidEmail(user.email)) {
373
+ throw new ValidationError(`User at index ${index}: Invalid email format`, {
374
+ code: 'INVALID_EMAIL',
375
+ fieldErrors: [{ field: `users[${index}].email`, message: 'Email must be a valid email address' }],
376
+ });
377
+ }
378
+
379
+ return buildUserRequestBody(user);
380
+ });
381
+
382
+ const response = await self._httpClient.post('/users/batch', { users: transformedUsers });
383
+ return response.data;
384
+ },
385
+
386
+ /**
387
+ * Get user profile with optional field selection
388
+ * @param {string} userId - User ID
389
+ * @param {Object} [options] - Query options
390
+ * @param {string[]} [options.fields] - Fields to include in response
391
+ * @returns {Promise<Object>} User profile
392
+ */
393
+ get: async (userId, options = {}) => {
394
+ const params = {};
74
395
 
75
- submitAnswers: (userId, questionnaireId, answers) =>
76
- this._request(`/user/${encodeURIComponent(userId)}/answers`, 'POST', {
77
- questionnaire_id: questionnaireId,
78
- answers,
79
- }),
396
+ // Add field selection if provided
397
+ if (options.fields && Array.isArray(options.fields) && options.fields.length > 0) {
398
+ params.fields = options.fields.join(',');
399
+ }
400
+
401
+ const response = await self._httpClient.get(`/users/${encodeURIComponent(userId)}`, params);
402
+ return self._parseUserProfile(response.data);
403
+ },
404
+
405
+ /**
406
+ * Search users with pagination
407
+ * @param {string} query - Search query
408
+ * @param {Object} [options] - Search options
409
+ * @param {number} [options.page=1] - Page number
410
+ * @param {number} [options.limit=50] - Items per page
411
+ * @param {string[]} [options.fields] - Fields to include in response
412
+ * @returns {Promise<Object>} Paginated search results
413
+ */
414
+ search: async (query, options = {}) => {
415
+ const params = {
416
+ q: query,
417
+ page: options.page || 1,
418
+ limit: options.limit || 50,
419
+ };
420
+
421
+ // Add field selection if provided
422
+ if (options.fields && Array.isArray(options.fields) && options.fields.length > 0) {
423
+ params.fields = options.fields.join(',');
424
+ }
425
+
426
+ const response = await self._httpClient.get('/users/search', params);
427
+ return {
428
+ ...response.data,
429
+ users: (response.data.users || []).map(user => self._parseUserProfile(user)),
430
+ };
431
+ },
432
+
433
+ /**
434
+ * Get multiple user profiles
435
+ * @param {string[]} userIds - Array of user IDs
436
+ * @returns {Promise<Object>} Bulk users response
437
+ */
438
+ getBulk: async (userIds) => {
439
+ const response = await self._httpClient.post('/users/bulk', { user_ids: userIds });
440
+ return {
441
+ ...response.data,
442
+ users: (response.data.users || []).map(user => self._parseUserProfile(user)),
443
+ };
444
+ },
445
+
446
+ /**
447
+ * Get user badges
448
+ * @param {string} userId - User ID
449
+ * @returns {Promise<Object[]>} User badges
450
+ */
451
+ getBadges: async (userId) => {
452
+ const response = await self._httpClient.get(`/users/${encodeURIComponent(userId)}/badges`);
453
+ return response.data;
454
+ },
455
+
456
+ /**
457
+ * Get user rank
458
+ * @param {string} userId - User ID
459
+ * @param {string} [timeframe='all-time'] - Timeframe for ranking
460
+ * @returns {Promise<Object>} User rank info
461
+ */
462
+ getRank: async (userId, timeframe = 'all-time') => {
463
+ const response = await self._httpClient.get(
464
+ `/users/${encodeURIComponent(userId)}/rank`,
465
+ { timeframe }
466
+ );
467
+ return response.data;
468
+ },
469
+
470
+ /**
471
+ * Submit questionnaire answers
472
+ * @param {string} userId - User ID
473
+ * @param {string} questionnaireId - Questionnaire ID
474
+ * @param {Object[]} answers - Array of answers
475
+ * @returns {Promise<Object>} Submission response
476
+ */
477
+ submitAnswers: async (userId, questionnaireId, answers) => {
478
+ const response = await self._httpClient.post(
479
+ `/users/${encodeURIComponent(userId)}/answers`,
480
+ {
481
+ questionnaire_id: questionnaireId,
482
+ answers,
483
+ }
484
+ );
485
+ return response.data;
486
+ },
80
487
  };
81
488
  }
82
489
 
490
+ /**
491
+ * Parse user profile to include activity summary, streak, and inventory
492
+ * @private
493
+ * @param {Object} profile - Raw user profile from API
494
+ * @returns {Object} Parsed user profile
495
+ */
496
+ _parseUserProfile(profile) {
497
+ if (!profile) return profile;
498
+
499
+ const parsed = { ...profile };
500
+
501
+ // Parse activity summary if present
502
+ if (profile.activity_summary) {
503
+ parsed.activitySummary = {
504
+ lastEventAt: profile.activity_summary.last_event_at
505
+ ? new Date(profile.activity_summary.last_event_at)
506
+ : null,
507
+ eventCount: profile.activity_summary.event_count || 0,
508
+ daysActive: profile.activity_summary.days_active || 0,
509
+ };
510
+ }
511
+
512
+ // Parse streak info if present
513
+ if (profile.streak) {
514
+ parsed.streak = {
515
+ currentStreak: profile.streak.current_streak || 0,
516
+ longestStreak: profile.streak.longest_streak || 0,
517
+ lastActivityAt: profile.streak.last_activity_at
518
+ ? new Date(profile.streak.last_activity_at)
519
+ : null,
520
+ streakStartedAt: profile.streak.streak_started_at
521
+ ? new Date(profile.streak.streak_started_at)
522
+ : null,
523
+ };
524
+ }
525
+
526
+ // Parse inventory summary if present
527
+ if (profile.inventory) {
528
+ parsed.inventory = {
529
+ itemCount: profile.inventory.item_count || 0,
530
+ activeEffects: profile.inventory.active_effects || [],
531
+ };
532
+ }
533
+
534
+ return parsed;
535
+ }
536
+
537
+ /**
538
+ * Leaderboards module for ranking queries
539
+ */
83
540
  get leaderboards() {
541
+ const self = this;
542
+
543
+ /**
544
+ * Build filter query parameters from options
545
+ * @private
546
+ * @param {Object} options - Filter options
547
+ * @returns {Object} Query parameters
548
+ */
549
+ const buildFilterParams = (options = {}) => {
550
+ const params = {};
551
+
552
+ // Pagination
553
+ if (options.page !== undefined) params.page = options.page;
554
+ if (options.limit !== undefined) params.limit = options.limit;
555
+ if (options.search !== undefined && options.search !== null) params.search = options.search;
556
+
557
+ // Persona filter
558
+ if (options.persona !== undefined && options.persona !== null) {
559
+ params.persona = options.persona;
560
+ }
561
+
562
+ // Level range filters
563
+ if (options.minLevel !== undefined && options.minLevel !== null) {
564
+ params.min_level = options.minLevel;
565
+ }
566
+ if (options.maxLevel !== undefined && options.maxLevel !== null) {
567
+ params.max_level = options.maxLevel;
568
+ }
569
+
570
+ // Date range filters (convert to ISO 8601)
571
+ if (options.startDate !== undefined && options.startDate !== null) {
572
+ const startDate = options.startDate instanceof Date
573
+ ? options.startDate
574
+ : new Date(options.startDate);
575
+ params.start_date = startDate.toISOString();
576
+ }
577
+ if (options.endDate !== undefined && options.endDate !== null) {
578
+ const endDate = options.endDate instanceof Date
579
+ ? options.endDate
580
+ : new Date(options.endDate);
581
+ params.end_date = endDate.toISOString();
582
+ }
583
+
584
+ // Timeframe
585
+ if (options.timeframe !== undefined) params.timeframe = options.timeframe;
586
+
587
+ return params;
588
+ };
589
+
590
+ /**
591
+ * Parse leaderboard response to include cache metadata and percentile ranks
592
+ * @private
593
+ * @param {Object} response - Raw API response
594
+ * @returns {Object} Parsed leaderboard response
595
+ */
596
+ const parseLeaderboardResponse = (response) => {
597
+ const parsed = { ...response };
598
+
599
+ // Parse cache metadata if present
600
+ if (response.cache_metadata || response.cacheMetadata) {
601
+ const cacheData = response.cache_metadata || response.cacheMetadata;
602
+ parsed.cacheMetadata = {
603
+ cachedAt: cacheData.cached_at ? new Date(cacheData.cached_at) : null,
604
+ ttl: cacheData.ttl || 0,
605
+ };
606
+ // Remove snake_case version
607
+ delete parsed.cache_metadata;
608
+ }
609
+
610
+ // Parse rankings to include percentile if present
611
+ if (parsed.rankings && Array.isArray(parsed.rankings)) {
612
+ parsed.rankings = parsed.rankings.map(entry => ({
613
+ ...entry,
614
+ percentile: entry.percentile !== undefined ? entry.percentile : null,
615
+ }));
616
+ }
617
+
618
+ return parsed;
619
+ };
620
+
84
621
  return {
85
- getGlobal: (timeframe = 'all-time', page = 1, limit = 50) =>
86
- this._request('/leaderboard', 'GET', null, { timeframe, page, limit }),
87
-
88
- list: (page = 1, limit = 50, search = null) =>
89
- this._request('/leaderboards', 'GET', null, { page, limit, search }),
90
-
91
- getCustom: (leaderboardId, page = 1, limit = 50, search = null) =>
92
- this._request(`/leaderboard/${encodeURIComponent(leaderboardId)}`, 'GET', null, { page, limit, search }),
622
+ /**
623
+ * Get global leaderboard with optional filters
624
+ * @param {string|Object} [timeframeOrOptions='all-time'] - Timeframe string or options object
625
+ * @param {number} [page=1] - Page number (deprecated, use options object)
626
+ * @param {number} [limit=50] - Items per page (deprecated, use options object)
627
+ * @param {Object} [options] - Additional filter options
628
+ * @param {string} [options.timeframe='all-time'] - Timeframe (all-time, weekly, monthly)
629
+ * @param {number} [options.page=1] - Page number
630
+ * @param {number} [options.limit=50] - Items per page
631
+ * @param {string} [options.persona] - Filter by persona
632
+ * @param {number} [options.minLevel] - Minimum level filter
633
+ * @param {number} [options.maxLevel] - Maximum level filter
634
+ * @param {Date|string} [options.startDate] - Start date filter (ISO 8601)
635
+ * @param {Date|string} [options.endDate] - End date filter (ISO 8601)
636
+ * @returns {Promise<Object>} Leaderboard response with rankings, stats, pagination, and cache metadata
637
+ */
638
+ getGlobal: async (timeframeOrOptions = 'all-time', page = 1, limit = 50, options = {}) => {
639
+ let params;
640
+
641
+ // Support both legacy signature and new options object
642
+ if (typeof timeframeOrOptions === 'object') {
643
+ // New signature: getGlobal(options)
644
+ params = buildFilterParams({
645
+ timeframe: 'all-time',
646
+ page: 1,
647
+ limit: 50,
648
+ ...timeframeOrOptions,
649
+ });
650
+ } else {
651
+ // Legacy signature: getGlobal(timeframe, page, limit, options)
652
+ params = buildFilterParams({
653
+ timeframe: timeframeOrOptions,
654
+ page,
655
+ limit,
656
+ ...options,
657
+ });
658
+ }
659
+
660
+ const response = await self._httpClient.get('/leaderboards/global', params);
661
+ return parseLeaderboardResponse(response.data);
662
+ },
663
+
664
+ /**
665
+ * List all leaderboards
666
+ * @param {number|Object} [pageOrOptions=1] - Page number or options object
667
+ * @param {number} [limit=50] - Items per page (deprecated, use options object)
668
+ * @param {string} [search=null] - Search query (deprecated, use options object)
669
+ * @returns {Promise<Object>} Leaderboards list
670
+ */
671
+ list: async (pageOrOptions = 1, limit = 50, search = null) => {
672
+ let params;
673
+
674
+ // Support both legacy signature and new options object
675
+ if (typeof pageOrOptions === 'object') {
676
+ params = {
677
+ page: pageOrOptions.page || 1,
678
+ limit: pageOrOptions.limit || 50,
679
+ };
680
+ if (pageOrOptions.search !== undefined && pageOrOptions.search !== null) {
681
+ params.search = pageOrOptions.search;
682
+ }
683
+ } else {
684
+ params = { page: pageOrOptions, limit };
685
+ if (search !== null) params.search = search;
686
+ }
687
+
688
+ const response = await self._httpClient.get('/leaderboards', params);
689
+ return response.data;
690
+ },
691
+
692
+ /**
693
+ * Get custom leaderboard with optional filters
694
+ * @param {string} leaderboardId - Leaderboard ID
695
+ * @param {number|Object} [pageOrOptions=1] - Page number or options object
696
+ * @param {number} [limit=50] - Items per page (deprecated, use options object)
697
+ * @param {string} [search=null] - Search query (deprecated, use options object)
698
+ * @param {Object} [options] - Additional filter options
699
+ * @param {number} [options.page=1] - Page number
700
+ * @param {number} [options.limit=50] - Items per page
701
+ * @param {string} [options.search] - Search query
702
+ * @param {string} [options.persona] - Filter by persona
703
+ * @param {number} [options.minLevel] - Minimum level filter
704
+ * @param {number} [options.maxLevel] - Maximum level filter
705
+ * @param {Date|string} [options.startDate] - Start date filter (ISO 8601)
706
+ * @param {Date|string} [options.endDate] - End date filter (ISO 8601)
707
+ * @returns {Promise<Object>} Leaderboard response with rankings, stats, pagination, and cache metadata
708
+ */
709
+ getCustom: async (leaderboardId, pageOrOptions = 1, limit = 50, search = null, options = {}) => {
710
+ let params;
711
+
712
+ // Support both legacy signature and new options object
713
+ if (typeof pageOrOptions === 'object') {
714
+ // New signature: getCustom(leaderboardId, options)
715
+ params = buildFilterParams({
716
+ page: 1,
717
+ limit: 50,
718
+ ...pageOrOptions,
719
+ });
720
+ } else {
721
+ // Legacy signature: getCustom(leaderboardId, page, limit, search, options)
722
+ params = buildFilterParams({
723
+ page: pageOrOptions,
724
+ limit,
725
+ search,
726
+ ...options,
727
+ });
728
+ }
729
+
730
+ const response = await self._httpClient.get(
731
+ `/leaderboards/${encodeURIComponent(leaderboardId)}`,
732
+ params
733
+ );
734
+ return parseLeaderboardResponse(response.data);
735
+ },
736
+
737
+ /**
738
+ * Get user rank in leaderboard
739
+ * @param {string} leaderboardId - Leaderboard ID
740
+ * @param {string} userId - User ID
741
+ * @returns {Promise<Object>} User rank response with rank, score, and percentile
742
+ */
743
+ getUserRank: async (leaderboardId, userId) => {
744
+ const response = await self._httpClient.get(
745
+ `/leaderboards/${encodeURIComponent(leaderboardId)}/users/${encodeURIComponent(userId)}/rank`
746
+ );
93
747
 
94
- getUserRank: (leaderboardId, userId) =>
95
- this._request(`/leaderboard/${encodeURIComponent(leaderboardId)}/user/${encodeURIComponent(userId)}/rank`),
748
+ // Include percentile in response if available
749
+ const data = response.data;
750
+ return {
751
+ ...data,
752
+ percentile: data.percentile !== undefined ? data.percentile : null,
753
+ };
754
+ },
755
+
756
+ /**
757
+ * Get leaderboard entries around a specific user ("around me" view)
758
+ * @param {string} leaderboardId - Leaderboard ID
759
+ * @param {string} userId - User ID to center the view around
760
+ * @param {number} [range=5] - Number of entries above and below the user
761
+ * @returns {Promise<Object>} Leaderboard entries around the user with user's rank and percentile
762
+ */
763
+ getAroundUser: async (leaderboardId, userId, range = 5) => {
764
+ const response = await self._httpClient.get(
765
+ `/leaderboards/${encodeURIComponent(leaderboardId)}/users/${encodeURIComponent(userId)}/around`,
766
+ { range }
767
+ );
768
+ return parseLeaderboardResponse(response.data);
769
+ },
96
770
  };
97
771
  }
98
772
 
773
+ /**
774
+ * Badges module for badge queries
775
+ */
99
776
  get badges() {
100
777
  return {
101
- list: (page = 1, limit = 50, activeOnly = false) =>
102
- this._request('/badges', 'GET', null, { page, limit, active_only: activeOnly }),
778
+ /**
779
+ * List all badges
780
+ * @param {number} [page=1] - Page number
781
+ * @param {number} [limit=50] - Items per page
782
+ * @param {boolean} [activeOnly=false] - Only active badges
783
+ * @returns {Promise<Object>} Badges list
784
+ */
785
+ list: async (page = 1, limit = 50, activeOnly = false) => {
786
+ const response = await this._httpClient.get('/badges', {
787
+ page,
788
+ limit,
789
+ active_only: activeOnly,
790
+ });
791
+ return response.data;
792
+ },
103
793
  };
104
794
  }
105
795
 
796
+ /**
797
+ * Levels module for level queries
798
+ */
106
799
  get levels() {
107
800
  return {
108
- list: (page = 1, limit = 50) =>
109
- this._request('/levels', 'GET', null, { page, limit }),
801
+ /**
802
+ * List all levels
803
+ * @param {number} [page=1] - Page number
804
+ * @param {number} [limit=50] - Items per page
805
+ * @returns {Promise<Object>} Levels list
806
+ */
807
+ list: async (page = 1, limit = 50) => {
808
+ const response = await this._httpClient.get('/levels', { page, limit });
809
+ return response.data;
810
+ },
110
811
  };
111
812
  }
112
813
 
814
+ /**
815
+ * Questionnaires module for questionnaire queries
816
+ */
113
817
  get questionnaires() {
114
818
  return {
115
- get: (slug) => this._request(`/questionnaire/${slug}`),
116
- getActive: () => this._request('/questionnaire/active'),
819
+ /**
820
+ * Get questionnaire by slug
821
+ * @param {string} slug - Questionnaire slug
822
+ * @returns {Promise<Object>} Questionnaire data
823
+ */
824
+ get: async (slug) => {
825
+ const response = await this._httpClient.get(`/questionnaires/${slug}`);
826
+ return response.data;
827
+ },
828
+
829
+ /**
830
+ * Get active questionnaire
831
+ * @returns {Promise<Object>} Active questionnaire
832
+ */
833
+ getActive: async () => {
834
+ const response = await this._httpClient.get('/questionnaires/active');
835
+ return response.data;
836
+ },
117
837
  };
118
838
  }
119
839
 
840
+ /**
841
+ * Aha moment module for engagement tracking
842
+ */
120
843
  get aha() {
121
844
  return {
122
- declare: (userId, value) => {
845
+ /**
846
+ * Declare aha moment score
847
+ * @param {string} userId - User ID
848
+ * @param {number} value - Score value (1-5)
849
+ * @returns {Promise<Object>} Declaration response
850
+ */
851
+ declare: async (userId, value) => {
123
852
  // Validate value is between 1 and 5
124
853
  if (!Number.isInteger(value) || value < 1 || value > 5) {
125
- throw new Error('Aha score value must be an integer between 1 and 5');
854
+ throw new ValidationError('Aha score value must be an integer between 1 and 5', {
855
+ code: 'INVALID_AHA_VALUE',
856
+ fieldErrors: [{ field: 'value', message: 'value must be an integer between 1 and 5' }],
857
+ });
126
858
  }
127
- return this._request('/aha/declare', 'POST', {
859
+ const response = await this._httpClient.post('/aha/declare', {
128
860
  user_id: userId,
129
861
  value,
130
862
  });
863
+ return response.data;
864
+ },
865
+
866
+ /**
867
+ * Get user aha score
868
+ * @param {string} userId - User ID
869
+ * @returns {Promise<Object>} User aha score
870
+ */
871
+ getUserScore: async (userId) => {
872
+ const response = await this._httpClient.get(`/users/${encodeURIComponent(userId)}/aha`);
873
+ return response.data;
874
+ },
875
+ };
876
+ }
877
+
878
+ /**
879
+ * Health module for API health checks
880
+ */
881
+ get health() {
882
+ return {
883
+ /**
884
+ * Check API health status
885
+ * @returns {Promise<Object>} Health status
886
+ */
887
+ check: async () => {
888
+ const response = await this._httpClient.get('/health');
889
+ return response.data;
131
890
  },
132
891
 
133
- getUserScore: (userId) => this._request(`/users/${encodeURIComponent(userId)}/aha`),
892
+ /**
893
+ * Quick availability check
894
+ * @returns {Promise<boolean>} True if API is ready
895
+ */
896
+ isReady: async () => {
897
+ try {
898
+ await this._httpClient.get('/health');
899
+ return true;
900
+ } catch {
901
+ return false;
902
+ }
903
+ },
134
904
  };
135
905
  }
136
906
  }
137
907
 
908
+ // Export error classes for external use
909
+ export {
910
+ RooguysError,
911
+ ValidationError,
912
+ AuthenticationError,
913
+ ForbiddenError,
914
+ NotFoundError,
915
+ ConflictError,
916
+ RateLimitError,
917
+ ServerError,
918
+ mapStatusToError,
919
+ };
920
+
921
+ // Export utility functions
922
+ export { extractRateLimitInfo, extractRequestId, parseResponseBody };
923
+
924
+ // Export HttpClient for advanced usage
925
+ export { HttpClient };
926
+
927
+ // Default export
138
928
  export default Rooguys;