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

341 lines
14 KiB
Markdown

# Implementation Plan: Analytics Dashboard (Admin)
**Branch**: `016-analytics-dashboard` | **Date**: 2026-04-14 | **Spec**: [spec.md](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](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 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](data-model.md) · [contracts/analytics-api.md](contracts/analytics-api.md) · [quickstart.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.
```json
{
"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
```json
{
"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
```json
{
"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`:
```python
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`)
```python
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)
```python
# 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`)**:
```typescript
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