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

15
src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
})
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma
}

58
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,58 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
/**
* Merge Tailwind CSS classes with proper precedence
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/**
* Format number with Portuguese locale
* @example formatNumber(1234567) => "1.234.567"
*/
export function formatNumber(num: number): string {
return new Intl.NumberFormat('pt-PT').format(num)
}
/**
* Format number as percentage
* @example formatPercent(0.1234) => "12,3%"
*/
export function formatPercent(num: number, decimals: number = 1): string {
return `${(num * 100).toFixed(decimals)}%`
}
/**
* Format number as compact (K, M, B)
* @example formatCompact(1234567) => "1,2M"
*/
export function formatCompact(num: number): string {
return new Intl.NumberFormat('pt-PT', {
notation: 'compact',
compactDisplay: 'short',
}).format(num)
}
/**
* Format date as Portuguese short date
* @example formatDate(new Date()) => "13/02/2026"
*/
export function formatDate(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date
return new Intl.DateTimeFormat('pt-PT', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(d)
}
/**
* Calculate percentage change
* @example calculateChange(100, 120) => 20
*/
export function calculateChange(oldValue: number, newValue: number): number {
if (oldValue === 0) return 0
return ((newValue - oldValue) / oldValue) * 100
}