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