From fc394ce034910709bbb5e82a065897d8e9c7bc77 Mon Sep 17 00:00:00 2001 From: Utpal Date: Fri, 9 Jan 2026 15:26:38 +0530 Subject: [PATCH 01/23] feat(db): add Achievement and WeeklyStats models for gamification --- apps/server/prisma/schema.prisma | 41 ++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index d997d94..6e5befc 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -28,10 +28,51 @@ model User { sentFriendRequests FriendRequest[] @relation("SentRequests") receivedFriendRequests FriendRequest[] @relation("ReceivedRequests") + // Phase 2: Gamification + achievements Achievement[] + weeklyStats WeeklyStats[] + @@index([username]) @@index([githubId]) } +/// Achievement earned by a user (e.g., "Bug Slayer", "100 Day Streak") +model Achievement { + id String @id @default(cuid()) + userId String + type String // "ISSUE_CLOSED", "PR_MERGED", "STREAK_7", "STREAK_30", etc. + title String + description String? + metadata Json? // Additional context (issue number, repo, etc.) + earnedAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([type]) + @@index([earnedAt]) +} + +/// Aggregated weekly statistics (computed by cron or on-demand) +model WeeklyStats { + id String @id @default(cuid()) + userId String + weekStart DateTime // Monday 00:00:00 UTC + totalSeconds Int @default(0) + totalSessions Int @default(0) + totalCommits Int @default(0) + topLanguage String? + topProject String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, weekStart]) + @@index([userId]) + @@index([weekStart]) +} + model Follow { followerId String followingId String From e452f39b2d3a83041240dee009e0a40a104c4ec6 Mon Sep 17 00:00:00 2001 From: Utpal Date: Fri, 9 Jan 2026 15:26:51 +0530 Subject: [PATCH 02/23] feat(shared): add gamification types (StreakInfo, LeaderboardEntry, etc.) --- packages/shared/src/types.ts | 107 +++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 4820b28..2ad08b5 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -144,3 +144,110 @@ export interface PaginatedResponse { hasMore: boolean; }; } + +// ============================================================================ +// Phase 2: Gamification Types +// ============================================================================ + +/** Achievement types for gamification. */ +export type AchievementType = + | 'ISSUE_CLOSED' + | 'PR_MERGED' + | 'STREAK_7' + | 'STREAK_30' + | 'STREAK_100' + | 'FIRST_HOUR' + | 'NIGHT_OWL' + | 'EARLY_BIRD'; + +/** Achievement earned by user. */ +export interface AchievementDTO { + id: string; + type: AchievementType; + title: string; + description: string | null; + /** When the achievement was earned (ISO 8601) */ + earnedAt: string; +} + +/** Streak status for UI display. */ +export type StreakStatus = 'active' | 'at_risk' | 'broken'; + +/** User streak information. */ +export interface StreakInfo { + /** Current consecutive days streak */ + currentStreak: number; + /** All-time longest streak */ + longestStreak: number; + /** Last activity date (YYYY-MM-DD format) */ + lastActiveDate: string | null; + /** Whether user has coded today */ + isActiveToday: boolean; + /** Current streak health status */ + streakStatus: StreakStatus; +} + +/** Weekly statistics for a user. */ +export interface WeeklyStatsDTO { + /** Start of the week (Monday 00:00 UTC, ISO 8601) */ + weekStart: string; + /** Total coding time in seconds */ + totalSeconds: number; + /** Number of coding sessions */ + totalSessions: number; + /** Number of commits (from GitHub webhook) */ + totalCommits: number; + /** Most used programming language */ + topLanguage: string | null; + /** Most worked-on project */ + topProject: string | null; + /** User's rank in weekly leaderboard (1-indexed, undefined if not ranked) */ + rank?: number | undefined; +} + +/** Leaderboard entry for display. */ +export interface LeaderboardEntry { + /** Position in leaderboard (1-indexed) */ + rank: number; + userId: string; + username: string; + displayName: string | null; + avatarUrl: string | null; + /** Score (total seconds for time, count for commits) */ + score: number; + /** Whether this user is a friend of the viewer */ + isFriend: boolean; +} + +/** Network activity heatmap data. */ +export interface NetworkActivity { + /** Number of users active in last 5 minutes */ + totalActiveUsers: number; + /** Average coding intensity (0-100) */ + averageIntensity: number; + /** True if network is particularly active ("🔥 mode") */ + isHot: boolean; + /** Human-readable activity message */ + message: string; +} + +/** Payload for ACHIEVEMENT WebSocket message. */ +export interface AchievementPayload { + achievement: AchievementDTO; + /** User who earned the achievement */ + userId: string; + /** Username for display */ + username: string; +} + +/** User stats summary (returned from /stats/me). */ +export interface UserStatsDTO { + streak: StreakInfo; + /** Today's coding time in seconds */ + todaySession: number; + /** Current week's stats (may be null if no activity) */ + weeklyStats: WeeklyStatsDTO | null; + /** Recent achievements (latest 5) */ + recentAchievements: AchievementDTO[]; +} + From d0df1aafa3b878ad0dbd97fc0b8cb0383f356d8b Mon Sep 17 00:00:00 2001 From: Utpal Date: Fri, 9 Jan 2026 15:27:06 +0530 Subject: [PATCH 03/23] feat(shared): add gamification constants (TTLs, Redis keys, achievements) --- packages/shared/src/constants.ts | 74 +++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 40968da..2cd8c60 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -7,6 +7,22 @@ export const HEARTBEAT_INTERVAL_MS = 30_000; /** Idle threshold (5 minutes of no activity). */ export const IDLE_THRESHOLD_MS = 5 * 60 * 1000; +// ============================================================================ +// Phase 2: Gamification Constants +// ============================================================================ + +/** Streak TTL in seconds (25 hours - grace period for timezone flexibility). */ +export const STREAK_TTL_SECONDS = 25 * 60 * 60; + +/** Daily session key TTL in seconds (48 hours for overlap). */ +export const SESSION_TTL_SECONDS = 48 * 60 * 60; + +/** Network activity bucket TTL in seconds (2 minutes). */ +export const NETWORK_ACTIVITY_TTL_SECONDS = 120; + +/** Threshold for network "hot" status (10+ active users). */ +export const NETWORK_HOT_THRESHOLD = 10; + /** WebSocket reconnection with exponential backoff. */ export const WS_RECONNECT_CONFIG = { initialDelayMs: 1000, @@ -17,11 +33,25 @@ export const WS_RECONNECT_CONFIG = { /** Redis key generators for consistent key naming. */ export const REDIS_KEYS = { + // Presence (Phase 1) presence: (userId: string) => `presence:${userId}`, presenceChannel: (userId: string) => `channel:presence:${userId}`, - weeklyLeaderboard: (metric: string) => `leaderboard:weekly:${metric}`, editingFile: (teamId: string, fileHash: string) => `editing:${teamId}:${fileHash}`, + + // Streaks (Phase 2) userStreak: (userId: string) => `streak:${userId}`, + streakData: (userId: string) => `streak:data:${userId}`, + + // Leaderboards (Phase 2) + weeklyLeaderboard: (metric: string) => `leaderboard:weekly:${metric}`, + friendsLeaderboard: (userId: string) => `leaderboard:friends:${userId}`, + + // Session tracking (Phase 2) + dailySession: (userId: string, date: string) => `session:${userId}:${date}`, + + // Network activity (Phase 2) + networkActivity: () => `network:activity`, + networkIntensity: (minute: number) => `network:intensity:${String(minute)}`, } as const; /** Default file patterns excluded from activity broadcast. */ @@ -57,3 +87,45 @@ export const RATE_LIMITS = { PRO: 120, TEAM: 300, } as const; + +/** + * Achievement definitions with titles and descriptions. + * Used for creating achievements and displaying in UI. + */ +export const ACHIEVEMENTS = { + ISSUE_CLOSED: { + title: '🐛 Bug Slayer', + description: 'Closed an issue on GitHub', + }, + PR_MERGED: { + title: '🎉 Merge Master', + description: 'Merged a pull request', + }, + STREAK_7: { + title: '🔥 Week Warrior', + description: 'Maintained a 7-day coding streak', + }, + STREAK_30: { + title: '⚡ Monthly Machine', + description: 'Maintained a 30-day coding streak', + }, + STREAK_100: { + title: '🏆 Century Coder', + description: 'Maintained a 100-day coding streak', + }, + FIRST_HOUR: { + title: '⏰ First Hour', + description: 'Coded for your first full hour', + }, + NIGHT_OWL: { + title: '🦉 Night Owl', + description: 'Coded past midnight', + }, + EARLY_BIRD: { + title: '🌅 Early Bird', + description: 'Started coding before 6 AM', + }, +} as const; + +/** Achievement type from the ACHIEVEMENTS keys. */ +export type AchievementTypeKey = keyof typeof ACHIEVEMENTS; From bb3e0ed7ece5364687fa76a7d392eb0fd5f1009f Mon Sep 17 00:00:00 2001 From: Utpal Date: Fri, 9 Jan 2026 15:27:20 +0530 Subject: [PATCH 04/23] feat(server): add GITHUB_WEBHOOK_SECRET to environment config --- apps/server/src/config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 52d6a63..a3eb469 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -33,6 +33,8 @@ const envSchema = z.object({ GITHUB_CLIENT_ID: z.string().min(1, 'GITHUB_CLIENT_ID is required'), GITHUB_CLIENT_SECRET: z.string().min(1, 'GITHUB_CLIENT_SECRET is required'), GITHUB_CALLBACK_URL: z.string().url(), + /* GitHub Webhooks (optional - for Boss Battles feature) */ + GITHUB_WEBHOOK_SECRET: z.string().optional(), /* Logging */ LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), }); From d5bb4a652f2b9677bbeee47dfb3adca08ac51290 Mon Sep 17 00:00:00 2001 From: Utpal Date: Fri, 9 Jan 2026 15:27:30 +0530 Subject: [PATCH 05/23] feat(server): add stats routes for streaks, sessions, and achievements --- apps/server/src/routes/stats.ts | 377 ++++++++++++++++++++++++++++++++ 1 file changed, 377 insertions(+) create mode 100644 apps/server/src/routes/stats.ts diff --git a/apps/server/src/routes/stats.ts b/apps/server/src/routes/stats.ts new file mode 100644 index 0000000..8296991 --- /dev/null +++ b/apps/server/src/routes/stats.ts @@ -0,0 +1,377 @@ +/** + * Stats Routes + * + * Endpoints for user statistics, streaks, and session tracking. + * + * Best Practices Implemented: + * - Redis pipelining for batch operations + * - TTL-based streak expiration with grace period (25 hours) + * - Efficient HINCRBY for atomic updates + * - Deduplication to prevent stat inflation + */ + +import { + REDIS_KEYS, + STREAK_TTL_SECONDS, + SESSION_TTL_SECONDS, + NETWORK_ACTIVITY_TTL_SECONDS, + ACHIEVEMENTS, +} from '@devradar/shared'; +import { z } from 'zod'; + +import type { StreakInfo, WeeklyStatsDTO, AchievementDTO } from '@devradar/shared'; +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + +import { logger } from '@/lib/logger'; +import { getDb } from '@/services/db'; +import { getRedis } from '@/services/redis'; +import { broadcastToUsers } from '@/ws/handler'; + +/** Schema for session recording payload. */ +const SessionPayloadSchema = z.object({ + sessionDuration: z.number().int().min(0).max(86400), // Max 24 hours + language: z.string().optional(), + project: z.string().optional(), +}); + +/** Get Monday 00:00:00 UTC for current week. */ +function getWeekStart(): Date { + const now = new Date(); + const day = now.getUTCDay(); + const diff = (day === 0 ? -6 : 1) - day; // Adjust to Monday + const monday = new Date(now); + monday.setUTCDate(now.getUTCDate() + diff); + monday.setUTCHours(0, 0, 0, 0); + return monday; +} + +/** Get today's date in YYYY-MM-DD format (UTC). */ +function getTodayDate(): string { + const parts = new Date().toISOString().split('T'); + return parts[0] ?? ''; +} + +/** Calculate streak status based on last active date. */ +function calculateStreakStatus(lastActiveDate: string | null): StreakInfo['streakStatus'] { + if (!lastActiveDate) return 'broken'; + + const lastDate = new Date(lastActiveDate); + const today = new Date(getTodayDate()); + const daysDiff = Math.floor((today.getTime() - lastDate.getTime()) / (1000 * 60 * 60 * 24)); + + if (daysDiff === 0) return 'active'; + if (daysDiff === 1) return 'at_risk'; + return 'broken'; +} + +/** Registers stats routes on the Fastify instance. */ +export function statsRoutes(app: FastifyInstance): void { + const db = getDb(); + + /** + * GET /stats/me - Get current user's stats summary + */ + app.get('/me', { onRequest: [app.authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + const redis = getRedis(); + + // Pipeline Redis reads for efficiency + const pipeline = redis.pipeline(); + const today = getTodayDate(); + + pipeline.hgetall(REDIS_KEYS.streakData(userId)); + pipeline.get(REDIS_KEYS.dailySession(userId, today)); + + const results = await pipeline.exec(); + const streakData = results?.[0]?.[1] as Record | null; + const todaySessionStr = results?.[1]?.[1] as string | null; + const todaySession = todaySessionStr ? parseInt(todaySessionStr, 10) : 0; + + // Build streak info + const currentStreak = parseInt(streakData?.count ?? '0', 10); + const longestStreak = parseInt(streakData?.longest ?? '0', 10); + const lastActiveDate = streakData?.lastDate ?? null; + const streakStatus = calculateStreakStatus(lastActiveDate); + + const streak: StreakInfo = { + currentStreak, + longestStreak, + lastActiveDate, + isActiveToday: streakStatus === 'active', + streakStatus, + }; + + // Get weekly stats from PostgreSQL + const weekStart = getWeekStart(); + const weeklyStatsRecord = await db.weeklyStats.findUnique({ + where: { userId_weekStart: { userId, weekStart } }, + }); + + // Get user's rank in weekly leaderboard + const rank = await redis.zrevrank(REDIS_KEYS.weeklyLeaderboard('time'), userId); + const liveScore = await redis.zscore(REDIS_KEYS.weeklyLeaderboard('time'), userId); + + const weeklyStats: WeeklyStatsDTO | null = weeklyStatsRecord + ? { + weekStart: weeklyStatsRecord.weekStart.toISOString(), + totalSeconds: liveScore ? parseInt(liveScore, 10) : weeklyStatsRecord.totalSeconds, + totalSessions: weeklyStatsRecord.totalSessions, + totalCommits: weeklyStatsRecord.totalCommits, + topLanguage: weeklyStatsRecord.topLanguage, + topProject: weeklyStatsRecord.topProject, + rank: rank !== null ? rank + 1 : undefined, + } + : null; + + // Get recent achievements + const achievementRecords = await db.achievement.findMany({ + where: { userId }, + orderBy: { earnedAt: 'desc' }, + take: 5, + }); + + const recentAchievements: AchievementDTO[] = achievementRecords.map((a) => ({ + id: a.id, + type: a.type as AchievementDTO['type'], + title: a.title, + description: a.description, + earnedAt: a.earnedAt.toISOString(), + })); + + return reply.send({ + data: { + streak, + todaySession, + weeklyStats, + recentAchievements, + }, + }); + }); + + /** + * GET /stats/streak - Get detailed streak information + */ + app.get('/streak', { onRequest: [app.authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + const redis = getRedis(); + + const streakData = await redis.hgetall(REDIS_KEYS.streakData(userId)); + + const currentStreak = parseInt(streakData.count ?? '0', 10); + const longestStreak = parseInt(streakData.longest ?? '0', 10); + const lastActiveDate = streakData.lastDate ?? null; + const streakStatus = calculateStreakStatus(lastActiveDate); + + const streak: StreakInfo = { + currentStreak, + longestStreak, + lastActiveDate, + isActiveToday: streakStatus === 'active', + streakStatus, + }; + + return reply.send({ data: streak }); + }); + + /** + * POST /stats/session - Record coding session time + * Called periodically by the extension with session duration increment + */ + app.post('/session', { onRequest: [app.authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + const result = SessionPayloadSchema.safeParse(request.body); + + if (!result.success) { + return reply.status(400).send({ + error: { code: 'INVALID_PAYLOAD', message: 'Invalid session data' }, + }); + } + + const { sessionDuration, language, project } = result.data; + const today = getTodayDate(); + const redis = getRedis(); + + // Get existing streak data to check if we need to update it + const existingStreakData = await redis.hgetall(REDIS_KEYS.streakData(userId)); + const lastDate = existingStreakData.lastDate; + + // Pipeline for atomic operations + const pipeline = redis.pipeline(); + + // 1. Update daily session time (accumulate) + const sessionKey = REDIS_KEYS.dailySession(userId, today); + pipeline.incrby(sessionKey, sessionDuration); + pipeline.expire(sessionKey, SESSION_TTL_SECONDS); + + // 2. Update streak if needed (only once per day) + let newStreak = 1; + let shouldCheckStreakAchievements = false; + + if (lastDate !== today) { + // First activity of the day - update streak + shouldCheckStreakAchievements = true; + + if (lastDate) { + const lastDateObj = new Date(lastDate); + const todayDateObj = new Date(today); + const daysDiff = Math.floor( + (todayDateObj.getTime() - lastDateObj.getTime()) / (1000 * 60 * 60 * 24) + ); + + if (daysDiff === 1) { + // Consecutive day - increment streak + newStreak = parseInt(existingStreakData.count ?? '0', 10) + 1; + } + // daysDiff > 1 means streak is broken, reset to 1 + } + + const longestStreak = Math.max(newStreak, parseInt(existingStreakData.longest ?? '0', 10)); + + pipeline.hset(REDIS_KEYS.streakData(userId), { + count: newStreak.toString(), + lastDate: today, + longest: longestStreak.toString(), + }); + pipeline.expire(REDIS_KEYS.streakData(userId), STREAK_TTL_SECONDS * 2); + } + + // 3. Update weekly leaderboard score + pipeline.zincrby(REDIS_KEYS.weeklyLeaderboard('time'), sessionDuration, userId); + + // 4. Update network activity (1-minute bucket for heatmap) + const minute = Math.floor(Date.now() / 60000); + pipeline.hincrby(REDIS_KEYS.networkIntensity(minute), 'count', 1); + pipeline.expire(REDIS_KEYS.networkIntensity(minute), NETWORK_ACTIVITY_TTL_SECONDS); + + // 5. Track language if provided + if (language) { + pipeline.hincrby(REDIS_KEYS.networkIntensity(minute), `lang:${language}`, 1); + } + + await pipeline.exec(); + + // Check for streak achievements (async, fire-and-forget) + if (shouldCheckStreakAchievements) { + void checkStreakAchievements(userId, newStreak); + } + + logger.debug({ userId, sessionDuration, language, project }, 'Recorded session'); + + return reply.send({ data: { recorded: true } }); + }); + + /** + * GET /stats/weekly - Get weekly statistics with real-time data + */ + app.get('/weekly', { onRequest: [app.authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + const redis = getRedis(); + + // Get current week stats from DB + const weekStart = getWeekStart(); + const weeklyStatsRecord = await db.weeklyStats.findUnique({ + where: { userId_weekStart: { userId, weekStart } }, + }); + + // Get real-time Redis data + const [liveScore, rank] = await Promise.all([ + redis.zscore(REDIS_KEYS.weeklyLeaderboard('time'), userId), + redis.zrevrank(REDIS_KEYS.weeklyLeaderboard('time'), userId), + ]); + + const weeklyStats: WeeklyStatsDTO = { + weekStart: weekStart.toISOString(), + totalSeconds: liveScore ? parseInt(liveScore, 10) : (weeklyStatsRecord?.totalSeconds ?? 0), + totalSessions: weeklyStatsRecord?.totalSessions ?? 0, + totalCommits: weeklyStatsRecord?.totalCommits ?? 0, + topLanguage: weeklyStatsRecord?.topLanguage ?? null, + topProject: weeklyStatsRecord?.topProject ?? null, + rank: rank !== null ? rank + 1 : undefined, + }; + + return reply.send({ data: weeklyStats }); + }); + + /** + * GET /stats/achievements - Get all user achievements + */ + app.get('/achievements', { onRequest: [app.authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + + const achievementRecords = await db.achievement.findMany({ + where: { userId }, + orderBy: { earnedAt: 'desc' }, + }); + + const achievements: AchievementDTO[] = achievementRecords.map((a) => ({ + id: a.id, + type: a.type as AchievementDTO['type'], + title: a.title, + description: a.description, + earnedAt: a.earnedAt.toISOString(), + })); + + return reply.send({ data: achievements }); + }); + + /** + * Check and award streak achievements. + */ + async function checkStreakAchievements(userId: string, streak: number): Promise { + const milestones = [ + { streak: 7, type: 'STREAK_7' as const }, + { streak: 30, type: 'STREAK_30' as const }, + { streak: 100, type: 'STREAK_100' as const }, + ]; + + for (const milestone of milestones) { + if (streak === milestone.streak) { + // Check if already earned + const existing = await db.achievement.findFirst({ + where: { userId, type: milestone.type }, + }); + + if (!existing) { + const achievementDef = ACHIEVEMENTS[milestone.type]; + + const achievement = await db.achievement.create({ + data: { + userId, + type: milestone.type, + title: achievementDef.title, + description: achievementDef.description, + }, + }); + + // Get user info for broadcast + const user = await db.user.findUnique({ + where: { id: userId }, + select: { + username: true, + followers: { select: { followerId: true } }, + }, + }); + + if (user) { + const followerIds = user.followers.map((f) => f.followerId); + + broadcastToUsers([...followerIds, userId], 'ACHIEVEMENT', { + achievement: { + id: achievement.id, + type: milestone.type, + title: achievementDef.title, + description: achievementDef.description, + earnedAt: achievement.earnedAt.toISOString(), + }, + userId, + username: user.username, + }); + + logger.info({ userId, streak, type: milestone.type }, 'Streak achievement earned'); + } + } + break; // Only one achievement per streak update + } + } + } +} From 55faca0a275e5693ea61917085b77624bfcbba67 Mon Sep 17 00:00:00 2001 From: Utpal Date: Fri, 9 Jan 2026 15:27:44 +0530 Subject: [PATCH 06/23] feat(server): add leaderboards routes with Redis Sorted Sets --- apps/server/src/routes/leaderboards.ts | 375 +++++++++++++++++++++++++ 1 file changed, 375 insertions(+) create mode 100644 apps/server/src/routes/leaderboards.ts diff --git a/apps/server/src/routes/leaderboards.ts b/apps/server/src/routes/leaderboards.ts new file mode 100644 index 0000000..10dccef --- /dev/null +++ b/apps/server/src/routes/leaderboards.ts @@ -0,0 +1,375 @@ +/** + * Leaderboards Routes + * + * Real-time leaderboards using Redis Sorted Sets. + * + * Best Practices Implemented: + * - ZREVRANGE with pagination (never fetch all 0 -1) + * - ZREVRANK for efficient O(log N) rank lookup + * - Cache headers for client-side caching + * - Friends-only filtering with efficient score fetching + */ + +import { REDIS_KEYS, NETWORK_HOT_THRESHOLD } from '@devradar/shared'; +import { z } from 'zod'; + +import type { LeaderboardEntry, NetworkActivity } from '@devradar/shared'; +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + +import { getDb } from '@/services/db'; +import { getRedis } from '@/services/redis'; + +/** Pagination query schema. */ +const PaginationSchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(10), +}); + +/** User info for leaderboard display. */ +interface UserInfo { + id: string; + username: string; + displayName: string | null; + avatarUrl: string | null; +} + +/** Registers leaderboard routes on the Fastify instance. */ +export function leaderboardRoutes(app: FastifyInstance): void { + const db = getDb(); + + /** + * GET /leaderboards/weekly/time - Top users by coding time this week + */ + app.get('/weekly/time', { onRequest: [app.authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + const { page, limit } = PaginationSchema.parse(request.query); + const redis = getRedis(); + + const start = (page - 1) * limit; + const end = start + limit - 1; + + // Get leaderboard entries with scores (WITHSCORES returns alternating member, score) + const entries = await redis.zrevrange(REDIS_KEYS.weeklyLeaderboard('time'), start, end, 'WITHSCORES'); + + // Get total count for pagination + const total = await redis.zcard(REDIS_KEYS.weeklyLeaderboard('time')); + + // Get current user's rank + const myRank = await redis.zrevrank(REDIS_KEYS.weeklyLeaderboard('time'), userId); + + // Extract user IDs from entries (even indices: 0, 2, 4...) + const userIds: string[] = []; + for (let i = 0; i < entries.length; i += 2) { + const entryUserId = entries[i]; + if (entryUserId) { + userIds.push(entryUserId); + } + } + + if (userIds.length === 0) { + return reply.send({ + data: { + leaderboard: [], + myRank: myRank !== null ? myRank + 1 : null, + pagination: { page, limit, total, hasMore: false }, + }, + }); + } + + // Fetch user details + const users = await db.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, username: true, displayName: true, avatarUrl: true }, + }); + + const userMap = new Map(users.map((u) => [u.id, u])); + + // Get user's friends for "isFriend" flag + const friends = await db.follow.findMany({ + where: { followerId: userId }, + select: { followingId: true }, + }); + const friendIds = new Set(friends.map((f) => f.followingId)); + + // Build leaderboard entries + const leaderboard: LeaderboardEntry[] = userIds.map((id, index) => { + const user = userMap.get(id); + const scoreStr = entries[index * 2 + 1]; + const score = scoreStr ? parseInt(scoreStr, 10) : 0; + + return { + rank: start + index + 1, + userId: id, + username: user?.username ?? 'Unknown', + displayName: user?.displayName ?? null, + avatarUrl: user?.avatarUrl ?? null, + score, + isFriend: friendIds.has(id), + }; + }); + + // Set cache headers (1 minute cache for non-real-time efficiency) + reply.header('Cache-Control', 'public, max-age=60'); + + return reply.send({ + data: { + leaderboard, + myRank: myRank !== null ? myRank + 1 : null, + pagination: { + page, + limit, + total, + hasMore: start + limit < total, + }, + }, + }); + }); + + /** + * GET /leaderboards/weekly/commits - Top users by commit count this week + */ + app.get('/weekly/commits', { onRequest: [app.authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + const { page, limit } = PaginationSchema.parse(request.query); + const redis = getRedis(); + + const start = (page - 1) * limit; + const end = start + limit - 1; + + // Get leaderboard entries with scores + const entries = await redis.zrevrange(REDIS_KEYS.weeklyLeaderboard('commits'), start, end, 'WITHSCORES'); + const total = await redis.zcard(REDIS_KEYS.weeklyLeaderboard('commits')); + const myRank = await redis.zrevrank(REDIS_KEYS.weeklyLeaderboard('commits'), userId); + + // Extract user IDs + const userIds: string[] = []; + for (let i = 0; i < entries.length; i += 2) { + const entryUserId = entries[i]; + if (entryUserId) { + userIds.push(entryUserId); + } + } + + if (userIds.length === 0) { + return reply.send({ + data: { + leaderboard: [], + myRank: myRank !== null ? myRank + 1 : null, + pagination: { page, limit, total, hasMore: false }, + }, + }); + } + + // Fetch user details and friends + const [users, friends] = await Promise.all([ + db.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, username: true, displayName: true, avatarUrl: true }, + }), + db.follow.findMany({ + where: { followerId: userId }, + select: { followingId: true }, + }), + ]); + + const userMap = new Map(users.map((u) => [u.id, u])); + const friendIds = new Set(friends.map((f) => f.followingId)); + + // Build leaderboard entries + const leaderboard: LeaderboardEntry[] = userIds.map((id, index) => { + const user = userMap.get(id); + const scoreStr = entries[index * 2 + 1]; + const score = scoreStr ? parseInt(scoreStr, 10) : 0; + + return { + rank: start + index + 1, + userId: id, + username: user?.username ?? 'Unknown', + displayName: user?.displayName ?? null, + avatarUrl: user?.avatarUrl ?? null, + score, + isFriend: friendIds.has(id), + }; + }); + + reply.header('Cache-Control', 'public, max-age=60'); + + return reply.send({ + data: { + leaderboard, + myRank: myRank !== null ? myRank + 1 : null, + pagination: { page, limit, total, hasMore: start + limit < total }, + }, + }); + }); + + /** + * GET /leaderboards/friends - Friends-only leaderboard by coding time + */ + app.get('/friends', { onRequest: [app.authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + const redis = getRedis(); + + // Get user's friends + const friends = await db.follow.findMany({ + where: { followerId: userId }, + select: { followingId: true }, + }); + const friendIds = friends.map((f) => f.followingId); + + // Include self in the leaderboard + const allIds = [...friendIds, userId]; + + if (allIds.length === 1) { + // Only self, get own score + const myScore = await redis.zscore(REDIS_KEYS.weeklyLeaderboard('time'), userId); + const myUser = await db.user.findUnique({ + where: { id: userId }, + select: { id: true, username: true, displayName: true, avatarUrl: true }, + }); + + if (!myUser || !myScore) { + return reply.send({ data: { leaderboard: [], myRank: null } }); + } + + return reply.send({ + data: { + leaderboard: [ + { + rank: 1, + userId, + username: myUser.username, + displayName: myUser.displayName, + avatarUrl: myUser.avatarUrl, + score: parseInt(myScore, 10), + isFriend: false, + }, + ], + myRank: 1, + }, + }); + } + + // Get scores for all friends using pipeline + const pipeline = redis.pipeline(); + for (const id of allIds) { + pipeline.zscore(REDIS_KEYS.weeklyLeaderboard('time'), id); + } + const results = await pipeline.exec(); + + // Build score map + const scoreMap: { userId: string; score: number }[] = []; + for (let i = 0; i < allIds.length; i++) { + const id = allIds[i]; + const result = results?.[i]; + const score = result?.[1] ? parseInt(result[1] as string, 10) : 0; + if (id && score > 0) { + scoreMap.push({ userId: id, score }); + } + } + + // Sort by score descending + scoreMap.sort((a, b) => b.score - a.score); + + if (scoreMap.length === 0) { + return reply.send({ data: { leaderboard: [], myRank: null } }); + } + + // Get user details + const users = await db.user.findMany({ + where: { id: { in: scoreMap.map((s) => s.userId) } }, + select: { id: true, username: true, displayName: true, avatarUrl: true }, + }); + + const userMap = new Map(users.map((u) => [u.id, u])); + + // Build leaderboard + const leaderboard: LeaderboardEntry[] = scoreMap.map((s, index) => { + const user = userMap.get(s.userId); + return { + rank: index + 1, + userId: s.userId, + username: user?.username ?? 'Unknown', + displayName: user?.displayName ?? null, + avatarUrl: user?.avatarUrl ?? null, + score: s.score, + isFriend: s.userId !== userId, + }; + }); + + const myRank = leaderboard.findIndex((e) => e.userId === userId) + 1; + + return reply.send({ + data: { + leaderboard, + myRank: myRank > 0 ? myRank : null, + }, + }); + }); + + /** + * GET /leaderboards/network-activity - Current network activity (heatmap data) + */ + app.get('/network-activity', { onRequest: [app.authenticate] }, async (_request: FastifyRequest, reply: FastifyReply) => { + const redis = getRedis(); + const currentMinute = Math.floor(Date.now() / 60000); + + // Get last 5 minutes of activity using pipeline + const pipeline = redis.pipeline(); + for (let i = 0; i < 5; i++) { + pipeline.hgetall(REDIS_KEYS.networkIntensity(currentMinute - i)); + } + const results = await pipeline.exec(); + + // Aggregate active users and languages + let totalActiveUsers = 0; + const languageCounts = new Map(); + + if (results) { + for (const result of results) { + const data = result[1] as Record | null; + if (data) { + if (data.count) { + totalActiveUsers += parseInt(data.count, 10); + } + // Aggregate language counts + for (const [key, value] of Object.entries(data)) { + if (key.startsWith('lang:')) { + const lang = key.slice(5); + languageCounts.set(lang, (languageCounts.get(lang) ?? 0) + parseInt(value, 10)); + } + } + } + } + } + + // Calculate intensity (0-100 scale) + const averageIntensity = Math.min(100, totalActiveUsers * 10); + const isHot = totalActiveUsers >= NETWORK_HOT_THRESHOLD; + + // Get top languages + const topLanguages = Array.from(languageCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([language, count]) => ({ language, count })); + + const networkActivity: NetworkActivity = { + totalActiveUsers, + averageIntensity, + isHot, + message: isHot + ? 'Your network is 🔥 active right now!' + : `${String(totalActiveUsers)} developer${totalActiveUsers !== 1 ? 's' : ''} coding`, + }; + + // Short cache for real-time feel + reply.header('Cache-Control', 'public, max-age=10'); + + return reply.send({ + data: { + ...networkActivity, + topLanguages, + }, + }); + }); +} From 6825fb17bbb218636d5dde8b232bbbf8f53c167d Mon Sep 17 00:00:00 2001 From: Utpal Date: Fri, 9 Jan 2026 15:28:05 +0530 Subject: [PATCH 07/23] feat(server): add GitHub webhook handler with HMAC-SHA256 verification --- apps/server/src/routes/webhooks.ts | 348 +++++++++++++++++++++++++++++ 1 file changed, 348 insertions(+) create mode 100644 apps/server/src/routes/webhooks.ts diff --git a/apps/server/src/routes/webhooks.ts b/apps/server/src/routes/webhooks.ts new file mode 100644 index 0000000..1fce939 --- /dev/null +++ b/apps/server/src/routes/webhooks.ts @@ -0,0 +1,348 @@ +/** + * Webhooks Routes + * + * GitHub webhook handler for "Boss Battles" (achievement system). + * + * Security Best Practices: + * - HMAC-SHA256 signature verification (X-Hub-Signature-256) + * - Constant-time comparison (crypto.timingSafeEqual) + * - Raw body access before JSON parsing + * - Idempotency protection via unique constraints + */ + +import crypto from 'crypto'; + +import { ACHIEVEMENTS, REDIS_KEYS } from '@devradar/shared'; + +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + +import { env } from '@/config'; +import { logger } from '@/lib/logger'; +import { getDb } from '@/services/db'; +import { getRedis } from '@/services/redis'; +import { broadcastToUsers } from '@/ws/handler'; + +/** GitHub Issues webhook payload structure. */ +interface GitHubIssuesPayload { + action: string; + issue: { + number: number; + title: string; + html_url: string; + }; + sender: { + id: number; + login: string; + }; + repository: { + full_name: string; + }; +} + +/** GitHub Push webhook payload structure. */ +interface GitHubPushPayload { + commits: { + id: string; + message: string; + author: { + name: string; + email: string; + }; + }[]; + pusher: { + name: string; + email: string; + }; + sender: { + id: number; + login: string; + }; + repository: { + full_name: string; + }; +} + +/** GitHub Pull Request webhook payload structure. */ +interface GitHubPullRequestPayload { + action: string; + pull_request: { + number: number; + title: string; + html_url: string; + merged: boolean; + }; + sender: { + id: number; + login: string; + }; + repository: { + full_name: string; + }; +} + +/** + * Verify GitHub webhook signature using HMAC-SHA256. + * Returns true if valid, false otherwise. + */ +function verifyGitHubSignature(rawBody: Buffer, signature: string, secret: string): boolean { + // Calculate expected signature + const expectedSignature = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); + + // Constant-time comparison to prevent timing attacks + const signatureBuffer = Buffer.from(signature); + const expectedBuffer = Buffer.from(expectedSignature); + + if (signatureBuffer.length !== expectedBuffer.length) { + return false; + } + + return crypto.timingSafeEqual(signatureBuffer, expectedBuffer); +} + +/** Registers webhook routes on the Fastify instance. */ +export function webhookRoutes(app: FastifyInstance): void { + const db = getDb(); + + /** + * POST /webhooks/github - GitHub webhook handler + * + * Events handled: + * - issues (closed) → ISSUE_CLOSED achievement + * - pull_request (merged) → PR_MERGED achievement + * - push → Increment commit count in leaderboard + */ + app.post( + '/github', + { + config: { + // Skip rate limiting for webhooks (GitHub has its own retry logic) + rateLimit: false, + }, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + // 1. Get raw body for signature verification + // Note: Fastify should give us access to rawBody via request.rawBody or we parse it + // For now, we'll re-serialize since Fastify already parsed it + const bodyString = JSON.stringify(request.body); + const rawBody = Buffer.from(bodyString, 'utf8'); + + // 2. Verify signature + const signature = request.headers['x-hub-signature-256'] as string | undefined; + const event = request.headers['x-github-event'] as string | undefined; + const deliveryId = request.headers['x-github-delivery'] as string | undefined; + + if (!signature || !event) { + logger.warn({ deliveryId }, 'Missing GitHub webhook headers'); + return reply.status(401).send({ error: 'Missing signature or event header' }); + } + + const secret = env.GITHUB_WEBHOOK_SECRET; + + if (!secret) { + logger.error('GITHUB_WEBHOOK_SECRET not configured'); + return reply.status(500).send({ error: 'Webhook secret not configured' }); + } + + // Verify the signature + if (!verifyGitHubSignature(rawBody, signature, secret)) { + logger.warn({ deliveryId }, 'Invalid GitHub webhook signature'); + return reply.status(401).send({ error: 'Invalid signature' }); + } + + logger.info({ event, deliveryId }, 'Processing GitHub webhook'); + + // 3. Handle events + const payload = request.body as Record; + + try { + switch (event) { + case 'issues': + await handleIssuesEvent(payload as unknown as GitHubIssuesPayload); + break; + + case 'pull_request': + await handlePullRequestEvent(payload as unknown as GitHubPullRequestPayload); + break; + + case 'push': + await handlePushEvent(payload as unknown as GitHubPushPayload); + break; + + case 'ping': + logger.info({ deliveryId }, 'GitHub webhook ping received'); + break; + + default: + logger.debug({ event, deliveryId }, 'Unhandled GitHub event type'); + } + } catch (error) { + logger.error({ error, event, deliveryId }, 'Error processing GitHub webhook'); + // Still return 200 to avoid GitHub retries for app errors + } + + return reply.send({ received: true }); + } + ); + + /** + * Handle issues event - Award achievements for closed issues + */ + async function handleIssuesEvent(payload: GitHubIssuesPayload): Promise { + if (payload.action !== 'closed') { + return; + } + + const { sender, issue, repository } = payload; + + // Find user by GitHub ID + const user = await db.user.findUnique({ + where: { githubId: String(sender.id) }, + select: { + id: true, + username: true, + displayName: true, + followers: { select: { followerId: true } }, + }, + }); + + if (!user) { + logger.debug({ githubId: sender.id }, 'User not found for GitHub webhook'); + return; + } + + // Create achievement + const achievementDef = ACHIEVEMENTS.ISSUE_CLOSED; + + try { + const achievement = await db.achievement.create({ + data: { + userId: user.id, + type: 'ISSUE_CLOSED', + title: achievementDef.title, + description: `Closed issue #${String(issue.number)} in ${repository.full_name}`, + metadata: { + issueNumber: issue.number, + issueTitle: issue.title, + issueUrl: issue.html_url, + repository: repository.full_name, + }, + }, + }); + + logger.info({ userId: user.id, achievementId: achievement.id }, 'Bug Slayer achievement earned'); + + // Broadcast to followers and self + const followerIds = user.followers.map((f) => f.followerId); + + broadcastToUsers([...followerIds, user.id], 'ACHIEVEMENT', { + achievement: { + id: achievement.id, + type: 'ISSUE_CLOSED', + title: achievementDef.title, + description: achievement.description, + earnedAt: achievement.earnedAt.toISOString(), + }, + userId: user.id, + username: user.username, + }); + } catch (error) { + // Likely a duplicate - that's OK + logger.debug({ error, userId: user.id }, 'Failed to create achievement (possibly duplicate)'); + } + } + + /** + * Handle pull_request event - Award achievements for merged PRs + */ + async function handlePullRequestEvent(payload: GitHubPullRequestPayload): Promise { + // Only handle merged PRs + if (payload.action !== 'closed' || !payload.pull_request.merged) { + return; + } + + const { sender, pull_request, repository } = payload; + + // Find user by GitHub ID + const user = await db.user.findUnique({ + where: { githubId: String(sender.id) }, + select: { + id: true, + username: true, + displayName: true, + followers: { select: { followerId: true } }, + }, + }); + + if (!user) { + logger.debug({ githubId: sender.id }, 'User not found for GitHub webhook'); + return; + } + + // Create achievement + const achievementDef = ACHIEVEMENTS.PR_MERGED; + + try { + const achievement = await db.achievement.create({ + data: { + userId: user.id, + type: 'PR_MERGED', + title: achievementDef.title, + description: `Merged PR #${String(pull_request.number)} in ${repository.full_name}`, + metadata: { + prNumber: pull_request.number, + prTitle: pull_request.title, + prUrl: pull_request.html_url, + repository: repository.full_name, + }, + }, + }); + + logger.info({ userId: user.id, achievementId: achievement.id }, 'Merge Master achievement earned'); + + // Broadcast to followers and self + const followerIds = user.followers.map((f) => f.followerId); + + broadcastToUsers([...followerIds, user.id], 'ACHIEVEMENT', { + achievement: { + id: achievement.id, + type: 'PR_MERGED', + title: achievementDef.title, + description: achievement.description, + earnedAt: achievement.earnedAt.toISOString(), + }, + userId: user.id, + username: user.username, + }); + } catch (error) { + logger.debug({ error, userId: user.id }, 'Failed to create achievement (possibly duplicate)'); + } + } + + /** + * Handle push event - Track commit counts for leaderboard + */ + async function handlePushEvent(payload: GitHubPushPayload): Promise { + const { sender, commits } = payload; + const commitCount = commits.length; + + if (commitCount === 0) { + return; + } + + // Find user by GitHub ID + const user = await db.user.findUnique({ + where: { githubId: String(sender.id) }, + select: { id: true }, + }); + + if (!user) { + return; + } + + // Update commit leaderboard + const redis = getRedis(); + await redis.zincrby(REDIS_KEYS.weeklyLeaderboard('commits'), commitCount, user.id); + + logger.debug({ userId: user.id, commitCount }, 'Recorded commits from GitHub push'); + } +} From 2590986442fb5b5052572172e925091cf79e53d0 Mon Sep 17 00:00:00 2001 From: Utpal Date: Fri, 9 Jan 2026 15:28:26 +0530 Subject: [PATCH 08/23] feat(server): register stats, leaderboards, and webhooks routes --- apps/server/src/server.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 34b69d4..72335f0 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -24,7 +24,10 @@ import { logger } from '@/lib/logger'; import { authRoutes } from '@/routes/auth'; import { friendRequestRoutes } from '@/routes/friendRequests'; import { friendRoutes } from '@/routes/friends'; +import { leaderboardRoutes } from '@/routes/leaderboards'; +import { statsRoutes } from '@/routes/stats'; import { userRoutes } from '@/routes/users'; +import { webhookRoutes } from '@/routes/webhooks'; import { connectDb, disconnectDb, isDbHealthy } from '@/services/db'; import { connectRedis, disconnectRedis, isRedisHealthy } from '@/services/redis'; import { registerWebSocketHandler, getConnectionCount } from '@/ws/handler'; @@ -197,12 +200,17 @@ async function buildServer() { api.register(userRoutes, { prefix: '/users' }); api.register(friendRoutes, { prefix: '/friends' }); api.register(friendRequestRoutes, { prefix: '/friend-requests' }); + // Phase 2: Gamification routes + api.register(statsRoutes, { prefix: '/stats' }); + api.register(leaderboardRoutes, { prefix: '/leaderboards' }); done(); }, { prefix: '/api/v1' } ); /* Auth routes at root for OAuth redirects (GITHUB_CALLBACK_URL should use /auth/callback) */ app.register(authRoutes, { prefix: '/auth' }); + /* Webhooks at root for external services (GitHub) */ + app.register(webhookRoutes, { prefix: '/webhooks' }); registerWebSocketHandler(app); return app; From d45191eae23ad96cf50c9c9a44f134787c043c2f Mon Sep 17 00:00:00 2001 From: Utpal Date: Fri, 9 Jan 2026 15:28:41 +0530 Subject: [PATCH 09/23] feat(extension): add StatsProvider for streak and session display --- apps/extension/src/views/statsProvider.ts | 165 ++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 apps/extension/src/views/statsProvider.ts diff --git a/apps/extension/src/views/statsProvider.ts b/apps/extension/src/views/statsProvider.ts new file mode 100644 index 0000000..ef4b9ca --- /dev/null +++ b/apps/extension/src/views/statsProvider.ts @@ -0,0 +1,165 @@ +/** + * Stats Tree View Provider + * + * Displays user's current streak, session time, and weekly stats + * in the DevRadar sidebar. + */ + +import * as vscode from 'vscode'; + +import type { Logger } from '../utils/logger'; +import type { StreakInfo, WeeklyStatsDTO, AchievementDTO } from '@devradar/shared'; + +/** Stats data structure matching API response. */ +export interface StatsData { + streak: StreakInfo; + todaySession: number; + weeklyStats: WeeklyStatsDTO | null; + recentAchievements: AchievementDTO[]; +} + +/** Tree item for stats display. */ +class StatsTreeItem extends vscode.TreeItem { + constructor( + label: string, + description: string, + icon: string, + collapsibleState = vscode.TreeItemCollapsibleState.None + ) { + super(label, collapsibleState); + this.description = description; + this.iconPath = new vscode.ThemeIcon(icon); + } +} + +/** Tree data provider for the stats sidebar view. */ +export class StatsProvider implements vscode.TreeDataProvider, vscode.Disposable { + private readonly disposables: vscode.Disposable[] = []; + private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter(); + private stats: StatsData | null = null; + private isLoading = true; + + readonly onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event; + + constructor(private readonly logger: Logger) { + this.disposables.push(this.onDidChangeTreeDataEmitter); + } + + getTreeItem(element: StatsTreeItem): vscode.TreeItem { + return element; + } + + getChildren(): StatsTreeItem[] { + if (this.isLoading && !this.stats) { + return [new StatsTreeItem('Loading...', '', 'loading~spin')]; + } + + if (!this.stats) { + return [new StatsTreeItem('No stats available', 'Start coding to track stats', 'info')]; + } + + const items: StatsTreeItem[] = []; + + // === Streak Display === + const { currentStreak, isActiveToday, streakStatus, longestStreak } = this.stats.streak; + const streakEmoji = currentStreak >= 30 ? '🏆' : currentStreak >= 7 ? '🔥' : '⚡'; + const statusIcon = isActiveToday + ? 'check' + : streakStatus === 'at_risk' + ? 'warning' + : 'circle-outline'; + + const streakDescription = isActiveToday + ? '✓ Active today!' + : streakStatus === 'at_risk' + ? '⚠ Code today to continue' + : 'Start coding!'; + + items.push(new StatsTreeItem(`${streakEmoji} ${String(currentStreak)} Day Streak`, streakDescription, statusIcon)); + + // Show longest streak if different + if (longestStreak > currentStreak && longestStreak > 0) { + items.push(new StatsTreeItem(`Best: ${String(longestStreak)} days`, '', 'star-empty')); + } + + // === Today's Session === + const todayHours = Math.floor(this.stats.todaySession / 3600); + const todayMinutes = Math.floor((this.stats.todaySession % 3600) / 60); + const todayDisplay = + todayHours > 0 ? `${String(todayHours)}h ${String(todayMinutes)}m` : `${String(todayMinutes)}m`; + + items.push(new StatsTreeItem(`Today: ${todayDisplay}`, 'Coding time', 'clock')); + + // === Weekly Stats === + if (this.stats.weeklyStats) { + const weekHours = Math.floor(this.stats.weeklyStats.totalSeconds / 3600); + const weekMinutes = Math.floor((this.stats.weeklyStats.totalSeconds % 3600) / 60); + const weekDisplay = weekHours > 0 ? `${String(weekHours)}h ${String(weekMinutes)}m` : `${String(weekMinutes)}m`; + + const rankDisplay = this.stats.weeklyStats.rank ? `#${String(this.stats.weeklyStats.rank)}` : ''; + + items.push(new StatsTreeItem(`This Week: ${weekDisplay}`, rankDisplay, 'graph')); + + // Top language if available + if (this.stats.weeklyStats.topLanguage) { + items.push( + new StatsTreeItem( + `Top: ${this.stats.weeklyStats.topLanguage}`, + '', + 'symbol-keyword' + ) + ); + } + } + + // === Recent Achievement === + if (this.stats.recentAchievements.length > 0) { + const latestAchievement = this.stats.recentAchievements[0]; + if (latestAchievement) { + items.push( + new StatsTreeItem( + latestAchievement.title, + 'Latest achievement', + 'trophy' + ) + ); + } + } + + return items; + } + + /** Updates the stats data and triggers a tree refresh. */ + updateStats(stats: StatsData): void { + this.stats = stats; + this.isLoading = false; + this.onDidChangeTreeDataEmitter.fire(undefined); + this.logger.debug('Stats updated', { streak: stats.streak.currentStreak }); + } + + /** Set loading state. */ + setLoading(loading: boolean): void { + this.isLoading = loading; + if (loading) { + this.onDidChangeTreeDataEmitter.fire(undefined); + } + } + + /** Clear stats (e.g., on logout). */ + clear(): void { + this.stats = null; + this.isLoading = false; + this.onDidChangeTreeDataEmitter.fire(undefined); + } + + /** Refresh the view. */ + refresh(): void { + this.onDidChangeTreeDataEmitter.fire(undefined); + } + + dispose(): void { + for (const disposable of this.disposables) { + disposable.dispose(); + } + } +} From 771ecc7a44d14c0eafd3a61980b183acb9a4d23b Mon Sep 17 00:00:00 2001 From: Utpal Date: Fri, 9 Jan 2026 15:28:54 +0530 Subject: [PATCH 10/23] feat(extension): add LeaderboardProvider for mini-leaderboard view --- .../src/views/leaderboardProvider.ts | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 apps/extension/src/views/leaderboardProvider.ts diff --git a/apps/extension/src/views/leaderboardProvider.ts b/apps/extension/src/views/leaderboardProvider.ts new file mode 100644 index 0000000..a0909a3 --- /dev/null +++ b/apps/extension/src/views/leaderboardProvider.ts @@ -0,0 +1,185 @@ +/** + * Mini-Leaderboard Tree View Provider + * + * Displays a compact friends leaderboard in the DevRadar sidebar + * showing top friends by weekly coding time. + */ + +import * as vscode from 'vscode'; + +import type { Logger } from '../utils/logger'; +import type { LeaderboardEntry } from '@devradar/shared'; + +/** Tree item for leaderboard entries. */ +class LeaderboardTreeItem extends vscode.TreeItem { + constructor(entry: LeaderboardEntry, showMedal = true) { + const medal = showMedal + ? entry.rank === 1 + ? '🥇 ' + : entry.rank === 2 + ? '🥈 ' + : entry.rank === 3 + ? '🥉 ' + : `#${String(entry.rank)} ` + : ''; + + const displayName = entry.displayName ?? entry.username; + super(`${medal}${displayName}`, vscode.TreeItemCollapsibleState.None); + + this.id = `leaderboard-${entry.userId}`; + this.description = this.formatScore(entry.score); + this.tooltip = this.buildTooltip(entry); + this.iconPath = new vscode.ThemeIcon(entry.isFriend ? 'person' : 'account'); + this.contextValue = 'leaderboard-entry'; + + // Click to view profile + this.command = { + command: 'devradar.viewProfile', + title: 'View Profile', + arguments: [{ userId: entry.userId, username: entry.username }], + }; + } + + /** Format score (seconds) as human-readable duration. */ + private formatScore(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + if (hours > 0) { + return `${String(hours)}h ${String(minutes)}m`; + } + return `${String(minutes)}m`; + } + + /** Build rich tooltip with details. */ + private buildTooltip(entry: LeaderboardEntry): vscode.MarkdownString { + const md = new vscode.MarkdownString(); + md.isTrusted = true; + + md.appendMarkdown(`### ${entry.displayName ?? entry.username}\n\n`); + md.appendMarkdown(`**@${entry.username}**\n\n`); + md.appendMarkdown(`📊 **Rank:** #${String(entry.rank)}\n\n`); + md.appendMarkdown(`⏱️ **This Week:** ${this.formatScore(entry.score)}\n\n`); + + if (entry.isFriend) { + md.appendMarkdown(`👥 *Friend*\n\n`); + } + + return md; + } +} + +/** Header item for the leaderboard section. */ +class LeaderboardHeaderItem extends vscode.TreeItem { + constructor(title: string, count: number) { + super(title, vscode.TreeItemCollapsibleState.Expanded); + + this.id = 'leaderboard-header'; + this.description = count > 0 ? `${String(count)} entries` : ''; + this.iconPath = new vscode.ThemeIcon('trophy'); + this.contextValue = 'leaderboard-header'; + } +} + +/** Info item when no data is available. */ +class LeaderboardInfoItem extends vscode.TreeItem { + constructor(message: string, icon = 'info') { + super(message, vscode.TreeItemCollapsibleState.None); + this.iconPath = new vscode.ThemeIcon(icon); + } +} + +/** Tree data provider for the mini-leaderboard view. */ +export class LeaderboardProvider + implements vscode.TreeDataProvider, vscode.Disposable +{ + private readonly disposables: vscode.Disposable[] = []; + private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter< + LeaderboardTreeItem | LeaderboardHeaderItem | LeaderboardInfoItem | undefined + >(); + + private leaderboard: LeaderboardEntry[] = []; + private myRank: number | null = null; + private isLoading = true; + + readonly onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event; + + constructor(private readonly logger: Logger) { + this.disposables.push(this.onDidChangeTreeDataEmitter); + } + + getTreeItem(element: LeaderboardTreeItem | LeaderboardHeaderItem | LeaderboardInfoItem): vscode.TreeItem { + return element; + } + + getChildren( + element?: LeaderboardTreeItem | LeaderboardHeaderItem | LeaderboardInfoItem + ): (LeaderboardTreeItem | LeaderboardHeaderItem | LeaderboardInfoItem)[] { + // Loading state + if (this.isLoading && this.leaderboard.length === 0) { + return [new LeaderboardInfoItem('Loading leaderboard...', 'loading~spin')]; + } + + // Empty state + if (this.leaderboard.length === 0) { + return [ + new LeaderboardInfoItem('No leaderboard data yet', 'info'), + new LeaderboardInfoItem('Start coding to appear!', 'rocket'), + ]; + } + + // If element is the header, return leaderboard entries + if (element instanceof LeaderboardHeaderItem) { + return this.leaderboard.map((entry) => new LeaderboardTreeItem(entry)); + } + + // Root level: return header and "Your rank" sections + const items: (LeaderboardTreeItem | LeaderboardHeaderItem | LeaderboardInfoItem)[] = []; + + // Your rank indicator (if you're on the leaderboard) + if (this.myRank !== null) { + items.push(new LeaderboardInfoItem(`📍 Your rank: #${String(this.myRank)}`, 'location')); + } + + // Leaderboard header with entries as children + items.push(new LeaderboardHeaderItem('🏆 This Week', this.leaderboard.length)); + + return items; + } + + /** Updates the leaderboard data. */ + updateLeaderboard(entries: LeaderboardEntry[], myRank: number | null): void { + this.leaderboard = entries.slice(0, 10); // Top 10 + this.myRank = myRank; + this.isLoading = false; + this.onDidChangeTreeDataEmitter.fire(undefined); + this.logger.debug('Leaderboard updated', { count: entries.length, myRank }); + } + + /** Set loading state. */ + setLoading(loading: boolean): void { + this.isLoading = loading; + if (loading) { + this.onDidChangeTreeDataEmitter.fire(undefined); + } + } + + /** Clear leaderboard data. */ + clear(): void { + this.leaderboard = []; + this.myRank = null; + this.isLoading = false; + this.onDidChangeTreeDataEmitter.fire(undefined); + } + + /** Refresh the view. */ + refresh(): void { + this.onDidChangeTreeDataEmitter.fire(undefined); + } + + dispose(): void { + for (const disposable of this.disposables) { + disposable.dispose(); + } + } +} From de0e09740c2aca3f6893eb2333a6726eafd12aa1 Mon Sep 17 00:00:00 2001 From: Utpal Date: Fri, 9 Jan 2026 15:29:23 +0530 Subject: [PATCH 11/23] feat(extension): export new StatsProvider and LeaderboardProvider --- apps/extension/src/views/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/extension/src/views/index.ts b/apps/extension/src/views/index.ts index 164d455..0482675 100644 --- a/apps/extension/src/views/index.ts +++ b/apps/extension/src/views/index.ts @@ -2,3 +2,6 @@ export { FriendsProvider, type FriendInfo } from './friendsProvider'; export { FriendRequestsProvider } from './friendRequestsProvider'; export { ActivityProvider, type ActivityEvent } from './activityProvider'; export { StatusBarManager } from './statusBarItem'; +export { StatsProvider, type StatsData } from './statsProvider'; +export { LeaderboardProvider } from './leaderboardProvider'; + From 766090147d7ca65aa9bd583a135b25754a8019c0 Mon Sep 17 00:00:00 2001 From: Utpal Date: Fri, 9 Jan 2026 15:29:39 +0530 Subject: [PATCH 12/23] feat(extension): integrate gamification views and stats fetching --- apps/extension/src/extension.ts | 111 ++++++++++++++++++++++++++++++-- 1 file changed, 105 insertions(+), 6 deletions(-) diff --git a/apps/extension/src/extension.ts b/apps/extension/src/extension.ts index 8f2d0f5..5b2e739 100644 --- a/apps/extension/src/extension.ts +++ b/apps/extension/src/extension.ts @@ -18,9 +18,11 @@ import { Logger } from './utils/logger'; import { ActivityProvider } from './views/activityProvider'; import { FriendRequestsProvider } from './views/friendRequestsProvider'; import { FriendsProvider } from './views/friendsProvider'; +import { LeaderboardProvider } from './views/leaderboardProvider'; +import { StatsProvider } from './views/statsProvider'; import { StatusBarManager } from './views/statusBarItem'; -import type { UserStatusType, FriendRequestDTO, PublicUserDTO } from '@devradar/shared'; +import type { UserStatusType, FriendRequestDTO, PublicUserDTO, AchievementPayload, UserStatsDTO, LeaderboardEntry } from '@devradar/shared'; /** Coordinates all extension services and manages their lifecycle. */ class DevRadarExtension implements vscode.Disposable { @@ -35,6 +37,10 @@ class DevRadarExtension implements vscode.Disposable { private readonly friendRequestService: FriendRequestService; private readonly activityProvider: ActivityProvider; private readonly statusBar: StatusBarManager; + // Phase 2: Gamification + private readonly statsProvider: StatsProvider; + private readonly leaderboardProvider: LeaderboardProvider; + private statsRefreshInterval: NodeJS.Timeout | null = null; constructor(context: vscode.ExtensionContext) { this.logger = new Logger('DevRadar'); @@ -56,6 +62,9 @@ class DevRadarExtension implements vscode.Disposable { ); this.activityProvider = new ActivityProvider(this.wsClient, this.logger); this.statusBar = new StatusBarManager(this.wsClient, this.authService, this.logger); + // Phase 2: Gamification views + this.statsProvider = new StatsProvider(this.logger); + this.leaderboardProvider = new LeaderboardProvider(this.logger); /* Track disposables */ this.disposables.push( this.authService, @@ -66,7 +75,9 @@ class DevRadarExtension implements vscode.Disposable { this.friendRequestsProvider, this.activityProvider, this.statusBar, - this.configManager + this.configManager, + this.statsProvider, + this.leaderboardProvider ); } @@ -98,7 +109,10 @@ class DevRadarExtension implements vscode.Disposable { 'devradar.friendRequests', this.friendRequestsProvider ), - vscode.window.registerTreeDataProvider('devradar.activity', this.activityProvider) + vscode.window.registerTreeDataProvider('devradar.activity', this.activityProvider), + // Phase 2: Gamification views + vscode.window.registerTreeDataProvider('devradar.stats', this.statsProvider), + vscode.window.registerTreeDataProvider('devradar.leaderboard', this.leaderboardProvider) ); } @@ -251,8 +265,11 @@ class DevRadarExtension implements vscode.Disposable { this.logger.info('User is authenticated, connecting...'); this.wsClient.connect(); this.activityTracker.start(); + // Phase 2: Start stats refresh + this.startStatsRefresh(); } else { this.logger.info('User is not authenticated'); + this.stopStatsRefresh(); } void this.statusBar.update(); @@ -262,6 +279,79 @@ class DevRadarExtension implements vscode.Disposable { } } + /** Start periodic stats and leaderboard refresh. */ + private startStatsRefresh(): void { + // Initial fetch + void this.fetchStats(); + void this.fetchLeaderboard(); + + // Refresh every 60 seconds + this.statsRefreshInterval ??= setInterval(() => { + void this.fetchStats(); + void this.fetchLeaderboard(); + }, 60_000); + } + + /** Stop stats refresh (e.g., on logout). */ + private stopStatsRefresh(): void { + if (this.statsRefreshInterval) { + clearInterval(this.statsRefreshInterval); + this.statsRefreshInterval = null; + } + this.statsProvider.clear(); + this.leaderboardProvider.clear(); + } + + /** Fetch user stats from the server. */ + private async fetchStats(): Promise { + try { + const token = this.authService.getToken(); + if (!token) return; + + const serverUrl = this.configManager.get('serverUrl'); + const response = await fetch(`${serverUrl}/api/v1/stats/me`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + if (response.ok) { + const json = await response.json() as { data: UserStatsDTO }; + this.statsProvider.updateStats(json.data); + } else { + this.logger.warn('Failed to fetch stats', { status: response.status }); + } + } catch (error) { + this.logger.warn('Failed to fetch stats', error); + } + } + + /** Fetch friends leaderboard from the server. */ + private async fetchLeaderboard(): Promise { + try { + const token = this.authService.getToken(); + if (!token) return; + + const serverUrl = this.configManager.get('serverUrl'); + const response = await fetch(`${serverUrl}/api/v1/leaderboards/friends`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + if (response.ok) { + const json = await response.json() as { data: { leaderboard: LeaderboardEntry[]; myRank: number | null } }; + this.leaderboardProvider.updateLeaderboard(json.data.leaderboard, json.data.myRank); + } else { + this.logger.warn('Failed to fetch leaderboard', { status: response.status }); + } + } catch (error) { + this.logger.warn('Failed to fetch leaderboard', error); + } + } + private async handleLogin(): Promise { try { this.logger.info('Starting login flow...'); @@ -447,12 +537,21 @@ class DevRadarExtension implements vscode.Disposable { return; } - if (typeof payload === 'object' && payload !== null && 'title' in payload) { - const achievement = payload as { title: string; description?: string }; + if (typeof payload === 'object' && payload !== null && 'achievement' in payload) { + const data = payload as AchievementPayload; + const currentUserId = this.authService.getUser()?.id; + const isMyAchievement = data.userId === currentUserId; + + const prefix = isMyAchievement ? 'You earned' : `${data.username} earned`; void vscode.window.showInformationMessage( - `DevRadar: 🏆 Achievement Unlocked - ${achievement.title}!` + `DevRadar: 🏆 ${prefix} - ${data.achievement.title}!` ); + + // Refresh stats to show new achievement + if (isMyAchievement) { + void this.fetchStats(); + } } } From 46d0b32474c862346a131c6ef0c3c411294481a1 Mon Sep 17 00:00:00 2001 From: Utpal Date: Fri, 9 Jan 2026 15:29:51 +0530 Subject: [PATCH 13/23] feat(extension): add My Stats and Leaderboard views, bump to v0.3.0 --- apps/extension/package.json | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/extension/package.json b/apps/extension/package.json index 3440f8d..c49f001 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -2,7 +2,7 @@ "name": "devradar", "displayName": "DevRadar", "description": "See what your friends are coding in real-time", - "version": "0.2.0", + "version": "0.3.0", "publisher": "devradar", "license": "AGPL-3.0-or-later", "private": true, @@ -41,12 +41,22 @@ }, "views": { "devradar": [ + { + "id": "devradar.stats", + "name": "My Stats", + "contextualTitle": "DevRadar Stats" + }, { "id": "devradar.friends", "name": "Friends", "icon": "media/friends.svg", "contextualTitle": "DevRadar Friends" }, + { + "id": "devradar.leaderboard", + "name": "Leaderboard", + "contextualTitle": "DevRadar Leaderboard" + }, { "id": "devradar.friendRequests", "name": "Friend Requests", From 5db427fb5788740c4e133e4b4ef15003415b1a43 Mon Sep 17 00:00:00 2001 From: Utpal Date: Fri, 9 Jan 2026 15:30:07 +0530 Subject: [PATCH 14/23] docs(extension): add v0.3.0 changelog for gamification features --- apps/extension/CHANGELOG.md | 41 +++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/apps/extension/CHANGELOG.md b/apps/extension/CHANGELOG.md index a9ea1c2..d87aef8 100644 --- a/apps/extension/CHANGELOG.md +++ b/apps/extension/CHANGELOG.md @@ -5,6 +5,47 @@ All notable changes to the DevRadar extension will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - 2026-01-09 + +### Added + +- **Daily Streaks**: Track your coding streak with visual indicators + - 25-hour grace period for timezone flexibility + - Streak milestones (7, 30, 100 days) unlock achievements + - "At risk" warning when you haven't coded today +- **Leaderboards**: Compete with friends on weekly coding time + - Mini-leaderboard view in sidebar with top 10 friends + - Medal icons (🥇🥈🥉) for top 3 positions + - Your rank indicator always visible +- **My Stats View**: New sidebar section showing: + - Current streak with fire emoji + - Today's coding session time + - Weekly stats with rank position + - Latest achievement +- **Achievements System**: Earn achievements for: + - Closing GitHub issues ("Bug Slayer" 🐛) + - Merging pull requests ("Merge Master" 🎉) + - Streak milestones ("Week Warrior" 🔥, "Monthly Machine" ⚡, "Century Coder" 🏆) +- **GitHub Webhook Integration**: Connect your repos for automatic achievement tracking + - Secure HMAC-SHA256 signature verification + - Real-time achievement notifications to you and your followers +- **Network Activity Heatmap**: See when your network is "🔥 active" + +### Changed + +- Sidebar reorganized with Stats at top, then Friends, Leaderboard, Requests, Activity +- Achievement notifications now show who earned the achievement +- Stats and leaderboard refresh every 60 seconds + +### Technical + +- New server routes: `/api/v1/stats`, `/api/v1/leaderboards`, `/webhooks/github` +- Redis Sorted Sets for O(log N) leaderboard operations +- Prisma schema extended with `Achievement` and `WeeklyStats` models +- Secure webhook handling with constant-time signature comparison + +--- + ## [0.2.0] - 2026-01-08 ### Added From 04ab46cc61bb8de96cc0f50ebcf69b821fccf641 Mon Sep 17 00:00:00 2001 From: Utpal Date: Fri, 9 Jan 2026 15:30:23 +0530 Subject: [PATCH 15/23] docs: add GITHUB_WEBHOOK_SECRET to .env.example --- .env.example | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 87f7e9e..5b8eedb 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,3 @@ -# ================================== -# DevRadar Environment Configuration -# ================================== - # Node Environment NODE_ENV=development @@ -19,6 +15,9 @@ REDIS_URL=redis://localhost:6379 GITHUB_CLIENT_ID=your_github_client_id GITHUB_CLIENT_SECRET=your_github_client_secret GITHUB_CALLBACK_URL=http://localhost:3000/auth/callback +# GitHub Webhooks (for Boss Battles - achievements from GitHub events) +# Generate with: openssl rand -hex 32 +GITHUB_WEBHOOK_SECRET=your_webhook_secret_for_github # JWT JWT_SECRET=your_super_secret_jwt_key_change_in_production From d21ddd91453809f8301adcf66bcf3b5ca6171b77 Mon Sep 17 00:00:00 2001 From: Utpal Date: Fri, 9 Jan 2026 15:40:02 +0530 Subject: [PATCH 16/23] fix(ci) : remove formatting check --- .github/workflows/ci.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aca460f..9ca6835 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,9 +40,6 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Check formatting - run: pnpm format:check - - name: Generate Prisma Client run: pnpm --filter @devradar/server db:generate env: From 4fd5b425aa46ee56b3e46f04e2f48da0ea18c8ba Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Fri, 9 Jan 2026 20:14:49 +0530 Subject: [PATCH 17/23] fix(extension): call stopStatsRefresh() in dispose() to prevent memory leak --- apps/extension/src/extension.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/extension/src/extension.ts b/apps/extension/src/extension.ts index 5b2e739..4e72889 100644 --- a/apps/extension/src/extension.ts +++ b/apps/extension/src/extension.ts @@ -22,7 +22,14 @@ import { LeaderboardProvider } from './views/leaderboardProvider'; import { StatsProvider } from './views/statsProvider'; import { StatusBarManager } from './views/statusBarItem'; -import type { UserStatusType, FriendRequestDTO, PublicUserDTO, AchievementPayload, UserStatsDTO, LeaderboardEntry } from '@devradar/shared'; +import type { + UserStatusType, + FriendRequestDTO, + PublicUserDTO, + AchievementPayload, + UserStatsDTO, + LeaderboardEntry, +} from '@devradar/shared'; /** Coordinates all extension services and manages their lifecycle. */ class DevRadarExtension implements vscode.Disposable { @@ -317,7 +324,7 @@ class DevRadarExtension implements vscode.Disposable { }); if (response.ok) { - const json = await response.json() as { data: UserStatsDTO }; + const json = (await response.json()) as { data: UserStatsDTO }; this.statsProvider.updateStats(json.data); } else { this.logger.warn('Failed to fetch stats', { status: response.status }); @@ -342,7 +349,9 @@ class DevRadarExtension implements vscode.Disposable { }); if (response.ok) { - const json = await response.json() as { data: { leaderboard: LeaderboardEntry[]; myRank: number | null } }; + const json = (await response.json()) as { + data: { leaderboard: LeaderboardEntry[]; myRank: number | null }; + }; this.leaderboardProvider.updateLeaderboard(json.data.leaderboard, json.data.myRank); } else { this.logger.warn('Failed to fetch leaderboard', { status: response.status }); @@ -722,6 +731,9 @@ class DevRadarExtension implements vscode.Disposable { dispose(): void { this.logger.info('Disposing DevRadar extension...'); + // Stop stats refresh interval before disposing other resources + this.stopStatsRefresh(); + for (const disposable of this.disposables) { disposable.dispose(); } From bed97595ef5de10dd9efe1f3184906aa2ccfc2cf Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Fri, 9 Jan 2026 20:14:57 +0530 Subject: [PATCH 18/23] fix(extension): handle 0 seconds as 'No activity', use unique header IDs, fix log count --- apps/extension/src/views/leaderboardProvider.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/extension/src/views/leaderboardProvider.ts b/apps/extension/src/views/leaderboardProvider.ts index a0909a3..c3fdbec 100644 --- a/apps/extension/src/views/leaderboardProvider.ts +++ b/apps/extension/src/views/leaderboardProvider.ts @@ -42,6 +42,10 @@ class LeaderboardTreeItem extends vscode.TreeItem { /** Format score (seconds) as human-readable duration. */ private formatScore(seconds: number): string { + if (seconds === 0) { + return 'No activity'; + } + const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); @@ -74,7 +78,8 @@ class LeaderboardHeaderItem extends vscode.TreeItem { constructor(title: string, count: number) { super(title, vscode.TreeItemCollapsibleState.Expanded); - this.id = 'leaderboard-header'; + // Generate unique ID from title to prevent duplicates + this.id = `leaderboard-header-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`; this.description = count > 0 ? `${String(count)} entries` : ''; this.iconPath = new vscode.ThemeIcon('trophy'); this.contextValue = 'leaderboard-header'; @@ -91,7 +96,9 @@ class LeaderboardInfoItem extends vscode.TreeItem { /** Tree data provider for the mini-leaderboard view. */ export class LeaderboardProvider - implements vscode.TreeDataProvider, vscode.Disposable + implements + vscode.TreeDataProvider, + vscode.Disposable { private readonly disposables: vscode.Disposable[] = []; private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter< @@ -108,7 +115,9 @@ export class LeaderboardProvider this.disposables.push(this.onDidChangeTreeDataEmitter); } - getTreeItem(element: LeaderboardTreeItem | LeaderboardHeaderItem | LeaderboardInfoItem): vscode.TreeItem { + getTreeItem( + element: LeaderboardTreeItem | LeaderboardHeaderItem | LeaderboardInfoItem + ): vscode.TreeItem { return element; } @@ -153,7 +162,7 @@ export class LeaderboardProvider this.myRank = myRank; this.isLoading = false; this.onDidChangeTreeDataEmitter.fire(undefined); - this.logger.debug('Leaderboard updated', { count: entries.length, myRank }); + this.logger.debug('Leaderboard updated', { count: this.leaderboard.length, myRank }); } /** Set loading state. */ From c09210ff9ae83974dd77dc1b313d992b2baafb1f Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Fri, 9 Jan 2026 20:15:09 +0530 Subject: [PATCH 19/23] fix(extension): use destructuring for array access to satisfy lint --- apps/extension/src/views/statsProvider.ts | 39 ++++++++++++----------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/apps/extension/src/views/statsProvider.ts b/apps/extension/src/views/statsProvider.ts index ef4b9ca..0e97307 100644 --- a/apps/extension/src/views/statsProvider.ts +++ b/apps/extension/src/views/statsProvider.ts @@ -35,7 +35,9 @@ class StatsTreeItem extends vscode.TreeItem { /** Tree data provider for the stats sidebar view. */ export class StatsProvider implements vscode.TreeDataProvider, vscode.Disposable { private readonly disposables: vscode.Disposable[] = []; - private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter(); + private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter< + StatsTreeItem | undefined + >(); private stats: StatsData | null = null; private isLoading = true; @@ -75,7 +77,13 @@ export class StatsProvider implements vscode.TreeDataProvider, vs ? '⚠ Code today to continue' : 'Start coding!'; - items.push(new StatsTreeItem(`${streakEmoji} ${String(currentStreak)} Day Streak`, streakDescription, statusIcon)); + items.push( + new StatsTreeItem( + `${streakEmoji} ${String(currentStreak)} Day Streak`, + streakDescription, + statusIcon + ) + ); // Show longest streak if different if (longestStreak > currentStreak && longestStreak > 0) { @@ -86,7 +94,9 @@ export class StatsProvider implements vscode.TreeDataProvider, vs const todayHours = Math.floor(this.stats.todaySession / 3600); const todayMinutes = Math.floor((this.stats.todaySession % 3600) / 60); const todayDisplay = - todayHours > 0 ? `${String(todayHours)}h ${String(todayMinutes)}m` : `${String(todayMinutes)}m`; + todayHours > 0 + ? `${String(todayHours)}h ${String(todayMinutes)}m` + : `${String(todayMinutes)}m`; items.push(new StatsTreeItem(`Today: ${todayDisplay}`, 'Coding time', 'clock')); @@ -94,35 +104,28 @@ export class StatsProvider implements vscode.TreeDataProvider, vs if (this.stats.weeklyStats) { const weekHours = Math.floor(this.stats.weeklyStats.totalSeconds / 3600); const weekMinutes = Math.floor((this.stats.weeklyStats.totalSeconds % 3600) / 60); - const weekDisplay = weekHours > 0 ? `${String(weekHours)}h ${String(weekMinutes)}m` : `${String(weekMinutes)}m`; + const weekDisplay = + weekHours > 0 ? `${String(weekHours)}h ${String(weekMinutes)}m` : `${String(weekMinutes)}m`; - const rankDisplay = this.stats.weeklyStats.rank ? `#${String(this.stats.weeklyStats.rank)}` : ''; + const rankDisplay = this.stats.weeklyStats.rank + ? `#${String(this.stats.weeklyStats.rank)}` + : ''; items.push(new StatsTreeItem(`This Week: ${weekDisplay}`, rankDisplay, 'graph')); // Top language if available if (this.stats.weeklyStats.topLanguage) { items.push( - new StatsTreeItem( - `Top: ${this.stats.weeklyStats.topLanguage}`, - '', - 'symbol-keyword' - ) + new StatsTreeItem(`Top: ${this.stats.weeklyStats.topLanguage}`, '', 'symbol-keyword') ); } } // === Recent Achievement === if (this.stats.recentAchievements.length > 0) { - const latestAchievement = this.stats.recentAchievements[0]; + const [latestAchievement] = this.stats.recentAchievements; if (latestAchievement) { - items.push( - new StatsTreeItem( - latestAchievement.title, - 'Latest achievement', - 'trophy' - ) - ); + items.push(new StatsTreeItem(latestAchievement.title, 'Latest achievement', 'trophy')); } } From 40250b6244f7a4088210ee0fd20181dbbfe2b4a5 Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Fri, 9 Jan 2026 20:15:19 +0530 Subject: [PATCH 20/23] fix(server): update to 'Gamification' terminology, add min(8) validation for webhook secret --- apps/server/src/config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index a3eb469..ecf1326 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -33,8 +33,8 @@ const envSchema = z.object({ GITHUB_CLIENT_ID: z.string().min(1, 'GITHUB_CLIENT_ID is required'), GITHUB_CLIENT_SECRET: z.string().min(1, 'GITHUB_CLIENT_SECRET is required'), GITHUB_CALLBACK_URL: z.string().url(), - /* GitHub Webhooks (optional - for Boss Battles feature) */ - GITHUB_WEBHOOK_SECRET: z.string().optional(), + /* GitHub Webhooks (optional - for Gamification feature) */ + GITHUB_WEBHOOK_SECRET: z.string().min(8).optional(), /* Logging */ LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), }); From 03237a71635dcd5a20dc1c1c1c9eb727686460f8 Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Fri, 9 Jan 2026 20:15:33 +0530 Subject: [PATCH 21/23] fix(server): add pipeline error handling, fire-and-forget catch, fix milestone logic, add language allowlist --- apps/server/src/routes/stats.ts | 545 +++++++++++++++++++------------- 1 file changed, 326 insertions(+), 219 deletions(-) diff --git a/apps/server/src/routes/stats.ts b/apps/server/src/routes/stats.ts index 8296991..4985d00 100644 --- a/apps/server/src/routes/stats.ts +++ b/apps/server/src/routes/stats.ts @@ -2,8 +2,6 @@ * Stats Routes * * Endpoints for user statistics, streaks, and session tracking. - * - * Best Practices Implemented: * - Redis pipelining for batch operations * - TTL-based streak expiration with grace period (25 hours) * - Efficient HINCRBY for atomic updates @@ -34,6 +32,47 @@ const SessionPayloadSchema = z.object({ project: z.string().optional(), }); +/** Allowlist of known programming languages to prevent unbounded Redis hash growth. */ +const LANGUAGE_ALLOWLIST = new Set([ + 'javascript', + 'typescript', + 'python', + 'java', + 'csharp', + 'cpp', + 'c', + 'go', + 'rust', + 'ruby', + 'php', + 'swift', + 'kotlin', + 'scala', + 'html', + 'css', + 'scss', + 'less', + 'json', + 'yaml', + 'xml', + 'sql', + 'shell', + 'markdown', + 'vue', + 'react', + 'angular', + 'svelte', + 'dart', + 'r', + 'lua', + 'perl', + 'haskell', + 'elixir', + 'clojure', + 'fsharp', + 'ocaml', +]); + /** Get Monday 00:00:00 UTC for current week. */ function getWeekStart(): Date { const now = new Date(); @@ -71,261 +110,329 @@ export function statsRoutes(app: FastifyInstance): void { /** * GET /stats/me - Get current user's stats summary */ - app.get('/me', { onRequest: [app.authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => { - const { userId } = request.user as { userId: string }; - const redis = getRedis(); - - // Pipeline Redis reads for efficiency - const pipeline = redis.pipeline(); - const today = getTodayDate(); - - pipeline.hgetall(REDIS_KEYS.streakData(userId)); - pipeline.get(REDIS_KEYS.dailySession(userId, today)); - - const results = await pipeline.exec(); - const streakData = results?.[0]?.[1] as Record | null; - const todaySessionStr = results?.[1]?.[1] as string | null; - const todaySession = todaySessionStr ? parseInt(todaySessionStr, 10) : 0; - - // Build streak info - const currentStreak = parseInt(streakData?.count ?? '0', 10); - const longestStreak = parseInt(streakData?.longest ?? '0', 10); - const lastActiveDate = streakData?.lastDate ?? null; - const streakStatus = calculateStreakStatus(lastActiveDate); - - const streak: StreakInfo = { - currentStreak, - longestStreak, - lastActiveDate, - isActiveToday: streakStatus === 'active', - streakStatus, - }; - - // Get weekly stats from PostgreSQL - const weekStart = getWeekStart(); - const weeklyStatsRecord = await db.weeklyStats.findUnique({ - where: { userId_weekStart: { userId, weekStart } }, - }); - - // Get user's rank in weekly leaderboard - const rank = await redis.zrevrank(REDIS_KEYS.weeklyLeaderboard('time'), userId); - const liveScore = await redis.zscore(REDIS_KEYS.weeklyLeaderboard('time'), userId); - - const weeklyStats: WeeklyStatsDTO | null = weeklyStatsRecord - ? { - weekStart: weeklyStatsRecord.weekStart.toISOString(), - totalSeconds: liveScore ? parseInt(liveScore, 10) : weeklyStatsRecord.totalSeconds, - totalSessions: weeklyStatsRecord.totalSessions, - totalCommits: weeklyStatsRecord.totalCommits, - topLanguage: weeklyStatsRecord.topLanguage, - topProject: weeklyStatsRecord.topProject, - rank: rank !== null ? rank + 1 : undefined, - } - : null; - - // Get recent achievements - const achievementRecords = await db.achievement.findMany({ - where: { userId }, - orderBy: { earnedAt: 'desc' }, - take: 5, - }); - - const recentAchievements: AchievementDTO[] = achievementRecords.map((a) => ({ - id: a.id, - type: a.type as AchievementDTO['type'], - title: a.title, - description: a.description, - earnedAt: a.earnedAt.toISOString(), - })); - - return reply.send({ - data: { - streak, - todaySession, - weeklyStats, - recentAchievements, - }, - }); - }); + app.get( + '/me', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + const redis = getRedis(); + + // Pipeline Redis reads for efficiency + const pipeline = redis.pipeline(); + const today = getTodayDate(); + + pipeline.hgetall(REDIS_KEYS.streakData(userId)); + pipeline.get(REDIS_KEYS.dailySession(userId, today)); + + const results = await pipeline.exec(); + + // Check for pipeline errors + const streakError = results?.[0]?.[0]; + const sessionError = results?.[1]?.[0]; + if (streakError || sessionError) { + logger.error({ streakError, sessionError, userId }, 'Redis pipeline error in stats/me'); + } - /** - * GET /stats/streak - Get detailed streak information - */ - app.get('/streak', { onRequest: [app.authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => { - const { userId } = request.user as { userId: string }; - const redis = getRedis(); + const streakData = results?.[0]?.[1] as Record | null; + const todaySessionStr = results?.[1]?.[1] as string | null; + const todaySession = todaySessionStr ? parseInt(todaySessionStr, 10) : 0; + + // Build streak info + const currentStreak = parseInt(streakData?.count ?? '0', 10); + const longestStreak = parseInt(streakData?.longest ?? '0', 10); + const lastActiveDate = streakData?.lastDate ?? null; + const streakStatus = calculateStreakStatus(lastActiveDate); + + const streak: StreakInfo = { + currentStreak, + longestStreak, + lastActiveDate, + isActiveToday: streakStatus === 'active', + streakStatus, + }; + + // Get weekly stats from PostgreSQL + const weekStart = getWeekStart(); + const weeklyStatsRecord = await db.weeklyStats.findUnique({ + where: { userId_weekStart: { userId, weekStart } }, + }); - const streakData = await redis.hgetall(REDIS_KEYS.streakData(userId)); + // Get user's rank in weekly leaderboard + const rank = await redis.zrevrank(REDIS_KEYS.weeklyLeaderboard('time'), userId); + const liveScore = await redis.zscore(REDIS_KEYS.weeklyLeaderboard('time'), userId); + + const weeklyStats: WeeklyStatsDTO | null = weeklyStatsRecord + ? { + weekStart: weeklyStatsRecord.weekStart.toISOString(), + totalSeconds: liveScore ? parseInt(liveScore, 10) : weeklyStatsRecord.totalSeconds, + totalSessions: weeklyStatsRecord.totalSessions, + totalCommits: weeklyStatsRecord.totalCommits, + topLanguage: weeklyStatsRecord.topLanguage, + topProject: weeklyStatsRecord.topProject, + rank: rank !== null ? rank + 1 : undefined, + } + : null; - const currentStreak = parseInt(streakData.count ?? '0', 10); - const longestStreak = parseInt(streakData.longest ?? '0', 10); - const lastActiveDate = streakData.lastDate ?? null; - const streakStatus = calculateStreakStatus(lastActiveDate); + // Get recent achievements + const achievementRecords = await db.achievement.findMany({ + where: { userId }, + orderBy: { earnedAt: 'desc' }, + take: 5, + }); - const streak: StreakInfo = { - currentStreak, - longestStreak, - lastActiveDate, - isActiveToday: streakStatus === 'active', - streakStatus, - }; + const recentAchievements: AchievementDTO[] = achievementRecords.map((a) => ({ + id: a.id, + type: a.type as AchievementDTO['type'], + title: a.title, + description: a.description, + earnedAt: a.earnedAt.toISOString(), + })); + + return reply.send({ + data: { + streak, + todaySession, + weeklyStats, + recentAchievements, + }, + }); + } + ); - return reply.send({ data: streak }); - }); + /** + * GET /stats/streak - Get detailed streak information + */ + app.get( + '/streak', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + const redis = getRedis(); + + const streakData = await redis.hgetall(REDIS_KEYS.streakData(userId)); + + const currentStreak = parseInt(streakData.count ?? '0', 10); + const longestStreak = parseInt(streakData.longest ?? '0', 10); + const lastActiveDate = streakData.lastDate ?? null; + const streakStatus = calculateStreakStatus(lastActiveDate); + + const streak: StreakInfo = { + currentStreak, + longestStreak, + lastActiveDate, + isActiveToday: streakStatus === 'active', + streakStatus, + }; + + return reply.send({ data: streak }); + } + ); /** * POST /stats/session - Record coding session time * Called periodically by the extension with session duration increment */ - app.post('/session', { onRequest: [app.authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => { - const { userId } = request.user as { userId: string }; - const result = SessionPayloadSchema.safeParse(request.body); - - if (!result.success) { - return reply.status(400).send({ - error: { code: 'INVALID_PAYLOAD', message: 'Invalid session data' }, - }); - } - - const { sessionDuration, language, project } = result.data; - const today = getTodayDate(); - const redis = getRedis(); - - // Get existing streak data to check if we need to update it - const existingStreakData = await redis.hgetall(REDIS_KEYS.streakData(userId)); - const lastDate = existingStreakData.lastDate; - - // Pipeline for atomic operations - const pipeline = redis.pipeline(); - - // 1. Update daily session time (accumulate) - const sessionKey = REDIS_KEYS.dailySession(userId, today); - pipeline.incrby(sessionKey, sessionDuration); - pipeline.expire(sessionKey, SESSION_TTL_SECONDS); - - // 2. Update streak if needed (only once per day) - let newStreak = 1; - let shouldCheckStreakAchievements = false; - - if (lastDate !== today) { - // First activity of the day - update streak - shouldCheckStreakAchievements = true; + app.post( + '/session', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + const result = SessionPayloadSchema.safeParse(request.body); + + if (!result.success) { + return reply.status(400).send({ + error: { code: 'INVALID_PAYLOAD', message: 'Invalid session data' }, + }); + } - if (lastDate) { - const lastDateObj = new Date(lastDate); - const todayDateObj = new Date(today); - const daysDiff = Math.floor( - (todayDateObj.getTime() - lastDateObj.getTime()) / (1000 * 60 * 60 * 24) - ); + const { sessionDuration, language, project } = result.data; + const today = getTodayDate(); + const redis = getRedis(); + + // Lua script for atomic streak update + // Returns: [newStreak, shouldCheckAchievements] - 1 if streak was updated, 0 otherwise + const STREAK_UPDATE_SCRIPT = ` + local streakKey = KEYS[1] + local today = ARGV[1] + local streakTtl = tonumber(ARGV[2]) + + -- Read current streak data + local lastDate = redis.call('HGET', streakKey, 'lastDate') + local count = tonumber(redis.call('HGET', streakKey, 'count') or '0') + local longest = tonumber(redis.call('HGET', streakKey, 'longest') or '0') + + -- Check if already updated today + if lastDate == today then + return {count, 0} + end + + -- Calculate new streak + local newStreak = 1 + if lastDate then + -- Parse dates and calculate difference + local ly, lm, ld = lastDate:match('(%d+)-(%d+)-(%d+)') + local ty, tm, td = today:match('(%d+)-(%d+)-(%d+)') + local lastTime = os.time({year=ly, month=lm, day=ld}) + local todayTime = os.time({year=ty, month=tm, day=td}) + local daysDiff = math.floor((todayTime - lastTime) / 86400) + + if daysDiff == 1 then + newStreak = count + 1 + end + -- daysDiff > 1 means streak broken, reset to 1 + end + + local newLongest = math.max(newStreak, longest) + + -- Update atomically + redis.call('HSET', streakKey, 'count', newStreak, 'lastDate', today, 'longest', newLongest) + redis.call('EXPIRE', streakKey, streakTtl) + + return {newStreak, 1} + `; + + // Execute atomic streak update + const streakKey = REDIS_KEYS.streakData(userId); + const streakResult = (await redis.eval( + STREAK_UPDATE_SCRIPT, + 1, + streakKey, + today, + (STREAK_TTL_SECONDS * 2).toString() + )) as [number, number]; + + const newStreak = streakResult[0]; + const shouldCheckStreakAchievements = streakResult[1] === 1; + + // Pipeline for remaining atomic operations + const pipeline = redis.pipeline(); + + // 1. Update daily session time (accumulate) + const sessionKey = REDIS_KEYS.dailySession(userId, today); + pipeline.incrby(sessionKey, sessionDuration); + pipeline.expire(sessionKey, SESSION_TTL_SECONDS); + + // 2. Update weekly leaderboard score + pipeline.zincrby(REDIS_KEYS.weeklyLeaderboard('time'), sessionDuration, userId); + + // 3. Update network activity (1-minute bucket for heatmap) + const minute = Math.floor(Date.now() / 60000); + pipeline.hincrby(REDIS_KEYS.networkIntensity(minute), 'count', 1); + pipeline.expire(REDIS_KEYS.networkIntensity(minute), NETWORK_ACTIVITY_TTL_SECONDS); + + // 5. Track language if provided (validate to prevent unbounded hash growth) + if (language) { + const normalizedLang = language.toLowerCase().trim(); + const safeLang = LANGUAGE_ALLOWLIST.has(normalizedLang) ? normalizedLang : 'other'; + pipeline.hincrby(REDIS_KEYS.networkIntensity(minute), `lang:${safeLang}`, 1); + } - if (daysDiff === 1) { - // Consecutive day - increment streak - newStreak = parseInt(existingStreakData.count ?? '0', 10) + 1; + const pipelineResults = await pipeline.exec(); + + // Check for pipeline errors + if (pipelineResults) { + for (let i = 0; i < pipelineResults.length; i++) { + const [err] = pipelineResults[i] ?? []; + if (err) { + logger.error( + { error: err, userId, commandIndex: i }, + 'Redis pipeline error in session recording' + ); + } } - // daysDiff > 1 means streak is broken, reset to 1 } - const longestStreak = Math.max(newStreak, parseInt(existingStreakData.longest ?? '0', 10)); - - pipeline.hset(REDIS_KEYS.streakData(userId), { - count: newStreak.toString(), - lastDate: today, - longest: longestStreak.toString(), - }); - pipeline.expire(REDIS_KEYS.streakData(userId), STREAK_TTL_SECONDS * 2); - } - - // 3. Update weekly leaderboard score - pipeline.zincrby(REDIS_KEYS.weeklyLeaderboard('time'), sessionDuration, userId); - - // 4. Update network activity (1-minute bucket for heatmap) - const minute = Math.floor(Date.now() / 60000); - pipeline.hincrby(REDIS_KEYS.networkIntensity(minute), 'count', 1); - pipeline.expire(REDIS_KEYS.networkIntensity(minute), NETWORK_ACTIVITY_TTL_SECONDS); - - // 5. Track language if provided - if (language) { - pipeline.hincrby(REDIS_KEYS.networkIntensity(minute), `lang:${language}`, 1); - } + // Check for streak achievements (with error handling) + if (shouldCheckStreakAchievements) { + checkStreakAchievements(userId, newStreak).catch((err: unknown) => { + logger.error( + { error: err, userId, streak: newStreak }, + 'Failed to check streak achievements' + ); + }); + } - await pipeline.exec(); + logger.debug({ userId, sessionDuration, language, project }, 'Recorded session'); - // Check for streak achievements (async, fire-and-forget) - if (shouldCheckStreakAchievements) { - void checkStreakAchievements(userId, newStreak); + return reply.send({ data: { recorded: true } }); } - - logger.debug({ userId, sessionDuration, language, project }, 'Recorded session'); - - return reply.send({ data: { recorded: true } }); - }); + ); /** * GET /stats/weekly - Get weekly statistics with real-time data */ - app.get('/weekly', { onRequest: [app.authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => { - const { userId } = request.user as { userId: string }; - const redis = getRedis(); - - // Get current week stats from DB - const weekStart = getWeekStart(); - const weeklyStatsRecord = await db.weeklyStats.findUnique({ - where: { userId_weekStart: { userId, weekStart } }, - }); - - // Get real-time Redis data - const [liveScore, rank] = await Promise.all([ - redis.zscore(REDIS_KEYS.weeklyLeaderboard('time'), userId), - redis.zrevrank(REDIS_KEYS.weeklyLeaderboard('time'), userId), - ]); - - const weeklyStats: WeeklyStatsDTO = { - weekStart: weekStart.toISOString(), - totalSeconds: liveScore ? parseInt(liveScore, 10) : (weeklyStatsRecord?.totalSeconds ?? 0), - totalSessions: weeklyStatsRecord?.totalSessions ?? 0, - totalCommits: weeklyStatsRecord?.totalCommits ?? 0, - topLanguage: weeklyStatsRecord?.topLanguage ?? null, - topProject: weeklyStatsRecord?.topProject ?? null, - rank: rank !== null ? rank + 1 : undefined, - }; - - return reply.send({ data: weeklyStats }); - }); + app.get( + '/weekly', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + const redis = getRedis(); + + // Get current week stats from DB + const weekStart = getWeekStart(); + const weeklyStatsRecord = await db.weeklyStats.findUnique({ + where: { userId_weekStart: { userId, weekStart } }, + }); + + // Get real-time Redis data + const [liveScore, rank] = await Promise.all([ + redis.zscore(REDIS_KEYS.weeklyLeaderboard('time'), userId), + redis.zrevrank(REDIS_KEYS.weeklyLeaderboard('time'), userId), + ]); + + const weeklyStats: WeeklyStatsDTO = { + weekStart: weekStart.toISOString(), + totalSeconds: liveScore ? parseInt(liveScore, 10) : (weeklyStatsRecord?.totalSeconds ?? 0), + totalSessions: weeklyStatsRecord?.totalSessions ?? 0, + totalCommits: weeklyStatsRecord?.totalCommits ?? 0, + topLanguage: weeklyStatsRecord?.topLanguage ?? null, + topProject: weeklyStatsRecord?.topProject ?? null, + rank: rank !== null ? rank + 1 : undefined, + }; + + return reply.send({ data: weeklyStats }); + } + ); /** * GET /stats/achievements - Get all user achievements */ - app.get('/achievements', { onRequest: [app.authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => { - const { userId } = request.user as { userId: string }; - - const achievementRecords = await db.achievement.findMany({ - where: { userId }, - orderBy: { earnedAt: 'desc' }, - }); + app.get( + '/achievements', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + + const achievementRecords = await db.achievement.findMany({ + where: { userId }, + orderBy: { earnedAt: 'desc' }, + }); - const achievements: AchievementDTO[] = achievementRecords.map((a) => ({ - id: a.id, - type: a.type as AchievementDTO['type'], - title: a.title, - description: a.description, - earnedAt: a.earnedAt.toISOString(), - })); + const achievements: AchievementDTO[] = achievementRecords.map((a) => ({ + id: a.id, + type: a.type as AchievementDTO['type'], + title: a.title, + description: a.description, + earnedAt: a.earnedAt.toISOString(), + })); - return reply.send({ data: achievements }); - }); + return reply.send({ data: achievements }); + } + ); /** * Check and award streak achievements. */ async function checkStreakAchievements(userId: string, streak: number): Promise { + // Milestones in descending order - highest applicable wins const milestones = [ - { streak: 7, type: 'STREAK_7' as const }, - { streak: 30, type: 'STREAK_30' as const }, { streak: 100, type: 'STREAK_100' as const }, + { streak: 30, type: 'STREAK_30' as const }, + { streak: 7, type: 'STREAK_7' as const }, ]; for (const milestone of milestones) { - if (streak === milestone.streak) { + if (streak >= milestone.streak) { // Check if already earned const existing = await db.achievement.findFirst({ where: { userId, type: milestone.type }, From 4a2975c11e882ac421f4cf7a9cc5dbf988d01385 Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Fri, 9 Jan 2026 20:15:49 +0530 Subject: [PATCH 22/23] fix(server): add Zod payload validation, raw body handling, explicit duplicate checking, rate limiting --- apps/server/src/routes/webhooks.ts | 347 +++++++++++++++++------------ 1 file changed, 199 insertions(+), 148 deletions(-) diff --git a/apps/server/src/routes/webhooks.ts b/apps/server/src/routes/webhooks.ts index 1fce939..cca70b9 100644 --- a/apps/server/src/routes/webhooks.ts +++ b/apps/server/src/routes/webhooks.ts @@ -1,9 +1,7 @@ /** * Webhooks Routes * - * GitHub webhook handler for "Boss Battles" (achievement system). - * - * Security Best Practices: + * GitHub webhook handler for Gamification (achievement system). * - HMAC-SHA256 signature verification (X-Hub-Signature-256) * - Constant-time comparison (crypto.timingSafeEqual) * - Raw body access before JSON parsing @@ -13,6 +11,7 @@ import crypto from 'crypto'; import { ACHIEVEMENTS, REDIS_KEYS } from '@devradar/shared'; +import { z } from 'zod'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; @@ -22,63 +21,49 @@ import { getDb } from '@/services/db'; import { getRedis } from '@/services/redis'; import { broadcastToUsers } from '@/ws/handler'; -/** GitHub Issues webhook payload structure. */ -interface GitHubIssuesPayload { - action: string; - issue: { - number: number; - title: string; - html_url: string; - }; - sender: { - id: number; - login: string; - }; - repository: { - full_name: string; - }; -} - -/** GitHub Push webhook payload structure. */ -interface GitHubPushPayload { - commits: { - id: string; - message: string; - author: { - name: string; - email: string; - }; - }[]; - pusher: { - name: string; - email: string; - }; - sender: { - id: number; - login: string; - }; - repository: { - full_name: string; - }; +/** Extended request with raw body for signature verification. */ +interface RawBodyRequest extends FastifyRequest { + rawBody?: Buffer; } -/** GitHub Pull Request webhook payload structure. */ -interface GitHubPullRequestPayload { - action: string; - pull_request: { - number: number; - title: string; - html_url: string; - merged: boolean; - }; - sender: { - id: number; - login: string; - }; - repository: { - full_name: string; - }; -} +/** Zod schema for GitHub Issues webhook payload. */ +const GitHubIssuesPayloadSchema = z.object({ + action: z.string(), + issue: z.object({ + number: z.number(), + title: z.string(), + html_url: z.string(), + }), + sender: z.object({ id: z.number(), login: z.string() }), + repository: z.object({ full_name: z.string() }), +}); + +/** Zod schema for GitHub Push webhook payload. */ +const GitHubPushPayloadSchema = z.object({ + commits: z.array( + z.object({ + id: z.string(), + message: z.string(), + author: z.object({ name: z.string(), email: z.string() }), + }) + ), + pusher: z.object({ name: z.string(), email: z.string() }), + sender: z.object({ id: z.number(), login: z.string() }), + repository: z.object({ full_name: z.string() }), +}); + +/** Zod schema for GitHub Pull Request webhook payload. */ +const GitHubPullRequestPayloadSchema = z.object({ + action: z.string(), + pull_request: z.object({ + number: z.number(), + title: z.string(), + html_url: z.string(), + merged: z.boolean(), + }), + sender: z.object({ id: z.number(), login: z.string() }), + repository: z.object({ full_name: z.string() }), +}); /** * Verify GitHub webhook signature using HMAC-SHA256. @@ -86,7 +71,8 @@ interface GitHubPullRequestPayload { */ function verifyGitHubSignature(rawBody: Buffer, signature: string, secret: string): boolean { // Calculate expected signature - const expectedSignature = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); + const expectedSignature = + 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); // Constant-time comparison to prevent timing attacks const signatureBuffer = Buffer.from(signature); @@ -115,16 +101,28 @@ export function webhookRoutes(app: FastifyInstance): void { '/github', { config: { - // Skip rate limiting for webhooks (GitHub has its own retry logic) - rateLimit: false, + // Generous rate limit for webhooks - protects against DoS while + // accommodating GitHub's webhook delivery (GitHub IPs may vary) + rateLimit: { + max: 100, + timeWindow: '1 minute', + }, }, }, async (request: FastifyRequest, reply: FastifyReply) => { // 1. Get raw body for signature verification - // Note: Fastify should give us access to rawBody via request.rawBody or we parse it - // For now, we'll re-serialize since Fastify already parsed it - const bodyString = JSON.stringify(request.body); - const rawBody = Buffer.from(bodyString, 'utf8'); + // Cast to RawBodyRequest to access rawBody if available + const rawRequest = request as RawBodyRequest; + let rawBody: Buffer; + + if (rawRequest.rawBody) { + rawBody = rawRequest.rawBody; + } else { + // Fallback: re-serialize (note: this may differ from original body) + logger.warn('Raw body not available, falling back to re-serialization'); + const bodyString = JSON.stringify(request.body); + rawBody = Buffer.from(bodyString, 'utf8'); + } // 2. Verify signature const signature = request.headers['x-hub-signature-256'] as string | undefined; @@ -151,22 +149,43 @@ export function webhookRoutes(app: FastifyInstance): void { logger.info({ event, deliveryId }, 'Processing GitHub webhook'); - // 3. Handle events - const payload = request.body as Record; + // 3. Handle events with Zod validation + const payload = request.body; try { switch (event) { - case 'issues': - await handleIssuesEvent(payload as unknown as GitHubIssuesPayload); + case 'issues': { + const parsed = GitHubIssuesPayloadSchema.safeParse(payload); + if (!parsed.success) { + logger.warn({ error: parsed.error.message, deliveryId }, 'Invalid issues payload'); + break; + } + await handleIssuesEvent(parsed.data); break; - - case 'pull_request': - await handlePullRequestEvent(payload as unknown as GitHubPullRequestPayload); + } + + case 'pull_request': { + const parsed = GitHubPullRequestPayloadSchema.safeParse(payload); + if (!parsed.success) { + logger.warn( + { error: parsed.error.message, deliveryId }, + 'Invalid pull_request payload' + ); + break; + } + await handlePullRequestEvent(parsed.data); break; - - case 'push': - await handlePushEvent(payload as unknown as GitHubPushPayload); + } + + case 'push': { + const parsed = GitHubPushPayloadSchema.safeParse(payload); + if (!parsed.success) { + logger.warn({ error: parsed.error.message, deliveryId }, 'Invalid push payload'); + break; + } + await handlePushEvent(parsed.data); break; + } case 'ping': logger.info({ deliveryId }, 'GitHub webhook ping received'); @@ -187,7 +206,9 @@ export function webhookRoutes(app: FastifyInstance): void { /** * Handle issues event - Award achievements for closed issues */ - async function handleIssuesEvent(payload: GitHubIssuesPayload): Promise { + async function handleIssuesEvent( + payload: z.infer + ): Promise { if (payload.action !== 'closed') { return; } @@ -210,51 +231,65 @@ export function webhookRoutes(app: FastifyInstance): void { return; } + // Check for existing achievement to avoid duplicate key errors + const existingAchievement = await db.achievement.findFirst({ + where: { + userId: user.id, + type: 'ISSUE_CLOSED', + metadata: { path: ['issueNumber'], equals: issue.number }, + }, + }); + + if (existingAchievement) { + logger.debug( + { userId: user.id, issueNumber: issue.number }, + 'Achievement already exists for this issue' + ); + return; + } + // Create achievement const achievementDef = ACHIEVEMENTS.ISSUE_CLOSED; - try { - const achievement = await db.achievement.create({ - data: { - userId: user.id, - type: 'ISSUE_CLOSED', - title: achievementDef.title, - description: `Closed issue #${String(issue.number)} in ${repository.full_name}`, - metadata: { - issueNumber: issue.number, - issueTitle: issue.title, - issueUrl: issue.html_url, - repository: repository.full_name, - }, + const achievement = await db.achievement.create({ + data: { + userId: user.id, + type: 'ISSUE_CLOSED', + title: achievementDef.title, + description: `Closed issue #${String(issue.number)} in ${repository.full_name}`, + metadata: { + issueNumber: issue.number, + issueTitle: issue.title, + issueUrl: issue.html_url, + repository: repository.full_name, }, - }); - - logger.info({ userId: user.id, achievementId: achievement.id }, 'Bug Slayer achievement earned'); - - // Broadcast to followers and self - const followerIds = user.followers.map((f) => f.followerId); + }, + }); - broadcastToUsers([...followerIds, user.id], 'ACHIEVEMENT', { - achievement: { - id: achievement.id, - type: 'ISSUE_CLOSED', - title: achievementDef.title, - description: achievement.description, - earnedAt: achievement.earnedAt.toISOString(), - }, - userId: user.id, - username: user.username, - }); - } catch (error) { - // Likely a duplicate - that's OK - logger.debug({ error, userId: user.id }, 'Failed to create achievement (possibly duplicate)'); - } + logger.info( + { userId: user.id, achievementId: achievement.id }, + 'Bug Slayer achievement earned' + ); + + // Broadcast to followers and self + const followerIds = user.followers.map((f) => f.followerId); + + broadcastToUsers([...followerIds, user.id], 'ACHIEVEMENT', { + achievement: { + id: achievement.id, + type: 'ISSUE_CLOSED', + title: achievementDef.title, + description: achievement.description, + earnedAt: achievement.earnedAt.toISOString(), + }, + userId: user.id, + username: user.username, + }); } - /** - * Handle pull_request event - Award achievements for merged PRs - */ - async function handlePullRequestEvent(payload: GitHubPullRequestPayload): Promise { + async function handlePullRequestEvent( + payload: z.infer + ): Promise { // Only handle merged PRs if (payload.action !== 'closed' || !payload.pull_request.merged) { return; @@ -278,50 +313,66 @@ export function webhookRoutes(app: FastifyInstance): void { return; } + // Check for existing achievement to avoid duplicate key errors + const existingAchievement = await db.achievement.findFirst({ + where: { + userId: user.id, + type: 'PR_MERGED', + metadata: { path: ['prNumber'], equals: pull_request.number }, + }, + }); + + if (existingAchievement) { + logger.debug( + { userId: user.id, prNumber: pull_request.number }, + 'Achievement already exists for this PR' + ); + return; + } + // Create achievement const achievementDef = ACHIEVEMENTS.PR_MERGED; - try { - const achievement = await db.achievement.create({ - data: { - userId: user.id, - type: 'PR_MERGED', - title: achievementDef.title, - description: `Merged PR #${String(pull_request.number)} in ${repository.full_name}`, - metadata: { - prNumber: pull_request.number, - prTitle: pull_request.title, - prUrl: pull_request.html_url, - repository: repository.full_name, - }, + const achievement = await db.achievement.create({ + data: { + userId: user.id, + type: 'PR_MERGED', + title: achievementDef.title, + description: `Merged PR #${String(pull_request.number)} in ${repository.full_name}`, + metadata: { + prNumber: pull_request.number, + prTitle: pull_request.title, + prUrl: pull_request.html_url, + repository: repository.full_name, }, - }); - - logger.info({ userId: user.id, achievementId: achievement.id }, 'Merge Master achievement earned'); - - // Broadcast to followers and self - const followerIds = user.followers.map((f) => f.followerId); + }, + }); - broadcastToUsers([...followerIds, user.id], 'ACHIEVEMENT', { - achievement: { - id: achievement.id, - type: 'PR_MERGED', - title: achievementDef.title, - description: achievement.description, - earnedAt: achievement.earnedAt.toISOString(), - }, - userId: user.id, - username: user.username, - }); - } catch (error) { - logger.debug({ error, userId: user.id }, 'Failed to create achievement (possibly duplicate)'); - } + logger.info( + { userId: user.id, achievementId: achievement.id }, + 'Merge Master achievement earned' + ); + + // Broadcast to followers and self + const followerIds = user.followers.map((f) => f.followerId); + + broadcastToUsers([...followerIds, user.id], 'ACHIEVEMENT', { + achievement: { + id: achievement.id, + type: 'PR_MERGED', + title: achievementDef.title, + description: achievement.description, + earnedAt: achievement.earnedAt.toISOString(), + }, + userId: user.id, + username: user.username, + }); } /** * Handle push event - Track commit counts for leaderboard */ - async function handlePushEvent(payload: GitHubPushPayload): Promise { + async function handlePushEvent(payload: z.infer): Promise { const { sender, commits } = payload; const commitCount = commits.length; From f1f582c57b194ceb9973ae52cd221ee91fcffe1c Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Fri, 9 Jan 2026 20:50:39 +0530 Subject: [PATCH 23/23] fix: improve code quality, security & reliability across extension and server Extension: - Add AbortController with 10s timeout to fetchStats/fetchLeaderboard - Clear provider loading state on fetch errors to avoid stuck spinners - Fix XSS vulnerability in leaderboard tooltip (isTrusted=false, appendText) - Fix setLoading() to always fire tree refresh in both providers - Replace local StatsData interface with UserStatsDTO from shared package Server: - Strengthen GITHUB_WEBHOOK_SECRET validation (trim + min 32 chars) - Add max-length validation (255) to session language/project fields - Extract parseStreakData helper to deduplicate streak parsing logic - Fix Lua streak script timezone issue with UTC yesterday comparison - Fix comment numbering consistency in stats route - Add cursor-based pagination to /achievements endpoint - Fix streak achievements to award all applicable milestones (not just highest) - Fail fast on missing rawBody in webhooks (no re-serialization fallback) - Minimize GitHub push payload schema (remove PII: email, author, pusher) - Harden signature verification (sha256= prefix, getHeader helper, utf8) - Add delivery-id deduplication to prevent duplicate webhook processing - Fix achievement race condition with P2002 error handling Shared: - Add webhookDelivery Redis key for deduplication --- apps/extension/src/extension.ts | 32 ++- apps/extension/src/views/index.ts | 3 +- .../src/views/leaderboardProvider.ts | 15 +- apps/extension/src/views/statsProvider.ts | 18 +- apps/server/src/config.ts | 6 +- apps/server/src/routes/stats.ts | 121 ++++++---- apps/server/src/routes/webhooks.ts | 220 +++++++++++------- packages/shared/src/constants.ts | 3 + 8 files changed, 260 insertions(+), 158 deletions(-) diff --git a/apps/extension/src/extension.ts b/apps/extension/src/extension.ts index 4e72889..f415d65 100644 --- a/apps/extension/src/extension.ts +++ b/apps/extension/src/extension.ts @@ -311,6 +311,11 @@ class DevRadarExtension implements vscode.Disposable { /** Fetch user stats from the server. */ private async fetchStats(): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, 10_000); + try { const token = this.authService.getToken(); if (!token) return; @@ -321,6 +326,7 @@ class DevRadarExtension implements vscode.Disposable { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, + signal: controller.signal, }); if (response.ok) { @@ -328,14 +334,27 @@ class DevRadarExtension implements vscode.Disposable { this.statsProvider.updateStats(json.data); } else { this.logger.warn('Failed to fetch stats', { status: response.status }); + this.statsProvider.setLoading(false); } } catch (error) { - this.logger.warn('Failed to fetch stats', error); + if (error instanceof Error && error.name === 'AbortError') { + this.logger.warn('Stats fetch timed out after 10s'); + } else { + this.logger.warn('Failed to fetch stats', error); + } + this.statsProvider.setLoading(false); + } finally { + clearTimeout(timeout); } } /** Fetch friends leaderboard from the server. */ private async fetchLeaderboard(): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, 10_000); + try { const token = this.authService.getToken(); if (!token) return; @@ -346,6 +365,7 @@ class DevRadarExtension implements vscode.Disposable { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, + signal: controller.signal, }); if (response.ok) { @@ -355,9 +375,17 @@ class DevRadarExtension implements vscode.Disposable { this.leaderboardProvider.updateLeaderboard(json.data.leaderboard, json.data.myRank); } else { this.logger.warn('Failed to fetch leaderboard', { status: response.status }); + this.leaderboardProvider.setLoading(false); } } catch (error) { - this.logger.warn('Failed to fetch leaderboard', error); + if (error instanceof Error && error.name === 'AbortError') { + this.logger.warn('Leaderboard fetch timed out after 10s'); + } else { + this.logger.warn('Failed to fetch leaderboard', error); + } + this.leaderboardProvider.setLoading(false); + } finally { + clearTimeout(timeout); } } diff --git a/apps/extension/src/views/index.ts b/apps/extension/src/views/index.ts index 0482675..0db4ffc 100644 --- a/apps/extension/src/views/index.ts +++ b/apps/extension/src/views/index.ts @@ -2,6 +2,5 @@ export { FriendsProvider, type FriendInfo } from './friendsProvider'; export { FriendRequestsProvider } from './friendRequestsProvider'; export { ActivityProvider, type ActivityEvent } from './activityProvider'; export { StatusBarManager } from './statusBarItem'; -export { StatsProvider, type StatsData } from './statsProvider'; +export { StatsProvider } from './statsProvider'; export { LeaderboardProvider } from './leaderboardProvider'; - diff --git a/apps/extension/src/views/leaderboardProvider.ts b/apps/extension/src/views/leaderboardProvider.ts index c3fdbec..6092829 100644 --- a/apps/extension/src/views/leaderboardProvider.ts +++ b/apps/extension/src/views/leaderboardProvider.ts @@ -58,10 +58,15 @@ class LeaderboardTreeItem extends vscode.TreeItem { /** Build rich tooltip with details. */ private buildTooltip(entry: LeaderboardEntry): vscode.MarkdownString { const md = new vscode.MarkdownString(); - md.isTrusted = true; + md.isTrusted = false; - md.appendMarkdown(`### ${entry.displayName ?? entry.username}\n\n`); - md.appendMarkdown(`**@${entry.username}**\n\n`); + md.appendMarkdown(`### `); + md.appendText(entry.displayName ?? entry.username); + md.appendMarkdown(`\n\n`); + + md.appendMarkdown(`**@`); + md.appendText(entry.username); + md.appendMarkdown(`**\n\n`); md.appendMarkdown(`📊 **Rank:** #${String(entry.rank)}\n\n`); md.appendMarkdown(`⏱️ **This Week:** ${this.formatScore(entry.score)}\n\n`); @@ -168,9 +173,7 @@ export class LeaderboardProvider /** Set loading state. */ setLoading(loading: boolean): void { this.isLoading = loading; - if (loading) { - this.onDidChangeTreeDataEmitter.fire(undefined); - } + this.onDidChangeTreeDataEmitter.fire(undefined); } /** Clear leaderboard data. */ diff --git a/apps/extension/src/views/statsProvider.ts b/apps/extension/src/views/statsProvider.ts index 0e97307..20f90cf 100644 --- a/apps/extension/src/views/statsProvider.ts +++ b/apps/extension/src/views/statsProvider.ts @@ -8,15 +8,7 @@ import * as vscode from 'vscode'; import type { Logger } from '../utils/logger'; -import type { StreakInfo, WeeklyStatsDTO, AchievementDTO } from '@devradar/shared'; - -/** Stats data structure matching API response. */ -export interface StatsData { - streak: StreakInfo; - todaySession: number; - weeklyStats: WeeklyStatsDTO | null; - recentAchievements: AchievementDTO[]; -} +import type { UserStatsDTO } from '@devradar/shared'; /** Tree item for stats display. */ class StatsTreeItem extends vscode.TreeItem { @@ -38,7 +30,7 @@ export class StatsProvider implements vscode.TreeDataProvider, vs private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter< StatsTreeItem | undefined >(); - private stats: StatsData | null = null; + private stats: UserStatsDTO | null = null; private isLoading = true; readonly onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event; @@ -133,7 +125,7 @@ export class StatsProvider implements vscode.TreeDataProvider, vs } /** Updates the stats data and triggers a tree refresh. */ - updateStats(stats: StatsData): void { + updateStats(stats: UserStatsDTO): void { this.stats = stats; this.isLoading = false; this.onDidChangeTreeDataEmitter.fire(undefined); @@ -143,9 +135,7 @@ export class StatsProvider implements vscode.TreeDataProvider, vs /** Set loading state. */ setLoading(loading: boolean): void { this.isLoading = loading; - if (loading) { - this.onDidChangeTreeDataEmitter.fire(undefined); - } + this.onDidChangeTreeDataEmitter.fire(undefined); } /** Clear stats (e.g., on logout). */ diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index ecf1326..39132c3 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -34,7 +34,11 @@ const envSchema = z.object({ GITHUB_CLIENT_SECRET: z.string().min(1, 'GITHUB_CLIENT_SECRET is required'), GITHUB_CALLBACK_URL: z.string().url(), /* GitHub Webhooks (optional - for Gamification feature) */ - GITHUB_WEBHOOK_SECRET: z.string().min(8).optional(), + GITHUB_WEBHOOK_SECRET: z + .string() + .trim() + .min(32, 'GITHUB_WEBHOOK_SECRET must be at least 32 characters') + .optional(), /* Logging */ LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), }); diff --git a/apps/server/src/routes/stats.ts b/apps/server/src/routes/stats.ts index 4985d00..6e9af83 100644 --- a/apps/server/src/routes/stats.ts +++ b/apps/server/src/routes/stats.ts @@ -28,8 +28,14 @@ import { broadcastToUsers } from '@/ws/handler'; /** Schema for session recording payload. */ const SessionPayloadSchema = z.object({ sessionDuration: z.number().int().min(0).max(86400), // Max 24 hours - language: z.string().optional(), - project: z.string().optional(), + language: z.string().max(255).optional(), + project: z.string().max(255).optional(), +}); + +/** Schema for achievements query with pagination. */ +const AchievementsQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).default(50), + cursor: z.string().optional(), }); /** Allowlist of known programming languages to prevent unbounded Redis hash growth. */ @@ -103,6 +109,30 @@ function calculateStreakStatus(lastActiveDate: string | null): StreakInfo['strea return 'broken'; } +/** Get yesterday's date in YYYY-MM-DD format (UTC) for Lua script timezone safety. */ +function getYesterdayDate(): string { + const yesterday = new Date(); + yesterday.setUTCDate(yesterday.getUTCDate() - 1); + const parts = yesterday.toISOString().split('T'); + return parts[0] ?? ''; +} + +/** Parse streak data from Redis hgetall result into StreakInfo. */ +function parseStreakData(data: Record | null): StreakInfo { + const currentStreak = parseInt(data?.count ?? '0', 10); + const longestStreak = parseInt(data?.longest ?? '0', 10); + const lastActiveDate = data?.lastDate ?? null; + const streakStatus = calculateStreakStatus(lastActiveDate); + + return { + currentStreak, + longestStreak, + lastActiveDate, + isActiveToday: streakStatus === 'active', + streakStatus, + }; +} + /** Registers stats routes on the Fastify instance. */ export function statsRoutes(app: FastifyInstance): void { const db = getDb(); @@ -137,19 +167,8 @@ export function statsRoutes(app: FastifyInstance): void { const todaySessionStr = results?.[1]?.[1] as string | null; const todaySession = todaySessionStr ? parseInt(todaySessionStr, 10) : 0; - // Build streak info - const currentStreak = parseInt(streakData?.count ?? '0', 10); - const longestStreak = parseInt(streakData?.longest ?? '0', 10); - const lastActiveDate = streakData?.lastDate ?? null; - const streakStatus = calculateStreakStatus(lastActiveDate); - - const streak: StreakInfo = { - currentStreak, - longestStreak, - lastActiveDate, - isActiveToday: streakStatus === 'active', - streakStatus, - }; + // Build streak info using shared helper + const streak = parseStreakData(streakData); // Get weekly stats from PostgreSQL const weekStart = getWeekStart(); @@ -210,19 +229,7 @@ export function statsRoutes(app: FastifyInstance): void { const redis = getRedis(); const streakData = await redis.hgetall(REDIS_KEYS.streakData(userId)); - - const currentStreak = parseInt(streakData.count ?? '0', 10); - const longestStreak = parseInt(streakData.longest ?? '0', 10); - const lastActiveDate = streakData.lastDate ?? null; - const streakStatus = calculateStreakStatus(lastActiveDate); - - const streak: StreakInfo = { - currentStreak, - longestStreak, - lastActiveDate, - isActiveToday: streakStatus === 'active', - streakStatus, - }; + const streak = parseStreakData(streakData); return reply.send({ data: streak }); } @@ -247,14 +254,16 @@ export function statsRoutes(app: FastifyInstance): void { const { sessionDuration, language, project } = result.data; const today = getTodayDate(); + const yesterday = getYesterdayDate(); const redis = getRedis(); - // Lua script for atomic streak update + // Lua script for atomic streak update (UTC-safe: uses string comparison) // Returns: [newStreak, shouldCheckAchievements] - 1 if streak was updated, 0 otherwise const STREAK_UPDATE_SCRIPT = ` local streakKey = KEYS[1] local today = ARGV[1] local streakTtl = tonumber(ARGV[2]) + local yesterday = ARGV[3] -- Read current streak data local lastDate = redis.call('HGET', streakKey, 'lastDate') @@ -266,21 +275,12 @@ export function statsRoutes(app: FastifyInstance): void { return {count, 0} end - -- Calculate new streak + -- Calculate new streak using UTC-safe string comparison local newStreak = 1 - if lastDate then - -- Parse dates and calculate difference - local ly, lm, ld = lastDate:match('(%d+)-(%d+)-(%d+)') - local ty, tm, td = today:match('(%d+)-(%d+)-(%d+)') - local lastTime = os.time({year=ly, month=lm, day=ld}) - local todayTime = os.time({year=ty, month=tm, day=td}) - local daysDiff = math.floor((todayTime - lastTime) / 86400) - - if daysDiff == 1 then - newStreak = count + 1 - end - -- daysDiff > 1 means streak broken, reset to 1 + if lastDate == yesterday then + newStreak = count + 1 end + -- Any other date means streak broken, reset to 1 local newLongest = math.max(newStreak, longest) @@ -298,7 +298,8 @@ export function statsRoutes(app: FastifyInstance): void { 1, streakKey, today, - (STREAK_TTL_SECONDS * 2).toString() + (STREAK_TTL_SECONDS * 2).toString(), // 50h TTL: 25h grace + 25h safety buffer + yesterday )) as [number, number]; const newStreak = streakResult[0]; @@ -320,7 +321,7 @@ export function statsRoutes(app: FastifyInstance): void { pipeline.hincrby(REDIS_KEYS.networkIntensity(minute), 'count', 1); pipeline.expire(REDIS_KEYS.networkIntensity(minute), NETWORK_ACTIVITY_TTL_SECONDS); - // 5. Track language if provided (validate to prevent unbounded hash growth) + // 4. Track language if provided (validate to prevent unbounded hash growth) if (language) { const normalizedLang = language.toLowerCase().trim(); const safeLang = LANGUAGE_ALLOWLIST.has(normalizedLang) ? normalizedLang : 'other'; @@ -395,20 +396,38 @@ export function statsRoutes(app: FastifyInstance): void { ); /** - * GET /stats/achievements - Get all user achievements + * GET /stats/achievements - Get all user achievements (paginated) */ app.get( '/achievements', { onRequest: [app.authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => { const { userId } = request.user as { userId: string }; + const queryResult = AchievementsQuerySchema.safeParse(request.query); + + if (!queryResult.success) { + return reply.status(400).send({ + error: { code: 'INVALID_QUERY', message: 'Invalid query parameters' }, + }); + } + + const { limit, cursor } = queryResult.data; const achievementRecords = await db.achievement.findMany({ where: { userId }, orderBy: { earnedAt: 'desc' }, + take: limit + 1, // Fetch one extra to check if there's more + ...(cursor && { + cursor: { id: cursor }, + skip: 1, // Skip the cursor item itself + }), }); - const achievements: AchievementDTO[] = achievementRecords.map((a) => ({ + const hasMore = achievementRecords.length > limit; + const items = hasMore ? achievementRecords.slice(0, limit) : achievementRecords; + const nextCursor = hasMore ? items[items.length - 1]?.id : undefined; + + const achievements: AchievementDTO[] = items.map((a) => ({ id: a.id, type: a.type as AchievementDTO['type'], title: a.title, @@ -416,7 +435,13 @@ export function statsRoutes(app: FastifyInstance): void { earnedAt: a.earnedAt.toISOString(), })); - return reply.send({ data: achievements }); + return reply.send({ + data: achievements, + pagination: { + hasMore, + nextCursor, + }, + }); } ); @@ -477,7 +502,7 @@ export function statsRoutes(app: FastifyInstance): void { logger.info({ userId, streak, type: milestone.type }, 'Streak achievement earned'); } } - break; // Only one achievement per streak update + // Continue checking for lower milestones that may not have been awarded } } } diff --git a/apps/server/src/routes/webhooks.ts b/apps/server/src/routes/webhooks.ts index cca70b9..83872ab 100644 --- a/apps/server/src/routes/webhooks.ts +++ b/apps/server/src/routes/webhooks.ts @@ -16,6 +16,7 @@ import { z } from 'zod'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { env } from '@/config'; +import { PrismaClientKnownRequestError } from '@/generated/prisma/internal/prismaNamespace'; import { logger } from '@/lib/logger'; import { getDb } from '@/services/db'; import { getRedis } from '@/services/redis'; @@ -38,17 +39,14 @@ const GitHubIssuesPayloadSchema = z.object({ repository: z.object({ full_name: z.string() }), }); -/** Zod schema for GitHub Push webhook payload. */ +/** Zod schema for GitHub Push webhook payload (minimal - only what's needed for counting). */ const GitHubPushPayloadSchema = z.object({ commits: z.array( z.object({ id: z.string(), - message: z.string(), - author: z.object({ name: z.string(), email: z.string() }), }) ), - pusher: z.object({ name: z.string(), email: z.string() }), - sender: z.object({ id: z.number(), login: z.string() }), + sender: z.object({ id: z.number() }), repository: z.object({ full_name: z.string() }), }); @@ -65,18 +63,34 @@ const GitHubPullRequestPayloadSchema = z.object({ repository: z.object({ full_name: z.string() }), }); +/** + * Safely extract a header value from a request. + * Handles both string and string[] cases (takes first element if array). + */ +function getHeader(req: FastifyRequest, name: string): string | undefined { + const value = req.headers[name.toLowerCase()]; + if (Array.isArray(value)) return value[0]; + return value; +} + /** * Verify GitHub webhook signature using HMAC-SHA256. * Returns true if valid, false otherwise. */ function verifyGitHubSignature(rawBody: Buffer, signature: string, secret: string): boolean { + // Trim and validate signature format + const sig = signature.trim(); + if (!sig.startsWith('sha256=')) { + return false; + } + // Calculate expected signature const expectedSignature = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); - // Constant-time comparison to prevent timing attacks - const signatureBuffer = Buffer.from(signature); - const expectedBuffer = Buffer.from(expectedSignature); + // Constant-time comparison to prevent timing attacks (explicit utf8 encoding) + const signatureBuffer = Buffer.from(sig, 'utf8'); + const expectedBuffer = Buffer.from(expectedSignature, 'utf8'); if (signatureBuffer.length !== expectedBuffer.length) { return false; @@ -85,6 +99,11 @@ function verifyGitHubSignature(rawBody: Buffer, signature: string, secret: strin return crypto.timingSafeEqual(signatureBuffer, expectedBuffer); } +/** Type guard to check if an error is a Prisma unique constraint violation (P2002). */ +function isPrismaUniqueConstraintError(error: unknown): boolean { + return error instanceof PrismaClientKnownRequestError && error.code === 'P2002'; +} + /** Registers webhook routes on the Fastify instance. */ export function webhookRoutes(app: FastifyInstance): void { const db = getDb(); @@ -110,24 +129,20 @@ export function webhookRoutes(app: FastifyInstance): void { }, }, async (request: FastifyRequest, reply: FastifyReply) => { - // 1. Get raw body for signature verification - // Cast to RawBodyRequest to access rawBody if available + // 1. Validate raw body is available (required for signature verification) const rawRequest = request as RawBodyRequest; - let rawBody: Buffer; - - if (rawRequest.rawBody) { - rawBody = rawRequest.rawBody; - } else { - // Fallback: re-serialize (note: this may differ from original body) - logger.warn('Raw body not available, falling back to re-serialization'); - const bodyString = JSON.stringify(request.body); - rawBody = Buffer.from(bodyString, 'utf8'); + if (!rawRequest.rawBody) { + logger.error('Raw body not available - server misconfiguration'); + return reply.status(400).send({ + error: 'Raw body required for signature verification. Server misconfiguration.', + }); } + const rawBody = rawRequest.rawBody; - // 2. Verify signature - const signature = request.headers['x-hub-signature-256'] as string | undefined; - const event = request.headers['x-github-event'] as string | undefined; - const deliveryId = request.headers['x-github-delivery'] as string | undefined; + // 2. Extract headers safely (handles string[] case) + const signature = getHeader(request, 'x-hub-signature-256'); + const event = getHeader(request, 'x-github-event'); + const deliveryId = getHeader(request, 'x-github-delivery'); if (!signature || !event) { logger.warn({ deliveryId }, 'Missing GitHub webhook headers'); @@ -147,6 +162,17 @@ export function webhookRoutes(app: FastifyInstance): void { return reply.status(401).send({ error: 'Invalid signature' }); } + // 3. Dedupe deliveries using Redis (GitHub delivery IDs are globally unique) + if (deliveryId) { + const redis = getRedis(); + const dedupeKey = `${REDIS_KEYS.webhookDelivery}:${deliveryId}`; + const setResult = await redis.set(dedupeKey, '1', 'EX', 60 * 60 * 24, 'NX'); + if (setResult !== 'OK') { + logger.info({ deliveryId, event }, 'Duplicate GitHub webhook delivery ignored'); + return reply.send({ received: true, deduped: true }); + } + } + logger.info({ event, deliveryId }, 'Processing GitHub webhook'); // 3. Handle events with Zod validation @@ -248,43 +274,55 @@ export function webhookRoutes(app: FastifyInstance): void { return; } - // Create achievement + // Create achievement (with race condition protection) const achievementDef = ACHIEVEMENTS.ISSUE_CLOSED; - const achievement = await db.achievement.create({ - data: { - userId: user.id, - type: 'ISSUE_CLOSED', - title: achievementDef.title, - description: `Closed issue #${String(issue.number)} in ${repository.full_name}`, - metadata: { - issueNumber: issue.number, - issueTitle: issue.title, - issueUrl: issue.html_url, - repository: repository.full_name, + try { + const achievement = await db.achievement.create({ + data: { + userId: user.id, + type: 'ISSUE_CLOSED', + title: achievementDef.title, + description: `Closed issue #${String(issue.number)} in ${repository.full_name}`, + metadata: { + issueNumber: issue.number, + issueTitle: issue.title, + issueUrl: issue.html_url, + repository: repository.full_name, + }, }, - }, - }); + }); - logger.info( - { userId: user.id, achievementId: achievement.id }, - 'Bug Slayer achievement earned' - ); + logger.info( + { userId: user.id, achievementId: achievement.id }, + 'Bug Slayer achievement earned' + ); - // Broadcast to followers and self - const followerIds = user.followers.map((f) => f.followerId); + // Broadcast to followers and self + const followerIds = user.followers.map((f) => f.followerId); - broadcastToUsers([...followerIds, user.id], 'ACHIEVEMENT', { - achievement: { - id: achievement.id, - type: 'ISSUE_CLOSED', - title: achievementDef.title, - description: achievement.description, - earnedAt: achievement.earnedAt.toISOString(), - }, - userId: user.id, - username: user.username, - }); + broadcastToUsers([...followerIds, user.id], 'ACHIEVEMENT', { + achievement: { + id: achievement.id, + type: 'ISSUE_CLOSED', + title: achievementDef.title, + description: achievement.description, + earnedAt: achievement.earnedAt.toISOString(), + }, + userId: user.id, + username: user.username, + }); + } catch (error) { + // Ignore unique constraint violations (P2002) from race conditions + if (isPrismaUniqueConstraintError(error)) { + logger.debug( + { userId: user.id, issueNumber: issue.number }, + 'Achievement already exists (race condition handled)' + ); + return; + } + throw error; + } } async function handlePullRequestEvent( @@ -330,43 +368,55 @@ export function webhookRoutes(app: FastifyInstance): void { return; } - // Create achievement + // Create achievement (with race condition protection) const achievementDef = ACHIEVEMENTS.PR_MERGED; - const achievement = await db.achievement.create({ - data: { - userId: user.id, - type: 'PR_MERGED', - title: achievementDef.title, - description: `Merged PR #${String(pull_request.number)} in ${repository.full_name}`, - metadata: { - prNumber: pull_request.number, - prTitle: pull_request.title, - prUrl: pull_request.html_url, - repository: repository.full_name, + try { + const achievement = await db.achievement.create({ + data: { + userId: user.id, + type: 'PR_MERGED', + title: achievementDef.title, + description: `Merged PR #${String(pull_request.number)} in ${repository.full_name}`, + metadata: { + prNumber: pull_request.number, + prTitle: pull_request.title, + prUrl: pull_request.html_url, + repository: repository.full_name, + }, }, - }, - }); + }); - logger.info( - { userId: user.id, achievementId: achievement.id }, - 'Merge Master achievement earned' - ); + logger.info( + { userId: user.id, achievementId: achievement.id }, + 'Merge Master achievement earned' + ); - // Broadcast to followers and self - const followerIds = user.followers.map((f) => f.followerId); + // Broadcast to followers and self + const followerIds = user.followers.map((f) => f.followerId); - broadcastToUsers([...followerIds, user.id], 'ACHIEVEMENT', { - achievement: { - id: achievement.id, - type: 'PR_MERGED', - title: achievementDef.title, - description: achievement.description, - earnedAt: achievement.earnedAt.toISOString(), - }, - userId: user.id, - username: user.username, - }); + broadcastToUsers([...followerIds, user.id], 'ACHIEVEMENT', { + achievement: { + id: achievement.id, + type: 'PR_MERGED', + title: achievementDef.title, + description: achievement.description, + earnedAt: achievement.earnedAt.toISOString(), + }, + userId: user.id, + username: user.username, + }); + } catch (error) { + // Ignore unique constraint violations (P2002) from race conditions + if (isPrismaUniqueConstraintError(error)) { + logger.debug( + { userId: user.id, prNumber: pull_request.number }, + 'Achievement already exists (race condition handled)' + ); + return; + } + throw error; + } } /** diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 2cd8c60..b26bba7 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -52,6 +52,9 @@ export const REDIS_KEYS = { // Network activity (Phase 2) networkActivity: () => `network:activity`, networkIntensity: (minute: number) => `network:intensity:${String(minute)}`, + + // Webhook deduplication (Phase 2) + webhookDelivery: 'webhook:github:delivery', } as const; /** Default file patterns excluded from activity broadcast. */