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

199 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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) |