# TOI Shortcut — Especificação Completa do Sistema

> **Domínio:** `https://toi.shinp.ai`
> **Marca:** TOI Shortcut — *"Powered by TOI"*
> **Data:** 2026-04-03
> **Autores:** Oscar (Produto & Arquitetura) + Albiere (Direção Visual)

---

## 1. Resumo Executivo

O **TOI Shortcut** é um sistema proprietário e premium de encurtamento de URLs, hospedado em `toi.shinp.ai`, que permite a criação, gerenciamento e rastreamento de links curtos com painel administrativo completo. O sistema oferece:

- **Encurtamento com slug customizado ou randômico** (unicidade global garantida)
- **Redirecionamento ultra-rápido** via cache Redis (sub-milissegundo)
- **Analytics completo** — contagem de cliques, histórico de acessos, referers, países
- **Painel admin premium** — dark-first, minimalista, com identidade visual forte
- **Segurança** — JWT, bcrypt, rate limiting, sanitização, IP anonimizado

**Stack:** FastAPI (Python) + PostgreSQL + Redis + Vanilla HTML/CSS/JS
**Deploy:** Monolito containerizado via Docker Compose

---

## 2. Visão do Produto

### 2.1 Propósito

TOI Shortcut é uma ferramenta proprietária de encurtamento de links com controle total sobre dados, branding e infraestrutura. Diferente de serviços genéricos como Bitly, todos os links curtos carregam o domínio `toi.shinp.ai`, reforçando a marca TOI.

### 2.2 Posicionamento

- **Ferramenta interna/proprietária** com painel administrativo completo
- **Branding consistente** sob o domínio `toi.shinp.ai`
- **Privacidade e controle** — dados de analytics sob controle próprio
- **Performance** — redirecionamento ultra-rápido via Redis, sem bloqueio por tracking

### 2.3 Usuários-Alvo

| Usuário | Descrição | Necessidade |
|---|---|---|
| Admin (operador) | Gerencia os links no painel | Criar, editar, ativar/desativar links e ver analytics |
| Visitante | Acessa um link curto | Ser redirecionado rapidamente ao destino |

---

## 3. Regras de Negócio

### 3.1 Criação de URLs

| # | Regra |
|---|---|
| RN01 | Todo link curto deve ter um `slug` único globalmente |
| RN02 | O slug pode ser customizado pelo admin ou gerado automaticamente |
| RN03 | Slug automático: 6 caracteres alfanuméricos (a-z, 0-9), gerado via `secrets.token_urlsafe(4)` truncado |
| RN04 | Slug customizado: 3-50 caracteres, apenas `[a-zA-Z0-9_-]` |
| RN05 | A URL de destino deve ser válida (http/https), max 2048 caracteres |
| RN06 | O título é opcional, max 255 caracteres, usado para identificação no painel |
| RN07 | Se título não informado, o sistema tenta extrair o `<title>` da página (best-effort) |
| RN08 | Link criado no estado `is_active = true` por padrão |
| RN09 | Não é permitido links apontando para o próprio domínio `toi.shinp.ai` (prevenção de loop) |

### 3.2 Slugs Reservados

| # | Regra |
|---|---|
| RN10 | Slugs reservados (case-insensitive): `api`, `admin`, `login`, `logout`, `static`, `health`, `healthz`, `favicon.ico`, `robots.txt`, `sitemap.xml`, `.well-known`, `assets`, `css`, `js`, `img`, `fonts`, `dashboard`, `analytics`, `auth`, `register`, `signup`, `signin`, `reset`, `404`, `500`, `error`, `about`, `terms`, `privacy`, `help`, `docs`, `status`, `new`, `edit`, `delete`, `settings`, `profile`, `account` |
| RN11 | Validação case-insensitive (`Admin` = `admin` = bloqueado) |
| RN12 | Lista mantida em constante no código, facilmente extensível |

### 3.3 Unicidade e Validação

| # | Regra |
|---|---|
| RN13 | Slug tem constraint `UNIQUE` no banco |
| RN14 | Verificação de existência via query antes de inserir (mensagem amigável) |
| RN15 | Slug auto-gerado: re-gera até 5 tentativas em caso de colisão |
| RN16 | Validação de URL usa regex + parse (urllib) — não faz request HEAD |

### 3.4 Estados do Link

| # | Regra |
|---|---|
| RN17 | `is_active = true`: redireciona ao destino |
| RN18 | `is_active = false`: retorna HTTP 410 (Gone) com página amigável |
| RN19 | Link deletado: removido do banco, retorna 404 |
| RN20 | Toggle de ativação invalida o cache Redis imediatamente |

### 3.5 Analytics

| # | Regra |
|---|---|
| RN21 | Todo acesso a link ativo incrementa `click_count` (contador rápido) |
| RN22 | Todo acesso gera registro em `click_events` com metadados (async) |
| RN23 | IP armazenado como hash SHA-256 (privacidade/LGPD) |
| RN24 | Campo `country` via lookup GeoIP (best-effort, pode ser `null`) |
| RN25 | Dados de analytics são somente-leitura |

### 3.6 Autenticação

| # | Regra |
|---|---|
| RN26 | Acesso ao admin requer JWT |
| RN27 | Token JWT expira em 24 horas |
| RN28 | Refresh token não implementado no MVP |
| RN29 | Senha com bcrypt, cost factor 12 |
| RN30 | Admin inicial criado via seed/script CLI |

---

## 4. Fluxos Principais

### 4.1 Criação de Link

```
Admin abre painel → Clica "Novo Link"
  → Preenche: destination_url (obrigatório), slug (opcional), title (opcional)
  → Frontend valida formato básico
  → POST /api/links
  → Backend:
    1. Valida JWT do admin
    2. Valida destination_url (formato, não aponta para toi.shinp.ai)
    3. Se slug informado:
       a. Normaliza para lowercase
       b. Valida regex [a-z0-9_-]{3,50}
       c. Verifica se não é reservado
       d. Verifica unicidade no banco
    4. Se slug não informado:
       a. Gera slug aleatório de 6 chars
       b. Verifica unicidade (até 5 tentativas)
    5. Insere no banco: short_links
    6. Insere no Redis: slug → {destination_url, is_active}
    7. Retorna 201 com objeto do link criado
  → Frontend exibe link criado com botão "Copiar"
```

### 4.2 Redirect

```
Visitante acessa: GET https://toi.shinp.ai/{slug}
  → FastAPI route captura {slug}
  → Backend:
    1. Busca no Redis: cache:slug:{slug}
       a. Se encontrou e is_active=true → usa destination_url do cache
       b. Se encontrou e is_active=false → retorna 410
       c. Se não encontrou → busca no PostgreSQL
          i.  Se encontrou → popula Redis, processa
          ii. Se não encontrou → retorna 404
    2. Retorna HTTP 302 (Found) com header Location: destination_url
    3. ASYNC (background task):
       a. Incrementa click_count no PostgreSQL
       b. Insere click_event com metadados do request
  → Visitante é redirecionado ao destino
```

### 4.3 Analytics

```
Admin acessa Dashboard
  → GET /api/analytics/overview
  → Backend retorna:
    - total_links, total_clicks, clicks_today
    - top_links (top 10 por click_count)
    - clicks_over_time (agrupado por dia, últimos 30 dias)
  → Frontend renderiza cards e gráficos

Admin clica em link específico
  → GET /api/links/{id}/analytics
  → Backend retorna:
    - Dados do link, click_count total
    - clicks_by_day, top_referers, top_countries
    - recent_clicks (últimos 50)
  → Frontend renderiza detalhes
```

### 4.4 Gerenciamento Admin

```
Listagem: GET /api/links?page=1&per_page=20&search=&sort=created_at&order=desc
Edição:   PUT /api/links/{id} — altera destination_url, title, slug
Toggle:   PATCH /api/links/{id}/toggle — inverte is_active, atualiza cache
Exclusão: DELETE /api/links/{id} — remove do banco e cache
```

---

## 5. Arquitetura Funcional

### Estrutura do Projeto

```
toi-shortcut/
├── app/
│   ├── main.py                  # FastAPI app, startup, routers
│   ├── config.py                # Settings via pydantic-settings (.env)
│   ├── database.py              # SQLAlchemy async engine + session
│   ├── redis.py                 # Redis client singleton
│   ├── models/
│   │   ├── admin.py             # Admin model
│   │   ├── short_link.py        # ShortLink model
│   │   └── click_event.py       # ClickEvent model
│   ├── schemas/
│   │   ├── auth.py              # LoginRequest, TokenResponse, AdminResponse
│   │   ├── link.py              # LinkCreate, LinkUpdate, LinkResponse, LinkList
│   │   └── analytics.py         # OverviewResponse, LinkAnalyticsResponse
│   ├── routers/
│   │   ├── auth.py              # /api/auth/*
│   │   ├── links.py             # /api/links/*
│   │   ├── analytics.py         # /api/analytics/*
│   │   └── redirect.py          # /{slug} (público)
│   ├── services/
│   │   ├── auth_service.py      # Login, JWT, password verify
│   │   ├── link_service.py      # CRUD de links, slug generation
│   │   ├── redirect_service.py  # Resolve slug, cache logic
│   │   └── analytics_service.py # Record click, aggregate stats
│   ├── core/
│   │   ├── security.py          # JWT encode/decode, bcrypt, IP hash
│   │   ├── dependencies.py      # get_current_admin, get_db, get_redis
│   │   ├── constants.py         # RESERVED_SLUGS, SLUG_REGEX
│   │   ├── exceptions.py        # Custom exceptions + handlers
│   │   └── slug_generator.py    # Gerar slug aleatório único
│   └── seed.py                  # Script para criar admin inicial
├── frontend/
│   ├── index.html               # SPA — login + dashboard
│   ├── css/
│   │   └── style.css
│   ├── js/
│   │   ├── app.js               # Router SPA, state management
│   │   ├── api.js               # Fetch wrapper com JWT
│   │   ├── auth.js              # Login/logout logic
│   │   ├── links.js             # CRUD de links UI
│   │   ├── analytics.js         # Dashboard e gráficos
│   │   └── utils.js             # Helpers (copy to clipboard, formatters)
│   └── assets/
│       └── logo.svg
├── requirements.txt
├── Dockerfile
├── docker-compose.yml
└── .env.example
```

### Módulos

| Módulo | Responsabilidades |
|---|---|
| **Auth** | Login (email+senha → JWT), verificação de token, sessão admin |
| **Links** | CRUD completo, geração/validação de slug, cache sync |
| **Redirect** | Resolução Redis-first → PG fallback → 302/404/410, tracking async |
| **Analytics** | Contador rápido + evento detalhado, agregações para dashboard |
| **Frontend** | SPA com login, dashboard, gerenciamento de links, analytics |

---

## 6. Arquitetura Técnica

### 6.1 Stack

| Componente | Tecnologia | Justificativa |
|---|---|---|
| **Backend** | FastAPI (Python 3.11+) | Async nativo, background tasks, Pydantic, OpenAPI |
| **ORM** | SQLAlchemy 2.0 (async) | Integridade referencial, async com asyncpg |
| **Database** | PostgreSQL 16 | UNIQUE constraints, índices, agregações, BRIN |
| **Cache** | Redis 7 | Sub-milissegundo, chave-valor ideal para slug→url |
| **Frontend** | HTML/CSS/JS vanilla + Chart.js | Zero build step, servido pelo FastAPI |
| **Auth** | PyJWT + bcrypt | Padrão seguro, sem overhead |
| **Server** | Uvicorn | ASGI performático |

### 6.2 Diagrama

```
                ┌──────────────────────────────────┐
                │          INTERNET                 │
                └──────────┬───────────────────────┘
                           │
                ┌──────────▼───────────────────────┐
                │   toi.shinp.ai (Reverse Proxy)   │
                └──────────┬───────────────────────┘
                           │
                ┌──────────▼───────────────────────┐
                │     FastAPI (Uvicorn)             │
                │                                   │
                │  ┌─────────┐  ┌──────────────┐   │
                │  │ /admin  │  │ /api/*       │   │
                │  │ Static  │  │ Auth, Links, │   │
                │  │ Files   │  │ Analytics    │   │
                │  └─────────┘  └──────────────┘   │
                │                                   │
                │  ┌──────────────────────────────┐ │
                │  │ GET /{slug}                  │ │
                │  │ Redirect Router              │ │
                │  │ Redis → PG → 302/404/410     │ │
                │  └──────────────────────────────┘ │
                └──────┬──────────────┬────────────┘
                       │              │
              ┌────────▼──────┐ ┌─────▼──────────┐
              │   Redis 7     │ │ PostgreSQL 16   │
              │               │ │                  │
              │ cache:slug:*  │ │ admins           │
              │ slug→url+flag │ │ short_links      │
              │ TTL: 1 hora   │ │ click_events     │
              └───────────────┘ └──────────────────┘
```

---

## 7. Modelagem de Dados

### 7.1 Tabela `admins`

```sql
CREATE TABLE admins (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email         VARCHAR(255) NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    name          VARCHAR(100) NOT NULL,
    is_active     BOOLEAN NOT NULL DEFAULT true,
    created_at    TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
    updated_at    TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);

CREATE UNIQUE INDEX idx_admins_email ON admins (LOWER(email));
```

### 7.2 Tabela `short_links`

```sql
CREATE TABLE short_links (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    slug            VARCHAR(50) NOT NULL,
    destination_url VARCHAR(2048) NOT NULL,
    title           VARCHAR(255),
    is_active       BOOLEAN NOT NULL DEFAULT true,
    created_by      UUID NOT NULL REFERENCES admins(id) ON DELETE RESTRICT,
    click_count     BIGINT NOT NULL DEFAULT 0,
    created_at      TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
    updated_at      TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);

CREATE UNIQUE INDEX idx_short_links_slug ON short_links (LOWER(slug));
CREATE INDEX idx_short_links_created_at ON short_links (created_at DESC);
CREATE INDEX idx_short_links_click_count ON short_links (click_count DESC);
CREATE INDEX idx_short_links_is_active ON short_links (is_active) WHERE is_active = true;
```

### 7.3 Tabela `click_events`

```sql
CREATE TABLE click_events (
    id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    short_link_id  UUID NOT NULL REFERENCES short_links(id) ON DELETE CASCADE,
    clicked_at     TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
    referer        VARCHAR(2048),
    user_agent     VARCHAR(512),
    ip_hash        VARCHAR(64),
    country        VARCHAR(2)
);

CREATE INDEX idx_click_events_short_link_id ON click_events (short_link_id);
CREATE INDEX idx_click_events_clicked_at ON click_events (clicked_at DESC);
CREATE INDEX idx_click_events_link_date ON click_events (short_link_id, clicked_at DESC);
```

### 7.4 Diagrama ER

```
┌──────────────┐       ┌──────────────────┐       ┌─────────────────┐
│   admins     │       │   short_links    │       │  click_events   │
├──────────────┤       ├──────────────────┤       ├─────────────────┤
│ id (PK)      │──1:N──│ id (PK)          │──1:N──│ id (PK)         │
│ email (UQ)   │       │ slug (UQ)        │       │ short_link_id   │
│ password_hash│       │ destination_url   │       │ clicked_at      │
│ name         │       │ title            │       │ referer         │
│ is_active    │       │ is_active        │       │ user_agent      │
│ created_at   │       │ created_by (FK)  │       │ ip_hash         │
│ updated_at   │       │ click_count      │       │ country         │
└──────────────┘       │ created_at       │       └─────────────────┘
                       │ updated_at       │
                       └──────────────────┘
```

---

## 8. Endpoints e Ações Principais

### 8.1 Autenticação

| Método | Path | Descrição | Auth |
|---|---|---|---|
| `POST` | `/api/auth/login` | Login do admin | Não |
| `GET` | `/api/auth/me` | Dados do admin logado | JWT |

**POST /api/auth/login:**
```json
// Request
{ "email": "admin@toi.shinp.ai", "password": "senhaSegura123" }

// Response 200
{
  "access_token": "eyJhbGciOiJIUzI1NiI...",
  "token_type": "bearer",
  "admin": { "id": "uuid", "email": "admin@toi.shinp.ai", "name": "Admin TOI" }
}

// Response 401
{ "detail": "Credenciais inválidas" }
```

### 8.2 Links (CRUD)

| Método | Path | Descrição | Auth |
|---|---|---|---|
| `GET` | `/api/links` | Listar links (paginado, busca, ordenação) | JWT |
| `POST` | `/api/links` | Criar novo link | JWT |
| `GET` | `/api/links/{id}` | Detalhes de um link | JWT |
| `PUT` | `/api/links/{id}` | Atualizar link | JWT |
| `DELETE` | `/api/links/{id}` | Deletar link | JWT |
| `PATCH` | `/api/links/{id}/toggle` | Ativar/desativar link | JWT |

**GET /api/links — Query Params:**
```
page: int = 1
per_page: int = 20 (max 100)
search: str = "" (busca em slug, title, destination_url)
sort: str = "created_at" (created_at | click_count | title | slug)
order: str = "desc" (asc | desc)
is_active: bool | null = null
```

**POST /api/links:**
```json
// Request
{
  "destination_url": "https://cogna.com.br/institucional",
  "slug": "cogna",        // opcional
  "title": "Cogna Institucional"  // opcional
}

// Response 201
{
  "id": "uuid",
  "slug": "cogna",
  "short_url": "https://toi.shinp.ai/cogna",
  "destination_url": "https://cogna.com.br/institucional",
  "title": "Cogna Institucional",
  "is_active": true,
  "click_count": 0,
  "created_at": "2026-04-03T12:00:00Z"
}

// Response 409: { "detail": "Slug 'cogna' já está em uso" }
// Response 400: { "detail": "Slug 'admin' é reservado" }
```

### 8.3 Analytics

| Método | Path | Descrição | Auth |
|---|---|---|---|
| `GET` | `/api/analytics/overview` | Métricas gerais do dashboard | JWT |
| `GET` | `/api/links/{id}/analytics` | Analytics detalhado de um link | JWT |

**GET /api/analytics/overview:**
```json
{
  "total_links": 150,
  "active_links": 142,
  "total_clicks": 48392,
  "clicks_today": 234,
  "clicks_this_week": 1893,
  "top_links": [ { "slug": "cogna", "click_count": 1547 } ],
  "clicks_over_time": [ { "date": "2026-03-04", "clicks": 145 } ]
}
```

**GET /api/links/{id}/analytics?period=30d:**
```json
{
  "link": { "slug": "cogna", "click_count": 1547, "...": "..." },
  "clicks_by_day": [ { "date": "2026-04-01", "clicks": 52 } ],
  "top_referers": [ { "referer": "https://google.com", "count": 342 } ],
  "top_countries": [ { "country": "BR", "count": 1203 } ],
  "recent_clicks": [ { "clicked_at": "...", "referer": "...", "country": "BR" } ]
}
```

### 8.4 Redirect Público

| Método | Path | Descrição | Auth |
|---|---|---|---|
| `GET` | `/{slug}` | Redireciona para URL de destino | Não |

```
302 Found → Location: https://destino.com
            Cache-Control: no-cache, no-store, must-revalidate
            X-Robots-Tag: noindex

404 → Página "Link não encontrado" com branding TOI
410 → Página "Link desativado" com branding TOI
```

### 8.5 Health Check

| Método | Path | Auth |
|---|---|---|
| `GET` | `/api/health` | Não |

---

## 9. Estratégia de Performance do Redirect

### 9.1 Cache Redis (Write-Through)

```
Chave:   cache:slug:{slug}
Valor:   JSON: {"url": "https://destino.com", "active": true}
TTL:     3600 segundos (1 hora)
```

**Fluxo:** Redis (sub-ms) → PostgreSQL fallback (~1-5ms) → Popula cache → Responde

### 9.2 Invalidação de Cache

| Evento | Ação no Cache |
|---|---|
| Link criado | `SET cache:slug:{slug}` |
| Link atualizado (URL/slug mudou) | `DEL` antigo + `SET` novo |
| Link toggleado | `SET` com novo `active` |
| Link deletado | `DEL cache:slug:{slug}` |

### 9.3 Analytics Assíncrono (Não Bloqueia Redirect)

```python
@router.get("/{slug}")
async def redirect(slug, request, background_tasks):
    result = await resolve_slug(slug)
    # ... handle 404/410 ...

    # Analytics NÃO bloqueia o redirect
    background_tasks.add_task(record_click, slug=slug, request=request)

    return RedirectResponse(url=result.url, status_code=302)
```

### 9.4 Decisão: 302 (não 301)

**Justificativa:** 301 faria o browser cachear o redirect e nunca mais consultar o servidor, perdendo dados de analytics. 302 garante que toda visita é registrada.

---

## 10. Estratégia de Analytics

### 10.1 Dupla Gravação

1. **Camada 1 — Contador Rápido (no background task):** `UPDATE short_links SET click_count = click_count + 1` — operação atômica, sem lock
2. **Camada 2 — Evento Detalhado (no background task):** `INSERT INTO click_events` com referer, user_agent, ip_hash, country

### 10.2 Queries de Agregação

```sql
-- Total de cliques (usa contador rápido)
SELECT SUM(click_count) FROM short_links;

-- Cliques hoje
SELECT COUNT(*) FROM click_events WHERE clicked_at >= CURRENT_DATE;

-- Top 10 links
SELECT slug, title, click_count FROM short_links ORDER BY click_count DESC LIMIT 10;

-- Cliques por dia (últimos 30 dias)
SELECT DATE(clicked_at), COUNT(*) FROM click_events
WHERE clicked_at >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY DATE(clicked_at) ORDER BY date;
```

### 10.3 Evolução Futura (V2+)

- Materialized views para agregações pesadas
- Particionamento de `click_events` por mês
- Worker queue para processar eventos em batch
- Tabela `daily_stats` pré-computada

---

## 11. Plano de Segurança

### 11.1 Autenticação Admin

| Aspecto | Decisão |
|---|---|
| **Mecanismo** | JWT (JSON Web Tokens) via header `Authorization: Bearer <token>` |
| **Algoritmo** | HS256 com secret de 256 bits gerado via `secrets.token_hex(32)` |
| **Expiração** | 24 horas (`exp` claim) |
| **Refresh Token** | Não implementado no MVP — admin faz login novamente |
| **Hashing de Senha** | bcrypt com cost factor 12 (`passlib.hash.bcrypt`) |
| **Admin Inicial** | Criado via script CLI `python -m app.seed` — nunca via endpoint público |
| **Logout** | Client-side — remove o token do localStorage |
| **Proteção JWT** | Todas as rotas `/api/*` (exceto `/api/auth/login` e `/api/health`) exigem JWT válido |

### 11.2 Validação e Sanitização de Input

| Campo | Validação |
|---|---|
| `destination_url` | Regex + `urllib.parse` — deve ser `http://` ou `https://`, max 2048 chars, não pode apontar para `toi.shinp.ai` |
| `slug` (custom) | Regex `^[a-z0-9_-]{3,50}$` — normalizado para lowercase antes da validação |
| `slug` (auto) | `secrets.token_urlsafe(4)` truncado para 6 chars, retry até 5x em colisão |
| `title` | Strip whitespace, max 255 chars, sanitizado contra XSS (`html.escape`) |
| `email` | Validado via `pydantic[email]` (formato RFC 5322) |
| `password` | Mínimo 8 caracteres — transmitido via HTTPS, nunca logado |
| **Geral** | Todos os inputs passam por Pydantic models com `strict=True` — rejeita tipos inesperados |
| **SQL Injection** | Prevenido nativamente pelo SQLAlchemy (queries parametrizadas) |
| **XSS** | Frontend não usa `innerHTML` com dados do usuário; backend escapa outputs sensíveis |

### 11.3 Proteção de Slugs Reservados

```python
RESERVED_SLUGS = {
    "api", "admin", "login", "logout", "static", "health", "healthz",
    "favicon.ico", "robots.txt", "sitemap.xml", ".well-known",
    "assets", "css", "js", "img", "fonts",
    "dashboard", "analytics", "auth", "register", "signup", "signin",
    "reset", "404", "500", "error",
    "about", "terms", "privacy", "help", "docs", "status",
    "new", "edit", "delete", "settings", "profile", "account"
}
```

- Comparação **case-insensitive** (`slug.lower() in RESERVED_SLUGS`)
- Mantida como `frozenset` em `app/core/constants.py`
- Verificada **antes** da query de unicidade (fail fast)

### 11.4 Prevenção de Duplicatas de Slug

| Camada | Mecanismo |
|---|---|
| **Aplicação** | `SELECT EXISTS(... WHERE LOWER(slug) = ...)` antes do INSERT — retorna mensagem amigável ao admin |
| **Banco** | `CREATE UNIQUE INDEX idx_short_links_slug ON short_links (LOWER(slug))` — safety net contra race condition |
| **Tratamento** | Handler de `IntegrityError` do SQLAlchemy retorna HTTP 409 com mensagem clara |

### 11.5 Rate Limiting

| Rota | Limite | Justificativa |
|---|---|---|
| `POST /api/auth/login` | 5 requests/minuto por IP | Proteção contra brute force |
| `GET /{slug}` (redirect) | 100 requests/segundo por IP | Proteção contra abuso/DDoS |
| `/api/*` (rotas admin) | 60 requests/minuto por token | Proteção geral da API |

**Implementação:** `slowapi` (wrapper do `limits`) com backend Redis para contadores distribuídos. Retorna HTTP 429 com header `Retry-After`.

### 11.6 Anonimização de IP (LGPD)

```python
import hashlib

def anonymize_ip(ip: str, salt: str) -> str:
    """Hash irreversível do IP com salt rotativo (diário)."""
    return hashlib.sha256(f"{ip}:{salt}".encode()).hexdigest()
```

- **Salt rotativo:** gerado diariamente, armazenado como variável de ambiente ou derivado da data (`YYYY-MM-DD` + secret)
- **Propósito:** permite contar visitantes únicos por dia sem armazenar IP real
- **Compliance:** hash com salt atende requisitos LGPD de minimização de dados
- IP bruto **nunca** é persistido em banco ou log

### 11.7 Headers de Segurança

```python
# Middleware aplicado a todas as respostas
headers = {
    "X-Content-Type-Options": "nosniff",
    "X-Frame-Options": "DENY",
    "X-XSS-Protection": "0",  # Desativado (CSP é mais eficaz)
    "Referrer-Policy": "strict-origin-when-cross-origin",
    "Permissions-Policy": "camera=(), microphone=(), geolocation=()",
    "Strict-Transport-Security": "max-age=31536000; includeSubDomains",
}

# No redirect
redirect_headers = {
    "Cache-Control": "no-cache, no-store, must-revalidate",
    "X-Robots-Tag": "noindex",
}
```

### 11.8 Audit Trail (V2+)

| Campo | Tipo | Descrição |
|---|---|---|
| `id` | UUID | PK |
| `admin_id` | UUID FK | Quem executou |
| `action` | VARCHAR(50) | `link.create`, `link.update`, `link.delete`, `link.toggle`, `admin.login` |
| `resource_type` | VARCHAR(50) | `short_link`, `admin` |
| `resource_id` | UUID | ID do recurso afetado |
| `details` | JSONB | Snapshot antes/depois (campos alterados) |
| `ip_hash` | VARCHAR(64) | IP anonimizado do admin |
| `created_at` | TIMESTAMPTZ | Quando ocorreu |

**MVP:** Logging estruturado (JSON) via `structlog` para stdout (capturado por Docker). **V2:** Tabela `audit_logs` com consulta no painel admin.

### 11.9 CORS

```python
app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://toi.shinp.ai"],  # Apenas o próprio domínio
    allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
    allow_headers=["Authorization", "Content-Type"],
    allow_credentials=True,
)
```

No MVP, o frontend é servido pelo próprio FastAPI, então CORS é configurado de forma restritiva. Sem wildcard `*`.

---

## 12. Planejamento Visual por Albiere

### 12.1 Referência Analisada: trendsoninfluence.com

**Descobertas:**
- Paleta dominante: tema escuro (`#1A1A1A`, `#242426`) com acentos coral (`#E76066`)
- Tipografia sans-serif com pesos bold/semibold, hierarquia agressiva
- Estilo dark-first, alto contraste, sofisticado e tecnológico
- Atmosfera profissional, inovadora, forward-thinking
- Botões coral com texto escuro, containers com bordas arredondadas

### 12.2 Conceito Visual: "Precision Noir"

Um sistema visual **dark-first** que comunica velocidade, confiança e premium minimalism. A interface parece uma ferramenta de engenharia de elite — cada pixel com propósito, cada espaço vazio intencional.

Inspirado em dashboards financeiros e developer tools premium (Linear, Vercel, Raycast), com a personalidade quente da marca TOI através do coral como acento.

**Mood:**
- Elegância técnica — clean, preciso, sem ruído visual
- Confiança silenciosa — o design sussurra autoridade
- Velocidade percebida — espaçamento generoso, transições rápidas
- Premium acessível — sofisticado mas não intimidador

### 12.3 Paleta de Cores

#### Cores da Marca
| Função | HEX | Uso |
|---|---|---|
| **Primary (TOI Coral)** | `#E76066` | Botões primários, links, CTAs |
| **Primary Hover** | `#ED7A7F` | Hover em primários |
| **Primary Active** | `#D14F55` | Estado pressed |
| **Secondary (TOI Gold)** | `#F5C542` | Badges premium, destaques |
| **Secondary Hover** | `#F7D06A` | Hover em secundários |

#### Backgrounds
| Função | HEX | Uso |
|---|---|---|
| **Main Background** | `#0C0C0E` | Fundo principal |
| **Card Background** | `#141416` | Cards, painéis |
| **Elevated** | `#1A1A1E` | Hover, modais, dropdowns |
| **Sidebar** | `#101012` | Navegação lateral |
| **Input** | `#18181B` | Campos de formulário |
| **Surface** | `#1E1E22` | Backgrounds alternativos |

#### Texto
| Função | HEX | Uso |
|---|---|---|
| **Primary** | `#F4F4F5` | Títulos, textos principais |
| **Secondary** | `#A1A1AA` | Subtítulos, apoio |
| **Muted** | `#71717A` | Placeholders, desabilitados |
| **Inverse** | `#18181B` | Texto sobre botões claros |

#### Semânticas
| Função | HEX |
|---|---|
| **Success** | `#10B981` |
| **Warning** | `#F59E0B` |
| **Error** | `#EF4444` |
| **Info** | `#38BDF8` |

#### Bordas
| Função | HEX |
|---|---|
| **Default** | `#27272A` |
| **Hover** | `#3F3F46` |
| **Focus** | `#E7606680` |

#### Gradientes
```css
--gradient-hero: linear-gradient(135deg, #E76066 0%, #F5C542 100%);
--gradient-surface: linear-gradient(180deg, #141416 0%, #0C0C0E 100%);
--gradient-glow: radial-gradient(ellipse at center, #E7606620 0%, transparent 70%);
```

### 12.4 Tipografia

| Função | Fonte | Fallback |
|---|---|---|
| **Headings + Body** | Inter (400, 500, 600, 700) | system-ui, sans-serif |
| **Mono (URLs/Slugs)** | JetBrains Mono (400, 500) | Courier New, monospace |

**Escala:**

| Nível | Tamanho | Weight | Uso |
|---|---|---|---|
| Display | 36px | 700 | Título principal dashboard |
| H1 | 28px | 700 | Títulos de página |
| H2 | 22px | 600 | Títulos de seção |
| H3 | 18px | 600 | Títulos de cards |
| Body | 14px | 400 | Texto geral |
| Small | 13px | 400 | Texto secundário |
| Caption | 12px | 400 | Timestamps, metadata |
| Overline | 11px | 600 | Labels uppercase |
| Mono | 13px | 400 | URLs, slugs |

### 12.5 Componentes UI

**Botões:**
- **Primary:** `bg:#E76066`, `text:#FFF`, `radius:8px`, `padding:10px 20px` → hover: `#ED7A7F` + elevação + glow
- **Secondary:** `bg:transparent`, `border:#27272A`, `text:#F4F4F5` → hover: `bg:#1A1A1E`
- **Ghost:** `bg:transparent`, `text:#A1A1AA` → hover: `bg:#1A1A1E`, `text:#F4F4F5`
- **Danger:** `bg:transparent`, `border:#EF444440`, `text:#EF4444` → hover: `bg:#EF444415`

**Cards:** `bg:#141416`, `border:#27272A`, `radius:12px`, `padding:24px` → hover: elevação + sombra

**Inputs:** `bg:#18181B`, `border:#27272A`, `radius:8px` → focus: `border:#E76066` + glow ring

**Tabelas:** Container com `radius:12px`, header `bg:#1E1E22` uppercase, rows com hover `bg:#1A1A1E`

**Badges:** `radius:6px`, com dot indicator — Ativo: `#10B981`, Inativo: `#71717A`, Erro: `#EF4444`

**Sidebar:** `width:260px`, `bg:#101012`, nav items com `radius:8px`, ativo: `bg:#E7606615`, `color:#E76066`

### 12.6 Iconografia

**Biblioteca:** Lucide Icons — stroke 1.5px, estética limpa e moderna.

Tamanhos: Sidebar 18px, Botões 16px, Stats 24px, Features 32px.

---

## 13. Telas do Sistema

### 13.1 Login

**Layout:** Split-screen 50/50.

**Lado Esquerdo (Branding):**
- Background `#0C0C0E` com glow radial sutil em coral
- Logo: "TOI" (Inter 700, 48px, `#F4F4F5`) + "Shortcut" (Inter 400, 48px, `#E76066`)
- Subtítulo: "Encurte. Rastreie. Domine." (Inter 400, 16px, `#71717A`)
- Pattern geométrico sutil em `#27272A`
- "Powered by TOI" no canto inferior (11px, `#71717A`, "TOI" em `#E76066`)

**Lado Direito (Formulário):**
- Background `#141416`, card centralizado
- Título "Entrar" (Inter 600, 28px)
- Campos email + senha (estilo padrão dos inputs)
- Botão "Entrar" full-width, Primary
- Sensação: cinematográfica, porta de entrada exclusiva

### 13.2 Dashboard

**Layout:** Sidebar fixa (260px) + área de conteúdo com scroll.

**Elementos:**
1. **Header:** Saudação "Boa tarde, [Nome]" + botão "Criar Link"
2. **Stats Cards (4 colunas):** Total Cliques, Links Ativos, Cliques Hoje, Taxa de Cliques — cada um com ícone colorido, valor grande (32px/700), variação com seta
3. **Gráfico de Área:** "Cliques nos Últimos 30 Dias" — linha `#E76066`, fill com gradiente, seletor de período (7d/30d/90d)
4. **Links Recentes:** Lista com slug (mono, coral), URL truncada, cliques, data

### 13.3 Lista de Links

**Elementos:**
- Header com título + contador + botão "Criar Link"
- Barra de ações: busca (320px) + filtro status + ordenação
- Tabela: checkbox, link encurtado (mono/coral + copy), destino (truncado), cliques, status (badge), data, ações (ícones ghost)
- Paginação: "Mostrando 1-20 de 156" + botões de página

### 13.4 Criar/Editar Link

**Formato:** Drawer lateral (520px) deslizando da direita, overlay escuro.

**Campos:**
- URL de Destino (input padrão)
- Slug Personalizado (com prefixo fixo `toi.shinp.ai/` em `#71717A`)
- Preview do link gerado (mono, coral, fundo `#1E1E22`)
- Título (opcional)
- Footer sticky: "Cancelar" (secondary) + "Criar Link" (primary)

### 13.5 Detalhe do Link com Analytics

**Layout:** Página full-width.

**Elementos:**
- Header: breadcrumb, URL encurtada (mono 22px/coral), URL destino, ações (copiar, editar, QR)
- Stats Cards (4 colunas): Total, Hoje, Únicos, Melhor Dia
- Seletor de Período: pills (Hoje, 7d, 30d, 90d, Custom)
- Gráficos (2 colunas): Cliques por tempo (area chart) + Referrers (horizontal bar)
- Seção inferior (2 colunas): Top Países (lista ranqueada) + Dispositivos (donut chart)
- Tabela de histórico recente com Data, IP mascarado, Referrer, Device, Browser, País

### 13.6 Micro-interações

- **Hover em botões:** `translateY(-1px)` + glow sutil (0.15s ease)
- **Hover em cards:** `translateY(-2px)` + border-color change + shadow (0.2s ease)
- **Loading:** Skeleton screens com shimmer (nunca spinners isolados)
- **Transições de página:** fadeIn (0.2s) com translateY(8px)
- **Toast notifications:** Top-right, barra lateral colorida, auto-dismiss 4s
- **Copy feedback:** Ícone copy → check verde (0.15s), volta após 2s

---

## 14. Roadmap por Fases

### Fase 1 — MVP (Semanas 1-2)

| Item | Prioridade |
|---|---|
| Setup: FastAPI + PostgreSQL + Redis + Docker Compose | P0 |
| Modelagem + migrations (Alembic) | P0 |
| Seed do admin inicial | P0 |
| Auth: login + me (JWT) | P0 |
| Links: CRUD completo + toggle | P0 |
| Redirect público: Redis cache + 302 | P0 |
| Analytics básico: click_count + background task | P0 |
| Frontend: login com branding TOI | P0 |
| Frontend: dashboard com stats cards | P0 |
| Frontend: lista de links paginada | P0 |
| Frontend: criar/editar link (drawer) | P0 |
| Frontend: copiar link com feedback | P0 |
| Páginas 404 e 410 com branding | P0 |
| Health check | P1 |

**Entregável:** Sistema completo deployável — admin cria links, visitantes são redirecionados, dashboard mostra contagens.

### Fase 2 — V2 Analytics & UX (Semanas 3-5)

| Item | Prioridade |
|---|---|
| Analytics detalhado por link (clicks/dia, referers, países) | P0 |
| Gráfico Chart.js no dashboard e por link | P0 |
| Busca e filtro por título/slug/URL/status | P0 |
| Click event history (tabela de eventos) | P1 |
| Top links no dashboard | P1 |
| Métricas semana/mês | P1 |
| GeoIP lookup (MaxMind GeoLite2) | P1 |
| UX: loading states, toasts, confirmações | P1 |
| QR Code generation | P2 |
| Bulk operations | P2 |
| Export CSV | P2 |

### Fase 3 — Scale (Semanas 6-10)

| Item | Prioridade |
|---|---|
| Multi-admin com convite | P1 |
| API Keys para integração externa | P1 |
| Particionamento click_events por mês | P1 |
| Materialized views | P1 |
| Audit trail | P1 |
| Roles (owner/editor) | P2 |
| Webhooks | P2 |
| Worker queue (arq) | P2 |
| Geographic analytics (mapa de calor) | P2 |
| UTM parameter tracking | P2 |
| Link expiration | P2 |
| Custom domains | P3 |

---

## 15. Riscos e Pontos de Atenção

| Categoria | Risco | Mitigação |
|---|---|---|
| **Slug** | Colisão em auto-gerados | Retry loop (5x) + UNIQUE constraint como safety net |
| **Slug** | Race condition entre check e insert | Constraint UNIQUE + handler de IntegrityError |
| **Cache** | Redis desatualizado após update | Write-through: toda mutação atualiza cache imediatamente |
| **Cache** | Redis cai | Fallback PostgreSQL sempre funciona; TTL 1h reconstrói cache |
| **Analytics** | Background task falha | click_count (principal) ainda é incrementado; log de erro |
| **Analytics** | click_count diverge de COUNT(events) | click_count é fonte primária; events são detalhes |
| **Analytics** | Bots inflando contagem | V2: filtrar user agents; V3: rate limit por IP hash |
| **Dados** | click_events cresce muito | Índices otimizados → V2: materialized views → V3: particionamento + retenção |
| **Segurança** | Open redirect para phishing | Validação de URL no cadastro; apenas admin autenticado cria links |
| **Segurança** | Brute force no login | Rate limit 5/min por IP |
| **Operacional** | PostgreSQL indisponível | Sistema para; V3: réplicas de leitura |
| **Operacional** | Perda de dados | Backup diário PG; Redis é cache (reconstruível) |

---

## 16. Recomendação Final

### Como Começar

1. **Implementar o MVP em ordem sequencial estrita** — infraestrutura base → models/migrations → core/segurança → routers/services → frontend → deploy
2. **Monolito primeiro** — um único container FastAPI servindo API + frontend + redirect
3. **Docker Compose orquestrando:** app + postgres + redis
4. **Começar pelo redirect** — é o core do produto e valida a arquitetura de cache
5. **Frontend vanilla** — sem build step, sem framework, Chart.js via CDN

### Dependências (requirements.txt)

```
fastapi==0.110.0
uvicorn[standard]==0.27.0
sqlalchemy[asyncio]==2.0.27
asyncpg==0.29.0
alembic==1.13.1
pydantic[email]==2.6.1
pydantic-settings==2.1.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
redis[hiredis]==5.0.1
slowapi==0.1.9
python-multipart==0.0.9
```

### CSS Variables Completas (para implementação)

```css
:root {
  /* Brand */
  --color-primary: #E76066;
  --color-primary-hover: #ED7A7F;
  --color-primary-active: #D14F55;
  --color-primary-muted: #E7606615;
  --color-secondary: #F5C542;
  --color-secondary-hover: #F7D06A;

  /* Backgrounds */
  --color-bg-main: #0C0C0E;
  --color-bg-card: #141416;
  --color-bg-elevated: #1A1A1E;
  --color-bg-sidebar: #101012;
  --color-bg-input: #18181B;
  --color-bg-surface: #1E1E22;
  --color-bg-overlay: #00000080;

  /* Text */
  --color-text-primary: #F4F4F5;
  --color-text-secondary: #A1A1AA;
  --color-text-muted: #71717A;
  --color-text-inverse: #18181B;

  /* Semantic */
  --color-success: #10B981;
  --color-warning: #F59E0B;
  --color-error: #EF4444;
  --color-info: #38BDF8;

  /* Borders */
  --color-border: #27272A;
  --color-border-hover: #3F3F46;
  --color-border-focus: #E7606680;

  /* Typography */
  --font-heading: 'Inter', system-ui, -apple-system, sans-serif;
  --font-body: 'Inter', system-ui, -apple-system, sans-serif;
  --font-mono: 'JetBrains Mono', 'Courier New', monospace;

  /* Sizes */
  --text-display: 2.25rem;
  --text-h1: 1.75rem;
  --text-h2: 1.375rem;
  --text-h3: 1.125rem;
  --text-body: 0.875rem;
  --text-small: 0.8125rem;
  --text-caption: 0.75rem;

  /* Spacing */
  --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px;
  --space-5: 20px; --space-6: 24px; --space-8: 32px; --space-12: 48px;

  /* Radius */
  --radius-sm: 6px;
  --radius-md: 8px;
  --radius-lg: 12px;
  --radius-xl: 16px;

  /* Shadows */
  --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2);
  --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.3);
  --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.3);
  --shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.5);
  --shadow-glow-primary: 0 4px 12px #E7606040;

  /* Transitions */
  --transition-fast: all 0.1s ease;
  --transition-base: all 0.15s ease;
  --transition-slow: all 0.25s ease;

  /* Layout */
  --sidebar-width: 260px;
  --topbar-height: 64px;
  --content-max-width: 1200px;
  --drawer-width: 520px;
}
```

---

> **Documento consolidado por Squad AI**
> Oscar (Produto & Arquitetura) + Albiere (Direção Visual)
> Pronto para implementação imediata.
