# Etapa 5 — Publicação automática + Admin web do pipeline

## Status

- Situação: **✅ Concluída e em produção (abril/2026)**
- Resultado: pipeline fechado de ponta a ponta — 69+ ofertas publicadas, 7 fontes monitoradas, 2 canais de destino. Schedule automático com toggle no painel admin. Admin web completo com CRUD de fontes, destinos e listagem de ofertas.
- Grupo / marca: **O Gerente Endoidou** (domínio `ogerenteendoidou.com.br`)

---

## Contexto

O pipeline atual faz:

```
Etapa 1: ler canais Telegram → captured_urls   (cadastro só via CLI hoje)
Etapa 2: resolver URL + extrair MLB
Etapa 3: scraping do produto → formatted_text (salvo em processed_offers)
Etapa 4: gerar link afiliado meli.la → embutido no formatted_text
   ↓
   (nenhuma publicação acontece automaticamente, ninguém vê no admin)
```

A Etapa 5 fecha o ciclo **e** dá controle operacional visual:
1. Publica `formatted_text` no canal Telegram de destino
2. Marca `posted_at` para idempotência
3. Entrega telas admin para gerenciar fontes, destinos e ofertas

---

## Escopo (duas metades)

### Metade A — Publicação automática

- Publicar ofertas **Mercado Livre** no canal Telegram de destino
- Publicar ofertas **Amazon** que venham pelo mesmo pipeline
- Imagem do produto no anexo da mensagem quando disponível
- Rate limit (intervalo mínimo + máximo por hora)
- Idempotência: não publicar a mesma oferta duas vezes
- Retentativa em caso de falha transitória da API Telegram

### Metade B — Admin web do pipeline

- Tela CRUD de **fontes de monitoramento** (Telegram hoje, WhatsApp no futuro)
- Tela CRUD de **canais de destino** (onde publicar)
- Tela de **ofertas processadas** com status, filtros e ações
- Dashboard unificado (links gerados manualmente + ofertas publicadas automaticamente)

### Fora do escopo

- Publicação em WhatsApp (frente F3 do roadmap)
- Shopee (congelada)
- Multi-tenant (frente F4; nesta etapa, destino único por sistema)
- Click tracking dos links (frente F6)
- Relatórios de conversão/vendas (frente F7)

---

## Regras de negócio

### Publicação

1. Uma oferta só é publicada se `formatted_text` estiver preenchido e `fetch_failed = false`
2. Uma oferta só é publicada **uma vez** — controlado por `posted_at`
3. Se a publicação falhar (timeout, 429 do Telegram, canal removido), a oferta **não** marca `posted_at`; o comando tenta de novo no próximo ciclo até o limite de tentativas
4. Respeitar rate limit:
   - Intervalo mínimo entre posts consecutivos (padrão: 5 minutos)
   - Máximo de posts por hora (padrão: 12 posts/hora)
5. Ordem de publicação: FIFO pelo `created_at` de `processed_offers` (oferta mais antiga primeiro)
6. Se o `formatted_text` estiver vazio/corrompido, marcar `fetch_failed = true` e não publicar

### Admin web

7. Fontes pausadas não são lidas pelo `telegram:read-links`
8. Destinos pausados não recebem publicação (se não houver nenhum ativo, o comando sai limpo sem erro)
9. Oferta marcada manualmente como "ignorar" na tela de ofertas não é publicada (equivale a `posted_at` com flag)
10. Botão "publicar agora" na tela ignora o rate limit para aquela oferta específica (útil para testes)

---

## Modelagem de dados

### Novos campos em `processed_offers`

```sql
ALTER TABLE processed_offers
    ADD COLUMN product_image_url VARCHAR(500) NULL AFTER product_price,
    ADD COLUMN posted_at TIMESTAMP NULL AFTER fetch_failed,
    ADD COLUMN post_error VARCHAR(500) NULL AFTER posted_at,
    ADD COLUMN post_attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0 AFTER post_error,
    ADD COLUMN is_ignored BOOLEAN NOT NULL DEFAULT FALSE AFTER post_attempts,
    ADD INDEX idx_ready_to_post (is_ignored, posted_at, fetch_failed);
```

- `product_image_url` — imagem do produto capturada no scraping (para enviar com caption)
- `posted_at` — marcado quando a publicação é bem-sucedida
- `post_error` — última mensagem de erro (debug)
- `post_attempts` — contador de tentativas; oferta é descartada após N
- `is_ignored` — marcação manual do admin para pular a oferta

### Nova tabela `publication_targets`

```sql
CREATE TABLE publication_targets (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT UNSIGNED NULL,                    -- nullable por enquanto (multi-tenant futuro)
    channel_identifier VARCHAR(255) NOT NULL,        -- @canal ou -100XXXXXXXXXX
    channel_name VARCHAR(255) NULL,                  -- rótulo amigável
    platform VARCHAR(32) NOT NULL DEFAULT 'telegram',-- prepara WhatsApp no futuro
    is_active BOOLEAN NOT NULL DEFAULT TRUE,
    notes TEXT NULL,
    created_at TIMESTAMP NULL,
    updated_at TIMESTAMP NULL,
    INDEX idx_active (is_active, platform),
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
```

Primeira linha populada via seed: `@ogerenteendoidou` (ou ID do canal real) com nome "O Gerente Endoidou — Oficial".

### Nova tabela `publication_logs`

Auditoria de cada tentativa de publicação:

```sql
CREATE TABLE publication_logs (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    processed_offer_id BIGINT UNSIGNED NOT NULL,
    target_id BIGINT UNSIGNED NOT NULL,
    telegram_message_id BIGINT NULL,
    status ENUM('success', 'failure') NOT NULL,
    error_message VARCHAR(500) NULL,
    created_at TIMESTAMP NULL,
    INDEX idx_offer (processed_offer_id),
    INDEX idx_target (target_id),
    INDEX idx_created (created_at),
    FOREIGN KEY (processed_offer_id) REFERENCES processed_offers(id) ON DELETE CASCADE,
    FOREIGN KEY (target_id) REFERENCES publication_targets(id) ON DELETE CASCADE
);
```

---

## Comando Artisan

### `php artisan telegram:post-offers`

**Três modos de operação:**

| Modo | Flag | Comportamento |
|---|---|---|
| **Interativo** | _padrão_ (sem flag) | Para cada oferta: imprime título, preço, texto completo, imagem e destino; **pergunta `Publicar? [s/N]`**; se `s`, publica de verdade; se `n`, pula (não marca `posted_at`, não incrementa tentativas); se `i`, marca como `is_ignored = true` |
| **Automático** | `--auto` | Publica direto sem perguntar. Usado pelo scheduler quando já estiver confiante |
| **Simulado** | `--dry-run` | Só imprime o que publicaria. Nunca envia. Nunca altera o banco |

O modo interativo é o **padrão de validação** no PowerShell — entrega visibilidade total antes de automatizar.

**Comportamento comum aos 3 modos:**

1. Busca destinos ativos em `publication_targets` (platform = telegram, is_active = true)
2. Lê `processed_offers` com: `formatted_text IS NOT NULL AND fetch_failed = 0 AND posted_at IS NULL AND is_ignored = 0 AND post_attempts < N`, ordenado por `created_at ASC`
3. Para cada oferta:
   - (Interativo) exibe detalhes e pergunta o que fazer
   - (Auto) checa se respeita intervalo mínimo; se não, aguarda/encerra
   - Envia via MadelineProto para cada destino ativo:
     - `sendPhoto` com caption se tiver `product_image_url`
     - `sendMessage` caso contrário
   - Em sucesso: `posted_at = now()`, limpa `post_error`, grava sucesso em `publication_logs`
   - Em falha: incrementa `post_attempts`, grava `post_error`, grava falha em `publication_logs`
4. (Auto) aguarda intervalo mínimo entre posts
5. Sai quando acabaram as ofertas elegíveis ou atingiu o máximo de posts por hora

**Argumentos extras:**

- `--limit=N` — limite de ofertas por execução
- `--offer-id=X` — publica só uma oferta específica, ignorando rate limit (usado pelo botão "publicar agora" da tela admin)

**Saída do modo interativo (exemplo):**

```
┌─────────────────────────────────────────────────────────────────────┐
│  Oferta #142                                    Mercado Livre       │
├─────────────────────────────────────────────────────────────────────┤
│  Título:    Smartphone Motorola Moto G15 - 256gb 12gb ram           │
│  Preço:     R$ 784,00  (de R$ 1.499,00 · 47% OFF)                   │
│  Fonte:     Canal XYZ (Telegram)                                    │
│  Destino:   @ogerenteendoidou                                       │
│  Imagem:    https://http2.mlstatic.com/D_NQ_NP_...jpg               │
│                                                                     │
│  Texto completo:                                                    │
│  --------------------------------------------------------------     │
│  Smartphone Motorola Moto G15 - 256gb 12gb ram                      │
│                                                                     │
│  De: R̶$̶ ̶1̶.̶4̶9̶9̶,̶0̶0̶                                                    │
│  Por: R$ 784,00 (47% OFF)                                           │
│                                                                     │
│  Compre aqui: https://meli.la/2SFbpNt                               │
│  (...rodapés...)                                                    │
│  --------------------------------------------------------------     │
└─────────────────────────────────────────────────────────────────────┘

  [s] publicar no canal
  [n] pular agora (tenta de novo depois)
  [i] ignorar para sempre
  [q] sair do comando

Escolha [s/n/i/q]:
```

---

## Admin web (metade B)

### Rotas novas em `routes/web.php`

```php
// Dentro do grupo admin (middleware auth)
Route::prefix('monitoring')->name('monitoring.')->group(function () {
    // Fontes de monitoramento (canais/grupos lidos)
    Route::resource('sources', MonitoringSourceController::class)->except(['show']);
    Route::post('sources/{source}/toggle', [MonitoringSourceController::class, 'toggle'])->name('sources.toggle');

    // Destinos de publicação
    Route::resource('targets', PublicationTargetController::class)->except(['show']);
    Route::post('targets/{target}/toggle', [PublicationTargetController::class, 'toggle'])->name('targets.toggle');

    // Ofertas processadas (listagem + ações)
    Route::get('offers', [ProcessedOfferController::class, 'index'])->name('offers.index');
    Route::post('offers/{offer}/publish', [ProcessedOfferController::class, 'publishNow'])->name('offers.publish');
    Route::post('offers/{offer}/ignore', [ProcessedOfferController::class, 'ignore'])->name('offers.ignore');
    Route::post('offers/{offer}/retry', [ProcessedOfferController::class, 'retry'])->name('offers.retry');
});
```

### Permissões (adicionar ao seeder)

- `monitoring.sources.view`, `monitoring.sources.manage`
- `monitoring.targets.view`, `monitoring.targets.manage`
- `monitoring.offers.view`, `monitoring.offers.manage`

### Telas a construir

#### 1. `/admin/monitoring/sources` — Fontes de monitoramento

Listagem em tabela:

| Coluna | Detalhe |
|---|---|
| Nome | Rótulo amigável |
| Tipo | Telegram / WhatsApp (futuro) |
| Identificador | `@canal` ou ID |
| Última leitura | `last_read_at` |
| Total capturado (7d) | `count(captured_urls)` últimos 7 dias |
| Status | Ativo / Pausado (toggle visual) |
| Ações | Editar, pausar/ativar, excluir |

Formulário (criar/editar):
- Nome, tipo, identificador (`@canal` ou URL do Telegram), limite de leitura, ativo sim/não

#### 2. `/admin/monitoring/targets` — Destinos de publicação

Listagem em tabela:

| Coluna | Detalhe |
|---|---|
| Nome | Rótulo amigável (ex: "O Gerente Endoidou") |
| Plataforma | Telegram (WhatsApp futuro) |
| Identificador | `@ogerenteendoidou` ou `-100XXXXX` |
| Total publicado (7d) | `count(publication_logs)` sucesso últimos 7 dias |
| Status | Ativo / Pausado |
| Ações | Editar, pausar/ativar, excluir |

#### 3. `/admin/monitoring/offers` — Ofertas processadas

Tabela principal com filtros (plataforma, status, busca por título, período):

| Coluna | Detalhe |
|---|---|
| Miniatura | `product_image_url` |
| Título | `product_title` |
| Plataforma | amazon / mercadolivre |
| Fonte | nome do `monitoring_source` |
| Preço | `product_price` |
| Status | Capturada / Sem preço / Pronta para publicar / Publicada / Falhou / Ignorada |
| Tentativas | `post_attempts` |
| Capturada em | `created_at` |
| Publicada em | `posted_at` |
| Ações | Ver texto completo (modal), publicar agora, ignorar, retentar |

Estados derivados (para a coluna "Status"):
- `fetch_failed = true` → **Falhou scraping**
- `is_ignored = true` → **Ignorada**
- `formatted_text IS NULL` → **Processando**
- `posted_at IS NOT NULL` → **Publicada**
- `post_attempts >= N` (sem sucesso) → **Desistida** (esgotou tentativas)
- caso contrário → **Pronta para publicar**

#### 4. Dashboard unificado `/admin` (reforma leve)

Hoje o dashboard mostra só `GeneratedLink`. Proposta:

- Manter os cards existentes (total, Amazon, Mercado Livre, Hoje)
- **Adicionar** 3 cards novos:
  - Ofertas capturadas (24h)
  - Publicadas (24h)
  - Falhas de publicação (24h)
- Tabela inferior mescla `GeneratedLink` (manual) e `ProcessedOffer` publicadas (automático) numa única timeline, com origem explícita na coluna (manual / auto)

---

## Configuração

### `config/telegram.php` (adições)

```php
'publication' => [
    'interval_minutes'      => (int) env('TELEGRAM_PUBLISH_INTERVAL_MINUTES', 5),
    'max_posts_per_hour'    => (int) env('TELEGRAM_PUBLISH_MAX_PER_HOUR', 12),
    'max_attempts_per_offer'=> (int) env('TELEGRAM_PUBLISH_MAX_ATTEMPTS', 3),
    'include_product_image' => (bool) env('TELEGRAM_PUBLISH_WITH_IMAGE', true),
],
```

### `.env.example` (novas entradas)

```
# Etapa 5 - publicacao automatica
TELEGRAM_PUBLISH_INTERVAL_MINUTES=5
TELEGRAM_PUBLISH_MAX_PER_HOUR=12
TELEGRAM_PUBLISH_MAX_ATTEMPTS=3
TELEGRAM_PUBLISH_WITH_IMAGE=true
```

---

## Schedule

Em `app/Console/Kernel.php`, adicionar (com flag `--auto`):

```php
$schedule->command('telegram:post-offers --auto')
    ->everyMinute()                 // o comando respeita seu proprio rate limit internamente
    ->withoutOverlapping()
    ->runInBackground();
```

Frequência de 1 minuto é segura porque o próprio comando checa o intervalo mínimo. Se nada deve publicar agora, ele sai rápido.

### Rodar o scheduler no Windows (XAMPP local 24h)

O `schedule:run` do Laravel precisa ser executado a cada minuto. No Linux isso vai no cron; no Windows, usa-se o **Agendador de Tarefas (schtasks)**.

Criar tarefa agendada (via PowerShell como Administrador):

```powershell
schtasks /Create /SC MINUTE /MO 1 ^
  /TN "LaravelScheduleRun-PromocoesAlyne" ^
  /TR "cmd.exe /c cd /d C:\xampp\htdocs\promocoes-alyne && C:\xampp\php\php.exe artisan schedule:run >> storage\logs\schedule.log 2>&1"
```

Validar:

```powershell
schtasks /Query /TN "LaravelScheduleRun-PromocoesAlyne"
```

Remover (se precisar):

```powershell
schtasks /Delete /TN "LaravelScheduleRun-PromocoesAlyne" /F
```

**Alternativa sem agendador:** rodar um worker PowerShell dedicado:

```powershell
while ($true) { php artisan schedule:run; Start-Sleep -Seconds 60 }
```

(Menos robusto — fecha se o PowerShell cair. Use schtasks para rodagem 24h.)

---

## Imagem do produto

Hoje o scraping extrai a imagem (`thumbnail` / `images[0].url`), mas **não salva** no banco. Vamos salvar em `product_image_url` (novo campo já previsto acima).

Atualizar `TelegramProcessPending` e `TelegramProcessMl` para preencher o campo após o scraping.

---

## Critérios de aceite

### Publicação
- Rodar `php artisan telegram:post-offers` manualmente publica no canal `@ogerenteendoidou`
- Ao rodar de novo, mesmas ofertas não são republicadas (`posted_at` preenchido)
- Respeita intervalo mínimo e máximo por hora (testado com logs)
- Falhas transitórias não marcam `posted_at` e são retentadas
- Logs em `storage/logs/laravel.log` com prefixo `telegram-publish`

### Admin web
- Acessar `/admin/monitoring/sources` mostra as fontes cadastradas; criar/editar/pausar funciona
- Acessar `/admin/monitoring/targets` mostra o canal "O Gerente Endoidou"; pausar e retomar surte efeito real no comando `telegram:post-offers`
- Acessar `/admin/monitoring/offers` mostra lista paginada com filtros; botão "publicar agora" força uma publicação fora do rate limit; botão "ignorar" faz a oferta nunca ser publicada
- Dashboard `/admin` exibe cards de ofertas capturadas/publicadas/falhadas nas últimas 24h
- Permissões: usuário sem `monitoring.*.manage` vê a tela mas não consegue editar

---

## Checklist técnico ✅ Todos concluídos

### Banco e modelos
- [x] Migration: `add_posted_at_and_image_to_processed_offers_table`
- [x] Migration: `create_publication_targets_table`
- [x] Migration: `create_publication_logs_table`
- [x] Migration: `add_target_id_to_sources_and_offers` (associa fonte ao destino padrão)
- [x] Model: `PublicationTarget`
- [x] Model: `PublicationLog`
- [x] Atualizar `ProcessedOffer`: campos novos + escopos `readyToPost()`, `markPublished()`, `markFailed()`, `statusLabel()`

### Pipeline
- [x] Atualizar `TelegramProcessPending` para salvar `product_image_url` e `target_id`
- [x] Command: `telegram:post-offers` (com `--dry-run`, `--limit`, `--offer-id`, `--auto`)
- [x] Entradas em `config/telegram.php` e `.env.example`
- [x] Schedule no `Kernel.php` com toggle via `Setting::get('telegram.auto_publish_enabled')`

### Admin web
- [x] Permissões novas no seeder (`monitoring.*`)
- [x] Controller: `MonitoringSourceController` (CRUD + toggle)
- [x] Controller: `PublicationTargetController` (CRUD + toggle)
- [x] Controller: `ProcessedOfferController` (index + publishNow + ignore + retry + unignore)
- [x] Views: `admin/monitoring/sources/{index,create,edit}.blade.php`
- [x] Views: `admin/monitoring/targets/{index,create,edit}.blade.php`
- [x] Views: `admin/monitoring/offers/index.blade.php` + show (detalhe)
- [x] Menu lateral: seção "Monitoramento" adicionada
- [x] Dashboard: cards de ofertas capturadas/publicadas/falhadas + últimas publicações

### Validação ponta a ponta
- [x] Fontes cadastradas via tela → mensagens lidas pelo cron → aparecem em `captured_urls`
- [x] Ofertas ML e Amazon passam por scraping → aparecem em `/admin/monitoring/offers`
- [x] `telegram:post-offers` publica no canal → 69+ publicações registradas em `publication_logs`
- [x] Ignorar oferta pela tela → não é publicada
- [x] Pausar destino → comando respeita e não publica

---

## Defaults propostos (pendem confirmação)

1. **Intervalo mínimo entre posts:** 5 minutos
2. **Máximo por hora:** 12 posts (= 1 a cada 5 min)
3. **Máximo de tentativas por oferta:** 3 (depois marca como "Desistida")
4. **Publicar imagem junto com o texto:** sim (`sendPhoto` + caption)
5. **Se texto > 1024 chars (limite de caption):** cair para `sendPhoto` sem caption + `sendMessage` separado em sequência

Se qualquer desses valores padrão precisar mudar, é só ajustar no `.env` sem recompilar código.

---

## Operação em localhost (XAMPP, sem domínio ainda)

A F1 foi projetada para rodar **100% em localhost/XAMPP** desde o dia 1, **sem depender** do domínio `ogerenteendoidou.com.br` estar no ar:

- Publicação no Telegram **não precisa** de domínio público — o MadelineProto roda direto com IP local
- O link dentro do `formatted_text` continua sendo `meli.la/xxx` (ML) ou `/dp/ASIN?tag=...` (Amazon) enquanto a F6 (tracking) não estiver ligada
- Quando a F6 for ativada e o domínio estiver no ar, basta ligar a flag `TRACKING_ENABLED=true` e os próximos `formatted_text` passam a usar o link encurtado — não quebra nada retroativamente
- Ofertas antigas publicadas com link direto continuam funcionando (o afiliado é preservado); só não terão estatística de clique

**Resumo:** começar localhost agora é seguro. Tracking (F6) é feature incremental que entra depois.

---

## Fluxo recomendado de validação (dias iniciais)

Sugestão operacional para os primeiros dias no PowerShell:

```powershell
# Dia 1 - validar cadastro e leitura
php artisan telegram:target-add @ogerenteendoidou "O Gerente Endoidou - Oficial"
php artisan telegram:target-list
php artisan telegram:read-links                     # puxa mensagens dos canais monitorados
php artisan telegram:process-pending                # faz scraping e monta formatted_text

# Dia 1 - validar PUBLICAÇÃO no modo interativo
php artisan telegram:post-offers                    # modo interativo (padrão) - pergunta a cada oferta

# Dia 2-N - confortável com o resultado, ligar o automático
# Criar tarefa agendada (uma única vez, como Administrador)
schtasks /Create /SC MINUTE /MO 1 ^
  /TN "LaravelScheduleRun-PromocoesAlyne" ^
  /TR "cmd.exe /c cd /d C:\xampp\htdocs\promocoes-alyne && C:\xampp\php\php.exe artisan schedule:run >> storage\logs\schedule.log 2>&1"
```

A partir daí o sistema roda sozinho 24h. Se quiser acompanhar de perto, o PowerShell pode abrir uma janela assistindo o log:

```powershell
Get-Content storage\logs\laravel.log -Wait -Tail 50
```
