5 Ревизии 13f08a4151 ... f51d0bdcf0

Автор SHA1 Съобщение Дата
  zntt f51d0bdcf0 Merge branch 'fix/diaria-kay-correções-backend' of Softpar/sfp_api_laravel_diarista into development преди 4 дни
  Gustavo Zanatta e43914a6e3 manual pushs no backoffice преди 4 дни
  Gustavo Zanatta b12cc9477d push ao criar sob medida, enviado para prestadores que podem aceitar a oportunidade, com base na availability преди 1 седмица
  Gustavo Zanatta eab28a61fa websocket преди 1 седмица
  Gustavo Zanatta 1b8bb59f2c remove logs преди 1 седмица
променени са 65 файла, в които са добавени 1500 реда и са изтрити 1537 реда
  1. 3 1
      .env.example
  2. 121 206
      PLANO_PUSH.md
  3. 1 1
      app/Broadcasting/Entity/WebsocketEventData.php
  4. 5 0
      app/Broadcasting/Events/WebsocketEvent.php
  5. 2 0
      app/Broadcasting/Events/WebsocketEventInterface.php
  6. 36 0
      app/Broadcasting/RealtimeEvent.php
  7. 50 0
      app/Broadcasting/RealtimeRoom.php
  8. 72 0
      app/Broadcasting/RealtimeService.php
  9. 7 10
      app/Enums/PushNotificationCategoryEnum.php
  10. 6 6
      app/Http/Controllers/NotificationController.php
  11. 1 1
      app/Http/Controllers/PaymentController.php
  12. 81 0
      app/Http/Controllers/PushNotificationController.php
  13. 21 0
      app/Http/Requests/ManualPushRequest.php
  14. 108 0
      app/Jobs/NotifyProvidersOfNewOpportunityJob.php
  15. 62 0
      app/Jobs/SendManualPushJob.php
  16. 54 0
      app/Jobs/SendOpportunityPushJob.php
  17. 4 0
      app/Models/PushNotificationLog.php
  18. 0 59
      app/Notifications/Push/Cliente/Contextual/ContextualSextaPush.php
  19. 0 44
      app/Notifications/Push/Cliente/Contextual/ContextualVisitaPush.php
  20. 0 44
      app/Notifications/Push/Cliente/Educativo/Educativo1Push.php
  21. 0 44
      app/Notifications/Push/Cliente/Educativo/Educativo2Push.php
  22. 0 44
      app/Notifications/Push/Cliente/Educativo/Educativo3Push.php
  23. 0 44
      app/Notifications/Push/Cliente/Educativo/Educativo4Push.php
  24. 0 64
      app/Notifications/Push/Cliente/EducativoConversao/EducativoConversao1Push.php
  25. 0 59
      app/Notifications/Push/Cliente/EducativoConversao/EducativoConversao2Push.php
  26. 12 13
      app/Notifications/Push/Cliente/Marketing/AbandonoFunilPush.php
  27. 61 0
      app/Notifications/Push/Cliente/Marketing/ClienteInativoPush.php
  28. 0 44
      app/Notifications/Push/Cliente/Marketing/Marketing1Push.php
  29. 0 44
      app/Notifications/Push/Cliente/Marketing/Marketing2Push.php
  30. 0 44
      app/Notifications/Push/Cliente/Marketing/Marketing3Push.php
  31. 0 44
      app/Notifications/Push/Cliente/Marketing/Marketing4Push.php
  32. 59 0
      app/Notifications/Push/Cliente/Marketing/RecorrenciaQuebradaPush.php
  33. 0 44
      app/Notifications/Push/Cliente/Motivacional/Motivacional1Push.php
  34. 0 44
      app/Notifications/Push/Cliente/Motivacional/Motivacional2Push.php
  35. 0 44
      app/Notifications/Push/Cliente/Motivacional/Motivacional3Push.php
  36. 0 45
      app/Notifications/Push/Cliente/Recorrencia/Recorrencia1Push.php
  37. 0 45
      app/Notifications/Push/Cliente/Recorrencia/Recorrencia2Push.php
  38. 0 45
      app/Notifications/Push/Cliente/Recorrencia/Recorrencia3Push.php
  39. 0 64
      app/Notifications/Push/Cliente/SocialProof/SocialProof1Push.php
  40. 0 59
      app/Notifications/Push/Cliente/SocialProof/SocialProof2Push.php
  41. 62 0
      app/Notifications/Push/Manual/ManualPush.php
  42. 64 0
      app/Notifications/Push/Prestador/Agendamento/NovaOportunidadePush.php
  43. 0 54
      app/Notifications/Push/Prestador/Motivacional/Motivacional1Push.php
  44. 0 78
      app/Notifications/Push/Prestador/ReforcoEducativo/ReforcoEducativo1Push.php
  45. 0 71
      app/Notifications/Push/Prestador/ReforcoEducativo/ReforcoEducativo2Push.php
  46. 43 0
      app/Observers/NotificationObserver.php
  47. 47 0
      app/Observers/ServicePackageObserver.php
  48. 6 0
      app/Providers/AppServiceProvider.php
  49. 9 11
      app/Rules/ScheduleBusinessRules.php
  50. 240 1
      app/Services/CustomScheduleService.php
  51. 75 0
      app/Services/ManualPushService.php
  52. 17 1
      app/Services/NotificationService.php
  53. 9 11
      app/Services/PaymentService.php
  54. 6 65
      app/Services/PushNotificationDispatcher.php
  55. 2 0
      app/Services/PushNotificationService.php
  56. 102 36
      app/Services/ScheduleService.php
  57. 1 3
      bootstrap/app.php
  58. 7 0
      config/realtime.php
  59. 23 0
      database/migrations/2026_09_04_120000_add_title_and_body_to_push_notification_logs_table.php
  60. 6 0
      database/seeders/PermissionSeeder.php
  61. 1 0
      database/seeders/UserTypePermissionSeeder.php
  62. 2 0
      lang/en/messages.php
  63. 2 0
      lang/es/messages.php
  64. 2 0
      lang/pt/messages.php
  65. 8 0
      routes/authRoutes/push_notification.php

+ 3 - 1
.env.example

@@ -21,6 +21,7 @@ QUEUE_CONNECTION=sync
 REDIS_HOST=127.0.0.1
 REDIS_PASSWORD=null
 REDIS_PORT=6379
+REDIS_PREFIX=laravel_database_
 
 MAIL_MAILER=log
 MAIL_HOST=mailpit
@@ -34,7 +35,8 @@ SUPPORT_EMAIL="ajuda@diaria.app.br"
 
 VITE_APP_NAME="${APP_NAME}"
 FILESYSTEM_DISK=local
-BROADCAST_DRIVER=log
+BROADCAST_CONNECTION=redis
+REALTIME_PROJECT=diaria
 SANCTUM_STATEFUL_DOMAINS=localhost
 
 FIREBASE_CREDENTIALS=/var/www/sfp_api_laravel_diarista/storage/diariaappsfp-firebase-adminsdk-fbsvc-276a716a1b.json

+ 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 |
 |---|---|---|
-| `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`.

+ 1 - 1
app/Broadcasting/Entity/WebsocketEventData.php

@@ -20,7 +20,7 @@ final readonly class WebsocketEventData
             room:        $room,
             data:        $data,
             event:       $event,
-            projectName: config('app.name')
+            projectName: config('realtime.project')
         );
     }
 

+ 5 - 0
app/Broadcasting/Events/WebsocketEvent.php

@@ -26,4 +26,9 @@ final class WebsocketEvent implements ShouldBroadcastNow, WebsocketEventInterfac
     {
         return ['data' => $this->dto->data];
     }
+
+    public function broadcastAs(): string
+    {
+        return $this->dto->event;
+    }
 }

+ 2 - 0
app/Broadcasting/Events/WebsocketEventInterface.php

@@ -14,4 +14,6 @@ interface WebsocketEventInterface
     public function broadcastOn(): Channel;
 
     public function broadcastWith(): array;
+
+    public function broadcastAs(): string;
 }

+ 36 - 0
app/Broadcasting/RealtimeEvent.php

@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Broadcasting;
+
+enum RealtimeEvent: string
+{
+    // Sino de notificacoes
+
+    case NOTIFICATION_CREATED  = 'notification.created';
+    case NOTIFICATION_READ     = 'notification.read';
+    case NOTIFICATION_READ_ALL = 'notification.read_all';
+
+    // Agendamentos
+
+    case SCHEDULE_CREATED        = 'schedule.created';
+    case SCHEDULE_STATUS_CHANGED = 'schedule.status_changed';
+
+    // Propostas
+
+    case PROPOSAL_CREATED  = 'proposal.created';
+    case PROPOSAL_ACCEPTED = 'proposal.accepted';
+    case PROPOSAL_REFUSED  = 'proposal.refused';
+
+    // Pacotes e pagamento
+
+    case PACKAGE_STATUS_CHANGED = 'package.status_changed';
+    case PAYMENT_STATUS_CHANGED = 'payment.status_changed';
+
+    // Prestador e backoffice
+
+    case PROVIDER_APPROVAL_CHANGED = 'provider.approval_changed';
+    case PROVIDER_PENDING_CREATED  = 'provider.pending_created';
+    case SUPPORT_REQUEST_CREATED   = 'support_request.created';
+}

+ 50 - 0
app/Broadcasting/RealtimeRoom.php

@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Broadcasting;
+
+use InvalidArgumentException;
+
+final readonly class RealtimeRoom
+{
+    private function __construct(
+        public string $name
+    ) {}
+
+    public static function user(int $userId): self
+    {
+        return self::make("user.{$userId}");
+    }
+
+    public static function schedule(int $scheduleId): self
+    {
+        return self::make("schedule.{$scheduleId}");
+    }
+
+    public static function package(int $servicePackageId): self
+    {
+        return self::make("package.{$servicePackageId}");
+    }
+
+    public static function provider(int $providerId): self
+    {
+        return self::make("provider.{$providerId}");
+    }
+
+    private static function make(string $name): self
+    {
+        if (str_contains($name, ':') || str_contains($name, '@')) {
+            throw new InvalidArgumentException(
+                "Nome de sala invalido: \"{$name}\". Nao pode conter \":\" nem \"@\"."
+            );
+        }
+
+        return new self($name);
+    }
+
+    public function __toString(): string
+    {
+        return $this->name;
+    }
+}

+ 72 - 0
app/Broadcasting/RealtimeService.php

@@ -0,0 +1,72 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Broadcasting;
+
+use App\Broadcasting\Entity\WebsocketEventData;
+use App\Broadcasting\Events\WebsocketEvent;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use Throwable;
+
+final class RealtimeService
+{
+    /**
+     * @param  RealtimeRoom|RealtimeRoom[]  $rooms
+     * @param  array<string, scalar|null>   $data  Payload magro (ver nota acima)
+     */
+    public function emit(RealtimeEvent $event, RealtimeRoom|array $rooms, array $data = []): void
+    {
+        $targets = collect(is_array($rooms) ? $rooms : [$rooms])
+            ->filter()
+            ->keyBy(fn (RealtimeRoom $room): string => $room->name)
+            ->values();
+
+        if ($targets->isEmpty()) {
+            return;
+        }
+
+        $payload = $data + ['at' => now()->toIso8601String()];
+
+        DB::afterCommit(function () use ($event, $targets, $payload): void {
+            foreach ($targets as $room) {
+                $this->publish($event, $room, $payload);
+            }
+        });
+    }
+
+    /**
+     * Atalho para o caso mais comum: avisar um usuario especifico.
+     */
+    public function emitToUser(RealtimeEvent $event, ?int $userId, array $data = []): void
+    {
+        if (! $userId) {
+            return;
+        }
+
+        $this->emit($event, RealtimeRoom::user($userId), $data);
+    }
+
+    /**
+     * @param  array<string, mixed>  $payload
+     */
+    private function publish(RealtimeEvent $event, RealtimeRoom $room, array $payload): void
+    {
+        try {
+            event(new WebsocketEvent(
+                WebsocketEventData::from(
+                    room:  $room->name,
+                    data:  $payload,
+                    event: $event->value,
+                )
+            ));
+        } catch (Throwable $e) {
+            Log::warning('Falha ao emitir evento de tempo real', [
+                'event'   => $event->value,
+                'room'    => $room->name,
+                'message' => $e->getMessage(),
+            ]);
+        }
+    }
+}

+ 7 - 10
app/Enums/PushNotificationCategoryEnum.php

@@ -4,14 +4,11 @@ namespace App\Enums;
 
 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';
 }

+ 6 - 6
app/Http/Controllers/NotificationController.php

@@ -3,12 +3,17 @@
 namespace App\Http\Controllers;
 
 use App\Models\Notification;
+use App\Services\NotificationService;
 use Carbon\Carbon;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Support\Facades\Auth;
 
 class NotificationController extends Controller
 {
+    public function __construct(
+        private NotificationService $service
+    ) {}
+
     public function index(): JsonResponse
     {
         $user = Auth::user();
@@ -54,12 +59,7 @@ class NotificationController extends Controller
 
     public function markAllAsRead(): JsonResponse
     {
-        Notification::where('user_id', Auth::id())
-            ->where('read', false)
-            ->update([
-                'read'    => true,
-                'read_at' => now(),
-            ]);
+        $this->service->markAllAsRead(Auth::id());
 
         return $this->successResponse(
             message: __('messages.updated')

+ 1 - 1
app/Http/Controllers/PaymentController.php

@@ -65,7 +65,7 @@ class PaymentController extends Controller
     public function payServicePackage(PayServicePackageRequest $request, int $servicePackageId): JsonResponse
     {
         $validated = $request->validated();
-        Log::info($validated);
+
         try {
             $item = $this->service->payServicePackage(
                 servicePackageId:      $servicePackageId,

+ 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'],
+        ];
+    }
+}

+ 108 - 0
app/Jobs/NotifyProvidersOfNewOpportunityJob.php

@@ -0,0 +1,108 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Enums\PushNotificationTargetEnum;
+use App\Models\Provider;
+use App\Models\Schedule;
+use App\Models\User;
+use App\Services\CustomScheduleService;
+use Carbon\Carbon;
+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 NotifyProvidersOfNewOpportunityJob implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    public int $tries = 3;
+
+    public int $timeout = 60;
+
+    public array $backoff = [10, 60, 300];
+
+    /**
+     * @param  int[]  $scheduleIds
+     */
+    public function __construct(public readonly array $scheduleIds) {}
+
+    public function handle(CustomScheduleService $customScheduleService): void
+    {
+        $scheduleId = $this->scheduleIds[0] ?? null;
+        if (! $scheduleId) {
+            return;
+        }
+        $schedule = Schedule::with(['customSchedule', 'address'])->find($scheduleId);
+
+        if (! $this->shouldNotify($schedule)) {
+            return;
+        }
+
+        $providerIds = $customScheduleService->getAvailableProvidersForOpportunity($schedule);
+
+        if ($providerIds->isEmpty()) {
+            Log::info('Nenhum prestador disponivel para a nova oportunidade', [
+                'schedule_id' => $schedule->id,
+            ]);
+
+            return;
+        }
+
+        $userIds = User::query()
+            ->whereIn('id', Provider::whereIn('id', $providerIds)->pluck('user_id'))
+            ->where('push_notifications_enabled', true)
+            ->whereHas('deviceTokens', function ($query) {
+                $query->where('app_type', PushNotificationTargetEnum::PRESTADOR->value)
+                    ->where('active', true);
+            })
+            ->pluck('id');
+
+        if ($userIds->isEmpty()) {
+            return;
+        }
+
+        $district  = $schedule->address?->district;
+        $dateLabel = Carbon::parse($schedule->date)->format('d/m');
+        $quantity  = count($this->scheduleIds);
+        $userIds->chunk(50)->each(function ($chunk) use ($schedule, $district, $dateLabel, $quantity) {
+            foreach ($chunk as $userId) {
+                SendOpportunityPushJob::dispatch(
+                    $userId,
+                    $schedule->id,
+                    $district,
+                    $dateLabel,
+                    $quantity,
+                );
+            }
+        });
+    }
+
+    public function failed(\Throwable $exception): void
+    {
+        Log::error('Falha ao notificar prestadores de nova oportunidade', [
+            'schedule_ids' => $this->scheduleIds,
+            'error'        => $exception->getMessage(),
+        ]);
+    }
+
+    private function shouldNotify(?Schedule $schedule): bool
+    {
+        if (! $schedule || ! $schedule->customSchedule) {
+            return false;
+        }
+
+        if ($schedule->schedule_type !== 'custom' || $schedule->status !== 'pending') {
+            return false;
+        }
+
+        if ($schedule->provider_id !== null) {
+            return false;
+        }
+
+        return ! Carbon::parse($schedule->date)->lt(today());
+    }
+}

+ 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(),
+                ]);
+            }
+        }
+    }
+}

+ 54 - 0
app/Jobs/SendOpportunityPushJob.php

@@ -0,0 +1,54 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Models\User;
+use App\Notifications\Push\Prestador\Agendamento\NovaOportunidadePush;
+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 SendOpportunityPushJob implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    public int $tries = 2;
+
+    public int $timeout = 30;
+
+    public array $backoff = [15, 60];
+
+    public function __construct(
+        public readonly int $userId,
+        public readonly int $scheduleId,
+        public readonly ?string $district = null,
+        public readonly ?string $dateLabel = null,
+        public readonly int $quantity = 1,
+    ) {}
+
+    public function handle(PushNotificationService $pushNotificationService): void
+    {
+        $user = User::find($this->userId);
+      Log::info('----------entrou disparo-------------');
+        if (! $user) {
+            return;
+        }
+        Log::info('tem user');
+        try {
+            $pushNotificationService->sendToUser(
+                $user,
+                new NovaOportunidadePush($this->district, $this->dateLabel, $this->quantity)
+            );
+        } catch (\Throwable $exception) {
+            Log::error('Falha ao enviar push de nova oportunidade', [
+                'user_id'     => $this->userId,
+                'schedule_id' => $this->scheduleId,
+                'error'       => $exception->getMessage(),
+            ]);
+        }
+    }
+}

+ 4 - 0
app/Models/PushNotificationLog.php

@@ -9,6 +9,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
 /**
  * @property int $id
  * @property string $label
+ * @property string|null $title
+ * @property string|null $body
  * @property int $user_id
  * @property PushNotificationTargetEnum $target
  * @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 query()
  * @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 whereId($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
 
-namespace App\Notifications\Push\Cliente\Contextual;
+namespace App\Notifications\Push\Cliente\Marketing;
 
 use App\Enums\PushNotificationCategoryEnum;
 use App\Enums\PushNotificationTargetEnum;
@@ -8,21 +8,24 @@ use App\Models\User;
 use App\Notifications\Push\BasePushNotification;
 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
     {
-        return 'cliente_contextual_segunda';
+        return 'cliente_abandono_funil';
     }
 
     public function title(): string
     {
-        return 'Segunda organizada';
+        return 'Casa limpa sem esforço! ✨';
     }
 
     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
@@ -32,7 +35,7 @@ class ContextualSegundaPush extends BasePushNotification
 
     public function category(): PushNotificationCategoryEnum
     {
-        return PushNotificationCategoryEnum::CONTEXTUAL;
+        return PushNotificationCategoryEnum::MARKETING;
     }
 
     public function notificationCooldownDays(): int
@@ -42,18 +45,14 @@ class ContextualSegundaPush extends BasePushNotification
 
     public function categoryCooldownDays(): int
     {
-        return 7;
+        return 0;
     }
 
-    /** Só retorna usuários se hoje for segunda-feira */
     public function eligibleUsers(): Collection
     {
-        if (! now()->isMonday()) {
-            return new Collection;
-        }
-
         return User::whereHas('client')
-            ->where('registration_complete', true)
+            ->where('users.created_at', '<=', now()->subHours(48))
+            ->whereDoesntHave('client.schedules')
             ->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();
+    }
+}

+ 64 - 0
app/Notifications/Push/Prestador/Agendamento/NovaOportunidadePush.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace App\Notifications\Push\Prestador\Agendamento;
+
+use App\Enums\PushNotificationCategoryEnum;
+use App\Enums\PushNotificationTargetEnum;
+use App\Notifications\Push\BasePushNotification;
+use Illuminate\Database\Eloquent\Collection;
+
+class NovaOportunidadePush extends BasePushNotification
+{
+    public function __construct(
+        private readonly ?string $district = null,
+        private readonly ?string $dateLabel = null,
+        private readonly int $quantity = 1,
+    ) {}
+
+    public function label(): string
+    {
+        return 'provider_new_custom_opportunity';
+    }
+
+    public function title(): string
+    {
+        return 'Nova oportunidade disponível';
+    }
+
+    public function body(): string
+    {
+        $where = $this->district ? " em {$this->district}" : '';
+        $when  = $this->dateLabel ? " para {$this->dateLabel}" : '';
+
+        if ($where === '' && $when === '') {
+            return 'Um cliente publicou um pedido sob medida perto de você. Envie sua proposta.';
+        }
+
+        return "Novo pedido sob medida{$where}{$when}. Envie sua proposta.";
+    }
+
+    public function target(): PushNotificationTargetEnum
+    {
+        return PushNotificationTargetEnum::PRESTADOR;
+    }
+
+    public function category(): PushNotificationCategoryEnum
+    {
+        return PushNotificationCategoryEnum::AGENDA;
+    }
+
+    public function eligibleUsers(): Collection
+    {
+        return new Collection();
+    }
+
+    public function notificationCooldownDays(): int
+    {
+        return 0;
+    }
+
+    public function categoryCooldownDays(): int
+    {
+        return 0;
+    }
+}

+ 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();
-    }
-}

+ 43 - 0
app/Observers/NotificationObserver.php

@@ -0,0 +1,43 @@
+<?php
+
+namespace App\Observers;
+
+use App\Broadcasting\RealtimeEvent;
+use App\Broadcasting\RealtimeService;
+use App\Models\Notification;
+
+class NotificationObserver
+{
+    public function __construct(
+        private RealtimeService $realtime
+    ) {}
+
+    public function created(Notification $notification): void
+    {
+        $this->realtime->emitToUser(
+            RealtimeEvent::NOTIFICATION_CREATED,
+            $notification->user_id,
+            [
+                'entity' => 'notification',
+                'id'     => $notification->id,
+                'type'   => $notification->type,
+            ],
+        );
+    }
+
+    public function updated(Notification $notification): void
+    {
+        if (! $notification->wasChanged('read') || ! $notification->read) {
+            return;
+        }
+
+        $this->realtime->emitToUser(
+            RealtimeEvent::NOTIFICATION_READ,
+            $notification->user_id,
+            [
+                'entity' => 'notification',
+                'id'     => $notification->id,
+            ],
+        );
+    }
+}

+ 47 - 0
app/Observers/ServicePackageObserver.php

@@ -0,0 +1,47 @@
+<?php
+
+namespace App\Observers;
+
+use App\Broadcasting\RealtimeEvent;
+use App\Broadcasting\RealtimeRoom;
+use App\Broadcasting\RealtimeService;
+use App\Enums\ServicePackageStatusEnum;
+use App\Models\ServicePackage;
+
+class ServicePackageObserver
+{
+    public function __construct(
+        private RealtimeService $realtime
+    ) {}
+
+    public function updated(ServicePackage $servicePackage): void
+    {
+        if (! $servicePackage->wasChanged('status')) {
+            return;
+        }
+
+        $rooms = [
+            RealtimeRoom::package($servicePackage->id),
+        ];
+
+        if ($servicePackage->client?->user_id) {
+            $rooms[] = RealtimeRoom::user($servicePackage->client->user_id);
+        }
+
+        if ($servicePackage->provider?->user_id) {
+            $rooms[] = RealtimeRoom::user($servicePackage->provider->user_id);
+        }
+
+        $status = $servicePackage->status;
+
+        $this->realtime->emit(
+            RealtimeEvent::PACKAGE_STATUS_CHANGED,
+            $rooms,
+            [
+                'entity' => 'service_package',
+                'id'     => $servicePackage->id,
+                'status' => $status instanceof ServicePackageStatusEnum ? $status->value : $status,
+            ],
+        );
+    }
+}

+ 6 - 0
app/Providers/AppServiceProvider.php

@@ -3,11 +3,15 @@
 namespace App\Providers;
 
 use App\Models\Client;
+use App\Models\Notification;
 use App\Models\Provider;
 use App\Models\Schedule;
+use App\Models\ServicePackage;
 use App\Observers\ClientObserver;
+use App\Observers\NotificationObserver;
 use App\Observers\ProviderObserver;
 use App\Observers\ScheduleObserver;
+use App\Observers\ServicePackageObserver;
 use Illuminate\Support\ServiceProvider;
 
 class AppServiceProvider extends ServiceProvider
@@ -35,7 +39,9 @@ class AppServiceProvider extends ServiceProvider
     public function boot(): void
     {
         Client::observe(ClientObserver::class);
+        Notification::observe(NotificationObserver::class);
         Provider::observe(ProviderObserver::class);
+        ServicePackage::observe(ServicePackageObserver::class);
 
         if ($this->app->environment(['local', 'development', 'dev'])) {
             Schedule::observe(ScheduleObserver::class);

+ 9 - 11
app/Rules/ScheduleBusinessRules.php

@@ -2,6 +2,7 @@
 
 namespace App\Rules;
 
+use App\Enums\BlockedPeriodEnum;
 use App\Models\ClientProviderBlock;
 use App\Models\Provider;
 use App\Models\ProviderBlockedDay;
@@ -105,19 +106,16 @@ class ScheduleBusinessRules
     public static function validateBlockedDay($provider_id, $date_ymd, $start_time, $end_time)
     {
         $blockedDay = ProviderBlockedDay::where('provider_id', $provider_id)
-            ->where('date', $date_ymd)
+            ->whereDate('date', $date_ymd)
             ->where(function ($query) use ($start_time, $end_time) {
-                $query->where('period', 'full')
+                $query->where('period', BlockedPeriodEnum::ALL->value)
                     ->orWhere(function ($q) use ($start_time, $end_time) {
-                        $q->where('period', 'partial')
-                            ->where(function ($q2) use ($start_time, $end_time) {
-                                $q2->whereBetween('init_hour', [$start_time, $end_time])
-                                    ->orWhereBetween('end_hour', [$start_time, $end_time])
-                                    ->orWhere(function ($q3) use ($start_time, $end_time) {
-                                        $q3->where('init_hour', '<=', $start_time)
-                                            ->where('end_hour', '>=', $end_time);
-                                    });
-                            });
+                        $q->whereIn('period', [
+                            BlockedPeriodEnum::MORNING->value,
+                            BlockedPeriodEnum::AFTERNOON->value,
+                        ])
+                            ->where('init_hour', '<', $end_time)
+                            ->where('end_hour', '>', $start_time);
                     });
             })
             ->first();

+ 240 - 1
app/Services/CustomScheduleService.php

@@ -2,7 +2,12 @@
 
 namespace App\Services;
 
+use App\Broadcasting\RealtimeEvent;
+use App\Broadcasting\RealtimeRoom;
+use App\Broadcasting\RealtimeService;
+use App\Enums\ApprovalStatusEnum;
 use App\Enums\NotificationTypeEnum;
+use App\Jobs\NotifyProvidersOfNewOpportunityJob;
 use App\Models\Address;
 use App\Models\CustomSchedule;
 use App\Models\CustomScheduleSpeciality;
@@ -20,6 +25,7 @@ use App\Rules\ScheduleBusinessRules;
 use App\Services\NotificationService;
 use App\Services\DistanceService;
 use Carbon\Carbon;
+use Illuminate\Support\Collection;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Storage;
@@ -30,6 +36,7 @@ class CustomScheduleService
 
     public function __construct(
         private readonly ZipCodeCoordinatesService $zipCodeCoordinatesService,
+        private readonly RealtimeService $realtime,
     ) {}
 
     public function getAll()
@@ -119,6 +126,8 @@ class CustomScheduleService
 
             DB::commit();
 
+            $this->dispatchOpportunityNotification($createdCustomSchedules);
+
             return $createdCustomSchedules;
         } catch (\Exception $e) {
             DB::rollBack();
@@ -367,6 +376,28 @@ class CustomScheduleService
         return $availableOpportunities->values();
     }
 
+    /**
+     * @return Collection<int, int>
+     */
+    public function getAvailableProvidersForOpportunity(Schedule $schedule): Collection
+    {
+        $schedule->loadMissing(['customSchedule', 'address']);
+
+        $candidateIds = $this->getCandidateProviderIdsForOpportunity($schedule);
+
+        if ($candidateIds->isEmpty()) {
+            return $candidateIds;
+        }
+
+        return $candidateIds->filter(function ($providerId) use ($schedule) {
+            try {
+                return $this->checkProviderAvailability($providerId, $schedule);
+            } catch (\Exception $e) {
+                return false;
+            }
+        })->values();
+    }
+
     public function getOpportunityProposals($scheduleId)
     {
         return ScheduleProposal::with(['provider.user'])
@@ -464,10 +495,22 @@ class CustomScheduleService
 
         $this->sendProposalReceivedPush($schedule, $provider->user->name);
 
-        return ScheduleProposal::create([
+        $proposal = ScheduleProposal::create([
             'schedule_id' => $scheduleId,
             'provider_id' => $providerId,
         ]);
+
+        $this->realtime->emit(
+            RealtimeEvent::PROPOSAL_CREATED,
+            $this->proposalRooms($schedule, $provider),
+            [
+                'entity'      => 'schedule_proposal',
+                'id'          => $proposal->id,
+                'schedule_id' => $schedule->id,
+            ],
+        );
+
+        return $proposal;
     }
 
     private function sendProposalReceivedPush(Schedule $schedule, string $providerName): void
@@ -538,9 +581,43 @@ class CustomScheduleService
             }
         }
 
+        $this->realtime->emit(
+            RealtimeEvent::PROPOSAL_REFUSED,
+            $this->proposalRooms($schedule, $provider),
+            [
+                'entity'      => 'schedule_refuse',
+                'id'          => $schedule_refuse->id,
+                'schedule_id' => $schedule->id,
+                'actor'       => 'provider',
+            ],
+        );
+
         return $schedule_refuse;
     }
 
+    /**
+     * Salas dos dois lados de uma proposta, mais quem estiver com o
+     * agendamento aberto.
+     *
+     * @return RealtimeRoom[]
+     */
+    private function proposalRooms(Schedule $schedule, Provider $provider): array
+    {
+        $rooms = [
+            RealtimeRoom::schedule($schedule->id),
+        ];
+
+        if ($schedule->client?->user_id) {
+            $rooms[] = RealtimeRoom::user($schedule->client->user_id);
+        }
+
+        if ($provider->user_id) {
+            $rooms[] = RealtimeRoom::user($provider->user_id);
+        }
+
+        return $rooms;
+    }
+
     //
 
     public function acceptProposal($proposalId)
@@ -595,6 +672,17 @@ class CustomScheduleService
                 'schedule_id' => $schedule->id,
             ]);
 
+            $this->realtime->emit(
+                RealtimeEvent::PROPOSAL_ACCEPTED,
+                $this->proposalRooms($schedule, $provider),
+                [
+                    'entity'             => 'schedule_proposal',
+                    'id'                 => $proposalId,
+                    'schedule_id'        => $schedule->id,
+                    'service_package_id' => $servicePackage->id,
+                ],
+            );
+
             return $servicePackage->fresh([
                 'items.schedule.client.user',
                 'items.schedule.provider.user',
@@ -660,8 +748,33 @@ class CustomScheduleService
                 }
             }
 
+            $scheduleId = $proposal->schedule_id;
+
+            $rooms = [
+                RealtimeRoom::schedule($scheduleId),
+            ];
+
+            if ($proposal->schedule?->client?->user_id) {
+                $rooms[] = RealtimeRoom::user($proposal->schedule->client->user_id);
+            }
+
+            if ($proposal->provider?->user_id) {
+                $rooms[] = RealtimeRoom::user($proposal->provider->user_id);
+            }
+
             $proposal->delete();
 
+            $this->realtime->emit(
+                RealtimeEvent::PROPOSAL_REFUSED,
+                $rooms,
+                [
+                    'entity'      => 'schedule_proposal',
+                    'id'          => $proposalId,
+                    'schedule_id' => $scheduleId,
+                    'actor'       => 'client',
+                ],
+            );
+
             return true;
         });
     }
@@ -777,6 +890,132 @@ class CustomScheduleService
 
     //
 
+    /**
+     * @return Collection<int, int>
+     */
+    private function getCandidateProviderIdsForOpportunity(Schedule $schedule): Collection
+    {
+        $address = Address::find($schedule->address_id);
+
+        $cityId = $address?->city_id;
+        $lat    = $address?->latitude !== null ? (float) $address->latitude : null;
+        $lng    = $address?->longitude !== null ? (float) $address->longitude : null;
+
+        if ($cityId === null && ($lat === null || $lng === null)) {
+            Log::warning('Oportunidade sem geolocalizacao; nenhum prestador elegivel', [
+                'schedule_id' => $schedule->id,
+                'address_id'  => $schedule->address_id,
+            ]);
+
+            return collect();
+        }
+
+        $periodType = (string) $schedule->period_type;
+
+        $factor = match ($periodType) {
+            '2'     => 0.30,
+            '4'     => 0.55,
+            '6'     => 0.85,
+            '8'     => 1.00,
+            default => null,
+        };
+
+        if ($factor === null) {
+            return collect();
+        }
+
+        $priceColumn = "providers.daily_price_{$periodType}h";
+
+        $date      = Carbon::parse($schedule->date);
+        $dayOfWeek = $date->dayOfWeek;
+
+        $period = $schedule->start_time < '13:00:00' ? 'morning' : 'afternoon';
+
+        $minProportional = (float) $schedule->customSchedule->min_price * $factor;
+        $maxProportional = (float) $schedule->customSchedule->max_price * $factor;
+
+        $providerAddressSubquery = DB::raw("
+            (
+                SELECT DISTINCT ON (source_id)
+                    *
+                FROM addresses
+                WHERE
+                    source = 'provider'
+                    AND deleted_at IS NULL
+                ORDER BY
+                    source_id,
+                    (latitude IS NOT NULL AND longitude IS NOT NULL) DESC,
+                    is_primary DESC,
+                    id DESC
+            ) AS provider_address
+        ");
+
+        return Provider::query()
+            ->join($providerAddressSubquery, 'provider_address.source_id', '=', 'providers.id')
+            ->where('providers.approval_status', ApprovalStatusEnum::ACCEPTED->value)
+
+            ->where(function ($query) use ($cityId, $lat, $lng) {
+                if ($cityId !== null) {
+                    $query->orWhere('provider_address.city_id', $cityId);
+                }
+
+                if ($lat !== null && $lng !== null) {
+                    $query->orWhereRaw(
+                        DistanceService::withinRadiusSqlCondition(
+                            $lat,
+                            $lng,
+                            self::NEARBY_RADIUS_KM,
+                            'provider_address.latitude',
+                            'provider_address.longitude',
+                        )
+                    );
+                }
+            })
+
+            ->whereExists(function ($query) use ($dayOfWeek, $period) {
+                $query->select(DB::raw(1))
+                    ->from('provider_working_days')
+                    ->whereColumn('provider_working_days.provider_id', 'providers.id')
+                    ->where('provider_working_days.day', $dayOfWeek)
+                    ->where('provider_working_days.period', $period)
+                    ->whereNull('provider_working_days.deleted_at');
+            })
+
+            ->whereNotNull($priceColumn)
+            ->whereBetween($priceColumn, [$minProportional, $maxProportional])
+
+            ->whereNotIn(
+                'providers.id',
+                ScheduleBusinessRules::getBlockedProviderIdsForClient($schedule->client_id)
+            )
+
+            ->pluck('providers.id');
+    }
+
+    private function dispatchOpportunityNotification(array $customSchedules): void
+    {
+        $scheduleIds = [];
+
+        try {
+            $scheduleIds = collect($customSchedules)
+                ->pluck('schedule_id')
+                ->filter()
+                ->values()
+                ->all();
+
+            if (empty($scheduleIds)) {
+                return;
+            }
+
+            NotifyProvidersOfNewOpportunityJob::dispatch($scheduleIds);
+        } catch (\Throwable $exception) {
+            Log::error('Falha ao enfileirar notificacao de nova oportunidade', [
+                'schedule_ids' => $scheduleIds,
+                'error'        => $exception->getMessage(),
+            ]);
+        }
+    }
+
     private function checkProviderAvailability($providerId, $schedule)
     {
         $client_id   = $schedule->client_id;

+ 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';
+    }
+}

+ 17 - 1
app/Services/NotificationService.php

@@ -2,11 +2,17 @@
 
 namespace App\Services;
 
+use App\Broadcasting\RealtimeEvent;
+use App\Broadcasting\RealtimeService;
 use App\Models\Notification;
 use Illuminate\Database\Eloquent\Collection;
 
 class NotificationService
 {
+    public function __construct(
+        private RealtimeService $realtime
+    ) {}
+
     public function getByUser(int $userId): Collection
     {
         return Notification::where('user_id', $userId)
@@ -44,12 +50,22 @@ class NotificationService
 
     public function markAllAsRead(int $userId): void
     {
-        Notification::where('user_id', $userId)
+        $updated = Notification::where('user_id', $userId)
             ->where('read', false)
             ->update([
                 'read'    => true,
                 'read_at' => now(),
             ]);
+
+        if ($updated === 0) {
+            return;
+        }
+
+        $this->realtime->emitToUser(
+            RealtimeEvent::NOTIFICATION_READ_ALL,
+            $userId,
+            ['entity' => 'notification', 'count' => $updated],
+        );
     }
 
     public function unreadCount(int $userId): int

+ 9 - 11
app/Services/PaymentService.php

@@ -94,7 +94,6 @@ class PaymentService
     if (! in_array($paymentMethod, ['credit_card', 'pix'], true)) {
       throw new PaymentException;
     }
-    Log::info('1');
     $paymentData = DB::transaction(function () use (
       $servicePackageId,
       $userId,
@@ -110,7 +109,6 @@ class PaymentService
       if ($servicePackage->client?->user_id !== $userId) {
         throw new AuthorizationException;
       }
-Log::info('2');
       $schedules = $this->activePackageSchedules($servicePackage);
 
       $this->validateServicePackageForPayment($servicePackage, $schedules);
@@ -125,8 +123,8 @@ Log::info('2');
         ])
         ->latest('id')
         ->first();
-Log::info('3');
-      if ($existingPayment) {
+
+        if ($existingPayment) {
         if ($this->isExpiredPixPayment($existingPayment)) {
           $existingPayment->forceFill([
             'status'          => PaymentStatusEnum::FAILED,
@@ -141,7 +139,7 @@ Log::info('3');
           if ($existingPayment->payment_method !== $paymentMethod) {
             throw new PaymentException;
           }
-Log::info('4');
+
           [, $cardId] = $this->resolveCard(
             clientId: $servicePackage->client_id,
             paymentMethod: $paymentMethod,
@@ -150,7 +148,7 @@ Log::info('4');
           );
 
           $this->servicePackagePaymentTotals($schedules, $paymentMethod);
-Log::info('5');
+
           return [
             'payment'   => $existingPayment,
             'schedules' => $schedules,
@@ -164,7 +162,7 @@ Log::info('5');
           return ['existing' => $existingPayment];
         }
       }
-Log::info('6');
+
       if ($servicePackage->status !== ServicePackageStatusEnum::OPEN) {
         throw new PaymentException;
       }
@@ -175,7 +173,7 @@ Log::info('6');
         clientPaymentMethodId: $clientPaymentMethodId,
         cardId: data_get($options, 'card_id'),
       );
-Log::info('7');
+
       $totals = $this->servicePackagePaymentTotals($schedules, $paymentMethod);
 
       $payment = Payment::create([
@@ -203,7 +201,7 @@ Log::info('7');
           'platform_fee'       => number_format(data_get($totals, 'platform_fee_amount'), 2, '.', ''),
         ],
       ]);
-Log::info('8');
+
       PaymentSplit::create([
         'payment_id'                        => $payment->id,
         'provider_id'                       => $servicePackage->provider_id,
@@ -223,10 +221,10 @@ Log::info('8');
 
       return compact('payment', 'schedules', 'cardId');
     });
-Log::info('9');
+
     if (data_get($paymentData, 'existing')) {
       $existingPayment = data_get($paymentData, 'existing');
-Log::info('10');
+
       $this->syncPaymentTargets($existingPayment);
 
       return $existingPayment->fresh(['client.user', 'provider.user', 'servicePackage.items.schedule']);

+ 6 - 65
app/Services/PushNotificationDispatcher.php

@@ -4,30 +4,9 @@ namespace App\Services;
 
 use App\Models\PushNotificationLog;
 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;
 
 class PushNotificationDispatcher
@@ -35,53 +14,15 @@ class PushNotificationDispatcher
     public function __construct(private PushNotificationService $pushService) {}
 
     /**
-     * Lista central de todas as notificações registradas.
-     * Para adicionar uma nova: basta instanciá-la aqui.
-     *
      * @return BasePushNotification[]
      */
     public function all(): array
     {
         return [
-            // Prestador
-            new ReforcoEducativo1Push,
-            new ReforcoEducativo2Push,
-            new PrestadorMotivacional1Push,
-
             // 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([
             'label'    => $notification->label(),
+            'title'    => $notification->title(),
+            'body'     => $notification->body(),
             'user_id'  => $user->id,
             'target'   => $notification->target()->value,
             'category' => $notification->category()->value,

+ 102 - 36
app/Services/ScheduleService.php

@@ -2,6 +2,9 @@
 
 namespace App\Services;
 
+use App\Broadcasting\RealtimeEvent;
+use App\Broadcasting\RealtimeRoom;
+use App\Broadcasting\RealtimeService;
 use App\Exceptions\ScheduleStatusTransitionException;
 use App\Enums\ServicePackageStatusEnum;
 use App\Enums\UserTypeEnum;
@@ -29,6 +32,10 @@ class ScheduleService
 {
     private const EXCLUDED_STATUSES = ['cancelled', 'rejected'];
 
+    public function __construct(
+        private readonly RealtimeService $realtime
+    ) {}
+
     public function getAll()
     {
         return Schedule::with(['client.user', 'provider.user', 'address'])
@@ -98,6 +105,17 @@ class ScheduleService
                 }
 
 
+                $this->realtime->emit(
+                    RealtimeEvent::SCHEDULE_CREATED,
+                    $this->scheduleRooms($newSchedule),
+                    [
+                        'entity'        => 'schedule',
+                        'id'            => $newSchedule->id,
+                        'status'        => $newSchedule->status,
+                        'schedule_type' => $newSchedule->schedule_type,
+                    ],
+                );
+
                 $createdSchedules[] = $newSchedule;
             }
 
@@ -357,6 +375,20 @@ class ScheduleService
                     break;
             }
 
+            $actor = Auth::user()?->type;
+
+            $this->realtime->emit(
+                RealtimeEvent::SCHEDULE_STATUS_CHANGED,
+                $this->scheduleRooms($schedule),
+                [
+                    'entity'        => 'schedule',
+                    'id'            => $schedule->id,
+                    'status'        => $status,
+                    'schedule_type' => $schedule->schedule_type,
+                    'actor'         => $actor instanceof UserTypeEnum ? strtolower($actor->value) : 'system',
+                ],
+            );
+
             DB::commit();
 
             return $schedule->fresh(['client.user', 'provider.user', 'address']);
@@ -373,46 +405,66 @@ class ScheduleService
         }
     }
 
+    /**
+     * @return RealtimeRoom[]
+     */
+    private function scheduleRooms(Schedule $schedule): array
+    {
+        $rooms = [
+            RealtimeRoom::schedule($schedule->id),
+        ];
+
+        if ($schedule->client?->user_id) {
+            $rooms[] = RealtimeRoom::user($schedule->client->user_id);
+        }
+
+        if ($schedule->provider?->user_id) {
+            $rooms[] = RealtimeRoom::user($schedule->provider->user_id);
+        }
+
+        return $rooms;
+    }
+
     //
 
     public function getClientProviderBlocks(int $clientId, int $providerId): array
     {
-        $today = Carbon::today()->format('Y-m-d');
-
-        $schedules = Schedule::where('client_id', $clientId)
-            ->where('provider_id', $providerId)
-            ->whereNotIn('status', self::EXCLUDED_STATUSES)
-            ->whereDate('date', '>=', $today)
-            ->orderBy('date')
-            ->orderBy('start_time')
-            ->get(['id', 'date', 'start_time', 'end_time', 'status']);
-
-        $existingSchedules = $schedules->map(function ($schedule) {
-            return [
-                'id'         => $schedule->id,
-                'date'       => Carbon::parse($schedule->date)->format('Y-m-d'),
-                'start_time' => $schedule->start_time,
-                'end_time'   => $schedule->end_time,
-                'status'     => $schedule->status,
-            ];
-        })->values();
-
-        $fullyBlockedWeeks = $schedules
-            ->groupBy(function ($schedule) {
-                return Carbon::parse($schedule->date)
-                    ->startOfWeek(Carbon::SUNDAY)
-                    ->format('Y-m-d');
-            })
-            ->filter(function ($weekSchedules) {
-                return $weekSchedules->count() >= 2;
-            })
-            ->keys()
-            ->values();
-
-        return [
-            'existing_schedules'  => $existingSchedules,
-            'fully_blocked_weeks' => $fullyBlockedWeeks,
-        ];
+      $weekStart = Carbon::today()->startOfWeek(Carbon::SUNDAY)->format('Y-m-d');
+
+      $schedules = Schedule::where('client_id', $clientId)
+          ->where('provider_id', $providerId)
+          ->whereNotIn('status', self::EXCLUDED_STATUSES)
+          ->whereDate('date', '>=', $weekStart)
+          ->orderBy('date')
+          ->orderBy('start_time')
+          ->get(['id', 'date', 'start_time', 'end_time', 'status']);
+
+      $existingSchedules = $schedules->map(function ($schedule) {
+          return [
+              'id'         => $schedule->id,
+              'date'       => Carbon::parse($schedule->date)->format('Y-m-d'),
+              'start_time' => $schedule->start_time,
+              'end_time'   => $schedule->end_time,
+              'status'     => $schedule->status,
+          ];
+      })->values();
+
+      $fullyBlockedWeeks = $schedules
+          ->groupBy(function ($schedule) {
+              return Carbon::parse($schedule->date)
+                  ->startOfWeek(Carbon::SUNDAY)
+                  ->format('Y-m-d');
+          })
+          ->filter(function ($weekSchedules) {
+              return $weekSchedules->count() >= 2;
+          })
+          ->keys()
+          ->values();
+
+      return [
+          'existing_schedules'  => $existingSchedules,
+          'fully_blocked_weeks' => $fullyBlockedWeeks,
+      ];
     }
 
     public function getFinished()
@@ -519,6 +571,20 @@ class ScheduleService
 
             $this->cascadeCancelServicePackages($schedule, $cancelText, $cancelled_by);
 
+            $actor = Auth::user()?->type;
+
+            $this->realtime->emit(
+                RealtimeEvent::SCHEDULE_STATUS_CHANGED,
+                $this->scheduleRooms($schedule),
+                [
+                    'entity'        => 'schedule',
+                    'id'            => $schedule->id,
+                    'status'        => 'cancelled',
+                    'schedule_type' => $schedule->schedule_type,
+                    'actor'         => $actor instanceof UserTypeEnum ? strtolower($actor->value) : 'system',
+                ],
+            );
+
             DB::commit();
 
             return $schedule->fresh(['client.user', 'provider.user', 'address']);

+ 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) {
         //

+ 7 - 0
config/realtime.php

@@ -0,0 +1,7 @@
+<?php
+
+return [
+
+    'project' => env('REALTIME_PROJECT', 'diaria'),
+
+];

+ 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,
                 'children'    => [],
             ],
+            [
+                'scope'       => 'push.notification',
+                'description' => 'Pushs',
+                'bits'        => 259,
+                'children'    => [],
+            ],
         ];
 
         $this->createPermissionsAndChildren(permissions: $permissions);

+ 1 - 0
database/seeders/UserTypePermissionSeeder.php

@@ -58,6 +58,7 @@ class UserTypePermissionSeeder extends Seeder
                         ['scope' => 'payment',                         'bits' => 257],
                         ['scope' => 'payment-split',                   'bits' => 1],
                         ['scope' => 'support.request',                 'bits' => 271],
+                        ['scope' => 'push.notification',               'bits' => 259],
                     ];
 
                     $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_already_favorite'                 => 'This cleaner is already in 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_already_favorite'                 => 'Este limpiador ya está en 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_already_favorite'                 => 'Este diarista já está nos seus favoritos.',
     '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');