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:
9
prisma/prisma.config.ts
Normal file
9
prisma/prisma.config.ts
Normal 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
84
prisma/schema.prisma
Normal 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")
|
||||
}
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
15
src/lib/prisma.ts
Normal file
15
src/lib/prisma.ts
Normal 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
58
src/lib/utils.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user