import { NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; import { db } from '@/lib/db'; import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; import { TrendData } from '@/types/analytics'; export const dynamic = 'force-dynamic'; // Helper function to calculate trend with proper edge case handling function calculateTrend(current: number, previous: number): TrendData { // Handle edge case: no data in either period if (previous === 0 && current === 0) { return { trend: 'flat', percentage: 0 }; } // Handle new growth from zero - mark as "new" to distinguish from actual 100% growth if (previous === 0 && current > 0) { return { trend: 'up', percentage: 100, isNew: true }; } // Calculate actual percentage change const change = ((current - previous) / previous) * 100; const roundedChange = Math.round(change); // Determine trend direction (use threshold of 5% to filter noise) let trend: 'up' | 'down' | 'flat'; if (roundedChange > 5) { trend = 'up'; } else if (roundedChange < -5) { trend = 'down'; } else { trend = 'flat'; } return { trend, percentage: Math.abs(roundedChange), isNegative: roundedChange < 0 }; } export async function GET(request: NextRequest) { try { const userId = cookies().get('userId')?.value; // Rate Limiting (user-based) const clientId = userId || getClientIdentifier(request); const rateLimitResult = rateLimit(clientId, RateLimits.ANALYTICS); if (!rateLimitResult.success) { return NextResponse.json( { error: 'Too many requests. Please try again later.', retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000) }, { status: 429, headers: { 'X-RateLimit-Limit': rateLimitResult.limit.toString(), 'X-RateLimit-Remaining': rateLimitResult.remaining.toString(), 'X-RateLimit-Reset': rateLimitResult.reset.toString(), } } ); } if (!userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } // Get date range from query params (default: last 30 days) const { searchParams } = request.nextUrl; const range = searchParams.get('range') || '30'; const daysInRange = parseInt(range, 10); // Standardize to week (7 days) or month (30 days) for clear comparison labels const comparisonDays = daysInRange <= 7 ? 7 : 30; const comparisonPeriod: 'week' | 'month' = comparisonDays === 7 ? 'week' : 'month'; // Calculate current and previous period dates const now = new Date(); const currentPeriodStart = new Date(); currentPeriodStart.setDate(now.getDate() - comparisonDays); const previousPeriodEnd = new Date(currentPeriodStart); const previousPeriodStart = new Date(previousPeriodEnd); previousPeriodStart.setDate(previousPeriodEnd.getDate() - comparisonDays); // Get user's QR codes with scans filtered by period const qrCodes = await db.qRCode.findMany({ where: { userId }, include: { scans: { where: { ts: { gte: currentPeriodStart, }, }, }, }, }); // Get previous period scans for comparison const qrCodesWithPreviousScans = await db.qRCode.findMany({ where: { userId }, include: { scans: { where: { ts: { gte: previousPeriodStart, lt: previousPeriodEnd, }, }, }, }, }); // Calculate current period stats const totalScans = qrCodes.reduce((sum, qr) => sum + qr.scans.length, 0); const uniqueScans = qrCodes.reduce((sum, qr) => sum + qr.scans.filter(s => s.isUnique).length, 0 ); // Calculate previous period stats for comparison const previousTotalScans = qrCodesWithPreviousScans.reduce((sum, qr) => sum + qr.scans.length, 0); const previousUniqueScans = qrCodesWithPreviousScans.reduce((sum, qr) => sum + qr.scans.filter(s => s.isUnique).length, 0 ); // Calculate average scans per QR code (only count QR codes with scans) const qrCodesWithScans = qrCodes.filter(qr => qr.scans.length > 0).length; const avgScansPerQR = qrCodesWithScans > 0 ? Math.round(totalScans / qrCodesWithScans) : 0; // Calculate previous period average scans per QR const previousQrCodesWithScans = qrCodesWithPreviousScans.filter(qr => qr.scans.length > 0).length; const previousAvgScansPerQR = previousQrCodesWithScans > 0 ? Math.round(previousTotalScans / previousQrCodesWithScans) : 0; // Calculate trends const scansTrend = calculateTrend(totalScans, previousTotalScans); // New Conversion Rate Logic: (Unique Scans / Total Scans) * 100 // This represents "Engagement Efficiency" - how many scans are from fresh users const currentConversion = totalScans > 0 ? Math.round((uniqueScans / totalScans) * 100) : 0; const previousConversion = previousTotalScans > 0 ? Math.round((previousUniqueScans / previousTotalScans) * 100) : 0; const avgScansTrend = calculateTrend(avgScansPerQR, previousAvgScansPerQR); // Device stats const deviceStats = qrCodes.flatMap(qr => qr.scans) .reduce((acc, scan) => { const device = scan.device || 'unknown'; acc[device] = (acc[device] || 0) + 1; return acc; }, {} as Record); const mobileScans = (deviceStats.mobile || 0) + (deviceStats.tablet || 0); const mobilePercentage = totalScans > 0 ? Math.round((mobileScans / totalScans) * 100) : 0; // Country stats (current period) const countryStats = qrCodes.flatMap(qr => qr.scans) .reduce((acc, scan) => { const country = scan.country ?? 'Unknown Location'; acc[country] = (acc[country] || 0) + 1; return acc; }, {} as Record); // Country stats (previous period) const previousCountryStats = qrCodesWithPreviousScans.flatMap(qr => qr.scans) .reduce((acc, scan) => { const country = scan.country ?? 'Unknown Location'; acc[country] = (acc[country] || 0) + 1; return acc; }, {} as Record); const topCountry = Object.entries(countryStats) .sort(([, a], [, b]) => b - a)[0]; // Daily scan counts for chart (current period) const dailyScans = qrCodes.flatMap(qr => qr.scans).reduce((acc, scan) => { const date = new Date(scan.ts).toISOString().split('T')[0]; acc[date] = (acc[date] || 0) + 1; return acc; }, {} as Record); // Generate last 7 days for sparkline const last7Days = Array.from({ length: 7 }, (_, i) => { const date = new Date(); date.setDate(date.getDate() - (6 - i)); return date.toISOString().split('T')[0]; }); // QR performance (only show DYNAMIC QR codes since STATIC don't track scans) const qrPerformance = qrCodes .filter(qr => qr.type === 'DYNAMIC') .map(qr => { const currentTotal = qr.scans.length; const currentUnique = qr.scans.filter(s => s.isUnique).length; // Find previous period data for this QR code const previousQR = qrCodesWithPreviousScans.find(prev => prev.id === qr.id); const previousTotal = previousQR ? previousQR.scans.length : 0; // Calculate trend const trendData = calculateTrend(currentTotal, previousTotal); // Calculate sparkline data (scans per day for last 7 days) const sparklineData = last7Days.map(date => { return qr.scans.filter(s => new Date(s.ts).toISOString().split('T')[0] === date ).length; }); // Find last scanned date const lastScanned = qr.scans.length > 0 ? new Date(Math.max(...qr.scans.map(s => new Date(s.ts).getTime()))) : null; return { id: qr.id, title: qr.title, type: qr.type, totalScans: currentTotal, uniqueScans: currentUnique, conversion: currentTotal > 0 ? Math.round((currentUnique / currentTotal) * 100) : 0, trend: trendData.trend, trendPercentage: trendData.percentage, sparkline: sparklineData, lastScanned: lastScanned?.toISOString() || null, ...(trendData.isNew && { isNew: true }), }; }) .sort((a, b) => b.totalScans - a.totalScans); return NextResponse.json({ summary: { totalScans, uniqueScans, avgScansPerQR, mobilePercentage, topCountry: topCountry ? topCountry[0] : 'N/A', topCountryPercentage: topCountry && totalScans > 0 ? Math.round((topCountry[1] / totalScans) * 100) : 0, scansTrend, avgScansTrend, comparisonPeriod, comparisonDays, }, deviceStats, countryStats: Object.entries(countryStats) .sort(([, a], [, b]) => b - a) .slice(0, 10) .map(([country, count]) => { const previousCount = previousCountryStats[country] || 0; const trendData = calculateTrend(count, previousCount); return { country, count, percentage: totalScans > 0 ? Math.round((count / totalScans) * 100) : 0, trend: trendData.trend, trendPercentage: trendData.percentage, ...(trendData.isNew && { isNew: true }), }; }), dailyScans, qrPerformance: qrPerformance.slice(0, 10), }); } catch (error) { console.error('Error fetching analytics:', error); return NextResponse.json( { error: 'Internal server error' }, { status: 500 } ); } }