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