@codingfactory/socialkit-vue 0.6.0 → 0.7.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.
@@ -0,0 +1,432 @@
1
+ /**
2
+ * Configurable gamification service for SocialKit-powered frontends.
3
+ */
4
+
5
+ import type { ApiService } from './api.js'
6
+ import type {
7
+ Achievement,
8
+ GamificationNotification,
9
+ LeaderboardEntry,
10
+ PointTransaction,
11
+ Quest,
12
+ Reward,
13
+ UserStats,
14
+ } from '../types/gamification.js'
15
+ import type {
16
+ ReputationPrivilege,
17
+ ReputationSnapshot,
18
+ UserStreakStatus,
19
+ } from '../types/reputation.js'
20
+
21
+ type LeaderboardPeriodInput = 'daily' | 'weekly' | 'monthly' | 'all-time' | 'all_time'
22
+
23
+ interface CursorCollectionResponse<TItem> {
24
+ data: TItem[]
25
+ next_cursor?: string
26
+ }
27
+
28
+ interface QuestCompletionResponse {
29
+ quest_id: string
30
+ completed_at: string
31
+ points_awarded: number
32
+ }
33
+
34
+ export interface GamificationWebSocketMessageEventLike {
35
+ data: unknown
36
+ }
37
+
38
+ export interface GamificationWebSocketLike {
39
+ readonly OPEN: number
40
+ readyState: number
41
+ onopen: ((event: unknown) => void) | null
42
+ onmessage: ((event: GamificationWebSocketMessageEventLike) => void) | null
43
+ onclose: ((event: unknown) => void) | null
44
+ onerror: ((event: unknown) => void) | null
45
+ close(): void
46
+ }
47
+
48
+ export type GamificationWebSocketFactory = (url: string) => GamificationWebSocketLike
49
+
50
+ export interface GamificationServiceLogger {
51
+ debug: (...args: unknown[]) => void
52
+ error: (...args: unknown[]) => void
53
+ }
54
+
55
+ export interface GamificationServiceConfig {
56
+ client: ApiService
57
+ webSocketUrl?: string | null
58
+ webSocketFactory?: GamificationWebSocketFactory
59
+ reconnectIntervalMs?: number
60
+ logger?: GamificationServiceLogger
61
+ debug?: boolean
62
+ }
63
+
64
+ export interface GamificationServiceInstance {
65
+ getUserStats(userId?: string): Promise<UserStats>
66
+ getUserReputation(userId?: string): Promise<UserStats>
67
+ getUserStreak(): Promise<UserStreakStatus>
68
+ getTransactions(options?: { limit?: number; offset?: number }): Promise<PointTransaction[]>
69
+ getReputationSnapshots(options?: { period?: 'week' | 'month' | 'year' }): Promise<ReputationSnapshot[]>
70
+ getPrivilegesCatalog(): Promise<ReputationPrivilege[]>
71
+ getAchievements(options?: { status?: 'locked' | 'unlocked' | 'all'; page?: number }): Promise<CursorCollectionResponse<Achievement>>
72
+ claimAchievement(achievementId: string): Promise<Achievement>
73
+ getQuests(options?: { status?: 'available' | 'in_progress' | 'completed' | 'all'; page?: number }): Promise<CursorCollectionResponse<Quest>>
74
+ claimQuestReward(questId: string): Promise<QuestCompletionResponse>
75
+ abandonQuest(questId: string): Promise<void>
76
+ getLeaderboard(options?: {
77
+ period?: LeaderboardPeriodInput
78
+ limit?: number
79
+ offset?: number
80
+ spaceId?: string
81
+ }): Promise<LeaderboardEntry[]>
82
+ getUserRank(): Promise<{ rank: number; percentile: number }>
83
+ getRewards(options?: {
84
+ status?: 'available' | 'redeemed' | 'all'
85
+ page?: number
86
+ }): Promise<CursorCollectionResponse<Reward>>
87
+ redeemReward(rewardId: string): Promise<Reward>
88
+ connectWebSocket(): GamificationWebSocketLike | null
89
+ disconnectWebSocket(): void
90
+ subscribe(event: string, callback: (data: GamificationNotification) => void): void
91
+ unsubscribe(event: string, callback: (data: GamificationNotification) => void): void
92
+ }
93
+
94
+ const createQuestCompletionIdempotencyKey = (questId: string): string => {
95
+ const randomPart = typeof globalThis.crypto?.randomUUID === 'function'
96
+ ? globalThis.crypto.randomUUID()
97
+ : Math.random().toString(36).slice(2)
98
+
99
+ return `quest-complete:${questId}:${randomPart}`
100
+ }
101
+
102
+ function createDefaultWebSocket(url: string): GamificationWebSocketLike {
103
+ if (typeof WebSocket === 'undefined') {
104
+ throw new Error('WebSocket is unavailable in this environment')
105
+ }
106
+
107
+ return new WebSocket(url) as unknown as GamificationWebSocketLike
108
+ }
109
+
110
+ class GamificationService implements GamificationServiceInstance {
111
+ private ws: GamificationWebSocketLike | null = null
112
+ private reconnectInterval: ReturnType<typeof setInterval> | null = null
113
+ private listeners: Map<string, Set<(data: GamificationNotification) => void>> = new Map()
114
+
115
+ public constructor(
116
+ private readonly client: ApiService,
117
+ private readonly webSocketUrl: string | null,
118
+ private readonly webSocketFactory: GamificationWebSocketFactory,
119
+ private readonly reconnectIntervalMs: number,
120
+ private readonly logger: GamificationServiceLogger,
121
+ private readonly debug: boolean,
122
+ ) {}
123
+
124
+ public async getUserStats(userId?: string): Promise<UserStats> {
125
+ const endpoint = userId
126
+ ? `/v1/gamification/users/${userId}/reputation`
127
+ : '/v1/gamification/users/me/reputation'
128
+ const response = await this.client.get<UserStats>(endpoint)
129
+ return response.data
130
+ }
131
+
132
+ public async getUserReputation(userId?: string): Promise<UserStats> {
133
+ return this.getUserStats(userId)
134
+ }
135
+
136
+ public async getUserStreak(): Promise<UserStreakStatus> {
137
+ const response = await this.client.get<UserStreakStatus>('/v1/gamification/users/me/streak')
138
+ return response.data
139
+ }
140
+
141
+ public async getTransactions(options?: {
142
+ limit?: number
143
+ offset?: number
144
+ }): Promise<PointTransaction[]> {
145
+ const params = new URLSearchParams()
146
+ if (options?.limit) {
147
+ params.append('limit', options.limit.toString())
148
+ }
149
+ if (options?.offset) {
150
+ params.append('offset', options.offset.toString())
151
+ }
152
+
153
+ const query = params.toString()
154
+ const url = query.length > 0
155
+ ? `/v1/gamification/users/me/transactions?${query}`
156
+ : '/v1/gamification/users/me/transactions'
157
+
158
+ const response = await this.client.get<PointTransaction[]>(url)
159
+ return response.data
160
+ }
161
+
162
+ public async getReputationSnapshots(options?: {
163
+ period?: 'week' | 'month' | 'year'
164
+ }): Promise<ReputationSnapshot[]> {
165
+ const params = new URLSearchParams()
166
+ if (options?.period) {
167
+ params.append('period', options.period)
168
+ }
169
+
170
+ const query = params.toString()
171
+ const url = query.length > 0
172
+ ? `/v1/gamification/users/me/reputation/snapshots?${query}`
173
+ : '/v1/gamification/users/me/reputation/snapshots'
174
+
175
+ const response = await this.client.get<ReputationSnapshot[]>(url)
176
+ return response.data
177
+ }
178
+
179
+ public async getPrivilegesCatalog(): Promise<ReputationPrivilege[]> {
180
+ const response = await this.client.get<ReputationPrivilege[]>('/v1/gamification/privileges')
181
+ return response.data
182
+ }
183
+
184
+ public async getAchievements(options?: {
185
+ status?: 'locked' | 'unlocked' | 'all'
186
+ page?: number
187
+ }): Promise<CursorCollectionResponse<Achievement>> {
188
+ const params = new URLSearchParams()
189
+ if (options?.status && options.status !== 'all') {
190
+ params.append('status', options.status)
191
+ }
192
+ if (options?.page) {
193
+ params.append('page', options.page.toString())
194
+ }
195
+
196
+ const response = await this.client.get<CursorCollectionResponse<Achievement>>(
197
+ `/v1/gamification/achievements?${params.toString()}`
198
+ )
199
+ return response.data
200
+ }
201
+
202
+ public async claimAchievement(achievementId: string): Promise<Achievement> {
203
+ const response = await this.client.post<Achievement>(`/v1/gamification/achievements/${achievementId}/claim`)
204
+ return response.data
205
+ }
206
+
207
+ public async getQuests(options?: {
208
+ status?: 'available' | 'in_progress' | 'completed' | 'all'
209
+ page?: number
210
+ }): Promise<CursorCollectionResponse<Quest>> {
211
+ const params = new URLSearchParams()
212
+ if (options?.status && options.status !== 'all') {
213
+ params.append('status', options.status)
214
+ }
215
+ if (options?.page) {
216
+ params.append('page', options.page.toString())
217
+ }
218
+
219
+ const endpoint = options?.status === 'available'
220
+ ? '/v1/gamification/quests/available'
221
+ : '/v1/gamification/quests/my-quests'
222
+ const response = await this.client.get<CursorCollectionResponse<Quest>>(`${endpoint}?${params.toString()}`)
223
+ return response.data
224
+ }
225
+
226
+ public async claimQuestReward(questId: string): Promise<QuestCompletionResponse> {
227
+ const idempotencyKey = createQuestCompletionIdempotencyKey(questId)
228
+ const response = await this.client.post<QuestCompletionResponse>(
229
+ `/v1/gamification/quests/${questId}/complete`,
230
+ undefined,
231
+ {
232
+ headers: {
233
+ 'Idempotency-Key': idempotencyKey,
234
+ },
235
+ },
236
+ )
237
+ return response.data
238
+ }
239
+
240
+ public async abandonQuest(questId: string): Promise<void> {
241
+ await this.client.delete(`/v1/gamification/quests/${questId}/abandon`)
242
+ }
243
+
244
+ public async getLeaderboard(options?: {
245
+ period?: LeaderboardPeriodInput
246
+ limit?: number
247
+ offset?: number
248
+ spaceId?: string
249
+ }): Promise<LeaderboardEntry[]> {
250
+ const params = new URLSearchParams()
251
+ if (options?.period) {
252
+ const normalizedPeriod = options.period === 'all_time' ? 'all-time' : options.period
253
+ params.append('period', normalizedPeriod)
254
+ }
255
+ if (options?.limit) {
256
+ params.append('limit', options.limit.toString())
257
+ }
258
+ if (options?.offset) {
259
+ params.append('offset', options.offset.toString())
260
+ }
261
+ if (options?.spaceId) {
262
+ params.append('space_id', options.spaceId)
263
+ }
264
+
265
+ const query = params.toString()
266
+ const url = query.length > 0
267
+ ? `/v1/gamification/leaderboard?${query}`
268
+ : '/v1/gamification/leaderboard'
269
+
270
+ const response = await this.client.get<LeaderboardEntry[]>(url)
271
+ return response.data
272
+ }
273
+
274
+ public async getUserRank(): Promise<{ rank: number; percentile: number }> {
275
+ const response = await this.client.get<{ rank: number; percentile: number }>('/v1/gamification/users/me/rank')
276
+ return response.data
277
+ }
278
+
279
+ public async getRewards(options?: {
280
+ status?: 'available' | 'redeemed' | 'all'
281
+ page?: number
282
+ }): Promise<CursorCollectionResponse<Reward>> {
283
+ const params = new URLSearchParams()
284
+ if (options?.status && options.status !== 'all') {
285
+ params.append('status', options.status)
286
+ }
287
+ if (options?.page) {
288
+ params.append('page', options.page.toString())
289
+ }
290
+
291
+ const query = params.toString()
292
+ const endpoint = query.length > 0
293
+ ? `/v1/gamification/rewards?${query}`
294
+ : '/v1/gamification/rewards'
295
+
296
+ const response = await this.client.get<Reward[]>(endpoint)
297
+ return { data: response.data }
298
+ }
299
+
300
+ public async redeemReward(rewardId: string): Promise<Reward> {
301
+ const response = await this.client.post<Reward>(`/v1/gamification/rewards/${rewardId}/redeem`)
302
+ return response.data
303
+ }
304
+
305
+ public connectWebSocket(): GamificationWebSocketLike | null {
306
+ if (this.ws && this.ws.readyState === this.ws.OPEN) {
307
+ return this.ws
308
+ }
309
+
310
+ const socketUrl = typeof this.webSocketUrl === 'string' && this.webSocketUrl.length > 0
311
+ ? this.webSocketUrl
312
+ : null
313
+ if (socketUrl === null) {
314
+ return null
315
+ }
316
+
317
+ try {
318
+ this.ws = this.webSocketFactory(`${socketUrl}/gamification`)
319
+
320
+ this.ws.onopen = () => {
321
+ this.debugLog('Gamification WebSocket connected')
322
+ if (this.reconnectInterval) {
323
+ clearInterval(this.reconnectInterval)
324
+ this.reconnectInterval = null
325
+ }
326
+ }
327
+
328
+ this.ws.onmessage = (event) => {
329
+ try {
330
+ const payload = typeof event.data === 'string'
331
+ ? JSON.parse(event.data)
332
+ : event.data
333
+ this.handleWebSocketMessage(payload as GamificationNotification)
334
+ } catch (error) {
335
+ this.logger.error('Failed to parse WebSocket message:', error)
336
+ }
337
+ }
338
+
339
+ this.ws.onclose = () => {
340
+ this.debugLog('Gamification WebSocket disconnected')
341
+ this.attemptReconnect()
342
+ }
343
+
344
+ this.ws.onerror = (error) => {
345
+ this.logger.error('Gamification WebSocket error:', error)
346
+ }
347
+
348
+ return this.ws
349
+ } catch (error) {
350
+ this.logger.error('Failed to connect WebSocket:', error)
351
+ return null
352
+ }
353
+ }
354
+
355
+ public disconnectWebSocket(): void {
356
+ if (this.ws) {
357
+ this.ws.close()
358
+ this.ws = null
359
+ }
360
+ if (this.reconnectInterval) {
361
+ clearInterval(this.reconnectInterval)
362
+ this.reconnectInterval = null
363
+ }
364
+ this.listeners.clear()
365
+ }
366
+
367
+ public subscribe(event: string, callback: (data: GamificationNotification) => void): void {
368
+ if (!this.listeners.has(event)) {
369
+ this.listeners.set(event, new Set())
370
+ }
371
+ this.listeners.get(event)?.add(callback)
372
+ }
373
+
374
+ public unsubscribe(event: string, callback: (data: GamificationNotification) => void): void {
375
+ this.listeners.get(event)?.delete(callback)
376
+ }
377
+
378
+ private debugLog(message: string, ...args: unknown[]): void {
379
+ if (!this.debug) {
380
+ return
381
+ }
382
+
383
+ this.logger.debug(message, ...args)
384
+ }
385
+
386
+ private attemptReconnect(): void {
387
+ if (this.reconnectInterval) {
388
+ return
389
+ }
390
+
391
+ this.reconnectInterval = setInterval(() => {
392
+ this.debugLog('Gamification WebSocket reconnect attempt')
393
+ this.connectWebSocket()
394
+ }, this.reconnectIntervalMs)
395
+ }
396
+
397
+ private handleWebSocketMessage(data: GamificationNotification): void {
398
+ const listeners = this.listeners.get(data.type)
399
+ if (listeners) {
400
+ listeners.forEach((listener) => {
401
+ listener(data)
402
+ })
403
+ }
404
+
405
+ const updateListeners = this.listeners.get('update')
406
+ if (updateListeners) {
407
+ updateListeners.forEach((listener) => {
408
+ listener(data)
409
+ })
410
+ }
411
+ }
412
+ }
413
+
414
+ export function createGamificationService(config: GamificationServiceConfig): GamificationServiceInstance {
415
+ const {
416
+ client,
417
+ webSocketUrl = null,
418
+ webSocketFactory = createDefaultWebSocket,
419
+ reconnectIntervalMs = 5000,
420
+ logger = console,
421
+ debug = false,
422
+ } = config
423
+
424
+ return new GamificationService(
425
+ client,
426
+ webSocketUrl,
427
+ webSocketFactory,
428
+ reconnectIntervalMs,
429
+ logger,
430
+ debug,
431
+ )
432
+ }