@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/README.md +478 -113
- package/dist/__tests__/utils/mockClient.d.ts +65 -3
- package/dist/__tests__/utils/mockClient.js +144 -5
- package/dist/errors.d.ts +123 -0
- package/dist/errors.js +163 -0
- package/dist/http-client.d.ts +167 -0
- package/dist/http-client.js +250 -0
- package/dist/index.d.ts +160 -10
- package/dist/index.js +585 -146
- package/dist/types.d.ts +372 -50
- package/dist/types.js +21 -0
- package/package.json +1 -1
- package/src/__tests__/property/request-construction.property.test.ts +142 -91
- package/src/__tests__/property/response-parsing.property.test.ts +118 -67
- package/src/__tests__/property/sdk-modules.property.test.ts +450 -0
- package/src/__tests__/unit/aha.test.ts +61 -50
- package/src/__tests__/unit/badges.test.ts +27 -33
- package/src/__tests__/unit/config.test.ts +94 -126
- package/src/__tests__/unit/errors.test.ts +106 -150
- package/src/__tests__/unit/events.test.ts +119 -144
- package/src/__tests__/unit/leaderboards.test.ts +173 -40
- package/src/__tests__/unit/levels.test.ts +25 -33
- package/src/__tests__/unit/questionnaires.test.ts +33 -42
- package/src/__tests__/unit/users.test.ts +214 -99
- package/src/__tests__/utils/mockClient.ts +193 -6
- package/src/errors.ts +255 -0
- package/src/http-client.ts +433 -0
- package/src/index.ts +742 -150
- package/src/types.ts +429 -51
package/src/index.ts
CHANGED
|
@@ -1,236 +1,828 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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,
|
|
23
|
-
options:
|
|
259
|
+
properties: Record<string, unknown> = {},
|
|
260
|
+
options: TrackOptions = {}
|
|
24
261
|
): Promise<TrackEventResponse> => {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
97
|
-
page
|
|
98
|
-
limit
|
|
579
|
+
timeframeOrOptions: Timeframe | LeaderboardFilterOptions = 'all-time',
|
|
580
|
+
page = 1,
|
|
581
|
+
limit = 50,
|
|
582
|
+
options: LeaderboardFilterOptions = {}
|
|
99
583
|
): Promise<LeaderboardResult> => {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
126
|
-
limit
|
|
127
|
-
search
|
|
639
|
+
pageOrOptions: number | LeaderboardFilterOptions = 1,
|
|
640
|
+
limit = 50,
|
|
641
|
+
search: string | null = null,
|
|
642
|
+
options: LeaderboardFilterOptions = {}
|
|
128
643
|
): Promise<LeaderboardResult> => {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
|
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
|
-
|
|
220
|
-
return
|
|
221
|
-
} catch
|
|
222
|
-
|
|
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;
|