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

9
prisma/prisma.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from '@prisma/client/generator-helper'
export default defineConfig({
datasources: {
db: {
url: process.env.DATABASE_URL!,
},
},
})

84
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,84 @@
// Prisma schema for BI Descomplicar
// Maps to existing PostgreSQL staging database
datasource db {
provider = "postgresql"
schemas = ["staging"]
}
generator client {
provider = "prisma-client-js"
previewFeatures = ["multiSchema"]
}
// Sites configuration
model Site {
id Int @id @default(autoincrement())
siteName String @map("site_name")
ga4PropertyId String? @map("ga4_property_id")
gscSiteUrl String? @map("gsc_site_url")
deskProjectId Int? @map("desk_project_id")
deskClientId Int? @map("desk_client_id")
active Boolean @default(true)
@@map("sites_config")
@@schema("staging")
}
// GA4 Daily Traffic
model GA4DailyTraffic {
id Int @id @default(autoincrement())
propertyId String @map("property_id")
siteName String? @map("site_name")
date DateTime @db.Date
pagePath String? @map("page_path")
activeUsers Int @default(0) @map("active_users")
sessions Int @default(0)
pageViews Int @default(0) @map("page_views")
engagementRate Decimal @default(0) @map("engagement_rate") @db.Decimal(5, 4)
avgSessionDuration Decimal @default(0) @map("avg_session_duration") @db.Decimal(10, 2)
bounceRate Decimal @default(0) @map("bounce_rate") @db.Decimal(5, 4)
newUsers Int @default(0) @map("new_users")
createdAt DateTime @default(now()) @map("created_at")
@@unique([propertyId, date, pagePath])
@@map("ga4_daily_traffic")
@@schema("staging")
}
// GA4 Traffic Sources
model GA4TrafficSources {
id Int @id @default(autoincrement())
propertyId String @map("property_id")
siteName String? @map("site_name")
date DateTime @db.Date
sessionSource String? @map("session_source")
sessionMedium String? @map("session_medium")
sessionCampaign String? @map("session_campaign")
sessions Int @default(0)
newUsers Int @default(0) @map("new_users")
engagedSessions Int @default(0) @map("engaged_sessions")
createdAt DateTime @default(now()) @map("created_at")
@@unique([propertyId, date, sessionSource, sessionMedium])
@@map("ga4_traffic_sources")
@@schema("staging")
}
// GSC Search Performance
model GSCSearchPerformance {
id Int @id @default(autoincrement())
siteUrl String @map("site_url")
date DateTime @db.Date
query String
page String?
clicks Int @default(0)
impressions Int @default(0)
ctr Decimal @default(0) @db.Decimal(5, 4)
position Decimal @default(0) @db.Decimal(6, 2)
createdAt DateTime @default(now()) @map("created_at")
@@unique([siteUrl, date, query, page])
@@map("gsc_search_performance")
@@schema("staging")
}

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
}