288 lines
10 KiB
TypeScript
288 lines
10 KiB
TypeScript
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(currentConversion, previousConversion);
|
|
|
|
// 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<string, number>);
|
|
|
|
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<string, number>);
|
|
|
|
// 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<string, number>);
|
|
|
|
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<string, number>);
|
|
|
|
// 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: currentConversion, // Now sending Unique Rate instead of Avg per QR
|
|
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 }
|
|
);
|
|
}
|
|
} |