@@ -28,8 +28,14 @@ import { broadcastToUsers } from '@/ws/handler';
2828/** Schema for session recording payload. */
2929const SessionPayloadSchema = z . object ( {
3030 sessionDuration : z . number ( ) . int ( ) . min ( 0 ) . max ( 86400 ) , // Max 24 hours
31- language : z . string ( ) . optional ( ) ,
32- project : z . string ( ) . optional ( ) ,
31+ language : z . string ( ) . max ( 255 ) . optional ( ) ,
32+ project : z . string ( ) . max ( 255 ) . optional ( ) ,
33+ } ) ;
34+
35+ /** Schema for achievements query with pagination. */
36+ const AchievementsQuerySchema = z . object ( {
37+ limit : z . coerce . number ( ) . int ( ) . min ( 1 ) . max ( 100 ) . default ( 50 ) ,
38+ cursor : z . string ( ) . optional ( ) ,
3339} ) ;
3440
3541/** Allowlist of known programming languages to prevent unbounded Redis hash growth. */
@@ -103,6 +109,30 @@ function calculateStreakStatus(lastActiveDate: string | null): StreakInfo['strea
103109 return 'broken' ;
104110}
105111
112+ /** Get yesterday's date in YYYY-MM-DD format (UTC) for Lua script timezone safety. */
113+ function getYesterdayDate ( ) : string {
114+ const yesterday = new Date ( ) ;
115+ yesterday . setUTCDate ( yesterday . getUTCDate ( ) - 1 ) ;
116+ const parts = yesterday . toISOString ( ) . split ( 'T' ) ;
117+ return parts [ 0 ] ?? '' ;
118+ }
119+
120+ /** Parse streak data from Redis hgetall result into StreakInfo. */
121+ function parseStreakData ( data : Record < string , string > | null ) : StreakInfo {
122+ const currentStreak = parseInt ( data ?. count ?? '0' , 10 ) ;
123+ const longestStreak = parseInt ( data ?. longest ?? '0' , 10 ) ;
124+ const lastActiveDate = data ?. lastDate ?? null ;
125+ const streakStatus = calculateStreakStatus ( lastActiveDate ) ;
126+
127+ return {
128+ currentStreak,
129+ longestStreak,
130+ lastActiveDate,
131+ isActiveToday : streakStatus === 'active' ,
132+ streakStatus,
133+ } ;
134+ }
135+
106136/** Registers stats routes on the Fastify instance. */
107137export function statsRoutes ( app : FastifyInstance ) : void {
108138 const db = getDb ( ) ;
@@ -137,19 +167,8 @@ export function statsRoutes(app: FastifyInstance): void {
137167 const todaySessionStr = results ?. [ 1 ] ?. [ 1 ] as string | null ;
138168 const todaySession = todaySessionStr ? parseInt ( todaySessionStr , 10 ) : 0 ;
139169
140- // Build streak info
141- const currentStreak = parseInt ( streakData ?. count ?? '0' , 10 ) ;
142- const longestStreak = parseInt ( streakData ?. longest ?? '0' , 10 ) ;
143- const lastActiveDate = streakData ?. lastDate ?? null ;
144- const streakStatus = calculateStreakStatus ( lastActiveDate ) ;
145-
146- const streak : StreakInfo = {
147- currentStreak,
148- longestStreak,
149- lastActiveDate,
150- isActiveToday : streakStatus === 'active' ,
151- streakStatus,
152- } ;
170+ // Build streak info using shared helper
171+ const streak = parseStreakData ( streakData ) ;
153172
154173 // Get weekly stats from PostgreSQL
155174 const weekStart = getWeekStart ( ) ;
@@ -210,19 +229,7 @@ export function statsRoutes(app: FastifyInstance): void {
210229 const redis = getRedis ( ) ;
211230
212231 const streakData = await redis . hgetall ( REDIS_KEYS . streakData ( userId ) ) ;
213-
214- const currentStreak = parseInt ( streakData . count ?? '0' , 10 ) ;
215- const longestStreak = parseInt ( streakData . longest ?? '0' , 10 ) ;
216- const lastActiveDate = streakData . lastDate ?? null ;
217- const streakStatus = calculateStreakStatus ( lastActiveDate ) ;
218-
219- const streak : StreakInfo = {
220- currentStreak,
221- longestStreak,
222- lastActiveDate,
223- isActiveToday : streakStatus === 'active' ,
224- streakStatus,
225- } ;
232+ const streak = parseStreakData ( streakData ) ;
226233
227234 return reply . send ( { data : streak } ) ;
228235 }
@@ -247,14 +254,16 @@ export function statsRoutes(app: FastifyInstance): void {
247254
248255 const { sessionDuration, language, project } = result . data ;
249256 const today = getTodayDate ( ) ;
257+ const yesterday = getYesterdayDate ( ) ;
250258 const redis = getRedis ( ) ;
251259
252- // Lua script for atomic streak update
260+ // Lua script for atomic streak update (UTC-safe: uses string comparison)
253261 // Returns: [newStreak, shouldCheckAchievements] - 1 if streak was updated, 0 otherwise
254262 const STREAK_UPDATE_SCRIPT = `
255263 local streakKey = KEYS[1]
256264 local today = ARGV[1]
257265 local streakTtl = tonumber(ARGV[2])
266+ local yesterday = ARGV[3]
258267
259268 -- Read current streak data
260269 local lastDate = redis.call('HGET', streakKey, 'lastDate')
@@ -266,21 +275,12 @@ export function statsRoutes(app: FastifyInstance): void {
266275 return {count, 0}
267276 end
268277
269- -- Calculate new streak
278+ -- Calculate new streak using UTC-safe string comparison
270279 local newStreak = 1
271- if lastDate then
272- -- Parse dates and calculate difference
273- local ly, lm, ld = lastDate:match('(%d+)-(%d+)-(%d+)')
274- local ty, tm, td = today:match('(%d+)-(%d+)-(%d+)')
275- local lastTime = os.time({year=ly, month=lm, day=ld})
276- local todayTime = os.time({year=ty, month=tm, day=td})
277- local daysDiff = math.floor((todayTime - lastTime) / 86400)
278-
279- if daysDiff == 1 then
280- newStreak = count + 1
281- end
282- -- daysDiff > 1 means streak broken, reset to 1
280+ if lastDate == yesterday then
281+ newStreak = count + 1
283282 end
283+ -- Any other date means streak broken, reset to 1
284284
285285 local newLongest = math.max(newStreak, longest)
286286
@@ -298,7 +298,8 @@ export function statsRoutes(app: FastifyInstance): void {
298298 1 ,
299299 streakKey ,
300300 today ,
301- ( STREAK_TTL_SECONDS * 2 ) . toString ( )
301+ ( STREAK_TTL_SECONDS * 2 ) . toString ( ) , // 50h TTL: 25h grace + 25h safety buffer
302+ yesterday
302303 ) ) as [ number , number ] ;
303304
304305 const newStreak = streakResult [ 0 ] ;
@@ -320,7 +321,7 @@ export function statsRoutes(app: FastifyInstance): void {
320321 pipeline . hincrby ( REDIS_KEYS . networkIntensity ( minute ) , 'count' , 1 ) ;
321322 pipeline . expire ( REDIS_KEYS . networkIntensity ( minute ) , NETWORK_ACTIVITY_TTL_SECONDS ) ;
322323
323- // 5 . Track language if provided (validate to prevent unbounded hash growth)
324+ // 4 . Track language if provided (validate to prevent unbounded hash growth)
324325 if ( language ) {
325326 const normalizedLang = language . toLowerCase ( ) . trim ( ) ;
326327 const safeLang = LANGUAGE_ALLOWLIST . has ( normalizedLang ) ? normalizedLang : 'other' ;
@@ -395,28 +396,52 @@ export function statsRoutes(app: FastifyInstance): void {
395396 ) ;
396397
397398 /**
398- * GET /stats/achievements - Get all user achievements
399+ * GET /stats/achievements - Get all user achievements (paginated)
399400 */
400401 app . get (
401402 '/achievements' ,
402403 { onRequest : [ app . authenticate ] } ,
403404 async ( request : FastifyRequest , reply : FastifyReply ) => {
404405 const { userId } = request . user as { userId : string } ;
406+ const queryResult = AchievementsQuerySchema . safeParse ( request . query ) ;
407+
408+ if ( ! queryResult . success ) {
409+ return reply . status ( 400 ) . send ( {
410+ error : { code : 'INVALID_QUERY' , message : 'Invalid query parameters' } ,
411+ } ) ;
412+ }
413+
414+ const { limit, cursor } = queryResult . data ;
405415
406416 const achievementRecords = await db . achievement . findMany ( {
407417 where : { userId } ,
408418 orderBy : { earnedAt : 'desc' } ,
419+ take : limit + 1 , // Fetch one extra to check if there's more
420+ ...( cursor && {
421+ cursor : { id : cursor } ,
422+ skip : 1 , // Skip the cursor item itself
423+ } ) ,
409424 } ) ;
410425
411- const achievements : AchievementDTO [ ] = achievementRecords . map ( ( a ) => ( {
426+ const hasMore = achievementRecords . length > limit ;
427+ const items = hasMore ? achievementRecords . slice ( 0 , limit ) : achievementRecords ;
428+ const nextCursor = hasMore ? items [ items . length - 1 ] ?. id : undefined ;
429+
430+ const achievements : AchievementDTO [ ] = items . map ( ( a ) => ( {
412431 id : a . id ,
413432 type : a . type as AchievementDTO [ 'type' ] ,
414433 title : a . title ,
415434 description : a . description ,
416435 earnedAt : a . earnedAt . toISOString ( ) ,
417436 } ) ) ;
418437
419- return reply . send ( { data : achievements } ) ;
438+ return reply . send ( {
439+ data : achievements ,
440+ pagination : {
441+ hasMore,
442+ nextCursor,
443+ } ,
444+ } ) ;
420445 }
421446 ) ;
422447
@@ -477,7 +502,7 @@ export function statsRoutes(app: FastifyInstance): void {
477502 logger . info ( { userId, streak, type : milestone . type } , 'Streak achievement earned' ) ;
478503 }
479504 }
480- break ; // Only one achievement per streak update
505+ // Continue checking for lower milestones that may not have been awarded
481506 }
482507 }
483508 }
0 commit comments