feat(api): require session authentication for admin routes and improve error handling fix(api): streamline project image generation by fetching data directly from the database fix(api): optimize project import/export functionality with session validation and improved error handling fix(api): enhance analytics dashboard and email manager with session token for admin requests fix(components): improve loading states and dynamic imports for better user experience chore(security): update Content Security Policy to avoid unsafe-eval in production chore(deps): update package.json scripts for consistent environment handling in linting and testing
175 lines
6.7 KiB
TypeScript
175 lines
6.7 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { prisma, projectService } from '@/lib/prisma';
|
|
import { analyticsCache } from '@/lib/redis';
|
|
import { requireSessionAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
|
|
|
|
export async function GET(request: NextRequest) {
|
|
try {
|
|
// Rate limiting - more generous for admin dashboard
|
|
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
|
if (!checkRateLimit(ip, 20, 60000)) { // 20 requests per minute
|
|
return new NextResponse(
|
|
JSON.stringify({ error: 'Rate limit exceeded' }),
|
|
{
|
|
status: 429,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...getRateLimitHeaders(ip, 20, 60000)
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
// Admin-only endpoint: require explicit admin header AND a valid signed session token
|
|
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
|
|
if (!isAdminRequest) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
|
|
const authError = requireSessionAuth(request);
|
|
if (authError) return authError;
|
|
|
|
// Check cache first (but allow bypass with cache-bust parameter)
|
|
const url = new URL(request.url);
|
|
const bypassCache = url.searchParams.get('nocache') === 'true';
|
|
|
|
if (!bypassCache) {
|
|
const cachedStats = await analyticsCache.getOverallStats();
|
|
if (cachedStats) {
|
|
return NextResponse.json(cachedStats);
|
|
}
|
|
}
|
|
|
|
// Get analytics data
|
|
const projectsResult = await projectService.getAllProjects();
|
|
const projects = projectsResult.projects || projectsResult;
|
|
const performanceStats = await projectService.getPerformanceStats();
|
|
|
|
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
|
|
// Use DB aggregation instead of loading every PageView row into memory
|
|
const [totalViews, sessionsByIp, viewsByProjectRows] = await Promise.all([
|
|
prisma.pageView.count({ where: { timestamp: { gte: since } } }),
|
|
prisma.pageView.groupBy({
|
|
by: ['ip'],
|
|
where: {
|
|
timestamp: { gte: since },
|
|
ip: { not: null },
|
|
},
|
|
_count: { _all: true },
|
|
_min: { timestamp: true },
|
|
_max: { timestamp: true },
|
|
}),
|
|
prisma.pageView.groupBy({
|
|
by: ['projectId'],
|
|
where: {
|
|
timestamp: { gte: since },
|
|
projectId: { not: null },
|
|
},
|
|
_count: { _all: true },
|
|
}),
|
|
]);
|
|
|
|
const totalSessions = sessionsByIp.length;
|
|
const bouncedSessions = sessionsByIp.filter(s => (s as unknown as { _count?: { _all?: number } })._count?._all === 1).length;
|
|
const bounceRate = totalSessions > 0 ? Math.round((bouncedSessions / totalSessions) * 100) : 0;
|
|
|
|
const sessionDurationsMs = sessionsByIp
|
|
.map(s => {
|
|
const count = (s as unknown as { _count?: { _all?: number } })._count?._all ?? 0;
|
|
if (count < 2) return 0;
|
|
const minTs = (s as unknown as { _min?: { timestamp?: Date | null } })._min?.timestamp;
|
|
const maxTs = (s as unknown as { _max?: { timestamp?: Date | null } })._max?.timestamp;
|
|
if (!minTs || !maxTs) return 0;
|
|
return maxTs.getTime() - minTs.getTime();
|
|
})
|
|
.filter(ms => ms > 0);
|
|
|
|
const avgSessionDuration = sessionDurationsMs.length > 0
|
|
? Math.round(sessionDurationsMs.reduce((a, b) => a + b, 0) / sessionDurationsMs.length / 1000)
|
|
: 0;
|
|
|
|
const totalUsers = totalSessions;
|
|
|
|
const viewsByProject = viewsByProjectRows.reduce((acc, row) => {
|
|
const projectId = row.projectId as number | null;
|
|
if (projectId != null) {
|
|
acc[projectId] = (row as unknown as { _count?: { _all?: number } })._count?._all ?? 0;
|
|
}
|
|
return acc;
|
|
}, {} as Record<number, number>);
|
|
|
|
// Calculate analytics metrics
|
|
const analytics = {
|
|
overview: {
|
|
totalProjects: projects.length,
|
|
publishedProjects: projects.filter(p => p.published).length,
|
|
featuredProjects: projects.filter(p => p.featured).length,
|
|
totalViews, // Real views from PageView table
|
|
totalLikes: 0, // Not implemented - no like buttons
|
|
totalShares: 0, // Not implemented - no share buttons
|
|
avgLighthouse: (() => {
|
|
// Only calculate if we have real performance data (not defaults)
|
|
const projectsWithPerf = projects.filter(p => {
|
|
const perf = (p.performance as Record<string, unknown>) || {};
|
|
const lighthouse = perf.lighthouse as number || 0;
|
|
return lighthouse > 0; // Only count projects with actual performance data
|
|
});
|
|
return projectsWithPerf.length > 0
|
|
? Math.round(projectsWithPerf.reduce((sum, p) => sum + ((p.performance as Record<string, unknown>)?.lighthouse as number || 0), 0) / projectsWithPerf.length)
|
|
: 0;
|
|
})()
|
|
},
|
|
projects: projects.map(project => ({
|
|
id: project.id,
|
|
title: project.title,
|
|
category: project.category,
|
|
difficulty: project.difficulty,
|
|
views: viewsByProject[project.id] || 0, // Only real views from PageView table
|
|
likes: 0, // Not implemented
|
|
shares: 0, // Not implemented
|
|
lighthouse: (() => {
|
|
const perf = (project.performance as Record<string, unknown>) || {};
|
|
const score = perf.lighthouse as number || 0;
|
|
return score > 0 ? score : 0; // Only return if we have real data
|
|
})(),
|
|
published: project.published,
|
|
featured: project.featured,
|
|
createdAt: project.createdAt,
|
|
updatedAt: project.updatedAt
|
|
})),
|
|
categories: performanceStats.byCategory,
|
|
difficulties: performanceStats.byDifficulty,
|
|
performance: {
|
|
avgLighthouse: (() => {
|
|
const projectsWithPerf = projects.filter(p => {
|
|
const perf = (p.performance as Record<string, unknown>) || {};
|
|
return (perf.lighthouse as number || 0) > 0;
|
|
});
|
|
return projectsWithPerf.length > 0
|
|
? Math.round(projectsWithPerf.reduce((sum, p) => sum + ((p.performance as Record<string, unknown>)?.lighthouse as number || 0), 0) / projectsWithPerf.length)
|
|
: 0;
|
|
})(),
|
|
totalViews, // Real total views
|
|
totalLikes: 0,
|
|
totalShares: 0
|
|
},
|
|
metrics: {
|
|
bounceRate,
|
|
avgSessionDuration,
|
|
pagesPerSession: totalSessions > 0 ? (totalViews / totalSessions).toFixed(1) : '0',
|
|
newUsers: totalUsers,
|
|
totalUsers
|
|
}
|
|
};
|
|
|
|
// Cache the results
|
|
await analyticsCache.setOverallStats(analytics);
|
|
|
|
return NextResponse.json(analytics);
|
|
} catch (error) {
|
|
console.error('Analytics dashboard error:', error);
|
|
return NextResponse.json(
|
|
{ error: 'Failed to fetch analytics data' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|