Explorar o código

manual pushs no backoffice

Gustavo Zanatta hai 4 días
pai
achega
e43914a6e3
Modificáronse 44 ficheiros con 618 adicións e 1468 borrados
  1. 121 206
      PLANO_PUSH.md
  2. 7 10
      app/Enums/PushNotificationCategoryEnum.php
  3. 81 0
      app/Http/Controllers/PushNotificationController.php
  4. 21 0
      app/Http/Requests/ManualPushRequest.php
  5. 62 0
      app/Jobs/SendManualPushJob.php
  6. 4 0
      app/Models/PushNotificationLog.php
  7. 0 59
      app/Notifications/Push/Cliente/Contextual/ContextualSextaPush.php
  8. 0 44
      app/Notifications/Push/Cliente/Contextual/ContextualVisitaPush.php
  9. 0 44
      app/Notifications/Push/Cliente/Educativo/Educativo1Push.php
  10. 0 44
      app/Notifications/Push/Cliente/Educativo/Educativo2Push.php
  11. 0 44
      app/Notifications/Push/Cliente/Educativo/Educativo3Push.php
  12. 0 44
      app/Notifications/Push/Cliente/Educativo/Educativo4Push.php
  13. 0 64
      app/Notifications/Push/Cliente/EducativoConversao/EducativoConversao1Push.php
  14. 0 59
      app/Notifications/Push/Cliente/EducativoConversao/EducativoConversao2Push.php
  15. 12 13
      app/Notifications/Push/Cliente/Marketing/AbandonoFunilPush.php
  16. 61 0
      app/Notifications/Push/Cliente/Marketing/ClienteInativoPush.php
  17. 0 44
      app/Notifications/Push/Cliente/Marketing/Marketing1Push.php
  18. 0 44
      app/Notifications/Push/Cliente/Marketing/Marketing2Push.php
  19. 0 44
      app/Notifications/Push/Cliente/Marketing/Marketing3Push.php
  20. 0 44
      app/Notifications/Push/Cliente/Marketing/Marketing4Push.php
  21. 59 0
      app/Notifications/Push/Cliente/Marketing/RecorrenciaQuebradaPush.php
  22. 0 44
      app/Notifications/Push/Cliente/Motivacional/Motivacional1Push.php
  23. 0 44
      app/Notifications/Push/Cliente/Motivacional/Motivacional2Push.php
  24. 0 44
      app/Notifications/Push/Cliente/Motivacional/Motivacional3Push.php
  25. 0 45
      app/Notifications/Push/Cliente/Recorrencia/Recorrencia1Push.php
  26. 0 45
      app/Notifications/Push/Cliente/Recorrencia/Recorrencia2Push.php
  27. 0 45
      app/Notifications/Push/Cliente/Recorrencia/Recorrencia3Push.php
  28. 0 64
      app/Notifications/Push/Cliente/SocialProof/SocialProof1Push.php
  29. 0 59
      app/Notifications/Push/Cliente/SocialProof/SocialProof2Push.php
  30. 62 0
      app/Notifications/Push/Manual/ManualPush.php
  31. 0 54
      app/Notifications/Push/Prestador/Motivacional/Motivacional1Push.php
  32. 0 78
      app/Notifications/Push/Prestador/ReforcoEducativo/ReforcoEducativo1Push.php
  33. 0 71
      app/Notifications/Push/Prestador/ReforcoEducativo/ReforcoEducativo2Push.php
  34. 75 0
      app/Services/ManualPushService.php
  35. 6 65
      app/Services/PushNotificationDispatcher.php
  36. 2 0
      app/Services/PushNotificationService.php
  37. 1 3
      bootstrap/app.php
  38. 23 0
      database/migrations/2026_09_04_120000_add_title_and_body_to_push_notification_logs_table.php
  39. 6 0
      database/seeders/PermissionSeeder.php
  40. 1 0
      database/seeders/UserTypePermissionSeeder.php
  41. 2 0
      lang/en/messages.php
  42. 2 0
      lang/es/messages.php
  43. 2 0
      lang/pt/messages.php
  44. 8 0
      routes/authRoutes/push_notification.php

+ 121 - 206
PLANO_PUSH.md

@@ -1,255 +1,170 @@
-# Plano de Implementação — Push Notifications Marketing
+# Push Notifications Marketing
 
 
-## Visão Geral
-
-Sistema de push notifications de marketing para os apps Prestador e Cliente, usando Firebase Cloud Messaging (FCM) via `kreait/laravel-firebase`. Scheduler dispara 3x/dia (08h, 13h, 19h), cada notificação tem sua própria condição de elegibilidade e cooldown controlado por log no banco.
+Documento de referência do sistema de push de **marketing**. As pushes
+**transacionais** (agendamento, pagamento, aprovação de cadastro) não são
+descritas aqui — ver a seção "Fronteira" no fim.
 
 
 ---
 ---
 
 
-## 1. Condições de Elegibilidade
-
-### PROVIDER
+## 1. Fronteira: marketing vs transacional
 
 
-| Label | Condição | Intervalo |
-|---|---|---|
-| `provider_reforco_educativo_1` | `approval_status = accepted` + nunca recebeu nenhum reforço educativo **OU** o último foi o #2 há ≥3 semanas | Alterna com #2 a cada 3 semanas |
-| `provider_reforco_educativo_2` | `approval_status = accepted` + último reforço recebido foi o #1 há ≥3 semanas | Alterna com #1 a cada 3 semanas |
-| `provider_motivacional_1` | `approval_status = accepted` + não recebeu esta msg há ≥7 dias | Semanal |
+A distinção não é por pasta nem por nome, e sim por **estar ou não registrado em
+`PushNotificationDispatcher::all()`**:
 
 
-### CLIENT
+- **Marketing** → está no array `all()`. O scheduler varre `eligibleUsers()`,
+  aplica cooldown e envia.
+- **Transacional** → **não** está no array. Tem `eligibleUsers()` devolvendo
+  `Collection` vazia e cooldowns `0`, e é disparada por chamada direta a
+  `PushNotificationService::sendToUser()` a partir do serviço de domínio.
 
 
-| Label | Condição | Cooldown categoria | Cooldown msg |
-|---|---|---|---|
-| `cliente_marketing_1..4` | `registration_complete = true` | 7 dias | 28 dias |
-| `cliente_recorrencia_1..3` | ≥1 agendamento `status = finished` + sem agendamento `finished` nos últimos 30 dias | 7 dias | 28 dias |
-| `cliente_educativo_1..4` | `registration_complete = true` (todos) | 7 dias | 28 dias |
-| `cliente_educ_conversao_1..2` | Nunca agendou com `schedule_type = custom` **OU** último foi há >30 dias | 14 dias | 28 dias |
-| `cliente_social_proof_1..2` | Nunca teve agendamento `status = finished` **OU** último foi há >30 dias | 14 dias | 28 dias |
-| `cliente_motivacional_1..3` | `registration_complete = true` (todos) | 7 dias | 28 dias |
-| `cliente_contextual_sexta` | `registration_complete = true` + hoje é sexta-feira | 7 dias | — |
-| `cliente_contextual_segunda` | `registration_complete = true` + hoje é segunda-feira | 7 dias | — |
-| `cliente_contextual_visita` | `registration_complete = true` (broadcast geral) | 7 dias | 28 dias |
+Categorias de marketing: `marketing`, `manual`.
+Categorias transacionais: `agenda`, `transacional`.
 
 
 ---
 ---
 
 
-## 2. Conteúdo das Notificações
-
-### PROVIDER — Reforço Educativo
-
-| Label | Título | Corpo |
-|---|---|---|
-| `provider_reforco_educativo_1` | Como funcionam os pedidos Sob Medida | Eles são enviados para várias diaristas ao mesmo tempo. Aceite rápido para garantir. |
-| `provider_reforco_educativo_2` | Dica importante | Pedidos Sob Medida são compartilhados. A confirmação acontece por ordem de aceite. |
-
-### PROVIDER — Motivacional
-
-| Label | Título | Corpo |
-|---|---|---|
-| `provider_motivacional_1` | Fique de olho 👀 | Novos pedidos Sob Medida surgem o tempo todo no app. |
-
-### CLIENT — Marketing
-
-| Label | Título | Corpo |
-|---|---|---|
-| `cliente_marketing_1` | Casa limpa sem esforço | Encontre uma diarista disponível em poucos minutos. |
-| `cliente_marketing_2` | Precisando de ajuda hoje? | Veja diaristas disponíveis perto de você. |
-| `cliente_marketing_3` | Menos preocupação, mais tempo | Agende sua próxima diária agora mesmo. |
-| `cliente_marketing_4` | Agenda cheia? | Uma diarista pode resolver isso hoje. |
+## 2. As 3 pushes automáticas
 
 
-### CLIENT — Recorrência
+Todas de cliente, categoria `marketing`, `categoryCooldownDays() = 0` — as três
+condições são mutuamente exclusivas por construção, então cooldown de categoria
+só atrapalharia.
 
 
-| Label | Título | Corpo |
-|---|---|---|
-| `cliente_recorrencia_1` | Hora da próxima diária? | Faz um tempo desde sua última limpeza 😊 |
-| `cliente_recorrencia_2` | Rotina em dia | Que tal agendar sua próxima diária? |
-| `cliente_recorrencia_3` | Casa limpa dura pouco | Garanta sua próxima diária no app. |
+Definições:
+- "agendamento / pedido" = qualquer linha em `schedules` do cliente, **em
+  qualquer status**, contada por `created_at`. Cobre Sob Medida, já que
+  `custom_schedules` é filha de `schedules`.
+- "primeiro acesso" = `users.created_at`.
 
 
-### CLIENT — Educativo
+| Label | Classe | Gatilho | Repetição |
+|---|---|---|---|
+| `cliente_abandono_funil` | `Cliente/Marketing/AbandonoFunilPush` | Cadastro há ≥ 48h **e** nenhum agendamento nunca | a cada 7 dias |
+| `cliente_recorrencia_quebrada` | `Cliente/Marketing/RecorrenciaQuebradaPush` | Tem agendamento, o último foi há ≥ 30 e < 90 dias | a cada 30 dias |
+| `cliente_inativo` | `Cliente/Marketing/ClienteInativoPush` | Tem agendamento, o último foi há ≥ 90 dias | **1 vez por cliente, para sempre** |
 
 
-| Label | Título | Corpo |
-|---|---|---|
-| `cliente_educativo_1` | Você sabia? | No Diária, o pagamento só é liberado após o serviço concluído. |
-| `cliente_educativo_2` | Mais segurança | Você acompanha todo o serviço direto pelo app. |
-| `cliente_educativo_3` | Dica importante | Avaliações ajudam a manter a qualidade das diaristas. |
-| `cliente_educativo_4` | Transparência | Você vê perfil, avaliações e valores antes de contratar. |
+O envio único de `cliente_inativo` é garantido por um `whereDoesntHave` sobre
+`push_notification_logs` dentro do próprio `eligibleUsers()` — não por cooldown.
 
 
-### CLIENT — Educativo + Conversão
+### Conteúdo
 
 
 | Label | Título | Corpo |
 | Label | Título | Corpo |
 |---|---|---|
 |---|---|---|
-| `cliente_educ_conversao_1` | Sob Medida funciona assim | Seu pedido é enviado para várias diaristas disponíveis. |
-| `cliente_educ_conversao_2` | Quer mais chances de aceite? | Pedidos Sob Medida aumentam a rapidez na confirmação. |
+| `cliente_abandono_funil` | Casa limpa sem esforço! ✨ | Notei que você olhou nosso app! Que tal garantir sua próxima faxina? Toque e agende. |
+| `cliente_recorrencia_quebrada` | Já faz um mês... 🗓️ | Sua casa merece aquele cuidado de novo. Encontre seu diarista favorito e agende em menos de 2 minutos! |
+| `cliente_inativo` | Que saudade de ver tudo brilhando! 🫧 | Faz tempo que você não passa por aqui. Vamos renovar o ambiente? |
 
 
-### CLIENT — Social Proof
+---
 
 
-| Label | Título | Corpo |
-|---|---|---|
-| `cliente_social_proof_1` | Clientes como você | Já estão usando o Diária para facilitar a rotina. |
-| `cliente_social_proof_2` | Diaristas bem avaliadas | Veja profissionais recomendadas perto de você. |
+## 3. Fluxo automático
 
 
-### CLIENT — Motivacional
+```
+bootstrap/app.php  →  dailyAt('10:00')
+  └─ SendPushNotificationsTask::__invoke()        (try/catch + Log::error)
+       └─ PushNotificationDispatcher::dispatch()
+            foreach (all() as $notification):
+              1. eligibleUsers()
+              2. applyCooldowns()                 (categoria + label)
+              3. PushNotificationService::sendToUsers()
+                   - aborta se !push_notifications_enabled
+                   - busca device_tokens (user_id + app_type = target + active)
+                   - sendMulticast (channel_id 'diaria', priority high)
+                   - desativa tokens inválidos
+                   - se ≥1 sucesso → grava push_notification_logs
+```
 
 
-| Label | Título | Corpo |
-|---|---|---|
-| `cliente_motivacional_1` | Sua casa merece cuidado | E você merece tempo livre. |
-| `cliente_motivacional_2` | Chegue em casa e relaxe | A limpeza fica por nossa conta. |
-| `cliente_motivacional_3` | Menos bagunça, mais bem-estar | Agende sua diária quando quiser. |
+Para adicionar uma push de marketing: criar a classe em
+`app/Notifications/Push/Cliente/Marketing/` estendendo `BasePushNotification`,
+implementar os 6 métodos abstratos, sobrescrever os cooldowns, e registrá-la em
+`PushNotificationDispatcher::all()`.
 
 
-### CLIENT — Contextual
+---
 
 
-| Label | Título | Corpo |
-|---|---|---|
-| `cliente_contextual_sexta` | Sexta chegando | Que tal deixar a casa pronta pro fim de semana? |
-| `cliente_contextual_segunda` | Segunda organizada | Comece a semana com a casa limpa. |
-| `cliente_contextual_visita` | Visita marcada? | Uma diarista pode ajudar hoje. |
+## 4. Push manual (backoffice)
 
 
----
+Módulo "Pushs" no menu do backoffice: o operador escolhe a origem
+(cliente/prestador), seleciona N usuários daquela origem, digita título e
+descrição e envia. **Sem cooldown**, com log próprio (categoria `manual`), e
+respeitando o opt-out `push_notifications_enabled`.
 
 
-## 3. Estrutura de Arquivos — Backend
+| Peça | Arquivo |
+|---|---|
+| Notificação com título/corpo dinâmicos | `app/Notifications/Push/Manual/ManualPush.php` |
+| Envio assíncrono (chunks de 50) | `app/Jobs/SendManualPushJob.php` |
+| Destinatários / envio / histórico | `app/Services/ManualPushService.php` |
+| Endpoints | `app/Http/Controllers/PushNotificationController.php` |
+| Validação | `app/Http/Requests/ManualPushRequest.php` |
+| Rotas | `routes/authRoutes/push_notification.php` |
+| Permissão | scope `push.notification` (bits 259) nos seeders |
 
 
 ```
 ```
-app/
-├── Enums/
-│   ├── PushNotificationTargetEnum.php
-│   └── PushNotificationCategoryEnum.php
-│
-├── Models/
-│   ├── DeviceToken.php
-│   └── PushNotificationLog.php
-│
-├── Services/
-│   ├── PushNotificationService.php       # envia via FCM (kreait)
-│   └── PushNotificationDispatcher.php    # orquestra o ciclo completo de envio
-│
-├── Notifications/
-│   └── Push/
-│       ├── BasePushNotification.php      # abstract com LABEL, CATEGORY, TARGET, title(), body(), eligibleUsers()
-│       │
-│       ├── Prestador/
-│       │   ├── ReforcoEducativo/
-│       │   │   ├── ReforcoEducativo1Push.php
-│       │   │   └── ReforcoEducativo2Push.php
-│       │   └── Motivacional/
-│       │       └── Motivacional1Push.php
-│       │
-│       └── Cliente/
-│           ├── Marketing/
-│           │   ├── Marketing1Push.php
-│           │   ├── Marketing2Push.php
-│           │   ├── Marketing3Push.php
-│           │   └── Marketing4Push.php
-│           ├── Recorrencia/
-│           │   ├── Recorrencia1Push.php
-│           │   ├── Recorrencia2Push.php
-│           │   └── Recorrencia3Push.php
-│           ├── Educativo/
-│           │   ├── Educativo1Push.php
-│           │   ├── Educativo2Push.php
-│           │   ├── Educativo3Push.php
-│           │   └── Educativo4Push.php
-│           ├── EducativoConversao/
-│           │   ├── EducativoConversao1Push.php
-│           │   └── EducativoConversao2Push.php
-│           ├── SocialProof/
-│           │   ├── SocialProof1Push.php
-│           │   └── SocialProof2Push.php
-│           ├── Motivacional/
-│           │   ├── Motivacional1Push.php
-│           │   ├── Motivacional2Push.php
-│           │   └── Motivacional3Push.php
-│           └── Contextual/
-│               ├── ContextualSextaPush.php
-│               ├── ContextualSegundaPush.php
-│               └── ContextualVisitaPush.php
-│
-├── Tasks/
-│   └── SendPushNotificationsTask.php
-│
-└── Http/Controllers/Api/
-    └── DeviceTokenController.php
+GET  /api/push-notifications/recipients?target=cliente|prestador   permission:push.notification,view
+GET  /api/push-notifications/history                               permission:push.notification,view
+POST /api/push-notifications/send                                  permission:push.notification,add
 ```
 ```
 
 
----
-
-## 4. Banco de Dados
+`ManualPush` **não** é registrada em `PushNotificationDispatcher::all()` — mesmo
+padrão das transacionais.
 
 
-### `device_tokens`
-```
-id                  bigint unsigned PK
-user_id             FK → users.id
-token               string (FCM device token)
-platform            enum: android | ios
-app_type            enum: prestador | cliente
-active              boolean default true
-created_at / updated_at
-```
+Vai para fila porque `sendToUsers()` faz um `sendMulticast` síncrono por
+usuário; um lote grande estouraria o timeout da request HTTP.
 
 
-### `push_notification_logs`
-```
-id                  bigint unsigned PK
-label               string (ex: 'cliente_marketing_1')
-user_id             FK → users.id
-target              string (prestador | cliente)
-category            string (marketing | recorrencia | educativo | ...)
-sent_at             timestamp
-created_at / updated_at
-```
+Frontend (`sfp_front_vue_diarista_backoffice`):
+`src/pages/pushNotification/PushNotificationsPage.vue`,
+`src/components/pushNotification/PushRecipientsSelect.vue`,
+`src/api/pushNotification.js`, `src/router/routes/pushNotification.route.js`,
+entrada em `src/stores/navigation.js`.
 
 
 ---
 ---
 
 
-## 5. Rotas de API
+## 5. Infra compartilhada
 
 
-```
-POST   /api/device-tokens            → registra token (login / abertura do app)
-DELETE /api/device-tokens/{token}    → remove token (logout)
-```
+Usada tanto por marketing quanto por transacional — **nenhuma assinatura pública
+aqui pode mudar sem revisar as duas pontas**.
 
 
----
+- `app/Services/PushNotificationService.php` — envio FCM + log + desativação de tokens
+- `app/Notifications/Push/BasePushNotification.php` — contrato das notificações
+- `app/Enums/PushNotificationTargetEnum.php` — `prestador` | `cliente`
+- `app/Enums/PushNotificationCategoryEnum.php` — `marketing`, `manual`, `agenda`, `transacional`
+- `app/Models/DeviceToken.php`, `app/Models/PushNotificationLog.php`
+- `app/Services/DeviceTokenService.php` + `DeviceTokenController` (`POST`/`DELETE /device-tokens`)
+- Flag de opt-out: `users.push_notifications_enabled`
 
 
-## 6. Scheduler
+### Tabelas
 
 
-Em `bootstrap/app.php`:
+`device_tokens`: `user_id`, `token` (unique), `platform` (android|ios),
+`app_type` (prestador|cliente), `active`.
 
 
-```php
-$schedule->call(new SendPushNotificationsTask)->dailyAt('08:00');
-$schedule->call(new SendPushNotificationsTask)->dailyAt('13:00');
-$schedule->call(new SendPushNotificationsTask)->dailyAt('19:00');
-```
+`push_notification_logs`: `label`, `user_id`, `target`, `category`, `sent_at`.
+`category` é `string` sem cast de enum — logs de categorias já descontinuadas
+continuam legíveis.
 
 
 ---
 ---
 
 
-## 7. Frontend — Ambos os Apps
+## 6. Fronteira: o que NÃO é marketing
 
 
-### Instalações necessárias (prestador e cliente)
-```
-@capacitor/push-notifications
-```
+Pushes transacionais (não alterar ao mexer em marketing):
 
 
-### Arquivos a criar em cada app
-```
-src/boot/push-notifications.ts          → solicita permissão, captura token, registra via API
-src/services/pushNotificationService.ts → abstrai chamadas à API de tokens
-```
+- `app/Notifications/Push/Cliente/Agendamento/` — `PrestadorAceitouPush`, `PrestadorRecusouPush`, `PrestadorCancelouPush`
+- `app/Notifications/Push/Prestador/Agendamento/` — `NewPushRequest`, `NovaOportunidadePush`, `ClienteAceitouPush`, `ClienteRecusouPush`, `ClienteCancelouPush`, `CodigoNaoPreenchidoPush`
+- `app/Notifications/Push/Prestador/Pagamento/ClienteEfetuouPagamentoPush`
+- `app/Notifications/Push/Prestador/Transacional/CadastroAprovadoPush`
+
+Call sites: `ScheduleService`, `CustomScheduleService`, `ProviderService`,
+`NotifyProvidersOfNewOpportunityJob`, `SendOpportunityPushJob`.
+Cobertura: `tests/Feature/ProviderApprovalPushTest.php`.
 
 
-### Firebase
-- Criar projeto Firebase (ou dois projetos separados)
-- Registrar app Android e iOS de cada um
-- Baixar `google-services.json` (Android) e `GoogleService-Info.plist` (iOS) para cada app
-- Configurar `capacitor.config.ts` de cada app
+**Gotcha ao disparar push a partir de um serviço de domínio:** resolver
+`PushNotificationService` com `app(PushNotificationService::class)` **dentro do
+try**, nunca por injeção no construtor — injetar faz o container instanciar o
+`Messaging` do Firebase, e uma credencial inválida derruba o CRUD inteiro.
+Sempre `try/catch (\Throwable)` + `Log::error`: falha de push não pode derrubar a
+operação de negócio.
 
 
 ---
 ---
 
 
-## 8. Ordem de Implementação
-
-1. Instalar `kreait/laravel-firebase` no backend
-2. Criar migrations (`device_tokens` e `push_notification_logs`)
-3. Criar Enums (`PushNotificationTargetEnum`, `PushNotificationCategoryEnum`)
-4. Criar Models (`DeviceToken`, `PushNotificationLog`)
-5. Criar `DeviceTokenController` + rotas
-6. Criar `PushNotificationService` (integração FCM)
-7. Criar `BasePushNotification` (classe abstrata)
-8. Criar todas as 24 classes de push notifications
-9. Criar `PushNotificationDispatcher` (lógica de cooldown + orquestração)
-10. Criar `SendPushNotificationsTask` + registrar no scheduler
-11. Frontend Prestador: instalar lib + boot + service
-12. Frontend Cliente: instalar lib + boot + service
+## 7. Apps cliente/prestador
+
+O backend nunca envia `data` payload e nenhum dos dois apps lê
+`notification.data` — **não existe roteamento de push por tipo**. Adicionar ou
+remover tipos de push no backend não exige mudança nos apps. Os únicos
+acoplamentos reais são o `channel_id: 'diaria'` (criado no boot
+`src/boot/push-notifications.js` de cada app) e o `app_type` gravado em
+`device_tokens`.

+ 7 - 10
app/Enums/PushNotificationCategoryEnum.php

@@ -4,14 +4,11 @@ namespace App\Enums;
 
 
 enum PushNotificationCategoryEnum: string
 enum PushNotificationCategoryEnum: string
 {
 {
-    case REFORCO_EDUCATIVO    = 'reforco_educativo';
-    case MOTIVACIONAL         = 'motivacional';
-    case MARKETING            = 'marketing';
-    case RECORRENCIA          = 'recorrencia';
-    case EDUCATIVO            = 'educativo';
-    case EDUCATIVO_CONVERSAO  = 'educativo_conversao';
-    case SOCIAL_PROOF         = 'social_proof';
-    case CONTEXTUAL           = 'contextual';
-    case AGENDA               = 'agenda';
-    case TRANSACIONAL         = 'transacional';
+    // Marketing
+    case MARKETING = 'marketing';
+    case MANUAL    = 'manual';
+
+    // Transacional
+    case AGENDA      = 'agenda';
+    case TRANSACIONAL = 'transacional';
 }
 }

+ 81 - 0
app/Http/Controllers/PushNotificationController.php

@@ -0,0 +1,81 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Enums\PushNotificationTargetEnum;
+use App\Http\Requests\ManualPushRequest;
+use App\Services\ManualPushService;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Validation\Rule;
+
+class PushNotificationController extends Controller
+{
+    public function __construct(protected ManualPushService $service) {}
+
+    public function recipients(Request $request): JsonResponse
+    {
+        $validated = $request->validate([
+            'target' => ['required', Rule::enum(PushNotificationTargetEnum::class)],
+            'search' => ['sometimes', 'nullable', 'string', 'max:255'],
+        ]);
+
+        $target = PushNotificationTargetEnum::from($validated['target']);
+
+        $users = $this->service->recipients($target, $validated['search'] ?? null);
+
+        return $this->successResponse(payload: $users->map(fn ($user) => [
+            'id'               => $user->id,
+            'name'             => $user->name,
+            'email'            => $user->email,
+            'has_device_token' => $user->active_device_tokens_count > 0,
+            'push_enabled'     => (bool) $user->push_notifications_enabled,
+        ])->values());
+    }
+
+    public function send(ManualPushRequest $request): JsonResponse
+    {
+        $data = $request->validated();
+
+        $total = $this->service->send(
+            target:  PushNotificationTargetEnum::from($data['target']),
+            userIds: $data['user_ids'],
+            title:   $data['title'],
+            body:    $data['body'],
+        );
+
+        if ($total === 0) {
+            return $this->errorResponse(message: __('messages.push_no_recipients'), code: 422);
+        }
+
+        return $this->successResponse(
+            payload: ['recipients' => $total],
+            message: __('messages.push_queued', ['count' => $total]),
+        );
+    }
+
+    public function history(Request $request): JsonResponse
+    {
+        $paginated = $this->service->history(
+            page:    $request->integer('page', 1),
+            perPage: $request->integer('per_page', 10),
+            search:  $request->input('filter'),
+        );
+
+        return $this->successResponse(payload: [
+            'data' => collect($paginated->items())->map(fn ($log) => [
+                'id'         => $log->id,
+                'user_id'    => $log->user_id,
+                'user_name'  => $log->user?->name,
+                'user_email' => $log->user?->email,
+                'target'     => $log->target?->value,
+                'title'      => $log->title,
+                'body'       => $log->body,
+                'sent_at'    => $log->sent_at?->format('d/m/Y H:i'),
+            ])->values(),
+            'total' => $paginated->total(),
+            'from'  => $paginated->firstItem() ?? 0,
+            'to'    => $paginated->lastItem()  ?? 0,
+        ]);
+    }
+}

+ 21 - 0
app/Http/Requests/ManualPushRequest.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Http\Requests;
+
+use App\Enums\PushNotificationTargetEnum;
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+
+class ManualPushRequest extends FormRequest
+{
+    public function rules(): array
+    {
+        return [
+            'target'     => ['required', Rule::enum(PushNotificationTargetEnum::class)],
+            'user_ids'   => ['required', 'array', 'min:1'],
+            'user_ids.*' => ['integer', 'exists:users,id'],
+            'title'      => ['required', 'string', 'max:65'],
+            'body'       => ['required', 'string', 'max:240'],
+        ];
+    }
+}

+ 62 - 0
app/Jobs/SendManualPushJob.php

@@ -0,0 +1,62 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Enums\PushNotificationTargetEnum;
+use App\Models\User;
+use App\Notifications\Push\Manual\ManualPush;
+use App\Services\PushNotificationService;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Log;
+
+class SendManualPushJob implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    public int $tries = 2;
+
+    public int $timeout = 120;
+
+    public array $backoff = [15, 60];
+
+    /**
+     * @param  int[]  $userIds
+     */
+    public function __construct(
+        public readonly array $userIds,
+        public readonly string $title,
+        public readonly string $body,
+        public readonly PushNotificationTargetEnum $target,
+    ) {}
+
+    public function handle(): void
+    {
+        try {
+            $pushService = app(PushNotificationService::class);
+        } catch (\Throwable $exception) {
+            Log::error('Falha ao resolver PushNotificationService na push manual', [
+                'error' => $exception->getMessage(),
+            ]);
+
+            return;
+        }
+
+        $notification = new ManualPush($this->title, $this->body, $this->target);
+
+        foreach (User::whereIn('id', $this->userIds)->get() as $user) {
+            try {
+                $pushService->sendToUser($user, $notification);
+            } catch (\Throwable $exception) {
+                Log::error('Falha ao enviar push manual', [
+                    'user_id' => $user->id,
+                    'target'  => $this->target->value,
+                    'error'   => $exception->getMessage(),
+                ]);
+            }
+        }
+    }
+}

+ 4 - 0
app/Models/PushNotificationLog.php

@@ -9,6 +9,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
 /**
 /**
  * @property int $id
  * @property int $id
  * @property string $label
  * @property string $label
+ * @property string|null $title
+ * @property string|null $body
  * @property int $user_id
  * @property int $user_id
  * @property PushNotificationTargetEnum $target
  * @property PushNotificationTargetEnum $target
  * @property string $category
  * @property string $category
@@ -20,6 +22,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
  * @method static \Illuminate\Database\Eloquent\Builder<static>|PushNotificationLog newQuery()
  * @method static \Illuminate\Database\Eloquent\Builder<static>|PushNotificationLog newQuery()
  * @method static \Illuminate\Database\Eloquent\Builder<static>|PushNotificationLog query()
  * @method static \Illuminate\Database\Eloquent\Builder<static>|PushNotificationLog query()
  * @method static \Illuminate\Database\Eloquent\Builder<static>|PushNotificationLog whereCategory($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|PushNotificationLog whereCategory($value)
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|PushNotificationLog whereBody($value)
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|PushNotificationLog whereTitle($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|PushNotificationLog whereCreatedAt($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|PushNotificationLog whereCreatedAt($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|PushNotificationLog whereId($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|PushNotificationLog whereId($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|PushNotificationLog whereLabel($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|PushNotificationLog whereLabel($value)

+ 0 - 59
app/Notifications/Push/Cliente/Contextual/ContextualSextaPush.php

@@ -1,59 +0,0 @@
-<?php
-
-namespace App\Notifications\Push\Cliente\Contextual;
-
-use App\Enums\PushNotificationCategoryEnum;
-use App\Enums\PushNotificationTargetEnum;
-use App\Models\User;
-use App\Notifications\Push\BasePushNotification;
-use Illuminate\Database\Eloquent\Collection;
-
-class ContextualSextaPush extends BasePushNotification
-{
-    public function label(): string
-    {
-        return 'cliente_contextual_sexta';
-    }
-
-    public function title(): string
-    {
-        return 'Sexta chegando';
-    }
-
-    public function body(): string
-    {
-        return 'Que tal deixar a casa pronta pro fim de semana?';
-    }
-
-    public function target(): PushNotificationTargetEnum
-    {
-        return PushNotificationTargetEnum::CLIENTE;
-    }
-
-    public function category(): PushNotificationCategoryEnum
-    {
-        return PushNotificationCategoryEnum::CONTEXTUAL;
-    }
-
-    public function notificationCooldownDays(): int
-    {
-        return 7;
-    }
-
-    public function categoryCooldownDays(): int
-    {
-        return 7;
-    }
-
-    /** Só retorna usuários se hoje for sexta-feira */
-    public function eligibleUsers(): Collection
-    {
-        if (! now()->isFriday()) {
-            return new Collection;
-        }
-
-        return User::whereHas('client')
-            ->where('registration_complete', true)
-            ->get();
-    }
-}

+ 0 - 44
app/Notifications/Push/Cliente/Contextual/ContextualVisitaPush.php

@@ -1,44 +0,0 @@
-<?php
-
-namespace App\Notifications\Push\Cliente\Contextual;
-
-use App\Enums\PushNotificationCategoryEnum;
-use App\Enums\PushNotificationTargetEnum;
-use App\Models\User;
-use App\Notifications\Push\BasePushNotification;
-use Illuminate\Database\Eloquent\Collection;
-
-class ContextualVisitaPush extends BasePushNotification
-{
-    public function label(): string
-    {
-        return 'cliente_contextual_visita';
-    }
-
-    public function title(): string
-    {
-        return 'Visita marcada?';
-    }
-
-    public function body(): string
-    {
-        return 'Uma diarista pode ajudar hoje.';
-    }
-
-    public function target(): PushNotificationTargetEnum
-    {
-        return PushNotificationTargetEnum::CLIENTE;
-    }
-
-    public function category(): PushNotificationCategoryEnum
-    {
-        return PushNotificationCategoryEnum::CONTEXTUAL;
-    }
-
-    public function eligibleUsers(): Collection
-    {
-        return User::whereHas('client')
-            ->where('registration_complete', true)
-            ->get();
-    }
-}

+ 0 - 44
app/Notifications/Push/Cliente/Educativo/Educativo1Push.php

@@ -1,44 +0,0 @@
-<?php
-
-namespace App\Notifications\Push\Cliente\Educativo;
-
-use App\Enums\PushNotificationCategoryEnum;
-use App\Enums\PushNotificationTargetEnum;
-use App\Models\User;
-use App\Notifications\Push\BasePushNotification;
-use Illuminate\Database\Eloquent\Collection;
-
-class Educativo1Push extends BasePushNotification
-{
-    public function label(): string
-    {
-        return 'cliente_educativo_1';
-    }
-
-    public function title(): string
-    {
-        return 'Você sabia?';
-    }
-
-    public function body(): string
-    {
-        return 'No Diária, o pagamento só é liberado após o serviço concluído.';
-    }
-
-    public function target(): PushNotificationTargetEnum
-    {
-        return PushNotificationTargetEnum::CLIENTE;
-    }
-
-    public function category(): PushNotificationCategoryEnum
-    {
-        return PushNotificationCategoryEnum::EDUCATIVO;
-    }
-
-    public function eligibleUsers(): Collection
-    {
-        return User::whereHas('client')
-            ->where('registration_complete', true)
-            ->get();
-    }
-}

+ 0 - 44
app/Notifications/Push/Cliente/Educativo/Educativo2Push.php

@@ -1,44 +0,0 @@
-<?php
-
-namespace App\Notifications\Push\Cliente\Educativo;
-
-use App\Enums\PushNotificationCategoryEnum;
-use App\Enums\PushNotificationTargetEnum;
-use App\Models\User;
-use App\Notifications\Push\BasePushNotification;
-use Illuminate\Database\Eloquent\Collection;
-
-class Educativo2Push extends BasePushNotification
-{
-    public function label(): string
-    {
-        return 'cliente_educativo_2';
-    }
-
-    public function title(): string
-    {
-        return 'Mais segurança';
-    }
-
-    public function body(): string
-    {
-        return 'Você acompanha todo o serviço direto pelo app.';
-    }
-
-    public function target(): PushNotificationTargetEnum
-    {
-        return PushNotificationTargetEnum::CLIENTE;
-    }
-
-    public function category(): PushNotificationCategoryEnum
-    {
-        return PushNotificationCategoryEnum::EDUCATIVO;
-    }
-
-    public function eligibleUsers(): Collection
-    {
-        return User::whereHas('client')
-            ->where('registration_complete', true)
-            ->get();
-    }
-}

+ 0 - 44
app/Notifications/Push/Cliente/Educativo/Educativo3Push.php

@@ -1,44 +0,0 @@
-<?php
-
-namespace App\Notifications\Push\Cliente\Educativo;
-
-use App\Enums\PushNotificationCategoryEnum;
-use App\Enums\PushNotificationTargetEnum;
-use App\Models\User;
-use App\Notifications\Push\BasePushNotification;
-use Illuminate\Database\Eloquent\Collection;
-
-class Educativo3Push extends BasePushNotification
-{
-    public function label(): string
-    {
-        return 'cliente_educativo_3';
-    }
-
-    public function title(): string
-    {
-        return 'Dica importante';
-    }
-
-    public function body(): string
-    {
-        return 'Avaliações ajudam a manter a qualidade das diaristas.';
-    }
-
-    public function target(): PushNotificationTargetEnum
-    {
-        return PushNotificationTargetEnum::CLIENTE;
-    }
-
-    public function category(): PushNotificationCategoryEnum
-    {
-        return PushNotificationCategoryEnum::EDUCATIVO;
-    }
-
-    public function eligibleUsers(): Collection
-    {
-        return User::whereHas('client')
-            ->where('registration_complete', true)
-            ->get();
-    }
-}

+ 0 - 44
app/Notifications/Push/Cliente/Educativo/Educativo4Push.php

@@ -1,44 +0,0 @@
-<?php
-
-namespace App\Notifications\Push\Cliente\Educativo;
-
-use App\Enums\PushNotificationCategoryEnum;
-use App\Enums\PushNotificationTargetEnum;
-use App\Models\User;
-use App\Notifications\Push\BasePushNotification;
-use Illuminate\Database\Eloquent\Collection;
-
-class Educativo4Push extends BasePushNotification
-{
-    public function label(): string
-    {
-        return 'cliente_educativo_4';
-    }
-
-    public function title(): string
-    {
-        return 'Transparência';
-    }
-
-    public function body(): string
-    {
-        return 'Você vê perfil, avaliações e valores antes de contratar.';
-    }
-
-    public function target(): PushNotificationTargetEnum
-    {
-        return PushNotificationTargetEnum::CLIENTE;
-    }
-
-    public function category(): PushNotificationCategoryEnum
-    {
-        return PushNotificationCategoryEnum::EDUCATIVO;
-    }
-
-    public function eligibleUsers(): Collection
-    {
-        return User::whereHas('client')
-            ->where('registration_complete', true)
-            ->get();
-    }
-}

+ 0 - 64
app/Notifications/Push/Cliente/EducativoConversao/EducativoConversao1Push.php

@@ -1,64 +0,0 @@
-<?php
-
-namespace App\Notifications\Push\Cliente\EducativoConversao;
-
-use App\Enums\PushNotificationCategoryEnum;
-use App\Enums\PushNotificationTargetEnum;
-use App\Models\User;
-use App\Notifications\Push\BasePushNotification;
-use Illuminate\Database\Eloquent\Collection;
-
-class EducativoConversao1Push extends BasePushNotification
-{
-    public function label(): string
-    {
-        return 'cliente_educ_conversao_1';
-    }
-
-    public function title(): string
-    {
-        return 'Sob Medida funciona assim';
-    }
-
-    public function body(): string
-    {
-        return 'Seu pedido é enviado para várias diaristas disponíveis.';
-    }
-
-    public function target(): PushNotificationTargetEnum
-    {
-        return PushNotificationTargetEnum::CLIENTE;
-    }
-
-    public function category(): PushNotificationCategoryEnum
-    {
-        return PushNotificationCategoryEnum::EDUCATIVO_CONVERSAO;
-    }
-
-    public function notificationCooldownDays(): int
-    {
-        return 28;
-    }
-
-    public function categoryCooldownDays(): int
-    {
-        return 14;
-    }
-
-    /**
-     * Elegível se:
-     * - Nunca agendou via Sob Medida (schedule_type = custom)
-     * - OU último agendamento Sob Medida foi há >30 dias
-     */
-    public function eligibleUsers(): Collection
-    {
-        return User::whereHas('client')
-            ->where('registration_complete', true)
-            ->where(function ($query) {
-                $query->whereDoesntHave('client.schedules', fn ($q) => $q->where('schedule_type', 'custom'))
-                    ->orWhereDoesntHave('client.schedules', fn ($q) => $q->where('schedule_type', 'custom')
-                        ->where('created_at', '>=', now()->subDays(30)));
-            })
-            ->get();
-    }
-}

+ 0 - 59
app/Notifications/Push/Cliente/EducativoConversao/EducativoConversao2Push.php

@@ -1,59 +0,0 @@
-<?php
-
-namespace App\Notifications\Push\Cliente\EducativoConversao;
-
-use App\Enums\PushNotificationCategoryEnum;
-use App\Enums\PushNotificationTargetEnum;
-use App\Models\User;
-use App\Notifications\Push\BasePushNotification;
-use Illuminate\Database\Eloquent\Collection;
-
-class EducativoConversao2Push extends BasePushNotification
-{
-    public function label(): string
-    {
-        return 'cliente_educ_conversao_2';
-    }
-
-    public function title(): string
-    {
-        return 'Quer mais chances de aceite?';
-    }
-
-    public function body(): string
-    {
-        return 'Pedidos Sob Medida aumentam a rapidez na confirmação.';
-    }
-
-    public function target(): PushNotificationTargetEnum
-    {
-        return PushNotificationTargetEnum::CLIENTE;
-    }
-
-    public function category(): PushNotificationCategoryEnum
-    {
-        return PushNotificationCategoryEnum::EDUCATIVO_CONVERSAO;
-    }
-
-    public function notificationCooldownDays(): int
-    {
-        return 28;
-    }
-
-    public function categoryCooldownDays(): int
-    {
-        return 14;
-    }
-
-    public function eligibleUsers(): Collection
-    {
-        return User::whereHas('client')
-            ->where('registration_complete', true)
-            ->where(function ($query) {
-                $query->whereDoesntHave('client.schedules', fn ($q) => $q->where('schedule_type', 'custom'))
-                    ->orWhereDoesntHave('client.schedules', fn ($q) => $q->where('schedule_type', 'custom')
-                        ->where('created_at', '>=', now()->subDays(30)));
-            })
-            ->get();
-    }
-}

+ 12 - 13
app/Notifications/Push/Cliente/Contextual/ContextualSegundaPush.php → app/Notifications/Push/Cliente/Marketing/AbandonoFunilPush.php

@@ -1,6 +1,6 @@
 <?php
 <?php
 
 
-namespace App\Notifications\Push\Cliente\Contextual;
+namespace App\Notifications\Push\Cliente\Marketing;
 
 
 use App\Enums\PushNotificationCategoryEnum;
 use App\Enums\PushNotificationCategoryEnum;
 use App\Enums\PushNotificationTargetEnum;
 use App\Enums\PushNotificationTargetEnum;
@@ -8,21 +8,24 @@ use App\Models\User;
 use App\Notifications\Push\BasePushNotification;
 use App\Notifications\Push\BasePushNotification;
 use Illuminate\Database\Eloquent\Collection;
 use Illuminate\Database\Eloquent\Collection;
 
 
-class ContextualSegundaPush extends BasePushNotification
+/**
+ * Abandono de funil: cliente se cadastrou há mais de 48h e nunca agendou.
+ */
+class AbandonoFunilPush extends BasePushNotification
 {
 {
     public function label(): string
     public function label(): string
     {
     {
-        return 'cliente_contextual_segunda';
+        return 'cliente_abandono_funil';
     }
     }
 
 
     public function title(): string
     public function title(): string
     {
     {
-        return 'Segunda organizada';
+        return 'Casa limpa sem esforço! ✨';
     }
     }
 
 
     public function body(): string
     public function body(): string
     {
     {
-        return 'Comece a semana com a casa limpa.';
+        return 'Notei que você olhou nosso app! Que tal garantir sua próxima faxina? Toque e agende.';
     }
     }
 
 
     public function target(): PushNotificationTargetEnum
     public function target(): PushNotificationTargetEnum
@@ -32,7 +35,7 @@ class ContextualSegundaPush extends BasePushNotification
 
 
     public function category(): PushNotificationCategoryEnum
     public function category(): PushNotificationCategoryEnum
     {
     {
-        return PushNotificationCategoryEnum::CONTEXTUAL;
+        return PushNotificationCategoryEnum::MARKETING;
     }
     }
 
 
     public function notificationCooldownDays(): int
     public function notificationCooldownDays(): int
@@ -42,18 +45,14 @@ class ContextualSegundaPush extends BasePushNotification
 
 
     public function categoryCooldownDays(): int
     public function categoryCooldownDays(): int
     {
     {
-        return 7;
+        return 0;
     }
     }
 
 
-    /** Só retorna usuários se hoje for segunda-feira */
     public function eligibleUsers(): Collection
     public function eligibleUsers(): Collection
     {
     {
-        if (! now()->isMonday()) {
-            return new Collection;
-        }
-
         return User::whereHas('client')
         return User::whereHas('client')
-            ->where('registration_complete', true)
+            ->where('users.created_at', '<=', now()->subHours(48))
+            ->whereDoesntHave('client.schedules')
             ->get();
             ->get();
     }
     }
 }
 }

+ 61 - 0
app/Notifications/Push/Cliente/Marketing/ClienteInativoPush.php

@@ -0,0 +1,61 @@
+<?php
+
+namespace App\Notifications\Push\Cliente\Marketing;
+
+use App\Enums\PushNotificationCategoryEnum;
+use App\Enums\PushNotificationTargetEnum;
+use App\Models\User;
+use App\Notifications\Push\BasePushNotification;
+use Illuminate\Database\Eloquent\Collection;
+
+/**
+ * Cliente inativo: já agendou alguma vez, mas está sem nenhum pedido há 90 dias ou mais.
+ * Envio único na vida do cliente — garantido pelo filtro em push_notification_logs,
+ * não por cooldown.
+ */
+class ClienteInativoPush extends BasePushNotification
+{
+    public function label(): string
+    {
+        return 'cliente_inativo';
+    }
+
+    public function title(): string
+    {
+        return 'Que saudade de ver tudo brilhando! 🫧';
+    }
+
+    public function body(): string
+    {
+        return 'Faz tempo que você não passa por aqui. Vamos renovar o ambiente?';
+    }
+
+    public function target(): PushNotificationTargetEnum
+    {
+        return PushNotificationTargetEnum::CLIENTE;
+    }
+
+    public function category(): PushNotificationCategoryEnum
+    {
+        return PushNotificationCategoryEnum::MARKETING;
+    }
+
+    public function notificationCooldownDays(): int
+    {
+        return 0;
+    }
+
+    public function categoryCooldownDays(): int
+    {
+        return 0;
+    }
+
+    public function eligibleUsers(): Collection
+    {
+        return User::whereHas('client')
+            ->whereHas('client.schedules')
+            ->whereDoesntHave('client.schedules', fn ($q) => $q->where('schedules.created_at', '>=', now()->subDays(90)))
+            ->whereDoesntHave('pushNotificationLogs', fn ($q) => $q->where('label', $this->label()))
+            ->get();
+    }
+}

+ 0 - 44
app/Notifications/Push/Cliente/Marketing/Marketing1Push.php

@@ -1,44 +0,0 @@
-<?php
-
-namespace App\Notifications\Push\Cliente\Marketing;
-
-use App\Enums\PushNotificationCategoryEnum;
-use App\Enums\PushNotificationTargetEnum;
-use App\Models\User;
-use App\Notifications\Push\BasePushNotification;
-use Illuminate\Database\Eloquent\Collection;
-
-class Marketing1Push extends BasePushNotification
-{
-    public function label(): string
-    {
-        return 'cliente_marketing_1';
-    }
-
-    public function title(): string
-    {
-        return 'Casa limpa sem esforço';
-    }
-
-    public function body(): string
-    {
-        return 'Encontre uma diarista disponível em poucos minutos.';
-    }
-
-    public function target(): PushNotificationTargetEnum
-    {
-        return PushNotificationTargetEnum::CLIENTE;
-    }
-
-    public function category(): PushNotificationCategoryEnum
-    {
-        return PushNotificationCategoryEnum::MARKETING;
-    }
-
-    public function eligibleUsers(): Collection
-    {
-        return User::whereHas('client')
-            ->where('registration_complete', true)
-            ->get();
-    }
-}

+ 0 - 44
app/Notifications/Push/Cliente/Marketing/Marketing2Push.php

@@ -1,44 +0,0 @@
-<?php
-
-namespace App\Notifications\Push\Cliente\Marketing;
-
-use App\Enums\PushNotificationCategoryEnum;
-use App\Enums\PushNotificationTargetEnum;
-use App\Models\User;
-use App\Notifications\Push\BasePushNotification;
-use Illuminate\Database\Eloquent\Collection;
-
-class Marketing2Push extends BasePushNotification
-{
-    public function label(): string
-    {
-        return 'cliente_marketing_2';
-    }
-
-    public function title(): string
-    {
-        return 'Precisando de ajuda hoje?';
-    }
-
-    public function body(): string
-    {
-        return 'Veja diaristas disponíveis perto de você.';
-    }
-
-    public function target(): PushNotificationTargetEnum
-    {
-        return PushNotificationTargetEnum::CLIENTE;
-    }
-
-    public function category(): PushNotificationCategoryEnum
-    {
-        return PushNotificationCategoryEnum::MARKETING;
-    }
-
-    public function eligibleUsers(): Collection
-    {
-        return User::whereHas('client')
-            ->where('registration_complete', true)
-            ->get();
-    }
-}

+ 0 - 44
app/Notifications/Push/Cliente/Marketing/Marketing3Push.php

@@ -1,44 +0,0 @@
-<?php
-
-namespace App\Notifications\Push\Cliente\Marketing;
-
-use App\Enums\PushNotificationCategoryEnum;
-use App\Enums\PushNotificationTargetEnum;
-use App\Models\User;
-use App\Notifications\Push\BasePushNotification;
-use Illuminate\Database\Eloquent\Collection;
-
-class Marketing3Push extends BasePushNotification
-{
-    public function label(): string
-    {
-        return 'cliente_marketing_3';
-    }
-
-    public function title(): string
-    {
-        return 'Menos preocupação, mais tempo';
-    }
-
-    public function body(): string
-    {
-        return 'Agende sua próxima diária agora mesmo.';
-    }
-
-    public function target(): PushNotificationTargetEnum
-    {
-        return PushNotificationTargetEnum::CLIENTE;
-    }
-
-    public function category(): PushNotificationCategoryEnum
-    {
-        return PushNotificationCategoryEnum::MARKETING;
-    }
-
-    public function eligibleUsers(): Collection
-    {
-        return User::whereHas('client')
-            ->where('registration_complete', true)
-            ->get();
-    }
-}

+ 0 - 44
app/Notifications/Push/Cliente/Marketing/Marketing4Push.php

@@ -1,44 +0,0 @@
-<?php
-
-namespace App\Notifications\Push\Cliente\Marketing;
-
-use App\Enums\PushNotificationCategoryEnum;
-use App\Enums\PushNotificationTargetEnum;
-use App\Models\User;
-use App\Notifications\Push\BasePushNotification;
-use Illuminate\Database\Eloquent\Collection;
-
-class Marketing4Push extends BasePushNotification
-{
-    public function label(): string
-    {
-        return 'cliente_marketing_4';
-    }
-
-    public function title(): string
-    {
-        return 'Agenda cheia?';
-    }
-
-    public function body(): string
-    {
-        return 'Uma diarista pode resolver isso hoje.';
-    }
-
-    public function target(): PushNotificationTargetEnum
-    {
-        return PushNotificationTargetEnum::CLIENTE;
-    }
-
-    public function category(): PushNotificationCategoryEnum
-    {
-        return PushNotificationCategoryEnum::MARKETING;
-    }
-
-    public function eligibleUsers(): Collection
-    {
-        return User::whereHas('client')
-            ->where('registration_complete', true)
-            ->get();
-    }
-}

+ 59 - 0
app/Notifications/Push/Cliente/Marketing/RecorrenciaQuebradaPush.php

@@ -0,0 +1,59 @@
+<?php
+
+namespace App\Notifications\Push\Cliente\Marketing;
+
+use App\Enums\PushNotificationCategoryEnum;
+use App\Enums\PushNotificationTargetEnum;
+use App\Models\User;
+use App\Notifications\Push\BasePushNotification;
+use Illuminate\Database\Eloquent\Collection;
+
+/**
+ * Recorrência quebrada: cliente já agendou, mas o último agendamento
+ * foi há 30 dias ou mais — e há menos de 90 (a faixa de 90+ é da ClienteInativoPush).
+ */
+class RecorrenciaQuebradaPush extends BasePushNotification
+{
+    public function label(): string
+    {
+        return 'cliente_recorrencia_quebrada';
+    }
+
+    public function title(): string
+    {
+        return 'Já faz um mês... 🗓️';
+    }
+
+    public function body(): string
+    {
+        return 'Sua casa merece aquele cuidado de novo. Encontre seu diarista favorito e agende em menos de 2 minutos!';
+    }
+
+    public function target(): PushNotificationTargetEnum
+    {
+        return PushNotificationTargetEnum::CLIENTE;
+    }
+
+    public function category(): PushNotificationCategoryEnum
+    {
+        return PushNotificationCategoryEnum::MARKETING;
+    }
+
+    public function notificationCooldownDays(): int
+    {
+        return 30;
+    }
+
+    public function categoryCooldownDays(): int
+    {
+        return 0;
+    }
+
+    public function eligibleUsers(): Collection
+    {
+        return User::whereHas('client')
+            ->whereDoesntHave('client.schedules', fn ($q) => $q->where('schedules.created_at', '>=', now()->subDays(30)))
+            ->whereHas('client.schedules', fn ($q) => $q->where('schedules.created_at', '>=', now()->subDays(90)))
+            ->get();
+    }
+}

+ 0 - 44
app/Notifications/Push/Cliente/Motivacional/Motivacional1Push.php

@@ -1,44 +0,0 @@
-<?php
-
-namespace App\Notifications\Push\Cliente\Motivacional;
-
-use App\Enums\PushNotificationCategoryEnum;
-use App\Enums\PushNotificationTargetEnum;
-use App\Models\User;
-use App\Notifications\Push\BasePushNotification;
-use Illuminate\Database\Eloquent\Collection;
-
-class Motivacional1Push extends BasePushNotification
-{
-    public function label(): string
-    {
-        return 'cliente_motivacional_1';
-    }
-
-    public function title(): string
-    {
-        return 'Sua casa merece cuidado';
-    }
-
-    public function body(): string
-    {
-        return 'E você merece tempo livre.';
-    }
-
-    public function target(): PushNotificationTargetEnum
-    {
-        return PushNotificationTargetEnum::CLIENTE;
-    }
-
-    public function category(): PushNotificationCategoryEnum
-    {
-        return PushNotificationCategoryEnum::MOTIVACIONAL;
-    }
-
-    public function eligibleUsers(): Collection
-    {
-        return User::whereHas('client')
-            ->where('registration_complete', true)
-            ->get();
-    }
-}

+ 0 - 44
app/Notifications/Push/Cliente/Motivacional/Motivacional2Push.php

@@ -1,44 +0,0 @@
-<?php
-
-namespace App\Notifications\Push\Cliente\Motivacional;
-
-use App\Enums\PushNotificationCategoryEnum;
-use App\Enums\PushNotificationTargetEnum;
-use App\Models\User;
-use App\Notifications\Push\BasePushNotification;
-use Illuminate\Database\Eloquent\Collection;
-
-class Motivacional2Push extends BasePushNotification
-{
-    public function label(): string
-    {
-        return 'cliente_motivacional_2';
-    }
-
-    public function title(): string
-    {
-        return 'Chegue em casa e relaxe';
-    }
-
-    public function body(): string
-    {
-        return 'A limpeza fica por nossa conta.';
-    }
-
-    public function target(): PushNotificationTargetEnum
-    {
-        return PushNotificationTargetEnum::CLIENTE;
-    }
-
-    public function category(): PushNotificationCategoryEnum
-    {
-        return PushNotificationCategoryEnum::MOTIVACIONAL;
-    }
-
-    public function eligibleUsers(): Collection
-    {
-        return User::whereHas('client')
-            ->where('registration_complete', true)
-            ->get();
-    }
-}

+ 0 - 44
app/Notifications/Push/Cliente/Motivacional/Motivacional3Push.php

@@ -1,44 +0,0 @@
-<?php
-
-namespace App\Notifications\Push\Cliente\Motivacional;
-
-use App\Enums\PushNotificationCategoryEnum;
-use App\Enums\PushNotificationTargetEnum;
-use App\Models\User;
-use App\Notifications\Push\BasePushNotification;
-use Illuminate\Database\Eloquent\Collection;
-
-class Motivacional3Push extends BasePushNotification
-{
-    public function label(): string
-    {
-        return 'cliente_motivacional_3';
-    }
-
-    public function title(): string
-    {
-        return 'Menos bagunça, mais bem-estar';
-    }
-
-    public function body(): string
-    {
-        return 'Agende sua diária quando quiser.';
-    }
-
-    public function target(): PushNotificationTargetEnum
-    {
-        return PushNotificationTargetEnum::CLIENTE;
-    }
-
-    public function category(): PushNotificationCategoryEnum
-    {
-        return PushNotificationCategoryEnum::MOTIVACIONAL;
-    }
-
-    public function eligibleUsers(): Collection
-    {
-        return User::whereHas('client')
-            ->where('registration_complete', true)
-            ->get();
-    }
-}

+ 0 - 45
app/Notifications/Push/Cliente/Recorrencia/Recorrencia1Push.php

@@ -1,45 +0,0 @@
-<?php
-
-namespace App\Notifications\Push\Cliente\Recorrencia;
-
-use App\Enums\PushNotificationCategoryEnum;
-use App\Enums\PushNotificationTargetEnum;
-use App\Models\User;
-use App\Notifications\Push\BasePushNotification;
-use Illuminate\Database\Eloquent\Collection;
-
-class Recorrencia1Push extends BasePushNotification
-{
-    public function label(): string
-    {
-        return 'cliente_recorrencia_1';
-    }
-
-    public function title(): string
-    {
-        return 'Hora da próxima diária?';
-    }
-
-    public function body(): string
-    {
-        return 'Faz um tempo desde sua última limpeza 😊';
-    }
-
-    public function target(): PushNotificationTargetEnum
-    {
-        return PushNotificationTargetEnum::CLIENTE;
-    }
-
-    public function category(): PushNotificationCategoryEnum
-    {
-        return PushNotificationCategoryEnum::RECORRENCIA;
-    }
-
-    public function eligibleUsers(): Collection
-    {
-        return User::whereHas('client.schedules', fn ($q) => $q->where('status', 'finished'))
-            ->whereDoesntHave('client.schedules', fn ($q) => $q->where('status', 'finished')
-                ->where('updated_at', '>=', now()->subDays(30)))
-            ->get();
-    }
-}

+ 0 - 45
app/Notifications/Push/Cliente/Recorrencia/Recorrencia2Push.php

@@ -1,45 +0,0 @@
-<?php
-
-namespace App\Notifications\Push\Cliente\Recorrencia;
-
-use App\Enums\PushNotificationCategoryEnum;
-use App\Enums\PushNotificationTargetEnum;
-use App\Models\User;
-use App\Notifications\Push\BasePushNotification;
-use Illuminate\Database\Eloquent\Collection;
-
-class Recorrencia2Push extends BasePushNotification
-{
-    public function label(): string
-    {
-        return 'cliente_recorrencia_2';
-    }
-
-    public function title(): string
-    {
-        return 'Rotina em dia';
-    }
-
-    public function body(): string
-    {
-        return 'Que tal agendar sua próxima diária?';
-    }
-
-    public function target(): PushNotificationTargetEnum
-    {
-        return PushNotificationTargetEnum::CLIENTE;
-    }
-
-    public function category(): PushNotificationCategoryEnum
-    {
-        return PushNotificationCategoryEnum::RECORRENCIA;
-    }
-
-    public function eligibleUsers(): Collection
-    {
-        return User::whereHas('client.schedules', fn ($q) => $q->where('status', 'finished'))
-            ->whereDoesntHave('client.schedules', fn ($q) => $q->where('status', 'finished')
-                ->where('updated_at', '>=', now()->subDays(30)))
-            ->get();
-    }
-}

+ 0 - 45
app/Notifications/Push/Cliente/Recorrencia/Recorrencia3Push.php

@@ -1,45 +0,0 @@
-<?php
-
-namespace App\Notifications\Push\Cliente\Recorrencia;
-
-use App\Enums\PushNotificationCategoryEnum;
-use App\Enums\PushNotificationTargetEnum;
-use App\Models\User;
-use App\Notifications\Push\BasePushNotification;
-use Illuminate\Database\Eloquent\Collection;
-
-class Recorrencia3Push extends BasePushNotification
-{
-    public function label(): string
-    {
-        return 'cliente_recorrencia_3';
-    }
-
-    public function title(): string
-    {
-        return 'Casa limpa dura pouco';
-    }
-
-    public function body(): string
-    {
-        return 'Garanta sua próxima diária no app.';
-    }
-
-    public function target(): PushNotificationTargetEnum
-    {
-        return PushNotificationTargetEnum::CLIENTE;
-    }
-
-    public function category(): PushNotificationCategoryEnum
-    {
-        return PushNotificationCategoryEnum::RECORRENCIA;
-    }
-
-    public function eligibleUsers(): Collection
-    {
-        return User::whereHas('client.schedules', fn ($q) => $q->where('status', 'finished'))
-            ->whereDoesntHave('client.schedules', fn ($q) => $q->where('status', 'finished')
-                ->where('updated_at', '>=', now()->subDays(30)))
-            ->get();
-    }
-}

+ 0 - 64
app/Notifications/Push/Cliente/SocialProof/SocialProof1Push.php

@@ -1,64 +0,0 @@
-<?php
-
-namespace App\Notifications\Push\Cliente\SocialProof;
-
-use App\Enums\PushNotificationCategoryEnum;
-use App\Enums\PushNotificationTargetEnum;
-use App\Models\User;
-use App\Notifications\Push\BasePushNotification;
-use Illuminate\Database\Eloquent\Collection;
-
-class SocialProof1Push extends BasePushNotification
-{
-    public function label(): string
-    {
-        return 'cliente_social_proof_1';
-    }
-
-    public function title(): string
-    {
-        return 'Clientes como você';
-    }
-
-    public function body(): string
-    {
-        return 'Já estão usando o Diária para facilitar a rotina.';
-    }
-
-    public function target(): PushNotificationTargetEnum
-    {
-        return PushNotificationTargetEnum::CLIENTE;
-    }
-
-    public function category(): PushNotificationCategoryEnum
-    {
-        return PushNotificationCategoryEnum::SOCIAL_PROOF;
-    }
-
-    public function notificationCooldownDays(): int
-    {
-        return 28;
-    }
-
-    public function categoryCooldownDays(): int
-    {
-        return 14;
-    }
-
-    /**
-     * Elegível se:
-     * - Nunca teve agendamento com status finished
-     * - OU último agendamento finished foi há >30 dias
-     */
-    public function eligibleUsers(): Collection
-    {
-        return User::whereHas('client')
-            ->where('registration_complete', true)
-            ->where(function ($query) {
-                $query->whereDoesntHave('client.schedules', fn ($q) => $q->where('status', 'finished'))
-                    ->orWhereDoesntHave('client.schedules', fn ($q) => $q->where('status', 'finished')
-                        ->where('updated_at', '>=', now()->subDays(30)));
-            })
-            ->get();
-    }
-}

+ 0 - 59
app/Notifications/Push/Cliente/SocialProof/SocialProof2Push.php

@@ -1,59 +0,0 @@
-<?php
-
-namespace App\Notifications\Push\Cliente\SocialProof;
-
-use App\Enums\PushNotificationCategoryEnum;
-use App\Enums\PushNotificationTargetEnum;
-use App\Models\User;
-use App\Notifications\Push\BasePushNotification;
-use Illuminate\Database\Eloquent\Collection;
-
-class SocialProof2Push extends BasePushNotification
-{
-    public function label(): string
-    {
-        return 'cliente_social_proof_2';
-    }
-
-    public function title(): string
-    {
-        return 'Diaristas bem avaliadas';
-    }
-
-    public function body(): string
-    {
-        return 'Veja profissionais recomendadas perto de você.';
-    }
-
-    public function target(): PushNotificationTargetEnum
-    {
-        return PushNotificationTargetEnum::CLIENTE;
-    }
-
-    public function category(): PushNotificationCategoryEnum
-    {
-        return PushNotificationCategoryEnum::SOCIAL_PROOF;
-    }
-
-    public function notificationCooldownDays(): int
-    {
-        return 28;
-    }
-
-    public function categoryCooldownDays(): int
-    {
-        return 14;
-    }
-
-    public function eligibleUsers(): Collection
-    {
-        return User::whereHas('client')
-            ->where('registration_complete', true)
-            ->where(function ($query) {
-                $query->whereDoesntHave('client.schedules', fn ($q) => $q->where('status', 'finished'))
-                    ->orWhereDoesntHave('client.schedules', fn ($q) => $q->where('status', 'finished')
-                        ->where('updated_at', '>=', now()->subDays(30)));
-            })
-            ->get();
-    }
-}

+ 62 - 0
app/Notifications/Push/Manual/ManualPush.php

@@ -0,0 +1,62 @@
+<?php
+
+namespace App\Notifications\Push\Manual;
+
+use App\Enums\PushNotificationCategoryEnum;
+use App\Enums\PushNotificationTargetEnum;
+use App\Notifications\Push\BasePushNotification;
+use Illuminate\Database\Eloquent\Collection;
+
+/**
+ * Push disparada manualmente pelo backoffice: título e corpo vêm do formulário,
+ * não do código. NÃO é registrada no PushNotificationDispatcher::all() — o envio
+ * é sempre por chamada direta, a partir do SendManualPushJob.
+ */
+class ManualPush extends BasePushNotification
+{
+    public function __construct(
+        private string $title,
+        private string $body,
+        private PushNotificationTargetEnum $target,
+    ) {}
+
+    public function label(): string
+    {
+        return 'manual_push';
+    }
+
+    public function title(): string
+    {
+        return $this->title;
+    }
+
+    public function body(): string
+    {
+        return $this->body;
+    }
+
+    public function target(): PushNotificationTargetEnum
+    {
+        return $this->target;
+    }
+
+    public function category(): PushNotificationCategoryEnum
+    {
+        return PushNotificationCategoryEnum::MANUAL;
+    }
+
+    public function notificationCooldownDays(): int
+    {
+        return 0;
+    }
+
+    public function categoryCooldownDays(): int
+    {
+        return 0;
+    }
+
+    public function eligibleUsers(): Collection
+    {
+        return new Collection();
+    }
+}

+ 0 - 54
app/Notifications/Push/Prestador/Motivacional/Motivacional1Push.php

@@ -1,54 +0,0 @@
-<?php
-
-namespace App\Notifications\Push\Prestador\Motivacional;
-
-use App\Enums\ApprovalStatusEnum;
-use App\Enums\PushNotificationCategoryEnum;
-use App\Enums\PushNotificationTargetEnum;
-use App\Models\User;
-use App\Notifications\Push\BasePushNotification;
-use Illuminate\Database\Eloquent\Collection;
-
-class Motivacional1Push extends BasePushNotification
-{
-    public function label(): string
-    {
-        return 'provider_motivacional_1';
-    }
-
-    public function title(): string
-    {
-        return 'Fique de olho 👀';
-    }
-
-    public function body(): string
-    {
-        return 'Novos pedidos Sob Medida surgem o tempo todo no app.';
-    }
-
-    public function target(): PushNotificationTargetEnum
-    {
-        return PushNotificationTargetEnum::PRESTADOR;
-    }
-
-    public function category(): PushNotificationCategoryEnum
-    {
-        return PushNotificationCategoryEnum::MOTIVACIONAL;
-    }
-
-    public function notificationCooldownDays(): int
-    {
-        return 7;
-    }
-
-    public function categoryCooldownDays(): int
-    {
-        return 7;
-    }
-
-    public function eligibleUsers(): Collection
-    {
-        return User::whereHas('provider', fn ($q) => $q->where('approval_status', ApprovalStatusEnum::ACCEPTED))
-            ->get();
-    }
-}

+ 0 - 78
app/Notifications/Push/Prestador/ReforcoEducativo/ReforcoEducativo1Push.php

@@ -1,78 +0,0 @@
-<?php
-
-namespace App\Notifications\Push\Prestador\ReforcoEducativo;
-
-use App\Enums\ApprovalStatusEnum;
-use App\Enums\PushNotificationCategoryEnum;
-use App\Enums\PushNotificationTargetEnum;
-use App\Models\User;
-use App\Notifications\Push\BasePushNotification;
-use Illuminate\Database\Eloquent\Collection;
-
-class ReforcoEducativo1Push extends BasePushNotification
-{
-    public function label(): string
-    {
-        return 'provider_reforco_educativo_1';
-    }
-
-    public function title(): string
-    {
-        return 'Como funcionam os pedidos Sob Medida';
-    }
-
-    public function body(): string
-    {
-        return 'Eles são enviados para várias diaristas ao mesmo tempo. Aceite rápido para garantir.';
-    }
-
-    public function target(): PushNotificationTargetEnum
-    {
-        return PushNotificationTargetEnum::PRESTADOR;
-    }
-
-    public function category(): PushNotificationCategoryEnum
-    {
-        return PushNotificationCategoryEnum::REFORCO_EDUCATIVO;
-    }
-
-    public function notificationCooldownDays(): int
-    {
-        return 42; // 6 semanas — alterna com ReforcoEducativo2
-    }
-
-    public function categoryCooldownDays(): int
-    {
-        return 0; // sem cooldown de categoria — os dois reforços se alternam por label
-    }
-
-    /**
-     * Elegível se:
-     * - Prestador aprovado
-     * - Nunca recebeu nenhum reforço educativo (1 ou 2)
-     *   OU o último reforço recebido foi o #2 há ≥3 semanas
-     */
-    public function eligibleUsers(): Collection
-    {
-        return User::whereHas('provider', fn ($q) => $q->where('approval_status', ApprovalStatusEnum::ACCEPTED))
-            ->where(function ($query) {
-                $threeWeeksAgo = now()->subWeeks(3);
-
-                $query->whereDoesntHave('pushNotificationLogs', fn ($q) => $q->whereIn('label', [
-                    'provider_reforco_educativo_1',
-                    'provider_reforco_educativo_2',
-                ]))
-                    ->orWhereHas('pushNotificationLogs', function ($q) use ($threeWeeksAgo) {
-                        $q->where('label', 'provider_reforco_educativo_2')
-                            ->where('sent_at', '<=', $threeWeeksAgo)
-                            ->whereNotExists(function ($sub) use ($threeWeeksAgo) {
-                                $sub->from('push_notification_logs as pnl2')
-                                    ->whereColumn('pnl2.user_id', 'push_notification_logs.user_id')
-                                    ->where('pnl2.label', 'provider_reforco_educativo_1')
-                                    ->where('pnl2.sent_at', '>', $threeWeeksAgo);
-                            });
-                    });
-            })
-            ->get();
-    }
-}

+ 0 - 71
app/Notifications/Push/Prestador/ReforcoEducativo/ReforcoEducativo2Push.php

@@ -1,71 +0,0 @@
-<?php
-
-namespace App\Notifications\Push\Prestador\ReforcoEducativo;
-
-use App\Enums\ApprovalStatusEnum;
-use App\Enums\PushNotificationCategoryEnum;
-use App\Enums\PushNotificationTargetEnum;
-use App\Models\User;
-use App\Notifications\Push\BasePushNotification;
-use Illuminate\Database\Eloquent\Collection;
-
-class ReforcoEducativo2Push extends BasePushNotification
-{
-    public function label(): string
-    {
-        return 'provider_reforco_educativo_2';
-    }
-
-    public function title(): string
-    {
-        return 'Dica importante';
-    }
-
-    public function body(): string
-    {
-        return 'Pedidos Sob Medida são compartilhados. A confirmação acontece por ordem de aceite.';
-    }
-
-    public function target(): PushNotificationTargetEnum
-    {
-        return PushNotificationTargetEnum::PRESTADOR;
-    }
-
-    public function category(): PushNotificationCategoryEnum
-    {
-        return PushNotificationCategoryEnum::REFORCO_EDUCATIVO;
-    }
-
-    public function notificationCooldownDays(): int
-    {
-        return 42; // 6 semanas — alterna com ReforcoEducativo1
-    }
-
-    public function categoryCooldownDays(): int
-    {
-        return 0; // sem cooldown de categoria — os dois reforços se alternam por label
-    }
-
-    /**
-     * Elegível se:
-     * - Prestador aprovado
-     * - O último reforço educativo recebido foi o #1 há ≥3 semanas
-     */
-    public function eligibleUsers(): Collection
-    {
-        return User::whereHas('provider', fn ($q) => $q->where('approval_status', ApprovalStatusEnum::ACCEPTED))
-            ->whereHas('pushNotificationLogs', function ($query) {
-                $threeWeeksAgo = now()->subWeeks(3);
-
-                $query->where('label', 'provider_reforco_educativo_1')
-                    ->where('sent_at', '<=', $threeWeeksAgo)
-                    ->whereNotExists(function ($sub) use ($threeWeeksAgo) {
-                        $sub->from('push_notification_logs as pnl2')
-                            ->whereColumn('pnl2.user_id', 'push_notification_logs.user_id')
-                            ->where('pnl2.label', 'provider_reforco_educativo_2')
-                            ->where('pnl2.sent_at', '>', $threeWeeksAgo);
-                    });
-            })
-            ->get();
-    }
-}

+ 75 - 0
app/Services/ManualPushService.php

@@ -0,0 +1,75 @@
+<?php
+
+namespace App\Services;
+
+use App\Enums\PushNotificationCategoryEnum;
+use App\Enums\PushNotificationTargetEnum;
+use App\Jobs\SendManualPushJob;
+use App\Models\PushNotificationLog;
+use App\Models\User;
+use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Pagination\LengthAwarePaginator;
+
+class ManualPushService
+{
+    private const CHUNK_SIZE = 50;
+
+    public function recipients(PushNotificationTargetEnum $target, ?string $search = null): Collection
+    {
+        return User::query()
+            ->whereHas($this->relationFor($target))
+            ->when($search, function ($query) use ($search) {
+                $query->where(function ($sub) use ($search) {
+                    $sub->where('name', 'like', "%{$search}%")
+                        ->orWhere('email', 'like', "%{$search}%");
+                });
+            })
+            ->withCount(['deviceTokens as active_device_tokens_count' => fn ($q) => $q
+                ->where('app_type', $target->value)
+                ->where('active', true),
+            ])
+            ->orderBy('name')
+            ->get(['id', 'name', 'email', 'push_notifications_enabled']);
+    }
+
+    /**
+     * Enfileira o envio manual e devolve quantos destinatários foram aceitos.
+     *
+     * @param  int[]  $userIds
+     */
+    public function send(PushNotificationTargetEnum $target, array $userIds, string $title, string $body): int
+    {
+        $validIds = User::whereIn('id', $userIds)
+            ->whereHas($this->relationFor($target))
+            ->pluck('id');
+
+        foreach ($validIds->chunk(self::CHUNK_SIZE) as $chunk) {
+            SendManualPushJob::dispatch($chunk->values()->all(), $title, $body, $target);
+        }
+
+        return $validIds->count();
+    }
+
+    public function history(int $page = 1, int $perPage = 10, ?string $search = null): LengthAwarePaginator
+    {
+        return PushNotificationLog::query()
+            ->where('category', PushNotificationCategoryEnum::MANUAL->value)
+            ->with('user:id,name,email')
+            ->when($search, fn ($query) => $query->where(function ($sub) use ($search) {
+                $sub->where('title', 'like', "%{$search}%")
+                    ->orWhereHas('user', fn ($user) => $user
+                        ->where('name', 'like', "%{$search}%")
+                        ->orWhere('email', 'like', "%{$search}%")
+                    );
+            }))
+            ->orderBy('sent_at', 'desc')
+            ->paginate($perPage, ['*'], 'page', $page);
+    }
+
+    //
+
+    private function relationFor(PushNotificationTargetEnum $target): string
+    {
+        return $target === PushNotificationTargetEnum::PRESTADOR ? 'provider' : 'client';
+    }
+}

+ 6 - 65
app/Services/PushNotificationDispatcher.php

@@ -4,30 +4,9 @@ namespace App\Services;
 
 
 use App\Models\PushNotificationLog;
 use App\Models\PushNotificationLog;
 use App\Notifications\Push\BasePushNotification;
 use App\Notifications\Push\BasePushNotification;
-use App\Notifications\Push\Cliente\Contextual\ContextualSegundaPush;
-use App\Notifications\Push\Cliente\Contextual\ContextualSextaPush;
-use App\Notifications\Push\Cliente\Contextual\ContextualVisitaPush;
-use App\Notifications\Push\Cliente\Educativo\Educativo1Push;
-use App\Notifications\Push\Cliente\Educativo\Educativo2Push;
-use App\Notifications\Push\Cliente\Educativo\Educativo3Push;
-use App\Notifications\Push\Cliente\Educativo\Educativo4Push;
-use App\Notifications\Push\Cliente\EducativoConversao\EducativoConversao1Push;
-use App\Notifications\Push\Cliente\EducativoConversao\EducativoConversao2Push;
-use App\Notifications\Push\Cliente\Marketing\Marketing1Push;
-use App\Notifications\Push\Cliente\Marketing\Marketing2Push;
-use App\Notifications\Push\Cliente\Marketing\Marketing3Push;
-use App\Notifications\Push\Cliente\Marketing\Marketing4Push;
-use App\Notifications\Push\Cliente\Motivacional\Motivacional1Push as ClienteMotivacional1Push;
-use App\Notifications\Push\Cliente\Motivacional\Motivacional2Push as ClienteMotivacional2Push;
-use App\Notifications\Push\Cliente\Motivacional\Motivacional3Push as ClienteMotivacional3Push;
-use App\Notifications\Push\Cliente\Recorrencia\Recorrencia1Push;
-use App\Notifications\Push\Cliente\Recorrencia\Recorrencia2Push;
-use App\Notifications\Push\Cliente\Recorrencia\Recorrencia3Push;
-use App\Notifications\Push\Cliente\SocialProof\SocialProof1Push;
-use App\Notifications\Push\Cliente\SocialProof\SocialProof2Push;
-use App\Notifications\Push\Prestador\Motivacional\Motivacional1Push as PrestadorMotivacional1Push;
-use App\Notifications\Push\Prestador\ReforcoEducativo\ReforcoEducativo1Push;
-use App\Notifications\Push\Prestador\ReforcoEducativo\ReforcoEducativo2Push;
+use App\Notifications\Push\Cliente\Marketing\AbandonoFunilPush;
+use App\Notifications\Push\Cliente\Marketing\ClienteInativoPush;
+use App\Notifications\Push\Cliente\Marketing\RecorrenciaQuebradaPush;
 use Illuminate\Support\Collection;
 use Illuminate\Support\Collection;
 
 
 class PushNotificationDispatcher
 class PushNotificationDispatcher
@@ -35,53 +14,15 @@ class PushNotificationDispatcher
     public function __construct(private PushNotificationService $pushService) {}
     public function __construct(private PushNotificationService $pushService) {}
 
 
     /**
     /**
-     * Lista central de todas as notificações registradas.
-     * Para adicionar uma nova: basta instanciá-la aqui.
-     *
      * @return BasePushNotification[]
      * @return BasePushNotification[]
      */
      */
     public function all(): array
     public function all(): array
     {
     {
         return [
         return [
-            // Prestador
-            new ReforcoEducativo1Push,
-            new ReforcoEducativo2Push,
-            new PrestadorMotivacional1Push,
-
             // Cliente — Marketing
             // Cliente — Marketing
-            new Marketing1Push,
-            new Marketing2Push,
-            new Marketing3Push,
-            new Marketing4Push,
-
-            // Cliente — Recorrência
-            new Recorrencia1Push,
-            new Recorrencia2Push,
-            new Recorrencia3Push,
-
-            // Cliente — Educativo
-            new Educativo1Push,
-            new Educativo2Push,
-            new Educativo3Push,
-            new Educativo4Push,
-
-            // Cliente — Educativo + Conversão
-            new EducativoConversao1Push,
-            new EducativoConversao2Push,
-
-            // Cliente — Social Proof
-            new SocialProof1Push,
-            new SocialProof2Push,
-
-            // Cliente — Motivacional
-            new ClienteMotivacional1Push,
-            new ClienteMotivacional2Push,
-            new ClienteMotivacional3Push,
-
-            // Cliente — Contextual
-            new ContextualSextaPush,
-            new ContextualSegundaPush,
-            new ContextualVisitaPush,
+            new AbandonoFunilPush,
+            new RecorrenciaQuebradaPush,
+            new ClienteInativoPush,
         ];
         ];
     }
     }
 
 

+ 2 - 0
app/Services/PushNotificationService.php

@@ -87,6 +87,8 @@ class PushNotificationService
     {
     {
         PushNotificationLog::create([
         PushNotificationLog::create([
             'label'    => $notification->label(),
             'label'    => $notification->label(),
+            'title'    => $notification->title(),
+            'body'     => $notification->body(),
             'user_id'  => $user->id,
             'user_id'  => $user->id,
             'target'   => $notification->target()->value,
             'target'   => $notification->target()->value,
             'category' => $notification->category()->value,
             'category' => $notification->category()->value,

+ 1 - 3
bootstrap/app.php

@@ -42,9 +42,7 @@ return Application::configure(basePath: dirname(__DIR__))
             }
             }
         };
         };
 
 
-        $schedule->call($sendPushNotifications)->dailyAt('08:00');
-        $schedule->call($sendPushNotifications)->dailyAt('13:00');
-        $schedule->call($sendPushNotifications)->dailyAt('19:00');
+        $schedule->call($sendPushNotifications)->dailyAt('10:00');
     })
     })
     ->withExceptions(function (Exceptions $exceptions) {
     ->withExceptions(function (Exceptions $exceptions) {
         //
         //

+ 23 - 0
database/migrations/2026_09_04_120000_add_title_and_body_to_push_notification_logs_table.php

@@ -0,0 +1,23 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::table('push_notification_logs', function (Blueprint $table) {
+            $table->string('title')->nullable()->after('label');
+            $table->text('body')->nullable()->after('title');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('push_notification_logs', function (Blueprint $table) {
+            $table->dropColumn(['title', 'body']);
+        });
+    }
+};

+ 6 - 0
database/seeders/PermissionSeeder.php

@@ -230,6 +230,12 @@ class PermissionSeeder extends Seeder
                 'bits'        => 271,
                 'bits'        => 271,
                 'children'    => [],
                 'children'    => [],
             ],
             ],
+            [
+                'scope'       => 'push.notification',
+                'description' => 'Pushs',
+                'bits'        => 259,
+                'children'    => [],
+            ],
         ];
         ];
 
 
         $this->createPermissionsAndChildren(permissions: $permissions);
         $this->createPermissionsAndChildren(permissions: $permissions);

+ 1 - 0
database/seeders/UserTypePermissionSeeder.php

@@ -58,6 +58,7 @@ class UserTypePermissionSeeder extends Seeder
                         ['scope' => 'payment',                         'bits' => 257],
                         ['scope' => 'payment',                         'bits' => 257],
                         ['scope' => 'payment-split',                   'bits' => 1],
                         ['scope' => 'payment-split',                   'bits' => 1],
                         ['scope' => 'support.request',                 'bits' => 271],
                         ['scope' => 'support.request',                 'bits' => 271],
+                        ['scope' => 'push.notification',               'bits' => 259],
                     ];
                     ];
 
 
                     $this->seedUserTypePermissions($userPermissions, UserTypeEnum::USER->value);
                     $this->seedUserTypePermissions($userPermissions, UserTypeEnum::USER->value);

+ 2 - 0
lang/en/messages.php

@@ -87,4 +87,6 @@ return [
     'favorite_code_not_found'                        => 'Code not found. Check the code and try again.',
     'favorite_code_not_found'                        => 'Code not found. Check the code and try again.',
     'favorite_code_already_favorite'                 => 'This cleaner is already in your favorites.',
     'favorite_code_already_favorite'                 => 'This cleaner is already in your favorites.',
     'favorite_code_blocked'                          => 'This cleaner cannot be added to your favorites.',
     'favorite_code_blocked'                          => 'This cleaner cannot be added to your favorites.',
+    'push_queued'                                    => 'Push sent to :count recipient(s).',
+    'push_no_recipients'                             => 'No valid recipients for the selected origin.',
 ];
 ];

+ 2 - 0
lang/es/messages.php

@@ -87,4 +87,6 @@ return [
     'favorite_code_not_found'                        => 'Código no encontrado. Verifica el código e inténtalo de nuevo.',
     'favorite_code_not_found'                        => 'Código no encontrado. Verifica el código e inténtalo de nuevo.',
     'favorite_code_already_favorite'                 => 'Este limpiador ya está en tus favoritos.',
     'favorite_code_already_favorite'                 => 'Este limpiador ya está en tus favoritos.',
     'favorite_code_blocked'                          => 'No es posible añadir este limpiador a tus favoritos.',
     'favorite_code_blocked'                          => 'No es posible añadir este limpiador a tus favoritos.',
+    'push_queued'                                    => 'Push enviada a :count destinatario(s).',
+    'push_no_recipients'                             => 'Ningún destinatario válido para el origen seleccionado.',
 ];
 ];

+ 2 - 0
lang/pt/messages.php

@@ -88,4 +88,6 @@ return [
     'favorite_code_not_found'                        => 'Código não encontrado. Confira o código e tente novamente.',
     'favorite_code_not_found'                        => 'Código não encontrado. Confira o código e tente novamente.',
     'favorite_code_already_favorite'                 => 'Este diarista já está nos seus favoritos.',
     'favorite_code_already_favorite'                 => 'Este diarista já está nos seus favoritos.',
     'favorite_code_blocked'                          => 'Não é possível favoritar este diarista.',
     'favorite_code_blocked'                          => 'Não é possível favoritar este diarista.',
+    'push_queued'                                    => 'Push enviada para :count destinatário(s).',
+    'push_no_recipients'                             => 'Nenhum destinatário válido para a origem selecionada.',
 ];
 ];

+ 8 - 0
routes/authRoutes/push_notification.php

@@ -0,0 +1,8 @@
+<?php
+
+use App\Http\Controllers\PushNotificationController;
+use Illuminate\Support\Facades\Route;
+
+Route::get('/push-notifications/recipients', [PushNotificationController::class, 'recipients'])->middleware('permission:push.notification,view');
+Route::get('/push-notifications/history',    [PushNotificationController::class, 'history'])->middleware('permission:push.notification,view');
+Route::post('/push-notifications/send',      [PushNotificationController::class, 'send'])->middleware('permission:push.notification,add');