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:
2026-02-13 17:56:56 +00:00
parent 01353cef36
commit b99baa1200
7 changed files with 435 additions and 0 deletions

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}