const express = require("express"); const auth = require("../middleware/auth"); const { asyncHandler } = require("../middleware/errorHandler"); const { getCacheMiddleware, invalidateCacheByPattern } = require("../middleware/cache"); const couchdbService = require("../services/couchdbService"); const router = express.Router(); /** * Parse timeframe parameter to date filter */ const getTimeframeFilter = (timeframe = "all") => { const now = new Date(); let startDate = null; switch (timeframe) { case "7d": startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); break; case "30d": startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); break; case "90d": startDate = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000); break; case "all": default: return null; } return startDate ? startDate.toISOString() : null; }; /** * Group data by time period (day, week, month) */ const groupByTimePeriod = (data, groupBy = "day", dateField = "createdAt") => { const grouped = {}; data.forEach((item) => { const date = new Date(item[dateField]); let key; switch (groupBy) { case "week": const weekStart = new Date(date); weekStart.setDate(date.getDate() - date.getDay()); key = weekStart.toISOString().split("T")[0]; break; case "month": key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; break; case "day": default: key = date.toISOString().split("T")[0]; break; } if (!grouped[key]) { grouped[key] = []; } grouped[key].push(item); }); return Object.keys(grouped) .sort() .map((key) => ({ period: key, count: grouped[key].length, items: grouped[key], })); }; /** * GET /api/analytics/overview * Get overall platform statistics */ router.get( "/overview", auth, getCacheMiddleware(300), // Cache for 5 minutes asyncHandler(async (req, res) => { const { timeframe = "all" } = req.query; const startDate = getTimeframeFilter(timeframe); // Build queries const userQuery = { selector: { type: "user" } }; const streetQuery = { selector: { type: "street" } }; const taskQuery = { selector: { type: "task" } }; const eventQuery = { selector: { type: "event" } }; const postQuery = { selector: { type: "post" } }; // Add timeframe filters if specified if (startDate) { taskQuery.selector.createdAt = { $gte: startDate }; eventQuery.selector.createdAt = { $gte: startDate }; postQuery.selector.createdAt = { $gte: startDate }; } // Execute queries in parallel const [users, streets, tasks, events, posts] = await Promise.all([ couchdbService.find(userQuery), couchdbService.find(streetQuery), couchdbService.find(taskQuery), couchdbService.find(eventQuery), couchdbService.find(postQuery), ]); // Calculate statistics const adoptedStreets = streets.filter((s) => s.status === "adopted").length; const completedTasks = tasks.filter((t) => t.status === "completed").length; const activeEvents = events.filter((e) => e.status === "upcoming").length; const totalPoints = users.reduce((sum, user) => sum + (user.points || 0), 0); const averagePointsPerUser = users.length > 0 ? Math.round(totalPoints / users.length) : 0; res.json({ overview: { totalUsers: users.length, totalStreets: streets.length, adoptedStreets, availableStreets: streets.length - adoptedStreets, totalTasks: tasks.length, completedTasks, pendingTasks: tasks.length - completedTasks, totalEvents: events.length, activeEvents, completedEvents: events.filter((e) => e.status === "completed").length, totalPosts: posts.length, totalPoints, averagePointsPerUser, }, timeframe, }); }), ); /** * GET /api/analytics/user/:userId * Get user-specific analytics */ router.get( "/user/:userId", auth, getCacheMiddleware(300), // Cache for 5 minutes asyncHandler(async (req, res) => { const { userId } = req.params; const { timeframe = "all" } = req.query; const startDate = getTimeframeFilter(timeframe); // Get user const user = await couchdbService.findUserById(userId); if (!user) { return res.status(404).json({ msg: "User not found" }); } // Build queries for user's activity const taskQuery = { selector: { type: "task", "completedBy.userId": userId, }, }; const postQuery = { selector: { type: "post", "user.userId": userId, }, }; const eventQuery = { selector: { type: "event", participants: { $elemMatch: { userId: userId }, }, }, }; const transactionQuery = { selector: { type: "point_transaction", "user.userId": userId, }, }; // Add timeframe filters if specified if (startDate) { taskQuery.selector.createdAt = { $gte: startDate }; postQuery.selector.createdAt = { $gte: startDate }; eventQuery.selector.createdAt = { $gte: startDate }; transactionQuery.selector.createdAt = { $gte: startDate }; } // Execute queries in parallel const [tasks, posts, events, transactions] = await Promise.all([ couchdbService.find(taskQuery), couchdbService.find(postQuery), couchdbService.find(eventQuery), couchdbService.find(transactionQuery), ]); // Get adopted streets const adoptedStreetsDetails = await Promise.all( (user.adoptedStreets || []).map((streetId) => couchdbService.getDocument(streetId)), ); // Calculate points earned/spent const pointsEarned = transactions .filter((t) => t.amount > 0) .reduce((sum, t) => sum + t.amount, 0); const pointsSpent = transactions .filter((t) => t.amount < 0) .reduce((sum, t) => sum + Math.abs(t.amount), 0); // Calculate engagement metrics const totalLikesReceived = posts.reduce((sum, post) => sum + (post.likesCount || 0), 0); const totalCommentsReceived = posts.reduce((sum, post) => sum + (post.commentsCount || 0), 0); res.json({ user: { id: user._id, name: user.name, email: user.email, points: user.points || 0, isPremium: user.isPremium || false, }, stats: { streetsAdopted: adoptedStreetsDetails.filter(Boolean).length, tasksCompleted: tasks.length, postsCreated: posts.length, eventsParticipated: events.length, badgesEarned: (user.earnedBadges || []).length, pointsEarned, pointsSpent, totalLikesReceived, totalCommentsReceived, }, recentActivity: { tasks: tasks.slice(0, 5), posts: posts.slice(0, 5), events: events.slice(0, 5), }, timeframe, }); }), ); /** * GET /api/analytics/activity * Get activity over time */ router.get( "/activity", auth, getCacheMiddleware(300), // Cache for 5 minutes asyncHandler(async (req, res) => { const { timeframe = "30d", groupBy = "day" } = req.query; const startDate = getTimeframeFilter(timeframe); // Build queries const taskQuery = { selector: { type: "task" } }; const postQuery = { selector: { type: "post" } }; const eventQuery = { selector: { type: "event" } }; const streetQuery = { selector: { type: "street", status: "adopted" } }; // Add timeframe filters if (startDate) { taskQuery.selector.createdAt = { $gte: startDate }; postQuery.selector.createdAt = { $gte: startDate }; eventQuery.selector.createdAt = { $gte: startDate }; streetQuery.selector["adoptedBy.userId"] = { $exists: true }; } // Execute queries in parallel const [tasks, posts, events, streets] = await Promise.all([ couchdbService.find(taskQuery), couchdbService.find(postQuery), couchdbService.find(eventQuery), couchdbService.find(streetQuery), ]); // Filter by timeframe const filterByTimeframe = (items) => { if (!startDate) return items; return items.filter((item) => { const itemDate = new Date(item.createdAt); return itemDate >= new Date(startDate); }); }; const filteredTasks = filterByTimeframe(tasks); const filteredPosts = filterByTimeframe(posts); const filteredEvents = filterByTimeframe(events); const filteredStreets = filterByTimeframe(streets); // Group by time period const groupedTasks = groupByTimePeriod(filteredTasks, groupBy); const groupedPosts = groupByTimePeriod(filteredPosts, groupBy); const groupedEvents = groupByTimePeriod(filteredEvents, groupBy); const groupedStreets = groupByTimePeriod(filteredStreets, groupBy); // Combine all periods const allPeriods = new Set([ ...groupedTasks.map((g) => g.period), ...groupedPosts.map((g) => g.period), ...groupedEvents.map((g) => g.period), ...groupedStreets.map((g) => g.period), ]); const activityData = Array.from(allPeriods) .sort() .map((period) => ({ period, tasks: groupedTasks.find((g) => g.period === period)?.count || 0, posts: groupedPosts.find((g) => g.period === period)?.count || 0, events: groupedEvents.find((g) => g.period === period)?.count || 0, streetsAdopted: groupedStreets.find((g) => g.period === period)?.count || 0, })); res.json({ activity: activityData, timeframe, groupBy, summary: { totalTasks: filteredTasks.length, totalPosts: filteredPosts.length, totalEvents: filteredEvents.length, totalStreetsAdopted: filteredStreets.length, }, }); }), ); /** * GET /api/analytics/top-contributors * Get top contributing users */ router.get( "/top-contributors", auth, getCacheMiddleware(300), // Cache for 5 minutes asyncHandler(async (req, res) => { const { limit = 10, timeframe = "all", metric = "points" } = req.query; const startDate = getTimeframeFilter(timeframe); // Get all users const users = await couchdbService.find({ selector: { type: "user" }, }); // If timeframe is specified, calculate contributions within that timeframe let contributors; if (startDate && metric !== "points") { // For time-based metrics, query activities const contributorsWithActivity = await Promise.all( users.map(async (user) => { const taskQuery = { selector: { type: "task", "completedBy.userId": user._id, createdAt: { $gte: startDate }, }, }; const postQuery = { selector: { type: "post", "user.userId": user._id, createdAt: { $gte: startDate }, }, }; const streetQuery = { selector: { type: "street", "adoptedBy.userId": user._id, }, }; const [tasks, posts, streets] = await Promise.all([ couchdbService.find(taskQuery), couchdbService.find(postQuery), couchdbService.find(streetQuery), ]); let score = 0; switch (metric) { case "tasks": score = tasks.length; break; case "posts": score = posts.length; break; case "streets": score = streets.length; break; default: score = user.points || 0; } return { userId: user._id, name: user.name, email: user.email, profilePicture: user.profilePicture, isPremium: user.isPremium, score, stats: { points: user.points || 0, tasksCompleted: tasks.length, postsCreated: posts.length, streetsAdopted: streets.length, badgesEarned: (user.earnedBadges || []).length, }, }; }), ); contributors = contributorsWithActivity .filter((c) => c.score > 0) .sort((a, b) => b.score - a.score) .slice(0, parseInt(limit)); } else { // For all-time or points metric, use user data directly contributors = users .map((user) => { let score = 0; switch (metric) { case "tasks": score = user.stats?.tasksCompleted || 0; break; case "posts": score = user.stats?.postsCreated || 0; break; case "streets": score = user.stats?.streetsAdopted || 0; break; default: score = user.points || 0; } return { userId: user._id, name: user.name, email: user.email, profilePicture: user.profilePicture, isPremium: user.isPremium, score, stats: { points: user.points || 0, tasksCompleted: user.stats?.tasksCompleted || 0, postsCreated: user.stats?.postsCreated || 0, streetsAdopted: user.stats?.streetsAdopted || 0, badgesEarned: (user.earnedBadges || []).length, }, }; }) .filter((c) => c.score > 0) .sort((a, b) => b.score - a.score) .slice(0, parseInt(limit)); } res.json({ contributors, metric, timeframe, limit: parseInt(limit), }); }), ); /** * GET /api/analytics/street-stats * Get street adoption and task completion statistics */ router.get( "/street-stats", auth, getCacheMiddleware(300), // Cache for 5 minutes asyncHandler(async (req, res) => { const { timeframe = "all" } = req.query; const startDate = getTimeframeFilter(timeframe); // Get all streets const streets = await couchdbService.find({ selector: { type: "street" }, }); // Get all tasks const taskQuery = { selector: { type: "task" } }; if (startDate) { taskQuery.selector.createdAt = { $gte: startDate }; } const tasks = await couchdbService.find(taskQuery); // Calculate street statistics const totalStreets = streets.length; const adoptedStreets = streets.filter((s) => s.status === "adopted").length; const availableStreets = streets.filter((s) => s.status === "available").length; const adoptionRate = totalStreets > 0 ? ((adoptedStreets / totalStreets) * 100).toFixed(2) : 0; // Task statistics const totalTasks = tasks.length; const completedTasks = tasks.filter((t) => t.status === "completed").length; const pendingTasks = tasks.filter((t) => t.status === "pending").length; const inProgressTasks = tasks.filter((t) => t.status === "in_progress").length; const completionRate = totalTasks > 0 ? ((completedTasks / totalTasks) * 100).toFixed(2) : 0; // Top streets by task completion const streetTaskCounts = {}; tasks .filter((t) => t.status === "completed" && t.street?.streetId) .forEach((task) => { const streetId = task.street.streetId; if (!streetTaskCounts[streetId]) { streetTaskCounts[streetId] = { streetId, streetName: task.street.name, count: 0, }; } streetTaskCounts[streetId].count++; }); const topStreets = Object.values(streetTaskCounts) .sort((a, b) => b.count - a.count) .slice(0, 10); res.json({ adoption: { totalStreets, adoptedStreets, availableStreets, adoptionRate: parseFloat(adoptionRate), }, tasks: { totalTasks, completedTasks, pendingTasks, inProgressTasks, completionRate: parseFloat(completionRate), }, topStreets, timeframe, }); }), ); module.exports = router;