From b99baa1200e3dabcef14cdab7ab32dac3cc5ccfb Mon Sep 17 00:00:00 2001 From: Emanuel Almeida Date: Fri, 13 Feb 2026 17:56:56 +0000 Subject: [PATCH] Phase 2: Database e API Layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- prisma/prisma.config.ts | 9 ++ prisma/schema.prisma | 84 ++++++++++ src/app/api/health/route.ts | 29 ++++ src/app/api/metrics/[siteId]/route.ts | 211 ++++++++++++++++++++++++++ src/app/api/sites/route.ts | 29 ++++ src/lib/prisma.ts | 15 ++ src/lib/utils.ts | 58 +++++++ 7 files changed, 435 insertions(+) create mode 100644 prisma/prisma.config.ts create mode 100644 prisma/schema.prisma create mode 100644 src/app/api/health/route.ts create mode 100644 src/app/api/metrics/[siteId]/route.ts create mode 100644 src/app/api/sites/route.ts create mode 100644 src/lib/prisma.ts create mode 100644 src/lib/utils.ts diff --git a/prisma/prisma.config.ts b/prisma/prisma.config.ts new file mode 100644 index 0000000..b697373 --- /dev/null +++ b/prisma/prisma.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from '@prisma/client/generator-helper' + +export default defineConfig({ + datasources: { + db: { + url: process.env.DATABASE_URL!, + }, + }, +}) diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..e39f6a4 --- /dev/null +++ b/prisma/schema.prisma @@ -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") +} diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..d93008e --- /dev/null +++ b/src/app/api/health/route.ts @@ -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 } + ) + } +} diff --git a/src/app/api/metrics/[siteId]/route.ts b/src/app/api/metrics/[siteId]/route.ts new file mode 100644 index 0000000..0b2a65d --- /dev/null +++ b/src/app/api/metrics/[siteId]/route.ts @@ -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 } + ) + } +} diff --git a/src/app/api/sites/route.ts b/src/app/api/sites/route.ts new file mode 100644 index 0000000..d0d6c5b --- /dev/null +++ b/src/app/api/sites/route.ts @@ -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 } + ) + } +} diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts new file mode 100644 index 0000000..a8739bc --- /dev/null +++ b/src/lib/prisma.ts @@ -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 +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..502e492 --- /dev/null +++ b/src/lib/utils.ts @@ -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 +}