feat: add full project - backend, frontend, docker, specs and configs

This commit is contained in:
MatheusAlves96 2026-04-20 23:59:45 -03:00
parent b77c7d5a01
commit e6cb06255b
24489 changed files with 61341 additions and 36 deletions

View file

@ -0,0 +1,35 @@
# Specification Quality Checklist: Analytics Dashboard (Admin)
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-14
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Spec gerado com base em descrição detalhada fornecida pelo usuário
- Todos os itens passaram na validação inicial — feature pronta para `/speckit.plan`

View file

@ -0,0 +1,341 @@
# 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 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](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

View file

@ -0,0 +1,118 @@
# Feature Specification: Analytics Dashboard (Admin)
**Feature Branch**: `016-analytics-dashboard`
**Created**: 2026-04-14
**Status**: Draft
**Input**: User description: "Dashboard de Analytics no painel admin para acompanhar acessos ao site."
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Visualizar Métricas de Acesso (Priority: P1)
O administrador do sistema acessa a página de analytics no painel admin e visualiza um resumo dos acessos ao site: total de visitas hoje, nesta semana e neste mês, além de um gráfico mostrando a evolução de acessos nos últimos 30 dias.
**Why this priority**: É o coração do dashboard — sem essa visão consolidada, o administrador não consegue avaliar o desempenho do site. Entrega valor imediato ao permitir compreender tendências de tráfego.
**Independent Test**: Pode ser testado de forma completa acessando `/admin/analytics` após algumas visitas registradas. O administrador deve ver os cards de métricas e o gráfico com dados reais.
**Acceptance Scenarios**:
1. **Given** o administrador está autenticado e existem registros de acesso, **When** ele acessa `/admin/analytics`, **Then** vê cards com total de acessos de hoje, da semana atual e do mês atual.
2. **Given** o administrador está na página de analytics, **When** a página carrega, **Then** um gráfico de linha exibe os acessos diários dos últimos 30 dias.
3. **Given** o administrador está na página de analytics, **When** ele altera o filtro de período para "7 dias", "30 dias" ou "90 dias", **Then** todas as métricas e gráficos atualizam para refletir o período selecionado.
4. **Given** não existe nenhum acesso registrado, **When** o administrador acessa o dashboard, **Then** vê métricas zeradas e mensagem indicando ausência de dados.
---
### User Story 2 - Consultar Páginas e Imóveis Mais Acessados (Priority: P2)
O administrador consulta as tabelas que listam as 10 páginas e os 10 imóveis mais visitados do site, para entender quais conteúdos são mais relevantes para os visitantes.
**Why this priority**: Permite decisões de negócio — quais imóveis destacar, quais páginas otimizar. Depende dos dados de rastreamento já coletados (P1).
**Independent Test**: Testável de forma independente realizando múltiplas visitas a páginas e imóveis distintos e verificando o ranking exibido no dashboard.
**Acceptance Scenarios**:
1. **Given** existem registros de acesso a múltiplas páginas, **When** o administrador visualiza o dashboard, **Then** uma tabela exibe as 10 páginas mais acessadas com o caminho e o total de visitas, ordenadas do maior para o menor.
2. **Given** existem registros de visualização de imóveis, **When** o administrador visualiza o dashboard, **Then** uma tabela exibe os 10 imóveis mais vistos com título, foto de capa e total de visualizações.
3. **Given** há menos de 10 páginas/imóveis distintos com registros, **When** o administrador visualiza as tabelas, **Then** a tabela exibe apenas os itens disponíveis (sem erros ou linhas vazias).
4. **Given** o administrador aplica um filtro de período, **When** filtra por "7 dias", **Then** as tabelas de top páginas e top imóveis refletem somente os acessos dentro do período.
---
### User Story 3 - Rastreamento Automático de Visitas Públicas (Priority: P3)
Cada vez que um visitante acessa uma página pública do site, o sistema registra automaticamente essa visita sem exigir nenhuma ação do usuário, preservando a privacidade ao armazenar um hash do IP em vez do IP completo.
**Why this priority**: É o mecanismo que alimenta todas as métricas do dashboard. Sem rastreamento, não há dados a exibir. Classificado como P3 pois pode ser implementado em paralelo com as histórias de UI, e os dados iniciais podem ser simulados nos testes.
**Independent Test**: Testável acessando páginas públicas e verificando no banco de dados que os registros foram criados com os campos corretos (path, timestamp, hash de IP, user agent).
**Acceptance Scenarios**:
1. **Given** um visitante acessa qualquer página pública (home, listagem, detalhe, sobre, contato), **When** a página é carregada, **Then** um registro de acesso é criado com caminho, horário, hash do IP e user agent.
2. **Given** um visitante acessa a página de detalhe de um imóvel, **When** a página carrega, **Then** o registro de acesso é associado ao ID do imóvel correspondente, incrementando seu contador de visualizações.
3. **Given** uma requisição é feita para rotas administrativas (`/admin/*`) ou de autenticação (`/api/auth/*`), **When** qualquer usuário acessa essas rotas, **Then** nenhum registro de acesso é criado (rastreamento excluído).
4. **Given** o mesmo visitante acessa múltiplas páginas em sequência, **When** os acessos são registrados, **Then** cada acesso gera um registro separado com seu próprio timestamp.
---
### Edge Cases
- O que acontece se o banco de dados estiver indisponível no momento do registro de uma visita? O acesso do visitante não deve ser bloqueado — o rastreamento deve falhar silenciosamente.
- Como o sistema lida com bots/crawlers? User agent é armazenado mas não há filtro automático de bots na v1 (pode ser adicionado posteriormente).
- O que acontece se um imóvel referenciado em um acesso for deletado? O registro de acesso deve ser mantido, com `property_id` referenciando um imóvel inexistente (integridade referencial relaxada ou via soft delete).
- Como se comporta o gráfico se houver dias sem nenhum acesso no período selecionado? O gráfico deve exibir esses dias com valor zero, sem lacunas na linha do tempo.
- O dashboard exibe dados em tempo real ou com delay? Os dados refletem os acessos até o momento da consulta (sem polling automático na v1).
---
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: O sistema DEVE registrar automaticamente cada acesso a páginas públicas com: caminho da URL, data/hora do acesso, user agent e hash anonimizado do IP.
- **FR-002**: O sistema DEVE associar o registro de acesso ao ID do imóvel quando a página visitada for a de detalhe de um imóvel.
- **FR-003**: O sistema NÃO DEVE registrar acessos a rotas administrativas (`/admin/*`) nem a rotas de autenticação (`/api/auth/*`).
- **FR-004**: O sistema DEVE disponibilizar ao administrador cards de métricas com total de acessos do dia atual, da semana atual e do mês atual.
- **FR-005**: O sistema DEVE disponibilizar ao administrador um gráfico de linha com o total de acessos por dia para o período selecionado.
- **FR-006**: O sistema DEVE disponibilizar ao administrador uma tabela com as 10 páginas mais acessadas (caminho + total de acessos) para o período selecionado.
- **FR-007**: O sistema DEVE disponibilizar ao administrador uma tabela com os 10 imóveis mais visualizados (título + foto de capa + total de visualizações) para o período selecionado.
- **FR-008**: O administrador DEVE conseguir filtrar todas as métricas e rankings por período: últimos 7 dias, últimos 30 dias ou últimos 90 dias.
- **FR-009**: O rastreamento de acessos NÃO DEVE impactar a disponibilidade das páginas públicas — falhas no registro devem ser silenciosas para o visitante.
- **FR-010**: O IP do visitante DEVE ser armazenado exclusivamente como hash irreversível, nunca em texto claro.
- **FR-011**: Somente administradores autenticados DEVEM ter acesso aos dados de analytics.
### Key Entities *(include if feature involves data)*
- **Registro de Acesso (PageView)**: Representa uma visita a uma página pública. Atributos: identificador único, caminho da URL visitada, referência opcional ao imóvel visualizado, data/hora do acesso, hash do IP do visitante, user agent do navegador.
- **Resumo de Analytics (Summary)**: Agregação calculada a partir dos registros de acesso, contendo totais por período (dia/semana/mês) e séries temporais diárias.
- **Ranking de Páginas**: Lista ordenada das páginas com maior número de acessos em um período, com caminho e contagem.
- **Ranking de Imóveis**: Lista ordenada dos imóveis com maior número de visualizações em um período, com título, foto e contagem.
---
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: O administrador consegue visualizar as métricas de acesso em menos de 3 segundos após acessar a página de analytics.
- **SC-002**: 100% dos acessos a páginas públicas são registrados sem impactar o tempo de resposta percebido pelo visitante (registro assíncrono ou com overhead inferior a 50ms por requisição).
- **SC-003**: O dashboard exibe corretamente métricas zeradas quando não há dados para o período selecionado, sem erros ou telas em branco.
- **SC-004**: A troca de filtro de período atualiza todas as métricas, gráfico e tabelas em menos de 2 segundos.
- **SC-005**: Nenhum dado pessoal identificável (IP completo) é armazenado — auditoria deve confirmar que apenas hashes são persistidos.
- **SC-006**: Acessos a rotas de administração (`/admin/*`) não aparecem nos registros de analytics após 1 semana de uso em produção.
---
## Assumptions
- O sistema de autenticação de administradores já está implementado e funcional (feature 005/007).
- A tabela de imóveis (`properties`) já existe com os campos de título e foto de capa.
- O painel admin já possui layout e navegação estabelecidos — a página de analytics será adicionada como nova rota dentro da estrutura existente.
- A privacidade de IPs via hash é suficiente para conformidade com boas práticas; conformidade formal com LGPD/GDPR fica fora do escopo desta feature.
- Não há requisito de exportação de dados (CSV/PDF) nesta versão.
- Filtros de período são globais — não há filtro por tipo de dispositivo, origem de tráfego ou localização geográfica nesta versão (v1).
- Os dados de analytics não precisam de paginação na v1 — top 10 é o limite exibido nas tabelas.
- O rastreamento é implementado no lado do servidor (middleware backend), não no frontend, para garantir consistência mesmo em casos de bloqueadores de JavaScript.

View file

@ -0,0 +1,199 @@
# Tasks: Analytics Dashboard (Admin)
**Input**: Design documents from `.specify/features/016-analytics-dashboard/`
**Branch**: `016-analytics-dashboard`
**Prerequisites**: plan.md ✓, spec.md ✓
**Organization**: Tasks organizadas por user story para permitir implementação e teste independentes de cada história.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Pode rodar em paralelo (arquivos diferentes, sem dependências incompletas)
- **[Story]**: User story correspondente ([US1], [US2], [US3])
- Todos os caminhos são relativos à raiz do repositório
---
## Phase 1: Setup
**Purpose**: Configuração obrigatória que deve existir antes de qualquer implementação.
- [ ] T001 Add `IP_SALT` env variable to `backend/app/config.py` — em `BaseConfig` adicionar `IP_SALT = os.environ.get("IP_SALT", "")` e em `TestingConfig` adicionar `IP_SALT = "test-salt-analytics-016"` para evitar falha silenciosa durante testes
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Infraestrutura compartilhada por todas as user stories — modelo, migração, schemas e blueprint.
**⚠️ CRITICAL**: Nenhuma user story pode ser implementada até esta fase estar completa.
- [ ] T002 Create `backend/app/models/page_view.py` — modelo SQLAlchemy `PageView` com `__tablename__ = "page_views"` e colunas: `id` (`db.UUID(as_uuid=True)`, PK, `default=uuid.uuid4`), `path` (`db.String(500)`, not null), `property_id` (`db.UUID(as_uuid=True)`, nullable, **sem ForeignKey**), `accessed_at` (`db.DateTime`, not null, `default=datetime.utcnow`), `ip_hash` (`db.String(64)`, not null), `user_agent` (`db.String(500)`, nullable); declarar índices via `__table_args__` com `db.Index("ix_page_views_accessed_at", "accessed_at")` e `db.Index("ix_page_views_property_id", "property_id")`
- [ ] T003 [P] Create `backend/app/schemas/analytics.py` — schemas Pydantic v2 (`from pydantic import BaseModel`): `DailyPoint(date: str, count: int)`, `AnalyticsSummary(today: int, this_week: int, this_month: int, period_total: int, daily_series: list[DailyPoint])`, `TopPageItem(path: str, count: int)`, `TopPagesResponse(items: list[TopPageItem])`, `TopPropertyItem(property_id: str, title: str, cover_photo: str | None, count: int)`, `TopPropertiesResponse(items: list[TopPropertyItem])`
- [ ] T004 [P] Create `backend/app/routes/analytics.py` — scaffold do blueprint: `analytics_bp = Blueprint("analytics", __name__)`; importar `require_admin` de `app.utils.auth`, `db` de `app.extensions`, `PageView` de `app.models.page_view`, todos os schemas de `app.schemas.analytics`; criar as 3 funções de endpoint decoradas com `@analytics_bp.get(...)` e `@require_admin` ainda retornando `({}, 200)` como placeholder (corpo implementado em T007, T012, T013)
- [ ] T005 Update `backend/app/__init__.py` — na seção de import de models adicionar `from app.models import page_view as _page_view_models # noqa: F401`; na seção de registro de blueprints adicionar `from app.routes.analytics import analytics_bp` e `app.register_blueprint(analytics_bp, url_prefix="/api/v1/admin")`
- [ ] T006 Generate and apply Alembic migration — dentro de `backend/` executar `alembic revision --autogenerate -m "add_page_views"`; abrir o arquivo gerado em `backend/migrations/versions/` e verificar/corrigir para que contenha: `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"))` e os dois `op.create_index`; no `downgrade` adicionar `op.drop_index` para ambos os índices antes de `op.drop_table`; executar `alembic upgrade head`
**Checkpoint**: Modelo registrado no ORM, tabela criada no banco, blueprint montado em `/api/v1/admin`.
---
## Phase 3: User Story 1 — Visualizar Métricas de Acesso (Priority: P1) 🎯 MVP
**Goal**: Administrador acessa `/admin/analytics`, vê cards com totais de hoje/semana/mês, seleciona período (7d/30d/90d) e visualiza gráfico de linha SVG com a série diária do período.
**Independent Test**: Inserir `PageView`s diretamente via fixture, chamar `GET /api/v1/admin/analytics/summary?days=30` com token admin e verificar resposta com shape correto (`today`, `this_week`, `this_month`, `period_total`, `daily_series` com exatamente 30 entradas); na UI acessar `/admin/analytics` e confirmar que cards e gráfico renderizam (com zeros se DB vazio).
### Implementation for User Story 1
- [ ] T007 [US1] Implement `GET /summary` in `backend/app/routes/analytics.py``@analytics_bp.get("/analytics/summary")` com `@require_admin`; ler query param `days = int(request.args.get("days", 30))`; calcular os 4 contadores via SQLAlchemy: `today` = `db.session.query(func.count(PageView.id)).filter(func.date(PageView.accessed_at) == date.today()).scalar()`, `this_week` = count com `PageView.accessed_at >= datetime.utcnow().replace(hour=0,minute=0,second=0) - timedelta(days=datetime.utcnow().weekday())`, `this_month` = count com dia 1 do mês corrente, `period_total` = count com `PageView.accessed_at >= datetime.utcnow() - timedelta(days=days)`; para `daily_series`: query `GROUP BY func.date(PageView.accessed_at)` within the period → produzir dict `{date_str: count}` → iterar `range(days-1, -1, -1)` construindo lista de `DailyPoint` (count=0 se data ausente); retornar `jsonify(AnalyticsSummary(...).model_dump())`
- [ ] T008 [P] [US1] Create `frontend/src/services/analytics.ts` — exportar interfaces TypeScript: `DailyPoint`, `AnalyticsSummary`, `TopPageItem`, `TopPagesResponse`, `TopPropertyItem`, `TopPropertiesResponse`; exportar função `getAnalyticsSummary(days: number): Promise<AnalyticsSummary>` implementada como `api.get<AnalyticsSummary>(\`/admin/analytics/summary?days=\${days}\`).then(r => r.data)` usando a instância `api` importada de `./api`
- [ ] T009 [P] [US1] Create `frontend/src/pages/admin/AdminAnalyticsPage.tsx` — estado `const [days, setDays] = useState<7|30|90>(30)` e `const [summary, setSummary] = useState<AnalyticsSummary | null>(null)` e `const [isLoading, setIsLoading] = useState(true)`; `useEffect` que chama `getAnalyticsSummary(days)` ao montar e ao trocar `days`; componente inline `PeriodFilter`: 3 botões `[7, 30, 90]` com classes Tailwind ativo=`bg-brand text-white rounded-md px-3 py-1 text-sm font-medium`, inativo=`bg-surface text-textSecondary rounded-md px-3 py-1 text-sm`; componente inline `MetricCard({ label, value })`: `div` com `bg-panel border border-borderSubtle shadow-card rounded-lg p-4`, `<p className="text-textSecondary text-sm mb-1">{label}</p>`, `<p className="text-textPrimary text-2xl font-semibold">{value}</p>`; renderizar 3 cards (Hoje, Esta Semana, Este Mês) e um card total do período; componente inline `LineChart({ series }: { series: DailyPoint[] })`: SVG `viewBox="0 0 600 120"` com `<polyline>` cujos pontos são calculados como `x = (i / (series.length-1)) * 580 + 10`, `y = 110 - (count / maxCount) * 100` (tratando `maxCount=0` como 1 para evitar NaN), `stroke="#7170ff" strokeWidth="2" fill="none"`; `<polygon>` de área preenchendo até y=110 com `fill="#5e6ad2" fillOpacity="0.1"`; labels do eixo X: renderizar data a cada 7ª posição em `<text>` com `fontSize="9" fill="#9ca3af"`; quando `maxCount === 0` exibir `<text x="300" y="65" textAnchor="middle" fill="#9ca3af" fontSize="12">Sem dados</text>` no centro; skeleton com `animate-pulse div` do Tailwind enquanto `isLoading === true`
- [ ] T010 [US1] Add `{ to: '/admin/analytics', label: 'Analytics' }` to `adminNavItems` array in `frontend/src/components/Navbar.tsx` — inserir como último item do array (após `{ to: '/admin/amenidades', label: 'Amenidades' }`)
- [ ] T011 [US1] Register route in `frontend/src/App.tsx` — adicionar `import AdminAnalyticsPage from './pages/admin/AdminAnalyticsPage'` no bloco de imports de páginas admin; adicionar `<Route path="analytics" element={<AdminAnalyticsPage />} />` dentro do bloco `<Route path="/admin" element={<AdminRoute>...}>` junto com as outras rotas admin existentes
**Checkpoint**: US1 totalmente funcional. Admin acessa `/admin/analytics`, vê cards e gráfico (zeros se nenhum PageView no banco). Link "Analytics" aparece na navbar do painel admin.
---
## Phase 4: User Story 2 — Consultar Páginas e Imóveis Mais Acessados (Priority: P2)
**Goal**: Administrador vê tabelas com top-10 páginas mais acessadas e top-10 imóveis mais visualizados para o período selecionado.
**Independent Test**: Inserir `PageView`s com paths e `property_id`s distintos; chamar `GET /api/v1/admin/analytics/top-pages?days=30` e `GET /api/v1/admin/analytics/top-properties?days=30` e verificar listas com `path+count` e `property_id+title+cover_photo+count` ordenadas DESC; confirmar que itens com `property_id` inexistente são omitidos de `/top-properties`.
### Implementation for User Story 2
- [ ] T012 [US2] Implement `GET /top-pages` in `backend/app/routes/analytics.py``@analytics_bp.get("/analytics/top-pages")` com `@require_admin`; ler `days`; query SQLAlchemy: `db.session.query(PageView.path, func.count(PageView.id).label("count")).filter(PageView.accessed_at >= datetime.utcnow() - timedelta(days=days)).group_by(PageView.path).order_by(func.count(PageView.id).desc()).limit(10).all()`; mapear para `list[TopPageItem]`; retornar `jsonify(TopPagesResponse(items=...).model_dump())`
- [ ] T013 [US2] Implement `GET /top-properties` in `backend/app/routes/analytics.py``@analytics_bp.get("/analytics/top-properties")` com `@require_admin`; ler `days`; query SQLAlchemy: selecionar `PageView.property_id`, `func.count(PageView.id).label("count")` WHERE `PageView.property_id != None` AND no período, `GROUP BY PageView.property_id`, `ORDER BY count DESC`, `LIMIT 10`; para cada resultado fazer `Property.query.get(row.property_id)` (pular se None — preserva comportamento de imóvel deletado); obter `cover_photo` via `PropertyPhoto.query.filter_by(property_id=prop.id).order_by(PropertyPhoto.display_order).first()`; montar `TopPropertyItem` e retornar `jsonify(TopPropertiesResponse(items=...).model_dump())`; importar `Property` de `app.models.property` e `PropertyPhoto` de `app.models.property`
- [ ] T014 [P] [US2] Add typed functions to `frontend/src/services/analytics.ts` — adicionar `getTopPages(days: number): Promise<TopPagesResponse>` e `getTopProperties(days: number): Promise<TopPropertiesResponse>` seguindo o mesmo padrão de `getAnalyticsSummary`
- [ ] T015 [US2] Add ranking tables to `frontend/src/pages/admin/AdminAnalyticsPage.tsx` — adicionar estado `const [topPages, setTopPages] = useState<TopPagesResponse | null>(null)` e `const [topProperties, setTopProperties] = useState<TopPropertiesResponse | null>(null)`; atualizar o `useEffect` para disparar os 3 fetches em paralelo via `Promise.all([getAnalyticsSummary(days), getTopPages(days), getTopProperties(days)])`; componente inline `TopPagesTable({ items })`: `table` com `bg-panel rounded-lg overflow-hidden w-full`, cabeçalho `text-textSecondary text-xs uppercase`, linhas com `text-textPrimary text-sm even:bg-surface`, colunas "Página" + "Acessos"; componente inline `TopPropertiesTable({ items })`: mesma estrutura, coluna com imagem `<img src={item.cover_photo || '/placeholder.jpg'} className="w-10 h-10 object-cover rounded" />` + título + visualizações; exibir mensagem "Sem dados para o período" quando listas estiverem vazias; renderizar as duas tabelas na página abaixo do gráfico em layout grid `grid-cols-1 lg:grid-cols-2 gap-6`
**Checkpoint**: US1 e US2 funcionais. Admin vê cards, gráfico e tabelas de ranking em uma única página com filtro de período unificado.
---
## Phase 5: User Story 3 — Rastreamento Automático de Visitas Públicas (Priority: P3)
**Goal**: Cada GET a uma rota pública cria um `PageView` com IP anonimizado e `property_id` quando aplicável, sem bloquear a resposta ao visitante.
**Independent Test**: No ambiente de teste, fazer `client.get("/api/v1/properties")` e verificar `PageView.query.count() == 1` com `path == "/api/v1/properties"` e `ip_hash` de 64 chars; fazer `client.get("/api/v1/admin/analytics/summary", headers=...)` e confirmar que o count NÃO aumenta.
### Implementation for User Story 3
- [ ] T016 [US3] Add `_track_page_view` before_request hook in `backend/app/__init__.py` — adicionar `import hashlib` e `import re` no topo do arquivo (junto aos outros imports de stdlib); dentro de `create_app()`, APÓS o registro de todos os blueprints, adicionar:
```python
@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()
```
Importar `PageView` de `app.models.page_view` no topo da função `create_app` (após os outros imports de modelo já existentes); importar `request` e `current_app` de `flask` caso não estejam já importados
**Checkpoint**: Rastreamento ativo. Acessar qualquer rota pública GET cria PageView no banco; `/api/v1/admin/*` e `/api/v1/auth/*` não são registrados; falha no commit não propaga exception ao visitante.
---
## Phase 6: Tests & Polish
**Purpose**: Cobrir os 9 cenários de teste especificados no plan.md.
- [ ] T017 Create `backend/tests/test_analytics.py` — helper `_make_admin_token(app)` que cria um `ClientUser(role="admin")` no banco e assina um JWT com `app.config["JWT_SECRET_KEY"]`; helper `_insert_page_view(db, path, accessed_at=None, property_id=None)` para inserção direta; implementar os 9 testes:
**Cenário 1**`test_summary_no_token_returns_401`: `GET /api/v1/admin/analytics/summary` sem header `Authorization``assert resp.status_code == 401`
**Cenário 2**`test_summary_with_admin_token_returns_200_and_correct_shape`: criar admin, obter token, `GET /summary?days=7` → 200; verificar `data.keys()` contém `today, this_week, this_month, period_total, daily_series`; `len(data["daily_series"]) == 7`; todos os items de `daily_series` têm chaves `date` e `count`
**Cenário 3**`test_summary_without_data_returns_all_zeros`: sem PageViews, `GET /summary?days=7``today == 0`, `this_week == 0`, `this_month == 0`, `period_total == 0`, todos os `count` em `daily_series` são 0
**Cenário 4**`test_top_pages_respects_days_filter`: inserir 2 PageViews com `accessed_at = datetime.utcnow() - timedelta(days=60)` e 1 recente; `GET /top-pages?days=7` → somente o recente aparece no resultado
**Cenário 5**`test_top_properties_omits_missing_property`: inserir PageView com `property_id = uuid4()` (imóvel inexistente); `GET /top-properties?days=30``data["items"] == []`
**Cenário 6**`test_hook_does_not_track_admin_routes`: `GET /api/v1/admin/analytics/summary` com token admin → `PageView.query.count() == 0`
**Cenário 7**`test_hook_does_not_track_auth_routes`: `POST /api/v1/auth/login` com body JSON → `PageView.query.count() == 0`
**Cenário 8**`test_hook_tracks_public_get_route`: `client.get("/api/v1/properties")``PageView.query.count() == 1`; verificar `pv.path == "/api/v1/properties"` e `len(pv.ip_hash) == 64`
**Cenário 9**`test_hook_swallows_db_exception_without_blocking_request`: usar `unittest.mock.patch("app.extensions.db.session.commit", side_effect=Exception("DB down"))`; `client.get("/api/v1/properties")``assert resp.status_code == 200` (a requisição não é bloqueada)
---
## Dependencies
```
T001 → T002,T003,T004 (paralelo) → T005 → T006
→ T007 [US1 backend]
→ T008,T009 [US1 frontend, paralelo]
T007 → T012,T013 [US2 backend]
T008 → T014 [US2 serviço]
T009 → T015 [US2 UI]
T005 → T016 [US3 hook]
T006,T007,T012,T013,T016 → T017 [Tests]
T009 → T010,T011 [Nav + Router]
```
## Parallel Execution per Phase
| Fase | Tarefas Paralelas |
|------|------------------|
| Phase 2 | T003 ∥ T004 (schemas e blueprint são arquivos distintos independentes) |
| Phase 3 | T008 ∥ T009 (serviço frontend e página são independentes do backend e entre si) |
| Phase 4 | T012 ∥ T013 (dois endpoints distintos no mesmo arquivo, mas sem dependência entre si); T014 ∥ T012,T013 |
## Implementation Strategy
**MVP (somente US1)** — ordem mínima para entregar o dashboard funcional com zeros:
> T001 → T002 → T003,T004 → T005 → T006 → T007 → T008,T009 → T010 → T011
Com US1 completo o admin já tem uma página funcional; os dados serão zero até US3 ser implementado.
**Entrega incremental**:
1. **MVP** (T001T011): Dashboard com cards e gráfico — vazio mas sem erros
2. **+US2** (T012T015): Tabelas de top páginas e imóveis aparecem na mesma página
3. **+US3** (T016): Rastreamento ativo — dados reais começam a popular o dashboard
4. **+Tests** (T017): Cobertura dos 9 cenários de aceitação
**Total de tarefas**: 17
| Fase | Tarefas |
|------|---------|
| Setup | 1 (T001) |
| Foundational | 5 (T002T006) |
| US1 (P1) | 5 (T007T011) |
| US2 (P2) | 4 (T012T015) |
| US3 (P3) | 1 (T016) |
| Tests & Polish | 1 (T017) |