import { FastifyInstance, FastifyRequest } from 'fastify'; import bcrypt from 'bcryptjs'; import { prisma } from '../../../config/database'; import { authenticate } from '../middleware/auth'; interface UpdateSettingsBody { showOnlineStatus?: boolean; showCurrentlyWatching?: boolean; showInFriendActivity?: boolean; anonymizePublicComments?: boolean; hidePublicReactions?: boolean; hidePublicFinishes?: boolean; allowFriendRequests?: boolean; closeFriendsOnlyWatching?: boolean; } interface UpdateProfileBody { name?: string; } interface ChangePasswordBody { currentPassword: string; newPassword: string; } interface WatchHistoryQuery { limit?: string; offset?: string; } const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{12,}$/; export async function userProfileRoutes(fastify: FastifyInstance) { // ─── Stats ───────────────────────────────────────────────── /** * GET /me/stats * Returns UserStats (upsert if missing), recent daily activity, achievement count */ fastify.get( '/me/stats', { preHandler: [authenticate] }, async (request: FastifyRequest, reply) => { const userId = request.user!.id; // Upsert UserStats (create with defaults if missing) const stats = await prisma.userStats.upsert({ where: { userId }, create: { userId }, update: {}, }); // Last 30 days of daily activity const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); const dateStr = thirtyDaysAgo.toISOString().split('T')[0]; const dailyActivity = await prisma.userDailyActivity.findMany({ where: { userId, activityDate: { gte: dateStr }, }, orderBy: { activityDate: 'asc' }, }); // Achievement count const achievementCount = await prisma.userAchievement.count({ where: { userId }, }); return reply.send({ stats, dailyActivity, achievementCount, }); } ); /** * GET /me/watch-history * Paginated recent VideoView records with video info */ fastify.get( '/me/watch-history', { preHandler: [authenticate] }, async ( request: FastifyRequest<{ Querystring: WatchHistoryQuery }>, reply ) => { const userId = request.user!.id; const limit = Math.min(parseInt(request.query.limit || '20', 10), 50); const offset = parseInt(request.query.offset || '0', 10); const [views, total] = await Promise.all([ prisma.videoView.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, take: limit, skip: offset, select: { id: true, watchTimeSeconds: true, completed: true, createdAt: true, video: { select: { id: true, title: true, filename: true, thumbnailPath: true, durationSeconds: true, }, }, }, }), prisma.videoView.count({ where: { userId } }), ]); return reply.send({ views, total, limit, offset }); } ); /** * POST /me/stats/recalculate * Recompute UserStats from raw data */ fastify.post( '/me/stats/recalculate', { preHandler: [authenticate] }, async (request: FastifyRequest, reply) => { const userId = request.user!.id; // Aggregate from raw tables const [viewAgg, commentCount, reactionCount, finishCount, dailyActivity] = await Promise.all([ prisma.videoView.aggregate({ where: { userId }, _sum: { watchTimeSeconds: true }, _count: true, }), prisma.comment.count({ where: { userId } }), prisma.videoReaction.count({ where: { userId } }), prisma.userFinish.count({ where: { userId } }), prisma.userDailyActivity.findMany({ where: { userId }, orderBy: { activityDate: 'desc' }, select: { activityDate: true, firstActivityHour: true }, }), ]); // Calculate streaks from daily activity let currentStreak = 0; let longestStreak = 0; let tempStreak = 0; let nightOwlCount = 0; let earlyBirdCount = 0; // Sort descending (most recent first) for streak calculation const sortedDates = dailyActivity .map((d) => d.activityDate) .sort() .reverse(); const today = new Date().toISOString().split('T')[0]; for (let i = 0; i < sortedDates.length; i++) { const date = new Date(sortedDates[i]); const expected = new Date(today); expected.setDate(expected.getDate() - i); if (date.toISOString().split('T')[0] === expected.toISOString().split('T')[0]) { tempStreak++; } else { break; } } currentStreak = tempStreak; // Calculate longest streak (ascending order) const ascending = [...sortedDates].reverse(); tempStreak = 1; for (let i = 1; i < ascending.length; i++) { const prev = new Date(ascending[i - 1]); const curr = new Date(ascending[i]); const diffMs = curr.getTime() - prev.getTime(); const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24)); if (diffDays === 1) { tempStreak++; } else { longestStreak = Math.max(longestStreak, tempStreak); tempStreak = 1; } } longestStreak = Math.max(longestStreak, tempStreak); if (ascending.length === 0) longestStreak = 0; // Night owl (activity hour >= 22 or < 5) vs Early bird (5-9) for (const d of dailyActivity) { if (d.firstActivityHour != null) { if (d.firstActivityHour >= 22 || d.firstActivityHour < 5) { nightOwlCount++; } else if (d.firstActivityHour >= 5 && d.firstActivityHour < 10) { earlyBirdCount++; } } } // Longest single session from VideoView const longestSession = await prisma.videoView.aggregate({ where: { userId }, _max: { watchTimeSeconds: true }, }); const stats = await prisma.userStats.upsert({ where: { userId }, create: { userId, totalWatchTimeSeconds: viewAgg._sum.watchTimeSeconds || 0, totalVideosWatched: viewAgg._count, totalCommentsMade: commentCount, totalUpvotesGiven: reactionCount, totalFinishes: finishCount, currentDayStreak: currentStreak, longestDayStreak: longestStreak, nightOwlCount, earlyBirdCount, longestSingleSession: longestSession._max.watchTimeSeconds || 0, lastActiveDate: today, updatedAt: new Date(), }, update: { totalWatchTimeSeconds: viewAgg._sum.watchTimeSeconds || 0, totalVideosWatched: viewAgg._count, totalCommentsMade: commentCount, totalUpvotesGiven: reactionCount, totalFinishes: finishCount, currentDayStreak: currentStreak, longestDayStreak: longestStreak, nightOwlCount, earlyBirdCount, longestSingleSession: longestSession._max.watchTimeSeconds || 0, lastActiveDate: today, updatedAt: new Date(), }, }); return reply.send({ stats, recalculated: true }); } ); // ─── Settings ────────────────────────────────────────────── /** * GET /me/settings * Returns PrivacySettings (upsert defaults if missing) + user profile */ fastify.get( '/me/settings', { preHandler: [authenticate] }, async (request: FastifyRequest, reply) => { const userId = request.user!.id; const [privacy, user] = await Promise.all([ prisma.privacySettings.upsert({ where: { userId }, create: { userId }, update: {}, }), prisma.user.findUnique({ where: { id: userId }, select: { name: true, email: true }, }), ]); return reply.send({ privacy, profile: user }); } ); /** * PUT /me/settings * Update PrivacySettings boolean toggles */ fastify.put( '/me/settings', { preHandler: [authenticate] }, async ( request: FastifyRequest<{ Body: UpdateSettingsBody }>, reply ) => { const userId = request.user!.id; const body = request.body; // Only allow known boolean fields const allowedFields: (keyof UpdateSettingsBody)[] = [ 'showOnlineStatus', 'showCurrentlyWatching', 'showInFriendActivity', 'anonymizePublicComments', 'hidePublicReactions', 'hidePublicFinishes', 'allowFriendRequests', 'closeFriendsOnlyWatching', ]; const updateData: Record = {}; for (const field of allowedFields) { if (typeof body[field] === 'boolean') { updateData[field] = body[field] as boolean; } } const privacy = await prisma.privacySettings.upsert({ where: { userId }, create: { userId, ...updateData, updatedAt: new Date() }, update: { ...updateData, updatedAt: new Date() }, }); return reply.send({ privacy }); } ); /** * PUT /me/profile * Update user name (email is read-only) */ fastify.put( '/me/profile', { preHandler: [authenticate] }, async ( request: FastifyRequest<{ Body: UpdateProfileBody }>, reply ) => { const userId = request.user!.id; const { name } = request.body; if (name !== undefined && (typeof name !== 'string' || name.length > 100)) { return reply.code(400).send({ message: 'Name must be a string under 100 characters' }); } const user = await prisma.user.update({ where: { id: userId }, data: { name: name?.trim() || null }, select: { name: true, email: true }, }); return reply.send({ profile: user }); } ); /** * PUT /me/password * Change password (requires current password verification) */ fastify.put( '/me/password', { preHandler: [authenticate] }, async ( request: FastifyRequest<{ Body: ChangePasswordBody }>, reply ) => { const userId = request.user!.id; const { currentPassword, newPassword } = request.body; if (!currentPassword || !newPassword) { return reply .code(400) .send({ message: 'Current password and new password are required' }); } // Validate new password meets policy if (!PASSWORD_REGEX.test(newPassword)) { return reply.code(400).send({ message: 'Password must be at least 12 characters with uppercase, lowercase, and a digit', }); } // Fetch current password hash const user = await prisma.user.findUnique({ where: { id: userId }, select: { password: true }, }); if (!user) { return reply.code(404).send({ message: 'User not found' }); } // Verify current password const isValid = await bcrypt.compare(currentPassword, user.password); if (!isValid) { return reply.code(401).send({ message: 'Current password is incorrect' }); } // Hash and save new password const hashedPassword = await bcrypt.hash(newPassword, 12); await prisma.user.update({ where: { id: userId }, data: { password: hashedPassword }, }); return reply.send({ message: 'Password updated successfully' }); } ); }