sass-imobiliaria/.specify/features/016-analytics-dashboard/plan.md

14 KiB

Implementation Plan: Analytics Dashboard (Admin)

Branch: 016-analytics-dashboard | Date: 2026-04-14 | Spec: spec.md Input: Feature specification from .specify/features/016-analytics-dashboard/spec.md

Summary

Adicionar um dashboard de analytics ao painel admin que exibe métricas de acesso ao site (total por dia/semana/mês, gráfico de linha dos últimos N dias, top 10 páginas e top 10 imóveis mais visitados). O rastreamento é feito via before_request hook no Flask — sem bibliotecas de charting externas no frontend (SVG/CSS com Tailwind tokens existentes).

Technical Context

Language/Version: Python 3.12 (backend) · TypeScript 5.5 (frontend) Primary Dependencies: Flask 3.x, SQLAlchemy 2.x, Pydantic v2, PyJWT (backend) · React 18, Tailwind CSS 3.4, Axios (frontend) Storage: PostgreSQL 16 — nova tabela page_views Testing: pytest (backend) · Vite build check (frontend) Target Platform: Web service (REST API) + SPA Project Type: Web application (backend + frontend) Performance Goals: Resposta do dashboard < 3 s (SC-001); overhead do hook < 50 ms por requisição (SC-002) Constraints: IP jamais persistido em claro — somente SHA-256 hash (FR-010, SC-005); rastreamento falha silenciosamente sem bloquear visitante (FR-009); somente admins autenticados acessam endpoints (FR-011) Scale/Scope: Volume proporcional ao tráfego; top-10 fixo; sem paginação em v1; sem polling automático em v1

Constitution Check

GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.

Princípio Status Observação
I. Design-First PASS Frontend usa tokens Tailwind do sistema (bg-canvas, bg-panel, bg-surface, text-textPrimary, border-borderPrimary, bg-brand etc.); gráfico de linha via SVG inline — sem libs externas
II. Separação de Responsabilidades PASS Flask expõe 3 endpoints REST JSON puros; React consome via Axios; sem SSR
III. Spec-Driven PASS spec.md com user stories e acceptance scenarios aprovado; spec → plan → tasks → implement
IV. Integridade de Dados PASS Migração Alembic para page_views; SQLAlchemy ORM para queries; Pydantic v2 nos schemas; property_id nullable sem FK (preserva histórico se imóvel deletado)
V. Segurança PASS Endpoints /api/v1/admin/analytics/* protegidos por require_admin; IP armazenado como SHA-256 hash irreversível com salt; rotas admin e auth excluídas do rastreamento (FR-003)
VI. Simplicidade PASS before_request no app factory sem camada extra; SVG puro sem Recharts/Chart.js; 3 endpoints de agregação SQL simples

Constitution Check Post-Design: PASS — nenhuma violação identificada após Phase 1.

Project Structure

Documentation (this feature)

.specify/features/016-analytics-dashboard/
├── plan.md              # Este arquivo
├── research.md          # Phase 0 output
├── data-model.md        # Phase 1 output
├── contracts/
│   └── analytics-api.md # Phase 1 output
├── quickstart.md        # Phase 1 output
└── tasks.md             # Phase 2 output (/speckit.tasks — NÃO criado por /speckit.plan)

Source Code (repository root)

backend/
├── app/
│   ├── models/
│   │   └── page_view.py          # NOVO — modelo PageView (tabela page_views)
│   ├── routes/
│   │   └── analytics.py          # NOVO — Blueprint analytics_bp, 3 endpoints GET
│   ├── schemas/
│   │   └── analytics.py          # NOVO — Pydantic schemas de resposta
│   └── __init__.py               # MODIFICADO — importa model, registra blueprint, adds before_request hook
├── migrations/versions/
│   └── xxxx_add_page_views.py    # NOVO — Alembic migration up/down
└── tests/
    └── test_analytics.py         # NOVO — 9 cenários de teste

frontend/
└── src/
    ├── pages/admin/
    │   └── AdminAnalyticsPage.tsx   # NOVO — cards, gráfico SVG, tabelas
    ├── services/
    │   └── analytics.ts             # NOVO — 3 funções Axios tipadas
    └── components/
        └── Navbar.tsx               # MODIFICADO — link "Analytics" na nav admin

Complexity Tracking

Nenhuma violação da Constituição identificada.


Phase 0: Research

Saída em: research.md

Unknowns Resolvidos

1. Estratégia de rastreamento: before_request vs. after_request

  • Decisão: before_request com commit dentro de try/except que engole todas as exceções.
  • Racional: Não bloqueia a resposta ao visitante (FR-009). before_request é mais simples — rota e método já estão disponíveis sem inspecionar o response object.
  • Alternativas descartadas: Celery/queue assíncrona — complexidade desnecessária para o volume esperado (YAGNI, Princípio VI).

2. Anonimização do IP

  • Decisão: hashlib.sha256((ip + salt).encode()).hexdigest() onde salt = app.config["IP_SALT"] (variável de ambiente IP_SALT, obrigatória, sem padrão).
  • Racional: Hash irreversível + salt impede rainbow-table; atende SC-005 e FR-010.
  • Alternativas descartadas: Truncar IP (reversível), MD5 (colisões conhecidas), bcrypt (lento demais para before_request).

3. Extração do property_id do caminho

  • Decisão: Regex r"^/api/v1/properties/([0-9a-f-]{36})$" aplicado sobre request.path dentro do hook.
  • Racional: A rota de detalhe (/api/v1/properties/<uuid>) é o único endpoint a associar property_id. Regex simples sem importar o mapa interno de rotas do Flask.
  • Alternativas descartadas: request.view_args (nem sempre disponível em before_request cross-blueprint).

4. Gráfico de linha sem biblioteca externa

  • Decisão: SVG inline gerado no componente React com <polyline> calculado a partir de daily_series. Eixo X = dias, eixo Y normalizado pela altura do viewBox.
  • Racional: Sem nova dependência npm (Princípio VI). Recharts/Chart.js adicionariam >200 KB ao bundle sem justificativa para o escopo da feature.
  • Alternativas descartadas: Recharts (dependência nova injustificada), bar chart CSS-only (menos informativo para série temporal contínua).

5. Filtro de período no frontend

  • Decisão: Estado local days: 7 | 30 | 90 na página; passado como query param ?days=N para os 3 endpoints.
  • Racional: Spec FR-008 define exatamente 3 períodos fixos; query param simples é suficiente.

Phase 1: Design & Contracts

Saídas em: data-model.md · contracts/analytics-api.md · quickstart.md

Data Model — tabela page_views

Coluna Tipo Nullable Notas
id UUID (PK, default gen_random_uuid()) NO
path VARCHAR(500) NO Caminho da URL, ex: /imoveis/slug
property_id UUID (sem FK constraint) YES Preenchido apenas para rotas de detalhe de imóvel
accessed_at TIMESTAMP (server default now()) NO Indexado — base de todas as queries por período
ip_hash VARCHAR(64) NO SHA-256 hex do (IP + salt)
user_agent VARCHAR(500) YES Truncado em 500 chars

Índices: ix_page_views_accessed_at, ix_page_views_property_id.

Sem FK em property_id: preserva registros históricos mesmo após remoção de imóveis.

API Endpoints

Blueprint: analytics_bp, montado em /api/v1/admin → prefixo efetivo /api/v1/admin/analytics

Todos os endpoints exigem Authorization: Bearer <token> de administrador (require_admin).

GET /api/v1/admin/analytics/summary?days=30

Cards de métricas + série temporal diária.

{
  "today": 42,
  "this_week": 310,
  "this_month": 1204,
  "period_total": 1204,
  "daily_series": [
    { "date": "2026-03-16", "count": 38 },
    { "date": "2026-03-17", "count": 0 }
  ]
}
  • today, this_week, this_month: calculados a partir da data corrente, independente de days.
  • daily_series: exatamente days entradas; dias sem dados têm count: 0.

GET /api/v1/admin/analytics/top-pages?days=30

{
  "items": [
    { "path": "/imoveis/apartamento-centro", "count": 87 }
  ]
}

Top 10 caminhos por contagem de acessos no período.

GET /api/v1/admin/analytics/top-properties?days=30

{
  "items": [
    {
      "property_id": "uuid",
      "title": "Apartamento Centro",
      "cover_photo": "/uploads/foto.jpg",
      "count": 87
    }
  ]
}
  • JOIN com properties para obter title e cover_photo (primeira foto da relação photos).
  • LEFT JOIN — se imóvel não existir mais, o item é omitido do resultado.

Before-Request Hook (rastreamento)

Inserido em create_app() após o registro do analytics_bp:

import hashlib, re
from app.models.page_view import PageView

@app.before_request
def _track_page_view():
    try:
        if request.method != "GET":
            return
        path = request.path
        excluded = ("/api/v1/admin", "/api/v1/auth", "/static")
        if any(path.startswith(p) for p in excluded):
            return
        ip_raw = request.remote_addr or ""
        salt = current_app.config.get("IP_SALT", "")
        ip_hash = hashlib.sha256((ip_raw + salt).encode()).hexdigest()
        property_id = None
        m = re.match(r"^/api/v1/properties/([0-9a-f-]{36})$", path)
        if m:
            property_id = m.group(1)
        pv = PageView(
            path=path[:500],
            ip_hash=ip_hash,
            user_agent=(request.user_agent.string or "")[:500],
            property_id=property_id,
        )
        db.session.add(pv)
        db.session.commit()
    except Exception:
        db.session.rollback()

Pydantic Schemas (backend/app/schemas/analytics.py)

from pydantic import BaseModel

class DailyPoint(BaseModel):
    date: str        # "YYYY-MM-DD"
    count: int

class AnalyticsSummary(BaseModel):
    today: int
    this_week: int
    this_month: int
    period_total: int
    daily_series: list[DailyPoint]

class TopPageItem(BaseModel):
    path: str
    count: int

class TopPagesResponse(BaseModel):
    items: list[TopPageItem]

class TopPropertyItem(BaseModel):
    property_id: str
    title: str
    cover_photo: str | None
    count: int

class TopPropertiesResponse(BaseModel):
    items: list[TopPropertyItem]

Alembic Migration (esqueleto)

# upgrade
op.create_table(
    "page_views",
    sa.Column("id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False),
    sa.Column("path", sa.String(500), nullable=False),
    sa.Column("property_id", sa.UUID(), nullable=True),
    sa.Column("accessed_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
    sa.Column("ip_hash", sa.String(64), nullable=False),
    sa.Column("user_agent", sa.String(500), nullable=True),
    sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_page_views_accessed_at", "page_views", ["accessed_at"])
op.create_index("ix_page_views_property_id", "page_views", ["property_id"])

# downgrade
op.drop_index("ix_page_views_property_id", table_name="page_views")
op.drop_index("ix_page_views_accessed_at", table_name="page_views")
op.drop_table("page_views")

Frontend — AdminAnalyticsPage.tsx

Rota: /admin/analytics (protegida por AdminRoute, adicionada no router em App.tsx)

Componentes internos (inline na página — sem arquivos separados por simplicidade v1):

  • PeriodFilter: botões "7d / 30d / 90d", ativo com bg-brand text-white; inativo com bg-surface text-textSecondary
  • MetricCard: bg-panel border border-borderSubtle shadow-card rounded-lg; valor em text-textPrimary text-2xl font-semibold; label em text-textSecondary text-sm
  • LineChart: SVG viewBox="0 0 600 120", <polyline> calculado; linha em stroke="#7170ff"; área de fundo com <polygon> opacity 0.1 fill="#5e6ad2"; eixo X com labels de data
  • TopPagesTable e TopPropertiesTable: bg-panel, linhas alternadas com bg-surface, text-textPrimary e text-textSecondary

Serviço (frontend/src/services/analytics.ts):

import { api } from './api'

export const getAnalyticsSummary = (days: number) =>
  api.get<AnalyticsSummary>(`/admin/analytics/summary?days=${days}`)

export const getTopPages = (days: number) =>
  api.get<TopPagesResponse>(`/admin/analytics/top-pages?days=${days}`)

export const getTopProperties = (days: number) =>
  api.get<TopPropertiesResponse>(`/admin/analytics/top-properties?days=${days}`)

Testes pytest — 9 cenários

# Cenário
1 GET /summary sem token → 401
2 GET /summary com token admin → 200, shape correto
3 GET /summary com 0 registros → campos zerados, daily_series com count: 0 para cada dia
4 GET /top-pages retorna apenas acessos dentro do período (days)
5 GET /top-properties LEFT JOIN correto — imóvel inexistente omitido
6 Hook NOT registra acesso a /api/v1/admin/*
7 Hook NOT registra acesso a /api/v1/auth/*
8 Hook registra acesso a rota pública e cria PageView no banco
9 Falha de banco no hook não propaga exceção para o cliente

Checklist de Entrega

  • backend/app/models/page_view.py criado e importado em __init__.py
  • Hook before_request em create_app(), excluindo /api/v1/admin/*, /api/v1/auth/*, /static
  • Migração Alembic criada e testada (upgrade + downgrade)
  • 3 endpoints GET em backend/app/routes/analytics.py, protegidos por require_admin
  • Pydantic schemas em backend/app/schemas/analytics.py
  • Testes em backend/tests/test_analytics.py (9 cenários)
  • frontend/src/services/analytics.ts com 3 funções tipadas
  • AdminAnalyticsPage.tsx com cards, gráfico SVG e tabelas usando tokens do design system
  • Link "Analytics" adicionado no Navbar.tsx (área admin)
  • Rota /admin/analytics registrada no router em App.tsx
  • Variável de ambiente IP_SALT documentada como obrigatória