feat: add full project - backend, frontend, docker, specs and configs
This commit is contained in:
parent
b77c7d5a01
commit
e6cb06255b
24489 changed files with 61341 additions and 36 deletions
341
.specify/features/016-analytics-dashboard/plan.md
Normal file
341
.specify/features/016-analytics-dashboard/plan.md
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue