Phase 2: Database e API Layer
- Configurado Prisma 7 com multiSchema para staging database - Models: Site, GA4DailyTraffic, GA4TrafficSources, GSCSearchPerformance - Created lib utilities (prisma.ts, utils.ts) com formatação PT-PT - API routes implementadas: * GET /api/sites - lista sites activos * GET /api/metrics/[siteId]?period=30d - métricas agregadas + charts * GET /api/health - health check com conexão DB Métricas incluem: - KPIs: visitors, sessions, pageViews, newUsers com % change - Charts: dailyTraffic, trafficSources, topQueries (GSC) - Comparação período anterior para trends Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
29
src/app/api/health/route.ts
Normal file
29
src/app/api/health/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/health
|
||||
* Health check endpoint
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
// Test database connection
|
||||
await prisma.$queryRaw`SELECT 1`
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
database: 'connected'
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'error',
|
||||
timestamp: new Date().toISOString(),
|
||||
database: 'disconnected',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
211
src/app/api/metrics/[siteId]/route.ts
Normal file
211
src/app/api/metrics/[siteId]/route.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/metrics/[siteId]?period=30d
|
||||
* Returns aggregated metrics for a specific site
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ siteId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { siteId } = await params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const period = searchParams.get('period') || '30d'
|
||||
|
||||
// Parse period (30d, 90d, 7d)
|
||||
const days = parseInt(period.replace('d', ''))
|
||||
const startDate = new Date()
|
||||
startDate.setDate(startDate.getDate() - days)
|
||||
|
||||
// Get site info
|
||||
const site = await prisma.site.findUnique({
|
||||
where: { id: parseInt(siteId) }
|
||||
})
|
||||
|
||||
if (!site || !site.ga4PropertyId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Site not found or GA4 not configured' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get current period metrics (aggregated)
|
||||
const currentMetrics = await prisma.gA4DailyTraffic.aggregate({
|
||||
where: {
|
||||
propertyId: site.ga4PropertyId,
|
||||
date: {
|
||||
gte: startDate
|
||||
}
|
||||
},
|
||||
_sum: {
|
||||
activeUsers: true,
|
||||
sessions: true,
|
||||
pageViews: true,
|
||||
newUsers: true,
|
||||
},
|
||||
_avg: {
|
||||
engagementRate: true,
|
||||
avgSessionDuration: true,
|
||||
bounceRate: true,
|
||||
}
|
||||
})
|
||||
|
||||
// Get previous period for comparison
|
||||
const previousStartDate = new Date(startDate)
|
||||
previousStartDate.setDate(previousStartDate.getDate() - days)
|
||||
|
||||
const previousMetrics = await prisma.gA4DailyTraffic.aggregate({
|
||||
where: {
|
||||
propertyId: site.ga4PropertyId,
|
||||
date: {
|
||||
gte: previousStartDate,
|
||||
lt: startDate
|
||||
}
|
||||
},
|
||||
_sum: {
|
||||
activeUsers: true,
|
||||
sessions: true,
|
||||
pageViews: true,
|
||||
newUsers: true,
|
||||
}
|
||||
})
|
||||
|
||||
// Get daily traffic for chart
|
||||
const dailyTraffic = await prisma.gA4DailyTraffic.findMany({
|
||||
where: {
|
||||
propertyId: site.ga4PropertyId,
|
||||
date: {
|
||||
gte: startDate
|
||||
},
|
||||
pagePath: null // Only aggregate data
|
||||
},
|
||||
select: {
|
||||
date: true,
|
||||
activeUsers: true,
|
||||
sessions: true,
|
||||
},
|
||||
orderBy: { date: 'asc' }
|
||||
})
|
||||
|
||||
// Get traffic sources
|
||||
const trafficSources = await prisma.gA4TrafficSources.groupBy({
|
||||
by: ['sessionSource', 'sessionMedium'],
|
||||
where: {
|
||||
propertyId: site.ga4PropertyId,
|
||||
date: {
|
||||
gte: startDate
|
||||
}
|
||||
},
|
||||
_sum: {
|
||||
sessions: true,
|
||||
newUsers: true,
|
||||
},
|
||||
orderBy: {
|
||||
_sum: {
|
||||
sessions: 'desc'
|
||||
}
|
||||
},
|
||||
take: 10
|
||||
})
|
||||
|
||||
// Get top search queries from GSC
|
||||
const topQueries = site.gscSiteUrl ? await prisma.gSCSearchPerformance.groupBy({
|
||||
by: ['query'],
|
||||
where: {
|
||||
siteUrl: site.gscSiteUrl,
|
||||
date: {
|
||||
gte: startDate
|
||||
}
|
||||
},
|
||||
_sum: {
|
||||
clicks: true,
|
||||
impressions: true,
|
||||
},
|
||||
_avg: {
|
||||
ctr: true,
|
||||
position: true,
|
||||
},
|
||||
orderBy: {
|
||||
_sum: {
|
||||
clicks: 'desc'
|
||||
}
|
||||
},
|
||||
take: 20
|
||||
}) : []
|
||||
|
||||
// Calculate changes
|
||||
const calculateChange = (current: number | null, previous: number | null) => {
|
||||
if (!current || !previous || previous === 0) return 0
|
||||
return ((current - previous) / previous) * 100
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
site: {
|
||||
id: site.id,
|
||||
name: site.siteName,
|
||||
},
|
||||
period: {
|
||||
days,
|
||||
startDate: startDate.toISOString(),
|
||||
},
|
||||
metrics: {
|
||||
visitors: currentMetrics._sum.activeUsers || 0,
|
||||
visitorsChange: calculateChange(
|
||||
currentMetrics._sum.activeUsers,
|
||||
previousMetrics._sum.activeUsers
|
||||
),
|
||||
sessions: currentMetrics._sum.sessions || 0,
|
||||
sessionsChange: calculateChange(
|
||||
currentMetrics._sum.sessions,
|
||||
previousMetrics._sum.sessions
|
||||
),
|
||||
pageViews: currentMetrics._sum.pageViews || 0,
|
||||
pageViewsChange: calculateChange(
|
||||
currentMetrics._sum.pageViews,
|
||||
previousMetrics._sum.pageViews
|
||||
),
|
||||
newUsers: currentMetrics._sum.newUsers || 0,
|
||||
newUsersChange: calculateChange(
|
||||
currentMetrics._sum.newUsers,
|
||||
previousMetrics._sum.newUsers
|
||||
),
|
||||
engagement: currentMetrics._avg.engagementRate || 0,
|
||||
avgSessionDuration: currentMetrics._avg.avgSessionDuration || 0,
|
||||
bounceRate: currentMetrics._avg.bounceRate || 0,
|
||||
},
|
||||
charts: {
|
||||
dailyTraffic: dailyTraffic.map(d => ({
|
||||
date: d.date.toISOString().split('T')[0],
|
||||
visitors: d.activeUsers,
|
||||
sessions: d.sessions,
|
||||
})),
|
||||
trafficSources: trafficSources.map(s => ({
|
||||
source: s.sessionSource || 'direct',
|
||||
medium: s.sessionMedium || 'none',
|
||||
sessions: s._sum.sessions || 0,
|
||||
newUsers: s._sum.newUsers || 0,
|
||||
})),
|
||||
topQueries: topQueries.map(q => ({
|
||||
query: q.query,
|
||||
clicks: q._sum.clicks || 0,
|
||||
impressions: q._sum.impressions || 0,
|
||||
ctr: q._avg.ctr || 0,
|
||||
position: q._avg.position || 0,
|
||||
}))
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching metrics:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to fetch metrics',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
29
src/app/api/sites/route.ts
Normal file
29
src/app/api/sites/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/sites
|
||||
* Returns all active sites
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const sites = await prisma.site.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { siteName: 'asc' },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
sites
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching sites:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to fetch sites'
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user