14 Revīzijas acefced319 ... 9db741f703

Autors SHA1 Ziņojums Datums
  Gustavo Zanatta 9db741f703 implementacao didit - validacao de documentos 3 dienas atpakaļ
  Gustavo Zanatta 7ecda61019 fix timezone prod + add env key 4 dienas atpakaļ
  zntt f51d0bdcf0 Merge branch 'fix/diaria-kay-correções-backend' of Softpar/sfp_api_laravel_diarista into development 4 dienas atpakaļ
  kayo henrique 13f08a4151 fix: :bug: fix(correções backend) Foi ajustado o envio da notificação por push de pagamento 4 dienas atpakaļ
  Gustavo Zanatta e43914a6e3 manual pushs no backoffice 4 dienas atpakaļ
  Gustavo Zanatta b12cc9477d push ao criar sob medida, enviado para prestadores que podem aceitar a oportunidade, com base na availability 1 nedēļu atpakaļ
  Gustavo Zanatta eab28a61fa websocket 1 nedēļu atpakaļ
  Gustavo Zanatta 1b8bb59f2c remove logs 1 nedēļu atpakaļ
  zntt a03e443e76 Merge branch 'fix/diaria-kay-correções-backend' of Softpar/sfp_api_laravel_diarista into development 1 nedēļu atpakaļ
  Gustavo Zanatta 1ba50e9ce5 feat: listagem propostas enviadas pelo prestador 1 nedēļu atpakaļ
  Gustavo Zanatta 087920c3c8 ajustes avaliacao: max 5 files, so permite anexar foto em avaliaca negativa, nao tava exibindo foto do avaliado, pré-seleciona favorito se ja era antes 1 nedēļu atpakaļ
  Gustavo Zanatta 3d8ce14031 fix avatar na avaliacao 1 nedēļu atpakaļ
  kayo henrique 2e34a53200 fix: :bug: fix(correções backend) Foi adicionado uma push notificação para cada status 1 nedēļu atpakaļ
  Gustavo Zanatta 5fd5ca664c fix atribuicao de finished na schedule 1 nedēļu atpakaļ
100 mainītis faili ar 3845 papildinājumiem un 1508 dzēšanām
  1. 16 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. 125 0
      app/Commands/SyncDiditSessions.php
  10. 41 0
      app/Data/Didit/DiditData.php
  11. 21 0
      app/Data/Didit/Session/Parts/Request/ContactDetailsData.php
  12. 42 0
      app/Data/Didit/Session/Parts/Request/ExpectedDetailsData.php
  13. 34 0
      app/Data/Didit/Session/SessionRequestData.php
  14. 41 0
      app/Data/Didit/Session/SessionResponseData.php
  15. 58 0
      app/Enums/IdentityVerificationStatusEnum.php
  16. 7 10
      app/Enums/PushNotificationCategoryEnum.php
  17. 6 6
      app/Http/Controllers/NotificationController.php
  18. 1 1
      app/Http/Controllers/PaymentController.php
  19. 81 0
      app/Http/Controllers/PushNotificationController.php
  20. 101 0
      app/Http/Controllers/VerificationController.php
  21. 150 0
      app/Http/Controllers/WebhookController.php
  22. 44 0
      app/Http/Middleware/EnsureClientIsVerified.php
  23. 8 1
      app/Http/Middleware/EnsureProviderIsAccepted.php
  24. 21 0
      app/Http/Requests/ManualPushRequest.php
  25. 1 0
      app/Http/Requests/RegisterClientRequest.php
  26. 0 2
      app/Http/Requests/RegisterProviderRequest.php
  27. 18 0
      app/Http/Requests/ReviewRequest.php
  28. 4 0
      app/Http/Resources/ClientResource.php
  29. 16 0
      app/Http/Resources/CustomScheduleResource.php
  30. 32 0
      app/Http/Resources/IdentityVerificationResource.php
  31. 6 0
      app/Http/Resources/ProviderResource.php
  32. 5 4
      app/Http/Resources/ReviewDetailResource.php
  33. 108 0
      app/Jobs/NotifyProvidersOfNewOpportunityJob.php
  34. 62 0
      app/Jobs/SendManualPushJob.php
  35. 54 0
      app/Jobs/SendOpportunityPushJob.php
  36. 12 0
      app/Models/Client.php
  37. 68 0
      app/Models/IdentityVerification.php
  38. 5 0
      app/Models/Provider.php
  39. 4 0
      app/Models/PushNotificationLog.php
  40. 10 0
      app/Models/User.php
  41. 17 21
      app/Notifications/Push/Cliente/Agendamento/PrestadorCancelouPush.php
  42. 22 12
      app/Notifications/Push/Cliente/Agendamento/PrestadorRecusouPush.php
  43. 0 44
      app/Notifications/Push/Cliente/Contextual/ContextualVisitaPush.php
  44. 0 44
      app/Notifications/Push/Cliente/Educativo/Educativo1Push.php
  45. 0 44
      app/Notifications/Push/Cliente/Educativo/Educativo2Push.php
  46. 0 44
      app/Notifications/Push/Cliente/Educativo/Educativo3Push.php
  47. 0 44
      app/Notifications/Push/Cliente/Educativo/Educativo4Push.php
  48. 0 64
      app/Notifications/Push/Cliente/EducativoConversao/EducativoConversao1Push.php
  49. 0 59
      app/Notifications/Push/Cliente/EducativoConversao/EducativoConversao2Push.php
  50. 12 13
      app/Notifications/Push/Cliente/Marketing/AbandonoFunilPush.php
  51. 61 0
      app/Notifications/Push/Cliente/Marketing/ClienteInativoPush.php
  52. 0 44
      app/Notifications/Push/Cliente/Marketing/Marketing1Push.php
  53. 0 44
      app/Notifications/Push/Cliente/Marketing/Marketing2Push.php
  54. 0 44
      app/Notifications/Push/Cliente/Marketing/Marketing3Push.php
  55. 0 44
      app/Notifications/Push/Cliente/Marketing/Marketing4Push.php
  56. 59 0
      app/Notifications/Push/Cliente/Marketing/RecorrenciaQuebradaPush.php
  57. 0 44
      app/Notifications/Push/Cliente/Motivacional/Motivacional1Push.php
  58. 0 44
      app/Notifications/Push/Cliente/Motivacional/Motivacional2Push.php
  59. 0 44
      app/Notifications/Push/Cliente/Motivacional/Motivacional3Push.php
  60. 0 45
      app/Notifications/Push/Cliente/Recorrencia/Recorrencia1Push.php
  61. 0 45
      app/Notifications/Push/Cliente/Recorrencia/Recorrencia2Push.php
  62. 0 64
      app/Notifications/Push/Cliente/SocialProof/SocialProof1Push.php
  63. 0 59
      app/Notifications/Push/Cliente/SocialProof/SocialProof2Push.php
  64. 62 0
      app/Notifications/Push/Manual/ManualPush.php
  65. 17 16
      app/Notifications/Push/Prestador/Agendamento/ClienteAceitouPush.php
  66. 55 0
      app/Notifications/Push/Prestador/Agendamento/ClienteCancelouPush.php
  67. 55 0
      app/Notifications/Push/Prestador/Agendamento/ClienteRecusouPush.php
  68. 64 0
      app/Notifications/Push/Prestador/Agendamento/NovaOportunidadePush.php
  69. 55 0
      app/Notifications/Push/Prestador/Pagamento/ClienteEfetuouPagamentoPush.php
  70. 0 78
      app/Notifications/Push/Prestador/ReforcoEducativo/ReforcoEducativo1Push.php
  71. 0 71
      app/Notifications/Push/Prestador/ReforcoEducativo/ReforcoEducativo2Push.php
  72. 43 0
      app/Observers/NotificationObserver.php
  73. 3 3
      app/Observers/ScheduleObserver.php
  74. 47 0
      app/Observers/ServicePackageObserver.php
  75. 6 0
      app/Providers/AppServiceProvider.php
  76. 9 11
      app/Rules/ScheduleBusinessRules.php
  77. 6 1
      app/Services/ClientService.php
  78. 313 16
      app/Services/CustomScheduleService.php
  79. 106 0
      app/Services/Didit/Concerns/SendsDiditRequests.php
  80. 106 0
      app/Services/Didit/DiditDecisionExtractor.php
  81. 259 0
      app/Services/Didit/DiditDecisionService.php
  82. 156 0
      app/Services/Didit/DiditSessionService.php
  83. 171 0
      app/Services/IdentityVerificationService.php
  84. 75 0
      app/Services/ManualPushService.php
  85. 17 1
      app/Services/NotificationService.php
  86. 19 15
      app/Services/PaymentService.php
  87. 8 24
      app/Services/ProviderService.php
  88. 6 65
      app/Services/PushNotificationDispatcher.php
  89. 2 0
      app/Services/PushNotificationService.php
  90. 275 62
      app/Services/ScheduleService.php
  91. 54 0
      app/Services/WebhookService.php
  92. 25 0
      app/Tasks/SyncStaleDiditVerifications.php
  93. 16 3
      bootstrap/app.php
  94. 1 0
      config/database.php
  95. 8 0
      config/logging.php
  96. 7 0
      config/realtime.php
  97. 16 0
      config/services.php
  98. 23 0
      database/migrations/2026_09_04_120000_add_title_and_body_to_push_notification_logs_table.php
  99. 54 0
      database/migrations/2026_09_08_100001_create_identity_verifications_table.php
  100. 46 0
      database/migrations/2026_09_08_100002_add_identity_verification_to_providers_and_clients.php

+ 16 - 1
.env.example

@@ -13,6 +13,7 @@ DB_PORT=5432
 DB_DATABASE=database
 DB_USERNAME=postgres
 DB_PASSWORD=postgres
+DB_TIMEZONE=America/Sao_Paulo
 
 CACHE_DRIVER=file
 SESSION_DRIVER=file
@@ -21,6 +22,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 +36,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
@@ -57,6 +60,18 @@ PAGARME_PLATFORM_SERVICE_PACKAGE_MIN_3_SCHEDULES_PIX_FEE_RATE=0.08
 PAGARME_TRANSFER_FEE_AMOUNT=3.67
 PAGARME_WITHDRAWAL_RELEASE_DAYS=2
 
+DIDIT_BASE_URL=https://verification.didit.me
+DIDIT_API_KEY=
+DIDIT_WEBHOOK_SECRET=
+DIDIT_WORKFLOW_PROVIDER=
+DIDIT_WORKFLOW_CLIENT=
+DIDIT_CALLBACK_URL=
+DIDIT_CONNECT_TIMEOUT=5
+DIDIT_TIMEOUT=15
+DIDIT_MAX_ATTEMPTS=3
+DIDIT_LIVENESS_MIN_SCORE=80
+DIDIT_FACE_MATCH_MIN_SCORE=85
+
 GEMINI_API_KEY=
 GEMINI_MODEL=
 

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

+ 125 - 0
app/Commands/SyncDiditSessions.php

@@ -0,0 +1,125 @@
+<?php
+
+namespace App\Commands;
+
+use App\Enums\IdentityVerificationStatusEnum;
+use App\Models\IdentityVerification;
+use App\Services\Didit\DiditDecisionService;
+use App\Services\Didit\DiditSessionService;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Log;
+use Throwable;
+
+/**
+ * Reconcilia verificacoes cujo webhook nao chegou.
+ *
+ * O Didit tenta entregar 3 vezes (imediata, ~1min, ~4min) e desiste. Se a API
+ * estiver fora do ar nessa janela, a verificacao fica presa em Not Started ou
+ * In Progress para sempre. Este comando busca a decisao pela API e aplica o
+ * mesmo caminho do webhook.
+ */
+class SyncDiditSessions extends Command
+{
+    protected $signature = 'didit:sync
+        {session_id? : Sessao especifica a reconciliar}
+        {--stale-hours=6 : Idade minima, em horas, para considerar uma verificacao travada}
+        {--limit=50 : Maximo de verificacoes por execucao}
+        {--dry-run : Apenas lista o que seria reconciliado}';
+
+    protected $description = 'Reconcilia verificacoes de identidade cujo webhook do Didit nao chegou';
+
+    public function __construct(
+        private readonly DiditSessionService $sessionService,
+        private readonly DiditDecisionService $decisionService,
+    ) {
+        parent::__construct();
+    }
+
+    public function handle(): int
+    {
+        $verifications = $this->targets();
+
+        if ($verifications->isEmpty()) {
+            $this->info('Nenhuma verificacao para reconciliar.');
+
+            return self::SUCCESS;
+        }
+
+        $this->info("Verificacoes a reconciliar: {$verifications->count()}");
+
+        if ($this->option('dry-run')) {
+            $this->table(
+                ['id', 'session_id', 'status', 'usuario', 'iniciada em'],
+                $verifications->map(fn (IdentityVerification $v) => [
+                    $v->id, $v->session_id, $v->didit_status, $v->user_id, (string) $v->started_at,
+                ])->all(),
+            );
+
+            return self::SUCCESS;
+        }
+
+        $reconciled = 0;
+        $unchanged  = 0;
+        $failed     = 0;
+
+        foreach ($verifications as $verification) {
+            try {
+                $decision = $this->sessionService->getDecision($verification->session_id);
+                $status   = (string) data_get($decision, 'status');
+
+                if ($status === '' || $status === $verification->didit_status) {
+                    $unchanged++;
+
+                    continue;
+                }
+
+                // Mesmo caminho do webhook: o payload real tambem traz o decision inteiro.
+                $this->decisionService->handleWebhook([
+                    'session_id'   => $verification->session_id,
+                    'webhook_type' => 'status.updated',
+                    'status'       => $status,
+                    'vendor_data'  => (string) $verification->user_id,
+                    'workflow_id'  => $verification->workflow_id,
+                    'decision'     => $decision,
+                ]);
+
+                $this->line("  #{$verification->id} {$verification->didit_status} -> {$status}");
+
+                $reconciled++;
+            } catch (Throwable $e) {
+                $failed++;
+
+                $this->error("  #{$verification->id}: {$e->getMessage()}");
+
+                Log::channel('didit')->error('Falha ao reconciliar sessao', [
+                    'session_id' => $verification->session_id,
+                    'exception'  => $e->getMessage(),
+                ]);
+            }
+        }
+
+        $this->newLine();
+        $this->info("Reconciliadas: {$reconciled} | sem mudanca: {$unchanged} | falhas: {$failed}");
+
+        return $failed > 0 ? self::FAILURE : self::SUCCESS;
+    }
+
+    //
+
+    /** @return \Illuminate\Support\Collection<int, IdentityVerification> */
+    private function targets()
+    {
+        $query = IdentityVerification::query();
+
+        if ($sessionId = $this->argument('session_id')) {
+            return $query->where('session_id', $sessionId)->get();
+        }
+
+        return $query
+            ->whereIn('didit_status', ['Not Started', 'In Progress'])
+            ->where('started_at', '<=', now()->subHours((int) $this->option('stale-hours')))
+            ->orderBy('started_at')
+            ->limit((int) $this->option('limit'))
+            ->get();
+    }
+}

+ 41 - 0
app/Data/Didit/DiditData.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace App\Data\Didit;
+
+abstract readonly class DiditData
+{
+    abstract public function toArray(): array;
+
+    protected static function digits(?string $value): string
+    {
+        return preg_replace('/\D+/', '', (string) $value) ?? '';
+    }
+
+    protected function filterFilledRecursive(array $data): array
+    {
+        $filtered = [];
+
+        foreach ($data as $key => $value) {
+            if ($value instanceof self) {
+                $value = $value->toArray();
+            }
+
+            if (is_array($value)) {
+                $value = $this->filterFilledRecursive($value);
+            }
+
+            if ($value !== null && $value !== '' && $value !== []) {
+                $filtered[$key] = $value;
+            }
+        }
+
+        return $filtered;
+    }
+
+    protected static function requireFilled(mixed $value, string $field): void
+    {
+        if ($value === null || $value === '' || $value === []) {
+            throw new \InvalidArgumentException("{$field} e obrigatorio.");
+        }
+    }
+}

+ 21 - 0
app/Data/Didit/Session/Parts/Request/ContactDetailsData.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Data\Didit\Session\Parts\Request;
+
+use App\Data\Didit\DiditData;
+
+final readonly class ContactDetailsData extends DiditData
+{
+    public function __construct(
+        public ?string $email = null,
+        public ?string $phone = null,
+    ) {}
+
+    public function toArray(): array
+    {
+        return $this->filterFilledRecursive([
+            'email' => $this->email,
+            'phone' => $this->phone,
+        ]);
+    }
+}

+ 42 - 0
app/Data/Didit/Session/Parts/Request/ExpectedDetailsData.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Data\Didit\Session\Parts\Request;
+
+use App\Data\Didit\DiditData;
+
+final readonly class ExpectedDetailsData extends DiditData
+{
+    public function __construct(
+        public ?string $firstName            = null,
+        public ?string $lastName             = null,
+        public ?string $dateOfBirth          = null,
+        public ?string $identificationNumber = null,
+        public string  $idCountry            = 'BRA',
+    ) {}
+
+    public static function fromName(
+        ?string $fullName,
+        ?string $dateOfBirth = null,
+        ?string $document = null,
+    ): self {
+        $parts = preg_split('/\s+/', trim((string) $fullName), 2) ?: [];
+
+        return new self(
+            firstName:            $parts[0] ?? null,
+            lastName:             $parts[1] ?? null,
+            dateOfBirth:          $dateOfBirth,
+            identificationNumber: $document ? self::digits($document) : null,
+        );
+    }
+
+    public function toArray(): array
+    {
+        return $this->filterFilledRecursive([
+            'first_name'            => $this->firstName,
+            'last_name'             => $this->lastName,
+            'date_of_birth'         => $this->dateOfBirth,
+            'identification_number' => $this->identificationNumber,
+            'id_country'            => $this->idCountry,
+        ]);
+    }
+}

+ 34 - 0
app/Data/Didit/Session/SessionRequestData.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace App\Data\Didit\Session;
+
+use App\Data\Didit\DiditData;
+use App\Data\Didit\Session\Parts\Request\ContactDetailsData;
+use App\Data\Didit\Session\Parts\Request\ExpectedDetailsData;
+
+final readonly class SessionRequestData extends DiditData
+{
+    public function __construct(
+        public string               $workflowId,
+        public string               $vendorData,
+        public ?string              $callback        = null,
+        public string               $language        = 'pt',
+        public ?ExpectedDetailsData $expectedDetails = null,
+        public ?ContactDetailsData  $contactDetails  = null,
+    ) {
+        self::requireFilled($this->workflowId, 'workflow_id');
+        self::requireFilled($this->vendorData, 'vendor_data');
+    }
+
+    public function toArray(): array
+    {
+        return $this->filterFilledRecursive([
+            'workflow_id'      => $this->workflowId,
+            'vendor_data'      => $this->vendorData,
+            'callback'         => $this->callback,
+            'language'         => $this->language,
+            'expected_details' => $this->expectedDetails,
+            'contact_details'  => $this->contactDetails,
+        ]);
+    }
+}

+ 41 - 0
app/Data/Didit/Session/SessionResponseData.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace App\Data\Didit\Session;
+
+use App\Data\Didit\DiditData;
+
+final readonly class SessionResponseData extends DiditData
+{
+    public function __construct(
+        public string  $sessionId,
+        public string  $url,
+        public ?string $sessionToken = null,
+        public ?string $workflowId   = null,
+        public ?string $status       = null,
+        public ?string $vendorData   = null,
+    ) {}
+
+    public static function fromResponse(array $response): self
+    {
+        return new self(
+            sessionId:    (string) data_get($response, 'session_id'),
+            url:          (string) data_get($response, 'url'),
+            sessionToken: data_get($response, 'session_token'),
+            workflowId:   data_get($response, 'workflow_id'),
+            status:       data_get($response, 'status'),
+            vendorData:   data_get($response, 'vendor_data'),
+        );
+    }
+
+    public function toArray(): array
+    {
+        return [
+            'session_id'    => $this->sessionId,
+            'url'           => $this->url,
+            'session_token' => $this->sessionToken,
+            'workflow_id'   => $this->workflowId,
+            'status'        => $this->status,
+            'vendor_data'   => $this->vendorData,
+        ];
+    }
+}

+ 58 - 0
app/Enums/IdentityVerificationStatusEnum.php

@@ -0,0 +1,58 @@
+<?php
+
+namespace App\Enums;
+
+use App\Traits\EnumHelper;
+
+enum IdentityVerificationStatusEnum: string
+{
+    use EnumHelper;
+
+    case NOT_STARTED = 'not_started';
+    case PENDING     = 'pending';
+    case APPROVED    = 'approved';
+    case IN_REVIEW   = 'in_review';
+    case DECLINED    = 'declined';
+    case EXPIRED     = 'expired';
+
+    public static function fromDidit(string $diditStatus): self
+    {
+        return match ($diditStatus) {
+            'Approved'                     => self::APPROVED,
+            'Declined'                     => self::DECLINED,
+            'In Review', 'Resubmitted'     => self::IN_REVIEW,
+            'Not Started', 'In Progress',
+            'Awaiting User'                => self::PENDING,
+            'Abandoned', 'Expired'         => self::NOT_STARTED,
+            'Kyc Expired'                  => self::EXPIRED,
+            default                        => self::PENDING,
+        };
+    }
+
+    public static function diditTerminalStatuses(): array
+    {
+        return ['Approved', 'Declined', 'In Review', 'Abandoned'];
+    }
+
+    public function isFinished(): bool
+    {
+        return in_array($this, [self::APPROVED, self::DECLINED, self::IN_REVIEW], true);
+    }
+
+    public function allowsNewAttempt(): bool
+    {
+        return in_array($this, [self::NOT_STARTED, self::DECLINED, self::EXPIRED], true);
+    }
+
+    public function label(): string
+    {
+        return match ($this) {
+            self::NOT_STARTED => __('identity_verification.status.not_started'),
+            self::PENDING     => __('identity_verification.status.pending'),
+            self::APPROVED    => __('identity_verification.status.approved'),
+            self::IN_REVIEW   => __('identity_verification.status.in_review'),
+            self::DECLINED    => __('identity_verification.status.declined'),
+            self::EXPIRED     => __('identity_verification.status.expired'),
+        };
+    }
+}

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

+ 101 - 0
app/Http/Controllers/VerificationController.php

@@ -0,0 +1,101 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Http\Resources\IdentityVerificationResource;
+use App\Services\IdentityVerificationService;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Throwable;
+
+class VerificationController extends Controller
+{
+    public function __construct(
+        private readonly IdentityVerificationService $service,
+    ) {}
+
+    public function createSession(Request $request): JsonResponse
+    {
+        try {
+            return $this->successResponse(
+                payload: $this->service->startFor($request->user(), $request->boolean('native')),
+                message: __('identity_verification.session_created'),
+                code:    201,
+            );
+        } catch (Throwable $e) {
+            return $this->errorResponse(message: $e->getMessage(), code: 422);
+        }
+    }
+
+    public function callback(Request $request)
+    {
+        $schemes = [
+            'client'   => 'br.com.diarista.client',
+            'provider' => 'br.com.diarista.provider',
+        ];
+
+        return response()
+            ->view('verification.callback', [
+                'status' => $request->query('status'),
+                'scheme' => $schemes[$request->query('app')] ?? null,
+            ])
+            ->header('Cache-Control', 'no-store');
+    }
+
+    public function me(Request $request): JsonResponse
+    {
+        return $this->successResponse(payload: $this->service->statusFor($request->user()));
+    }
+
+    public function pending(Request $request): JsonResponse
+    {
+        return $this->successResponse(
+            payload: $this->service->pending(
+                perPage: (int) $request->integer('per_page', 10),
+                page:    (int) $request->integer('page', 1),
+            ),
+        );
+    }
+
+    public function show(int $id): JsonResponse
+    {
+        $verification = $this->service->findWithFreshDecision($id);
+
+        if (! $verification) {
+            return $this->errorResponse(message: __('identity_verification.not_found'), code: 404);
+        }
+
+        return $this->successResponse(payload: $verification);
+    }
+
+    public function approve(Request $request, int $id): JsonResponse
+    {
+        return $this->review($request, $id, approved: true);
+    }
+
+    public function reject(Request $request, int $id): JsonResponse
+    {
+        return $this->review($request, $id, approved: false);
+    }
+
+    //
+
+    private function review(Request $request, int $id, bool $approved): JsonResponse
+    {
+        try {
+            $verification = $this->service->review(
+                id:       $id,
+                approved: $approved,
+                reviewer: $request->user(),
+                comment:  $request->string('comment')->toString() ?: null,
+            );
+
+            return $this->successResponse(
+                payload: new IdentityVerificationResource($verification),
+                message: __('identity_verification.reviewed'),
+            );
+        } catch (Throwable $e) {
+            return $this->errorResponse(message: $e->getMessage(), code: 422);
+        }
+    }
+}

+ 150 - 0
app/Http/Controllers/WebhookController.php

@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
 use App\Services\WebhookService;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Log;
 
 class WebhookController extends Controller
 {
@@ -23,6 +24,29 @@ class WebhookController extends Controller
         return $this->successResponse(message: __('http.webhook_received'));
     }
 
+    public function didit(Request $request): JsonResponse
+    {
+        $payload = $request->all();
+
+        Log::channel('didit')->info('Webhook recebido do Didit', [
+            'event_id'     => data_get($payload, 'event_id'),
+            'webhook_type' => data_get($payload, 'webhook_type'),
+            'session_id'   => data_get($payload, 'session_id'),
+            'status'       => data_get($payload, 'status'),
+            'vendor_data'  => data_get($payload, 'vendor_data'),
+            'environment'  => data_get($payload, 'environment'),
+            'is_test'      => $request->hasHeader('X-Didit-Test-Webhook'),
+        ]);
+
+        if (! $this->validDiditSignature($request)) {
+            return $this->errorResponse(message: __('http.unauthorized_token'), code: 401);
+        }
+
+        $this->webhookService->handleDidit($payload);
+
+        return $this->successResponse(message: __('http.webhook_received'));
+    }
+
     //
 
     private function validPagarmeCredentials(Request $request): bool
@@ -49,4 +73,130 @@ class WebhookController extends Controller
             && hash_equals($configuredUser, $receivedUser)
             && hash_equals($configuredPassword, $receivedPassword);
     }
+
+    private function validDiditSignature(Request $request): bool
+    {
+        $secrets = $this->diditWebhookSecrets();
+
+        if ($secrets === []) {
+            Log::channel('didit')->error('DIDIT_WEBHOOK_SECRET nao configurado; webhook rejeitado');
+
+            return false;
+        }
+
+        if (! $this->validDiditTimestamp($request)) {
+            return false;
+        }
+
+        $rawBody = $request->getContent();
+
+        $candidates = array_filter([
+            'X-Signature-V2' => $this->diditCanonicalBody($rawBody),
+            'X-Signature'    => $rawBody,
+        ], static fn ($body) => is_string($body));
+
+        foreach ($candidates as $header => $body) {
+            $received = (string) $request->header($header, '');
+
+            if ($received === '') {
+                continue;
+            }
+
+            foreach ($secrets as $secret) {
+                if (hash_equals(hash_hmac('sha256', $body, $secret), $received)) {
+                    return true;
+                }
+            }
+        }
+
+        Log::channel('didit')->warning('Assinatura do webhook Didit invalida', [
+            'secrets_testados' => count($secrets),
+            'received_v2'      => substr((string) $request->header('X-Signature-V2', ''), 0, 12),
+            'body_length'      => strlen($rawBody),
+        ]);
+
+        return false;
+    }
+
+    /**
+     * @return list<string>
+     */
+    private function diditWebhookSecrets(): array
+    {
+        $configured = config('services.didit.webhook_secret');
+
+        if (! is_string($configured)) {
+            return [];
+        }
+
+        return array_values(array_filter(
+            array_map('trim', explode(',', $configured)),
+            static fn (string $secret) => $secret !== '',
+        ));
+    }
+
+    private function validDiditTimestamp(Request $request): bool
+    {
+        $timestamp = $request->header('X-Timestamp');
+
+        if (! is_numeric($timestamp)) {
+            Log::channel('didit')->warning('Webhook Didit sem X-Timestamp valido');
+
+            return false;
+        }
+
+        $tolerance = (int) config('services.didit.webhook_tolerance', 300);
+
+        if (abs(time() - (int) $timestamp) > $tolerance) {
+            Log::channel('didit')->warning('Webhook Didit fora da janela de tolerancia', [
+                'timestamp' => (int) $timestamp,
+                'now'       => time(),
+            ]);
+
+            return false;
+        }
+
+        return true;
+    }
+
+    private function diditCanonicalBody(string $rawBody): ?string
+    {
+        try {
+            $decoded = json_decode($rawBody, false, 512, JSON_THROW_ON_ERROR);
+        } catch (\JsonException) {
+            return null;
+        }
+
+        $canonical = json_encode(
+            $this->canonicalizeDiditValue($decoded),
+            JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
+        );
+
+        return $canonical === false ? null : $canonical;
+    }
+
+    private function canonicalizeDiditValue(mixed $value): mixed
+    {
+        if (is_array($value)) {
+            return array_map(fn ($item) => $this->canonicalizeDiditValue($item), $value);
+        }
+
+        if ($value instanceof \stdClass) {
+            $data = get_object_vars($value);
+
+            ksort($data, SORT_STRING);
+
+            foreach ($data as $key => $item) {
+                $data[$key] = $this->canonicalizeDiditValue($item);
+            }
+
+            return (object) $data;
+        }
+
+        if (is_float($value) && is_finite($value) && floor($value) === $value && abs($value) < PHP_INT_MAX) {
+            return (int) $value;
+        }
+
+        return $value;
+    }
 }

+ 44 - 0
app/Http/Middleware/EnsureClientIsVerified.php

@@ -0,0 +1,44 @@
+<?php
+
+namespace App\Http\Middleware;
+
+use App\Enums\IdentityVerificationStatusEnum;
+use App\Enums\UserTypeEnum;
+use Closure;
+use Illuminate\Http\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+class EnsureClientIsVerified
+{
+    public function handle(Request $request, Closure $next): Response
+    {
+        $user = $request->user();
+
+        if (! $user || $user->type !== UserTypeEnum::CLIENT) {
+            return $next($request);
+        }
+
+        $client = $user->client;
+
+        if (! $client || ! $client->identity_verification_required) {
+            return $next($request);
+        }
+
+        if ($client->identity_verification_status === IdentityVerificationStatusEnum::APPROVED) {
+            return $next($request);
+        }
+
+        return response()->json([
+            'payload' => [
+                'identity_verification_status' => $client->identity_verification_status?->value,
+                'attempts_left'                => $this->attemptsLeft($client->identity_verification_attempts),
+            ],
+            'message' => __('identity_verification.client_not_verified'),
+        ], 403);
+    }
+
+    private function attemptsLeft(?int $attempts): int
+    {
+        return max(0, (int) config('services.didit.max_attempts', 3) - (int) $attempts);
+    }
+}

+ 8 - 1
app/Http/Middleware/EnsureProviderIsAccepted.php

@@ -27,7 +27,14 @@ class EnsureProviderIsAccepted
 
         if ($provider->approval_status === ApprovalStatusEnum::PENDING) {
             return response()->json([
-                'payload' => ['approval_status' => ApprovalStatusEnum::PENDING->value],
+                'payload' => [
+                    'approval_status'              => ApprovalStatusEnum::PENDING->value,
+                    'identity_verification_status' => $provider->identity_verification_status?->value,
+                    'attempts_left'                => max(
+                        0,
+                        (int) config('services.didit.max_attempts', 3) - (int) $provider->identity_verification_attempts,
+                    ),
+                ],
                 'message' => __('auth.provider_pending'),
             ], 202);
         }

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

+ 1 - 0
app/Http/Requests/RegisterClientRequest.php

@@ -20,6 +20,7 @@ class RegisterClientRequest extends FormRequest
             'name'           => 'required|string|max:255',
             'phone'          => 'required|string|max:20',
             'document'       => 'required|string|max:20',
+            'birth_date'     => 'nullable|date|before:today',
             'zip_code'       => 'required|string|max:20',
             'number'         => 'required|string|max:20',
             'address'        => 'required|string|max:255',

+ 0 - 2
app/Http/Requests/RegisterProviderRequest.php

@@ -40,8 +40,6 @@ class RegisterProviderRequest extends FormRequest
       'daily_price_4h'  => 'required|numeric|min:0',
       'daily_price_2h'  => 'required|numeric|min:0',
       'selfie'          => 'required|file|image|mimes:jpg,jpeg,png,webp|max:5120',
-      'document_front'  => 'required|file|image|mimes:jpg,jpeg,png,webp|max:10240',
-      'document_back'   => 'required|file|image|mimes:jpg,jpeg,png,webp|max:10240',
 
       'working_days'          => 'required|array|min:1',
       'working_days.*.day'    => 'required|integer|min:0|max:6',

+ 18 - 0
app/Http/Requests/ReviewRequest.php

@@ -57,6 +57,24 @@ class ReviewRequest extends FormRequest
         return $rules;
     }
 
+    public function withValidator($validator): void
+    {
+        $validator->after(function ($validator) {
+            $photos = $this->file('photos');
+
+            if (empty($photos)) {
+                return;
+            }
+
+            if ((float) $this->input('stars') > 2) {
+                $validator->errors()->add(
+                    'photos',
+                    __('validation.custom.review.photos_only_negative'),
+                );
+            }
+        });
+    }
+
     public function messages(): array
     {
         return [

+ 4 - 0
app/Http/Resources/ClientResource.php

@@ -19,6 +19,10 @@ class ClientResource extends JsonResource
             'document'         => $this->document,
             'user_id'          => $this->user_id,
             'selfie_verified'  => $this->selfie_verified,
+            'birth_date'       => $this->birth_date?->format('Y-m-d'),
+            'identity_verification_status'   => $this->identity_verification_status?->value,
+            'identity_verified_at'           => $this->identity_verified_at,
+            'identity_verification_attempts' => $this->identity_verification_attempts,
             'first_access'     => $this->first_access,
             'profile_media_id' => $this->profileMedia?->id,
             'profile_media'    => $this->profileMedia ? new MediaResource($this->profileMedia) : null,

+ 16 - 0
app/Http/Resources/CustomScheduleResource.php

@@ -2,8 +2,10 @@
 
 namespace App\Http\Resources;
 
+use App\Models\ScheduleProposal;
 use Illuminate\Http\Request;
 use Illuminate\Http\Resources\Json\JsonResource;
+use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\Storage;
 
 class CustomScheduleResource extends JsonResource
@@ -18,6 +20,7 @@ class CustomScheduleResource extends JsonResource
         return [
             'id'              => $this->id,
             'schedule_id'     => $this->schedule_id,
+            'proposal_sent'   => $this->proposalSentByAuthProvider(),
             'address_type'    => $this->address_type,
             'service_type_id' => $this->service_type_id,
 
@@ -81,4 +84,17 @@ class CustomScheduleResource extends JsonResource
             }),
         ];
     }
+
+    private function proposalSentByAuthProvider(): bool
+    {
+        $providerId = Auth::user()?->provider?->id;
+
+        if (! $providerId) {
+            return false;
+        }
+
+        return ScheduleProposal::where('schedule_id', $this->schedule_id)
+            ->where('provider_id', $providerId)
+            ->exists();
+    }
 }

+ 32 - 0
app/Http/Resources/IdentityVerificationResource.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Http\Resources;
+
+use Illuminate\Http\Request;
+use Illuminate\Http\Resources\Json\JsonResource;
+
+class IdentityVerificationResource extends JsonResource
+{
+    public function toArray(Request $request): array
+    {
+        return [
+            'id'                         => $this->id,
+            'session_id'                 => $this->session_id,
+            'didit_status'               => $this->didit_status,
+            'attempt'                    => $this->attempt,
+            'id_verification_status'     => $this->id_verification_status,
+            'liveness_status'            => $this->liveness_status,
+            'face_match_status'          => $this->face_match_status,
+            'liveness_score'             => $this->liveness_score,
+            'face_match_score'           => $this->face_match_score,
+            'warnings'                   => $this->warnings,
+            'actionable_warnings'        => $this->actionableWarnings(),
+            'reviewed_at'                => $this->reviewed_at,
+            'review_comment'             => $this->review_comment,
+            'started_at'                 => $this->started_at,
+            'completed_at'               => $this->completed_at,
+            'user'                       => new UserResource($this->whenLoaded('user')),
+            'created_at'                 => $this->created_at,
+        ];
+    }
+}

+ 6 - 0
app/Http/Resources/ProviderResource.php

@@ -27,6 +27,12 @@ class ProviderResource extends JsonResource
             'selfie_verified'                => $this->selfie_verified,
             'document_verified'              => $this->document_verified,
             'approval_status'                => $this->approval_status?->value ?? $this->approval_status,
+            'identity_verification_status'   => $this->identity_verification_status?->value,
+            'identity_verified_at'           => $this->identity_verified_at,
+            'identity_verification_attempts' => $this->identity_verification_attempts,
+            'identity_verification'          => $this->user?->latestIdentityVerification
+                ? new IdentityVerificationResource($this->user->latestIdentityVerification)
+                : null,
             'daily_price_8h'                 => $this->daily_price_8h,
             'daily_price_6h'                 => $this->daily_price_6h,
             'daily_price_4h'                 => $this->daily_price_4h,

+ 5 - 4
app/Http/Resources/ReviewDetailResource.php

@@ -42,10 +42,11 @@ class ReviewDetailResource extends JsonResource
                 'code'          => $schedule->code,
 
                 'client' => $schedule->client ? [
-                    'id'    => $schedule->client->id,
-                    'name'  => $schedule->client->user?->name,
-                    'email' => $schedule->client->user?->email,
-                    'phone' => $schedule->client->user?->phone,
+                    'id'            => $schedule->client->id,
+                    'name'          => $schedule->client->user?->name,
+                    'email'         => $schedule->client->user?->email,
+                    'phone'         => $schedule->client->user?->phone,
+                    'profile_photo' => $schedule->client->profile_photo ?? null,
                 ] : null,
 
                 'provider' => $schedule->provider ? [

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

+ 12 - 0
app/Models/Client.php

@@ -2,6 +2,7 @@
 
 namespace App\Models;
 
+use App\Enums\IdentityVerificationStatusEnum;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -59,6 +60,7 @@ class Client extends Model
 
     protected $fillable = [
         'document',
+        'birth_date',
         'gateway_customer_id',
         'gateway_customer_code',
         'idempotency_key',
@@ -66,6 +68,10 @@ class Client extends Model
         'profile_media_id',
         'selfie_verified',
         'first_access',
+        'identity_verification_status',
+        'identity_verified_at',
+        'identity_verification_attempts',
+        'identity_verification_required',
     ];
 
     protected $casts = [
@@ -74,6 +80,12 @@ class Client extends Model
         'deleted_at'      => 'datetime',
         'selfie_verified' => 'boolean',
         'first_access'    => 'boolean',
+        'birth_date'      => 'date',
+
+        'identity_verification_status'   => IdentityVerificationStatusEnum::class,
+        'identity_verified_at'           => 'datetime',
+        'identity_verification_attempts' => 'integer',
+        'identity_verification_required' => 'boolean',
     ];
 
     public function user(): BelongsTo

+ 68 - 0
app/Models/IdentityVerification.php

@@ -0,0 +1,68 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+class IdentityVerification extends Model
+{
+    use HasFactory;
+
+    protected $table = 'identity_verifications';
+
+    protected $fillable = [
+        'user_id',
+        'session_id',
+        'session_token',
+        'workflow_id',
+        'didit_status',
+        'attempt',
+        'id_verification_status',
+        'liveness_status',
+        'face_match_status',
+        'liveness_score',
+        'face_match_score',
+        'warnings',
+        'decision',
+        'reviewed_by',
+        'reviewed_at',
+        'review_comment',
+        'started_at',
+        'completed_at',
+    ];
+
+    protected function casts(): array
+    {
+        return [
+            'attempt'          => 'integer',
+            'liveness_score'   => 'float',
+            'face_match_score' => 'float',
+            'warnings'         => 'array',
+            'decision'         => 'array',
+            'reviewed_at'      => 'datetime',
+            'started_at'       => 'datetime',
+            'completed_at'     => 'datetime',
+        ];
+    }
+
+    public function user(): BelongsTo
+    {
+        return $this->belongsTo(User::class);
+    }
+
+    public function reviewer(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'reviewed_by');
+    }
+
+    /** Warnings acionaveis: o Didit marca os informativos com log_type = information. */
+    public function actionableWarnings(): array
+    {
+        return array_values(array_filter(
+            $this->warnings ?? [],
+            static fn ($warning) => data_get($warning, 'log_type') === 'warning',
+        ));
+    }
+}

+ 5 - 0
app/Models/Provider.php

@@ -3,6 +3,7 @@
 namespace App\Models;
 
 use App\Enums\ApprovalStatusEnum;
+use App\Enums\IdentityVerificationStatusEnum;
 use App\Enums\GenderEnum;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
@@ -122,6 +123,10 @@ class Provider extends Model
     {
         return [
             'birth_date'                                => 'date',
+            'identity_verification_status'              => IdentityVerificationStatusEnum::class,
+            'identity_verified_at'                      => 'datetime',
+            'identity_verification_attempts'            => 'integer',
+            'identity_verification_required'            => 'boolean',
             'gender'                                    => GenderEnum::class,
             'selfie_verified'                           => 'boolean',
             'document_verified'                         => 'boolean',

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

+ 10 - 0
app/Models/User.php

@@ -116,6 +116,16 @@ class User extends Authenticatable
         return $this->hasOne(Provider::class, 'user_id');
     }
 
+    public function identityVerifications()
+    {
+        return $this->hasMany(IdentityVerification::class, 'user_id');
+    }
+
+    public function latestIdentityVerification()
+    {
+        return $this->hasOne(IdentityVerification::class, 'user_id')->latestOfMany();
+    }
+
     //
 
     public function deviceTokens()

+ 17 - 21
app/Notifications/Push/Cliente/Contextual/ContextualSextaPush.php → app/Notifications/Push/Cliente/Agendamento/PrestadorCancelouPush.php

@@ -1,28 +1,31 @@
 <?php
 
-namespace App\Notifications\Push\Cliente\Contextual;
+namespace App\Notifications\Push\Cliente\Agendamento;
 
 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
+class PrestadorCancelouPush extends BasePushNotification
 {
+    public function __construct(
+        private readonly string $providerName,
+    ) {}
+
     public function label(): string
     {
-        return 'cliente_contextual_sexta';
+        return 'provider_cancelled_schedule';
     }
 
     public function title(): string
     {
-        return 'Sexta chegando';
+        return 'Agendamento cancelado!';
     }
 
     public function body(): string
     {
-        return 'Que tal deixar a casa pronta pro fim de semana?';
+        return "{$this->providerName} cancelou seu agendamento.";
     }
 
     public function target(): PushNotificationTargetEnum
@@ -32,28 +35,21 @@ class ContextualSextaPush extends BasePushNotification
 
     public function category(): PushNotificationCategoryEnum
     {
-        return PushNotificationCategoryEnum::CONTEXTUAL;
+        return PushNotificationCategoryEnum::AGENDA;
     }
 
-    public function notificationCooldownDays(): int
+    public function eligibleUsers(): Collection
     {
-        return 7;
+        return new Collection();
     }
 
-    public function categoryCooldownDays(): int
+    public function notificationCooldownDays(): int
     {
-        return 7;
+        return 0;
     }
 
-    /** Só retorna usuários se hoje for sexta-feira */
-    public function eligibleUsers(): Collection
+    public function categoryCooldownDays(): int
     {
-        if (! now()->isFriday()) {
-            return new Collection;
-        }
-
-        return User::whereHas('client')
-            ->where('registration_complete', true)
-            ->get();
+        return 0;
     }
-}
+}

+ 22 - 12
app/Notifications/Push/Cliente/Recorrencia/Recorrencia3Push.php → app/Notifications/Push/Cliente/Agendamento/PrestadorRecusouPush.php

@@ -1,28 +1,31 @@
 <?php
 
-namespace App\Notifications\Push\Cliente\Recorrencia;
+namespace App\Notifications\Push\Cliente\Agendamento;
 
 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
+class PrestadorRecusouPush extends BasePushNotification
 {
+    public function __construct(
+        private readonly string $providerName,
+    ) {}
+
     public function label(): string
     {
-        return 'cliente_recorrencia_3';
+        return 'client_provider_refused';
     }
 
     public function title(): string
     {
-        return 'Casa limpa dura pouco';
+        return 'Agendamento recusado';
     }
 
     public function body(): string
     {
-        return 'Garanta sua próxima diária no app.';
+        return "{$this->providerName} recusou seu agendamento.";
     }
 
     public function target(): PushNotificationTargetEnum
@@ -32,14 +35,21 @@ class Recorrencia3Push extends BasePushNotification
 
     public function category(): PushNotificationCategoryEnum
     {
-        return PushNotificationCategoryEnum::RECORRENCIA;
+        return PushNotificationCategoryEnum::AGENDA;
     }
 
     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();
+        return new Collection();
+    }
+
+    public function notificationCooldownDays(): int
+    {
+        return 0;
+    }
+
+    public function categoryCooldownDays(): int
+    {
+        return 0;
     }
-}
+}

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

+ 17 - 16
app/Notifications/Push/Prestador/Motivacional/Motivacional1Push.php → app/Notifications/Push/Prestador/Agendamento/ClienteAceitouPush.php

@@ -1,29 +1,31 @@
 <?php
 
-namespace App\Notifications\Push\Prestador\Motivacional;
+namespace App\Notifications\Push\Prestador\Agendamento;
 
-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
+class ClienteAceitouPush extends BasePushNotification
 {
+    public function __construct(
+        private readonly string $clientName,
+    ) {}
+
     public function label(): string
     {
-        return 'provider_motivacional_1';
+        return 'provider_client_accepted';
     }
 
     public function title(): string
     {
-        return 'Fique de olho 👀';
+        return 'Agendamento confirmado!';
     }
 
     public function body(): string
     {
-        return 'Novos pedidos Sob Medida surgem o tempo todo no app.';
+        return "{$this->clientName} aceitou sua proposta de agendamento.";
     }
 
     public function target(): PushNotificationTargetEnum
@@ -33,22 +35,21 @@ class Motivacional1Push extends BasePushNotification
 
     public function category(): PushNotificationCategoryEnum
     {
-        return PushNotificationCategoryEnum::MOTIVACIONAL;
+        return PushNotificationCategoryEnum::AGENDA;
     }
 
-    public function notificationCooldownDays(): int
+    public function eligibleUsers(): Collection
     {
-        return 7;
+        return new Collection();
     }
 
-    public function categoryCooldownDays(): int
+    public function notificationCooldownDays(): int
     {
-        return 7;
+        return 0;
     }
 
-    public function eligibleUsers(): Collection
+    public function categoryCooldownDays(): int
     {
-        return User::whereHas('provider', fn ($q) => $q->where('approval_status', ApprovalStatusEnum::ACCEPTED))
-            ->get();
+        return 0;
     }
-}
+}

+ 55 - 0
app/Notifications/Push/Prestador/Agendamento/ClienteCancelouPush.php

@@ -0,0 +1,55 @@
+<?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 ClienteCancelouPush extends BasePushNotification
+{
+    public function __construct(
+        private readonly string $clientName,
+    ) {}
+
+    public function label(): string
+    {
+        return 'provider_client_cancelled';
+    }
+
+    public function title(): string
+    {
+        return 'Agendamento cancelado!';
+    }
+
+    public function body(): string
+    {
+        return "{$this->clientName} cancelou seu agendamento.";
+    }
+
+    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;
+    }
+}

+ 55 - 0
app/Notifications/Push/Prestador/Agendamento/ClienteRecusouPush.php

@@ -0,0 +1,55 @@
+<?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 ClienteRecusouPush extends BasePushNotification
+{
+    public function __construct(
+        private readonly string $clientName,
+    ) {}
+
+    public function label(): string
+    {
+        return 'provider_client_refused';
+    }
+
+    public function title(): string
+    {
+        return 'Proposta recusada!';
+    }
+
+    public function body(): string
+    {
+        return "{$this->clientName} recusou sua proposta de agendamento.";
+    }
+
+    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;
+    }
+}

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

+ 55 - 0
app/Notifications/Push/Prestador/Pagamento/ClienteEfetuouPagamentoPush.php

@@ -0,0 +1,55 @@
+<?php
+
+namespace App\Notifications\Push\Prestador\Pagamento;
+
+use App\Enums\PushNotificationCategoryEnum;
+use App\Enums\PushNotificationTargetEnum;
+use App\Notifications\Push\BasePushNotification;
+use Illuminate\Database\Eloquent\Collection;
+
+class ClienteEfetuouPagamentoPush extends BasePushNotification
+{
+    public function __construct(
+        private readonly string $clientName,
+    ) {}
+
+    public function label(): string
+    {
+        return 'provider_client_payment_completed';
+    }
+
+    public function title(): string
+    {
+        return 'Pagamento realizado!';
+    }
+
+    public function body(): string
+    {
+        return "{$this->clientName} efetuou o pagamento do serviço.";
+    }
+
+    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 - 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,
+            ],
+        );
+    }
+}

+ 3 - 3
app/Observers/ScheduleObserver.php

@@ -19,9 +19,9 @@ class ScheduleObserver
 
         $endedAt = Carbon::parse($schedule->date->format('Y-m-d').' '.$schedule->end_time);
 
-        // if ($endedAt->isFuture()) {
-        //    return;
-        // }
+        if ($endedAt->isFuture()) {
+           return;
+        }
 
         $schedule->forceFill(['status' => 'finished'])->saveQuietly();
 

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

+ 6 - 1
app/Services/ClientService.php

@@ -101,10 +101,15 @@ class ClientService
 
             if (! empty(data_get($data, 'document'))) {
                 $client->document = data_get($data, 'document');
+            }
 
-                $client->save();
+
+            if (! empty(data_get($data, 'birth_date'))) {
+                $client->birth_date = data_get($data, 'birth_date');
             }
 
+            $client->save();
+
             $client->refresh();
 
             if (data_get($data, 'avatar') !== null && data_get($data, 'avatar') instanceof UploadedFile) {

+ 313 - 16
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;
@@ -12,11 +17,15 @@ use App\Models\ScheduleProposal;
 use App\Models\ScheduleRefuse;
 use App\Models\ServicePackage;
 use App\Notifications\Push\Cliente\Agendamento\PrestadorAceitouPush;
+use App\Notifications\Push\Cliente\Agendamento\PrestadorRecusouPush;
+use App\Notifications\Push\Prestador\Agendamento\ClienteRecusouPush;
+use App\Notifications\Push\Prestador\Agendamento\ClienteAceitouPush;
+use App\Services\PushNotificationService;
 use App\Rules\ScheduleBusinessRules;
 use App\Services\NotificationService;
 use App\Services\DistanceService;
-use App\Services\PushNotificationService;
 use Carbon\Carbon;
+use Illuminate\Support\Collection;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Storage;
@@ -27,6 +36,7 @@ class CustomScheduleService
 
     public function __construct(
         private readonly ZipCodeCoordinatesService $zipCodeCoordinatesService,
+        private readonly RealtimeService $realtime,
     ) {}
 
     public function getAll()
@@ -58,6 +68,12 @@ class CustomScheduleService
 
     public function create(array $data)
     {
+        $address = Address::find(data_get($data, 'address_id'));
+
+        if (! $address || $address->latitude === null || $address->longitude === null) {
+            throw new \Exception(__('messages.client_address_location_required'));
+        }
+
         DB::beginTransaction();
 
         try {
@@ -116,11 +132,13 @@ class CustomScheduleService
 
             DB::commit();
 
+            $this->dispatchOpportunityNotification($createdCustomSchedules);
+
             return $createdCustomSchedules;
         } catch (\Exception $e) {
             DB::rollBack();
 
-            Log::error('Erro ao criar agendamento personalizado: '.$e->getMessage());
+            Log::error('Erro ao criar agendamento personalizado: ' . $e->getMessage());
 
             throw $e;
         }
@@ -215,7 +233,7 @@ class CustomScheduleService
         } catch (\Exception $e) {
             DB::rollBack();
 
-            Log::error('Erro ao atualizar agendamento personalizado: '.$e->getMessage());
+            Log::error('Erro ao atualizar agendamento personalizado: ' . $e->getMessage());
 
             throw $e;
         }
@@ -241,7 +259,7 @@ class CustomScheduleService
         } catch (\Exception $e) {
             DB::rollBack();
 
-            Log::error('Erro ao excluir agendamento personalizado: '.$e->getMessage());
+            Log::error('Erro ao excluir agendamento personalizado: ' . $e->getMessage());
 
             throw $e;
         }
@@ -326,7 +344,15 @@ class CustomScheduleService
             )
             ->get();
 
-        $availableOpportunities = $opportunities->filter(function ($opportunity) use ($providerId) {
+        $proposedScheduleIds = ScheduleProposal::where('provider_id', $providerId)
+            ->pluck('schedule_id')
+            ->flip();
+
+        $availableOpportunities = $opportunities->filter(function ($opportunity) use ($providerId, $proposedScheduleIds) {
+            if ($proposedScheduleIds->has($opportunity->id)) {
+                return true;
+            }
+
             try {
                 return $this->checkProviderAvailability($providerId, $opportunity);
             } catch (\Exception $e) {
@@ -334,7 +360,7 @@ class CustomScheduleService
             }
         });
 
-        $availableOpportunities->each(function ($opportunity) use ($providerAddress) {
+        $availableOpportunities->each(function ($opportunity) use ($providerAddress, $proposedScheduleIds) {
             $opportunity->distance_km = $this->zipCodeCoordinatesService->calculateDistance(
                 $providerAddress?->latitude !== null ? (float) $providerAddress->latitude : null,
                 $providerAddress?->longitude !== null ? (float) $providerAddress->longitude : null,
@@ -349,11 +375,35 @@ class CustomScheduleService
             $opportunity->customer_photo = $photoPath
                 ? Storage::temporaryUrl($photoPath, now()->addMinutes(60))
                 : null;
+
+            $opportunity->proposal_sent = $proposedScheduleIds->has($opportunity->id);
         });
 
         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'])
@@ -451,10 +501,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
@@ -485,9 +547,7 @@ class CustomScheduleService
 
     public function refuseOpportunity($scheduleId, $providerId)
     {
-
         $schedule = Schedule::with(['client.user'])->findOrFail($scheduleId);
-
         $provider = Provider::with(['user'])->findOrFail($providerId);
 
         $schedule_refuse = ScheduleRefuse::create([
@@ -498,17 +558,72 @@ class CustomScheduleService
         $notificationService = app(NotificationService::class);
 
         $notificationService->create([
-            'title'       => __('notifications.opportunity_refused_title'),
-            'description' => __('notifications.opportunity_refused_description', ['provider' => $provider->user->name]),
-            'origin'      => 'schedule',
-            'origin_id'   => $scheduleId,
-            'type'        => NotificationTypeEnum::SCHEDULE_CLIENT_PROVIDER_REFUSED->value,
-            'user_id'     => $schedule->client->user_id,
+            'title' => __('notifications.opportunity_refused_title'),
+            'description' => __('notifications.opportunity_refused_description', [
+                'provider' => $provider->user->name,
+            ]),
+            'origin' => 'schedule',
+            'origin_id' => $scheduleId,
+            'type' => NotificationTypeEnum::SCHEDULE_CLIENT_PROVIDER_REFUSED->value,
+            'user_id' => $schedule->client->user_id,
         ]);
 
+        // Push notification
+        if ($schedule->client?->user) {
+            try {
+                app(PushNotificationService::class)->sendToUser(
+                    $schedule->client->user,
+                    new PrestadorRecusouPush(
+                        $provider->user->name
+                    )
+                );
+            } catch (\Throwable $exception) {
+                Log::error('Falha ao enviar push de recusa da oportunidade', [
+                    'schedule_id' => $schedule->id,
+                    'provider_id' => $providerId,
+                    'user_id' => $schedule->client->user->id,
+                    'error' => $exception->getMessage(),
+                ]);
+            }
+        }
+
+        $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)
@@ -546,7 +661,9 @@ class CustomScheduleService
                 'user_id'     => $provider->user_id,
             ]);
 
-            app(ScheduleService::class)->updateStatus($schedule->id, 'accepted');
+
+
+            app(ScheduleService::class)->updateStatus($schedule->id, 'paid');
 
             ScheduleProposal::where('schedule_id', $schedule->id)
                 ->where('id', '!=', $proposalId)
@@ -561,6 +678,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',
@@ -606,10 +734,53 @@ class CustomScheduleService
                 'type'        => NotificationTypeEnum::SCHEDULE_CLIENT_PROVIDER_REFUSED->value,
                 'user_id'     => $proposal->provider->user_id,
             ]);
+            // Push notification
+            if ($proposal->provider?->user) {
+                try {
+                    app(PushNotificationService::class)->sendToUser(
+                        $proposal->provider->user,
+                        new ClienteRecusouPush(
+                            $proposal->schedule?->client?->user?->name
+                        )
+                    );
+                } catch (\Throwable $exception) {
+                    Log::error('Falha ao enviar push de recusa da proposta', [
+                        'proposal_id' => $proposal->id,
+                        'schedule_id' => $proposal->schedule_id,
+                        'provider_id' => $proposal->provider_id,
+                        'user_id'     => $proposal->provider->user->id,
+                        'error'       => $exception->getMessage(),
+                    ]);
+                }
+            }
 
+            $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;
         });
     }
@@ -725,6 +896,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;

+ 106 - 0
app/Services/Didit/Concerns/SendsDiditRequests.php

@@ -0,0 +1,106 @@
+<?php
+
+namespace App\Services\Didit\Concerns;
+
+use App\Data\Didit\DiditData;
+use Illuminate\Http\Client\PendingRequest;
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Log;
+use Throwable;
+
+trait SendsDiditRequests
+{
+    protected function diditRequest(
+        string $method,
+        string $path,
+        string $errorMessage,
+        array|DiditData|null $payload = null,
+    ): array {
+        $payload  = $payload instanceof DiditData ? $payload->toArray() : $payload;
+        $endpoint = $this->diditUrl($path);
+
+        try {
+            $options = $payload === null ? [] : ['json' => $payload];
+
+            $response = $this->diditHttp()
+                ->send($method, $endpoint, $options)
+                ->throw();
+
+            $result = $response->json() ?? [];
+
+            if (app()->environment('local', 'development')) {
+                Log::channel('didit')->info('Requisicao ao Didit concluida com sucesso', [
+                    'method'   => strtoupper($method),
+                    'endpoint' => $endpoint,
+                    'payload'  => $this->maskDiditPayload($payload ?? []),
+                    'result'   => $this->maskDiditPayload($result),
+                ]);
+            }
+
+            return $result;
+        } catch (Throwable $e) {
+            $responseBody = null;
+
+            if (method_exists($e, 'getResponse')) {
+                $responseBody = $e->getResponse()?->json();
+            } elseif (isset($e->response)) {
+                $responseBody = $e->response->json();
+            }
+
+            Log::channel('didit')->error('Falha na requisicao ao Didit', [
+                'method'    => strtoupper($method),
+                'endpoint'  => $endpoint,
+                'payload'   => $this->maskDiditPayload($payload ?? []),
+                'exception' => $e->getMessage(),
+                'result'    => $responseBody,
+            ]);
+
+            $message = $errorMessage;
+            $detail  = data_get($responseBody, 'detail') ?? data_get($responseBody, 'message');
+
+            if (is_string($detail) && $detail !== '') {
+                $message .= ': '.$detail;
+            }
+
+            throw new \RuntimeException($message, previous: $e);
+        }
+    }
+
+    protected function diditHttp(): PendingRequest
+    {
+        return Http::withHeaders([
+            'x-api-key'    => (string) config('services.didit.api_key'),
+            'Content-Type' => 'application/json',
+        ])
+            ->connectTimeout((int) config('services.didit.connect_timeout', 5))
+            ->timeout((int) config('services.didit.timeout', 15))
+            ->acceptJson();
+    }
+
+    protected function diditUrl(string $path): string
+    {
+        return rtrim((string) config('services.didit.base_url'), '/').'/'.ltrim($path, '/');
+    }
+
+    protected function maskDiditPayload(array $data): array
+    {
+        $sensitive = [
+            'identification_number', 'personal_number', 'document_number', 'tax_number',
+            'date_of_birth', 'first_name', 'last_name', 'full_name', 'email', 'phone',
+        ];
+
+        array_walk_recursive($data, function (&$value, $key) use ($sensitive) {
+            if (in_array($key, $sensitive, true) && is_scalar($value)) {
+                $value = '***';
+
+                return;
+            }
+
+            if (is_string($value) && str_starts_with($value, 'http')) {
+                $value = explode('?', $value)[0];
+            }
+        });
+
+        return $data;
+    }
+}

+ 106 - 0
app/Services/Didit/DiditDecisionExtractor.php

@@ -0,0 +1,106 @@
+<?php
+
+namespace App\Services\Didit;
+
+class DiditDecisionExtractor
+{
+    public function extract(array $decision): array
+    {
+        $id       = $this->first($decision, 'id_verifications');
+        $liveness = $this->first($decision, 'liveness_checks');
+        $face     = $this->first($decision, 'face_matches');
+
+        return [
+            'id_verification_status'     => data_get($id, 'status'),
+            'liveness_status'            => data_get($liveness, 'status'),
+            'face_match_status'          => data_get($face, 'status'),
+
+            'liveness_score'   => $this->score($liveness, 'liveness_score'),
+            'face_match_score' => $this->score($face, 'face_match_score'),
+
+            'warnings' => $this->warnings($decision),
+        ];
+    }
+
+    public function extractDocument(array $decision): ?string
+    {
+        $id = $this->first($decision, 'id_verifications');
+
+        $taxNumber = data_get($id, 'extra_fields.tax_number');
+
+        return is_string($taxNumber) && $taxNumber !== ''
+            ? preg_replace('/\D+/', '', $taxNumber)
+            : null;
+    }
+
+    /** @return list<array<string, mixed>> */
+    public function warnings(array $decision): array
+    {
+        $warnings = [];
+
+        foreach ($this->featureKeys() as $key) {
+            foreach ($this->items($decision, $key) as $item) {
+                foreach ((array) data_get($item, 'warnings', []) as $warning) {
+                    $warnings[] = $warning;
+                }
+            }
+        }
+
+        return $warnings;
+    }
+
+    public function actionableWarnings(array $decision): array
+    {
+        return array_values(array_filter(
+            $this->warnings($decision),
+            static fn ($warning) => data_get($warning, 'log_type') === 'warning',
+        ));
+    }
+
+    /** @return list<string> */
+    public function featureKeys(): array
+    {
+        return [
+            'id_verifications',
+            'liveness_checks',
+            'face_matches',
+            'ip_analyses',
+            'nfc_verifications',
+            'aml_screenings',
+            'poa_verifications',
+        ];
+    }
+
+    public function items(array $decision, string $key): array
+    {
+        $value = data_get($decision, $key);
+
+        return is_array($value) ? $value : [];
+    }
+
+    public function first(array $decision, string $key): ?array
+    {
+        $items = $this->items($decision, $key);
+
+        return $items === [] ? null : (array) reset($items);
+    }
+
+    private function score(?array $item, string $fallbackKey): ?float
+    {
+        if ($item === null) {
+            return null;
+        }
+
+        $value = data_get($item, 'score')
+            ?? data_get($item, $fallbackKey)
+            ?? data_get($item, 'similarity_percentage');
+
+        if (! is_numeric($value)) {
+            return null;
+        }
+
+        $value = (float) $value;
+
+        return $value <= 1 ? round($value * 100, 2) : round($value, 2);
+    }
+}

+ 259 - 0
app/Services/Didit/DiditDecisionService.php

@@ -0,0 +1,259 @@
+<?php
+
+namespace App\Services\Didit;
+
+use App\Enums\IdentityVerificationStatusEnum;
+use App\Enums\UserTypeEnum;
+use App\Models\Client;
+use App\Models\IdentityVerification;
+use App\Models\Provider;
+use App\Models\User;
+use App\Services\ProviderService;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+
+class DiditDecisionService
+{
+    public function __construct(
+        private readonly DiditDecisionExtractor $extractor,
+        private readonly ProviderService $providerService,
+    ) {}
+
+    public function handleWebhook(array $payload): ?IdentityVerification
+    {
+        $sessionId = data_get($payload, 'session_id');
+        $status    = (string) data_get($payload, 'status');
+
+        if (! is_string($sessionId) || $sessionId === '') {
+            Log::channel('didit')->warning('Webhook sem session_id; ignorado');
+
+            return null;
+        }
+
+        $user = $this->resolveUser($payload);
+
+        if (! $user) {
+            Log::channel('didit')->warning('Webhook sem usuario correspondente', [
+                'session_id'  => $sessionId,
+                'vendor_data' => data_get($payload, 'vendor_data'),
+            ]);
+
+            return null;
+        }
+
+        $decision     = (array) (data_get($payload, 'decision') ?? []);
+        $verification = $this->persist($user, $sessionId, $status, $payload, $decision);
+
+        if (! in_array($status, IdentityVerificationStatusEnum::diditTerminalStatuses(), true)) {
+            return $verification;
+        }
+
+        $this->applyDecision($user, $verification, $status, $decision);
+
+        return $verification->refresh();
+    }
+
+    //
+
+    private function persist(
+        User $user,
+        string $sessionId,
+        string $status,
+        array $payload,
+        array $decision,
+    ): IdentityVerification {
+        $verification = IdentityVerification::firstOrNew(['session_id' => $sessionId]);
+
+        $attributes = [
+            'user_id'      => $user->id,
+            'didit_status' => $status,
+            'workflow_id'  => data_get($payload, 'workflow_id') ?? $verification->workflow_id,
+        ];
+
+        if ($decision !== []) {
+            $attributes = [
+                ...$attributes,
+                ...$this->extractor->extract($decision),
+                'decision' => $this->sanitize($decision),
+            ];
+        }
+
+        if (! $verification->exists) {
+            $attributes['attempt']    = (int) $user->identityVerifications()->count() + 1;
+            $attributes['started_at'] = now();
+        }
+
+        if (in_array($status, IdentityVerificationStatusEnum::diditTerminalStatuses(), true)) {
+            $attributes['completed_at'] = now();
+        }
+
+        $verification->fill($attributes)->save();
+
+        return $verification;
+    }
+
+    private function applyDecision(
+        User $user,
+        IdentityVerification $verification,
+        string $status,
+        array $decision,
+    ): void {
+        $profile = $this->profileFor($user);
+
+        if (! $profile) {
+            return;
+        }
+
+        if ($status === 'Approved' && $this->passesAllGates($verification, $decision)) {
+            $this->approve($user, $profile, $verification);
+
+            return;
+        }
+
+        if ($status === 'Declined') {
+            $this->decline($profile);
+
+            return;
+        }
+
+        // Approved com pendencia, In Review ou Abandoned: decisao humana.
+        $this->sendToReview($profile);
+    }
+
+    private function passesAllGates(IdentityVerification $verification, array $decision): bool
+    {
+        $required = ['id_verifications', 'liveness_checks', 'face_matches'];
+
+        foreach ($required as $key) {
+            $items = $this->extractor->items($decision, $key);
+
+            if ($items === []) {
+                $this->logGate($verification, "feature ausente: {$key}");
+
+                return false;
+            }
+
+            foreach ($items as $item) {
+                if (data_get($item, 'status') !== 'Approved') {
+                    $this->logGate($verification, "feature reprovada: {$key}");
+
+                    return false;
+                }
+            }
+        }
+
+        $minLiveness  = (float) config('services.didit.liveness_min_score');
+        $minFaceMatch = (float) config('services.didit.face_match_min_score');
+
+        if ($verification->liveness_score === null || $verification->liveness_score < $minLiveness) {
+            $this->logGate($verification, 'liveness abaixo do minimo');
+
+            return false;
+        }
+
+        if ($verification->face_match_score === null || $verification->face_match_score < $minFaceMatch) {
+            $this->logGate($verification, 'face match abaixo do minimo');
+
+            return false;
+        }
+
+        if ($this->extractor->actionableWarnings($decision) !== []) {
+            $this->logGate($verification, 'warnings acionaveis presentes');
+
+            return false;
+        }
+
+        return true;
+    }
+
+    private function approve(User $user, Model $profile, IdentityVerification $verification): void
+    {
+        DB::transaction(function () use ($profile, $verification) {
+            $profile->forceFill([
+                'identity_verification_status' => IdentityVerificationStatusEnum::APPROVED->value,
+                'identity_verified_at'         => now(),
+            ]);
+
+            if ($profile instanceof Provider) {
+                $profile->document_verified = true;
+            }
+
+            if ($profile->profile_media_id) {
+                $profile->selfie_verified = true;
+            }
+
+            $profile->save();
+
+            $verification->forceFill(['completed_at' => now()])->save();
+        });
+
+        if ($profile instanceof Provider) {
+            $this->providerService->approve($profile->id);
+        }
+
+        Log::channel('didit')->info('Verificacao aprovada automaticamente', [
+            'user_id'    => $user->id,
+            'session_id' => $verification->session_id,
+        ]);
+    }
+
+    private function decline(Model $profile): void
+    {
+        $attempts = (int) $profile->identity_verification_attempts + 1;
+        $max      = (int) config('services.didit.max_attempts', 3);
+
+        $profile->forceFill([
+            'identity_verification_attempts' => $attempts,
+            'identity_verification_status'   => $attempts >= $max
+                ? IdentityVerificationStatusEnum::IN_REVIEW->value
+                : IdentityVerificationStatusEnum::DECLINED->value,
+        ])->save();
+    }
+
+    private function sendToReview(Model $profile): void
+    {
+        $profile->forceFill([
+            'identity_verification_status' => IdentityVerificationStatusEnum::IN_REVIEW->value,
+        ])->save();
+    }
+
+    private function resolveUser(array $payload): ?User
+    {
+        $vendorData = data_get($payload, 'vendor_data');
+
+        if (is_numeric($vendorData)) {
+            return User::find((int) $vendorData);
+        }
+
+        $sessionId = data_get($payload, 'session_id');
+
+        return IdentityVerification::where('session_id', $sessionId)->first()?->user;
+    }
+
+    private function profileFor(User $user): Provider|Client|null
+    {
+        return $user->type === UserTypeEnum::PROVIDER
+            ? $user->provider
+            : $user->client;
+    }
+
+    private function sanitize(array $decision): array
+    {
+        array_walk_recursive($decision, static function (&$value) {
+            if (is_string($value) && str_starts_with($value, 'http')) {
+                $value = explode('?', $value)[0];
+            }
+        });
+
+        return $decision;
+    }
+
+    private function logGate(IdentityVerification $verification, string $reason): void
+    {
+        Log::channel('didit')->info('Aprovacao automatica barrada', [
+            'session_id' => $verification->session_id,
+            'motivo'     => $reason,
+        ]);
+    }
+}

+ 156 - 0
app/Services/Didit/DiditSessionService.php

@@ -0,0 +1,156 @@
+<?php
+
+namespace App\Services\Didit;
+
+use App\Data\Didit\Session\Parts\Request\ContactDetailsData;
+use App\Data\Didit\Session\Parts\Request\ExpectedDetailsData;
+use App\Data\Didit\Session\SessionRequestData;
+use App\Data\Didit\Session\SessionResponseData;
+use App\Enums\IdentityVerificationStatusEnum;
+use App\Enums\UserTypeEnum;
+use App\Models\Client;
+use App\Models\IdentityVerification;
+use App\Models\Provider;
+use App\Models\User;
+use App\Services\Didit\Concerns\SendsDiditRequests;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\DB;
+use RuntimeException;
+
+class DiditSessionService
+{
+    use SendsDiditRequests;
+
+    public function createSession(User $user, bool $native = false): IdentityVerification
+    {
+        $profile = $this->profileFor($user);
+
+        if (! $profile) {
+            throw new RuntimeException(__('identity_verification.profile_missing'));
+        }
+
+        if ($profile->identity_verification_status === IdentityVerificationStatusEnum::APPROVED) {
+            throw new RuntimeException(__('identity_verification.already_approved'));
+        }
+
+        $maxAttempts = (int) config('services.didit.max_attempts', 3);
+
+        if ((int) $profile->identity_verification_attempts >= $maxAttempts) {
+            throw new RuntimeException(__('identity_verification.attempts_exhausted'));
+        }
+
+        $open = IdentityVerification::query()
+            ->where('user_id', $user->id)
+            ->whereIn('didit_status', ['Not Started', 'In Progress'])
+            ->latest('id')
+            ->first();
+
+        if ($open) {
+            return $open;
+        }
+
+        $response = $this->diditRequest(
+            method:       'POST',
+            path:         '/v3/session/',
+            errorMessage: __('identity_verification.session_failed'),
+            payload:      $this->buildRequest($user, $profile, $native),
+        );
+
+        $session = SessionResponseData::fromResponse($response);
+
+        return DB::transaction(function () use ($user, $profile, $session) {
+            $attempt = (int) $profile->identity_verification_attempts + 1;
+
+            $profile->forceFill([
+                'identity_verification_status' => IdentityVerificationStatusEnum::PENDING->value,
+            ])->save();
+
+            return IdentityVerification::create([
+                'user_id'       => $user->id,
+                'session_id'    => $session->sessionId,
+                'session_token' => $session->sessionToken,
+                'workflow_id'   => $session->workflowId,
+                'didit_status'  => $session->status ?? 'Not Started',
+                'attempt'       => $attempt,
+                'started_at'    => now(),
+            ]);
+        });
+    }
+
+    public function getDecision(string $sessionId): array
+    {
+        return $this->diditRequest(
+            method:       'GET',
+            path:         "/v3/session/{$sessionId}/decision/",
+            errorMessage: __('identity_verification.decision_failed'),
+        );
+    }
+
+    public function verificationUrl(IdentityVerification $verification): string
+    {
+        return 'https://verify.didit.me/pt/session/'.$verification->session_token;
+    }
+
+    //
+
+    private function buildRequest(User $user, Model $profile, bool $native = false): SessionRequestData
+    {
+        return new SessionRequestData(
+            workflowId:      $this->workflowFor($user),
+            vendorData:      (string) $user->id,
+            callback:        $this->callbackFor($user, $native),
+            language:        'pt',
+            expectedDetails: ExpectedDetailsData::fromName(
+                fullName:    $user->name,
+                dateOfBirth: $profile->birth_date?->format('Y-m-d'),
+                document:    $profile->document,
+            ),
+            contactDetails: new ContactDetailsData(
+                email: $user->email,
+                phone: $user->phone,
+            ),
+        );
+    }
+
+    private const APP_SCHEMES = [
+        'provider' => 'br.com.diarista.provider',
+        'client'   => 'br.com.diarista.client',
+    ];
+
+    private function callbackFor(User $user, bool $native = false): ?string
+    {
+        $app = $user->type === UserTypeEnum::PROVIDER ? 'provider' : 'client';
+
+        if ($native) {
+            return self::APP_SCHEMES[$app].'://verification/done';
+        }
+
+        $callback = config('services.didit.callback_url');
+
+        if (! is_string($callback) || $callback === '') {
+            return null;
+        }
+
+        return $callback.(str_contains($callback, '?') ? '&' : '?').'app='.$app;
+    }
+
+    private function workflowFor(User $user): string
+    {
+        $workflow = $user->type === UserTypeEnum::PROVIDER
+            ? config('services.didit.workflow_provider')
+            : config('services.didit.workflow_client');
+
+        if (! is_string($workflow) || $workflow === '') {
+            throw new RuntimeException(__('identity_verification.workflow_missing'));
+        }
+
+        return $workflow;
+    }
+
+    private function profileFor(User $user): Provider|Client|null
+    {
+        return $user->type === UserTypeEnum::PROVIDER
+            ? $user->provider
+            : $user->client;
+    }
+}

+ 171 - 0
app/Services/IdentityVerificationService.php

@@ -0,0 +1,171 @@
+<?php
+
+namespace App\Services;
+
+use App\Enums\IdentityVerificationStatusEnum;
+use App\Enums\UserTypeEnum;
+use App\Http\Resources\IdentityVerificationResource;
+use App\Models\Client;
+use App\Models\IdentityVerification;
+use App\Models\Provider;
+use App\Models\User;
+use App\Services\Didit\DiditSessionService;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\DB;
+use RuntimeException;
+
+class IdentityVerificationService
+{
+    public function __construct(
+        private readonly DiditSessionService $sessionService,
+        private readonly ProviderService $providerService,
+    ) {}
+
+    public function startFor(User $user, bool $native = false): array
+    {
+        $verification = $this->sessionService->createSession($user, $native);
+
+        return [
+            'session_id'       => $verification->session_id,
+            'verification_url' => $this->sessionService->verificationUrl($verification),
+            'status'           => $verification->didit_status,
+            'attempt'          => $verification->attempt,
+            'attempts_left'    => $this->attemptsLeft($this->profileFor($user)),
+        ];
+    }
+
+    public function statusFor(User $user): array
+    {
+        $profile      = $this->profileFor($user);
+        $verification = $user->latestIdentityVerification()->first();
+
+        return [
+            'identity_verification_status' => $profile?->identity_verification_status?->value
+                ?? IdentityVerificationStatusEnum::NOT_STARTED->value,
+            'identity_verified_at' => $profile?->identity_verified_at,
+            'required'             => (bool) ($profile?->identity_verification_required ?? false),
+            'attempts_left'        => $this->attemptsLeft($profile),
+            'can_retry'            => $this->canRetry($profile),
+            'has_open_session'     => $verification !== null
+                && in_array($verification->didit_status, ['Not Started', 'In Progress'], true),
+            'last_verification'    => $verification
+                ? new IdentityVerificationResource($verification)
+                : null,
+        ];
+    }
+
+    public function pending(int $perPage, int $page): array
+    {
+        $paginator = IdentityVerification::query()
+            ->with('user')
+            ->whereIn('didit_status', ['In Review', 'Declined'])
+            ->orderBy('completed_at')
+            ->paginate(perPage: $perPage, page: $page);
+
+        return [
+            'data'  => IdentityVerificationResource::collection($paginator->items()),
+            'total' => $paginator->total(),
+            'from'  => $paginator->firstItem(),
+            'to'    => $paginator->lastItem(),
+        ];
+    }
+
+    /**
+     * Busca o laudo com as imagens frescas: as URLs do Didit sao presignadas e de
+     * vida curta, por isso nao sao persistidas e sim buscadas na hora de exibir.
+     */
+    public function findWithFreshDecision(int $id): ?array
+    {
+        $verification = IdentityVerification::with('user')->find($id);
+
+        if (! $verification) {
+            return null;
+        }
+
+        $decision = null;
+
+        try {
+            $decision = $this->sessionService->getDecision($verification->session_id);
+        } catch (\Throwable) {
+            // Laudo persistido ja basta para a decisao; so as imagens ficam indisponiveis.
+        }
+
+        return [
+            'verification'   => new IdentityVerificationResource($verification),
+            'decision'       => $decision ?? $verification->decision,
+            'decision_fresh' => $decision !== null,
+        ];
+    }
+
+    public function review(int $id, bool $approved, User $reviewer, ?string $comment): IdentityVerification
+    {
+        $verification = IdentityVerification::with('user')->find($id);
+
+        if (! $verification) {
+            throw new RuntimeException(__('identity_verification.not_found'));
+        }
+
+        $user    = $verification->user;
+        $profile = $user ? $this->profileFor($user) : null;
+
+        if (! $profile) {
+            throw new RuntimeException(__('identity_verification.profile_missing'));
+        }
+
+        DB::transaction(function () use ($verification, $profile, $approved, $reviewer, $comment) {
+            $verification->forceFill([
+                'reviewed_by'    => $reviewer->id,
+                'reviewed_at'    => now(),
+                'review_comment' => $comment,
+            ])->save();
+
+            $profile->forceFill([
+                'identity_verification_status' => $approved
+                    ? IdentityVerificationStatusEnum::APPROVED->value
+                    : IdentityVerificationStatusEnum::DECLINED->value,
+                'identity_verified_at' => $approved ? now() : null,
+            ]);
+
+            if ($approved && $profile instanceof Provider) {
+                $profile->document_verified = true;
+            }
+
+            $profile->save();
+        });
+
+        if ($approved && $profile instanceof Provider) {
+            $this->providerService->approve($profile->id);
+        }
+
+        return $verification->refresh();
+    }
+
+    //
+
+    private function attemptsLeft(?Model $profile): int
+    {
+        $max = (int) config('services.didit.max_attempts', 3);
+
+        return max(0, $max - (int) ($profile?->identity_verification_attempts ?? 0));
+    }
+
+    private function canRetry(?Model $profile): bool
+    {
+        if (! $profile) {
+            return false;
+        }
+
+        $status = $profile->identity_verification_status;
+
+        return $status instanceof IdentityVerificationStatusEnum
+            && $status->allowsNewAttempt()
+            && $this->attemptsLeft($profile) > 0;
+    }
+
+    private function profileFor(User $user): Provider|Client|null
+    {
+        return $user->type === UserTypeEnum::PROVIDER
+            ? $user->provider
+            : $user->client;
+    }
+}

+ 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

+ 19 - 15
app/Services/PaymentService.php

@@ -16,6 +16,7 @@ use App\Models\PaymentSplit;
 use App\Models\Schedule;
 use App\Models\ScheduleProposal;
 use App\Services\Pagarme\PagarmePaymentService;
+use App\Services\ScheduleService;
 use Carbon\Carbon;
 use Illuminate\Auth\Access\AuthorizationException;
 use Illuminate\Database\Eloquent\Collection;
@@ -93,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,
@@ -109,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);
@@ -124,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,
@@ -140,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,
@@ -149,7 +148,7 @@ Log::info('4');
           );
 
           $this->servicePackagePaymentTotals($schedules, $paymentMethod);
-Log::info('5');
+
           return [
             'payment'   => $existingPayment,
             'schedules' => $schedules,
@@ -163,7 +162,7 @@ Log::info('5');
           return ['existing' => $existingPayment];
         }
       }
-Log::info('6');
+
       if ($servicePackage->status !== ServicePackageStatusEnum::OPEN) {
         throw new PaymentException;
       }
@@ -174,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([
@@ -202,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,
@@ -222,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']);
@@ -817,10 +816,15 @@ Log::info('10');
           ->where('status', 'accepted')
           ->get();
 
-        Schedule::query()
-          ->whereIn('id', $schedulesToPay->pluck('id'))
-          ->where('status', 'accepted')
-          ->update(['status' => 'paid']);
+
+          foreach ($schedulesToPay as $schedule) {
+             app(ScheduleService::class)->updateStatus($schedule->id, 'paid');
+          }
+
+        // Schedule::query()
+        //   ->whereIn('id', $schedulesToPay->pluck('id'))
+        //   ->where('status', 'accepted')
+        //   ->update(['status' => 'paid']);
 
         if ($servicePackage->status !== ServicePackageStatusEnum::PAID) {
           $servicePackage->update(['status' => ServicePackageStatusEnum::PAID->value]);

+ 8 - 24
app/Services/ProviderService.php

@@ -43,7 +43,13 @@ class ProviderService
 
     public function findById(int $id): ?Provider
     {
-        return Provider::with(['user', 'profileMedia', 'documentFrontMedia', 'documentBackMedia'])->find($id);
+        return Provider::with([
+            'user',
+            'user.latestIdentityVerification',
+            'profileMedia',
+            'documentFrontMedia',
+            'documentBackMedia',
+        ])->find($id);
     }
 
     public function create(array $data): Provider
@@ -182,7 +188,7 @@ class ProviderService
 
             $provider->refresh();
 
-            $provider->load('profileMedia', 'documentFrontMedia', 'documentBackMedia');
+            $provider->load('profileMedia');
 
             $selfie = $this->mediaService->replaceFile(
                 newFile:  data_get($data, 'selfie'),
@@ -194,28 +200,6 @@ class ProviderService
 
             $provider->profile_media_id = $selfie->id;
 
-            $front = $this->mediaService->replaceFile(
-                newFile:  data_get($data, 'document_front'),
-                folder:   "provider/documentos/{$provider->id}",
-                source:   'provider_document',
-                sourceId: $provider->id,
-                old:      $provider->documentFrontMedia,
-                filename: 'frente.'.data_get($data, 'document_front')->getClientOriginalExtension(),
-            );
-
-            $provider->document_front_media_id = $front->id;
-
-            $back = $this->mediaService->replaceFile(
-                newFile:  data_get($data, 'document_back'),
-                folder:   "provider/documentos/{$provider->id}",
-                source:   'provider_document',
-                sourceId: $provider->id,
-                old:      $provider->documentBackMedia,
-                filename: 'verso.'.data_get($data, 'document_back')->getClientOriginalExtension(),
-            );
-
-            $provider->document_back_media_id = $back->id;
-
             $provider->save();
 
             Address::where('source', 'provider')->where('source_id', $provider->id)->delete();

+ 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,

+ 275 - 62
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;
@@ -14,6 +17,11 @@ use App\Rules\ScheduleBusinessRules;
 use App\Services\NotificationService;
 use App\Services\PushNotificationService;
 use App\Notifications\Push\Cliente\Agendamento\PrestadorAceitouPush;
+use App\Notifications\Push\Cliente\Agendamento\PrestadorRecusouPush;
+use App\Notifications\Push\Prestador\Agendamento\ClienteAceitouPush;
+use App\Notifications\Push\Prestador\Pagamento\ClienteEfetuouPagamentoPush;
+use App\Notifications\Push\Cliente\Agendamento\PrestadorCancelouPush;
+use App\Notifications\Push\Prestador\Agendamento\ClienteCancelouPush;
 use App\Notifications\Push\Prestador\Agendamento\NewPushRequest;
 use Carbon\Carbon;
 use Illuminate\Support\Facades\Auth;
@@ -24,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'])
@@ -93,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;
             }
 
@@ -161,7 +184,7 @@ class ScheduleService
             }
 
             $allowedTransitions = [
-                'pending'   => ['accepted', 'rejected', 'cancelled'],
+                'pending'   => ['accepted', 'rejected', 'paid', 'cancelled'],
                 'accepted'  => ['paid', 'cancelled'],
                 'paid'      => ['cancelled', 'started'],
                 'started'   => ['finished'],
@@ -177,6 +200,7 @@ class ScheduleService
             }
 
             if (! in_array($status, data_get($allowedTransitions, $currentStatus))) {
+                log::info("Transição de status inválida: {$currentStatus} para {$status}");
                 throw new ScheduleStatusTransitionException;
             }
 
@@ -191,7 +215,6 @@ class ScheduleService
 
                 case 'accepted':
                     $notificationService = app(NotificationService::class);
-
                     switch (Auth::user()?->type) {
                         case UserTypeEnum::PROVIDER:
                             $notificationService->create([
@@ -219,6 +242,8 @@ class ScheduleService
                                 ]);
                             }
 
+                            $this->sendClientAcceptedPush($schedule);
+
                             break;
 
                         default:
@@ -240,6 +265,7 @@ class ScheduleService
                                 'type'        => NotificationTypeEnum::SCHEDULE_PROVIDER_CLIENT_CANCELLED->value,
                                 'user_id'     => $schedule->provider->user_id,
                             ]);
+                            $this->sendProviderCancelledPush($schedule);
 
                             break;
 
@@ -252,6 +278,7 @@ class ScheduleService
                                 'type'        => NotificationTypeEnum::SCHEDULE_CLIENT_PROVIDER_CANCELLED->value,
                                 'user_id'     => $schedule->client->user_id,
                             ]);
+                            $this->sendClientCancelledPush($schedule);
 
                             break;
 
@@ -306,25 +333,18 @@ class ScheduleService
 
                 case 'paid':
                     $notificationService = app(NotificationService::class);
-
-                    switch (Auth::user()?->type) {
-                        case UserTypeEnum::CLIENT:
-                            if ($schedule->provider_id) {
-                                $notificationService->create([
-                                    'title'       => __('notifications.payment_confirmed_title'),
-                                    'description' => __('notifications.payment_confirmed_description'),
-                                    'origin'      => 'schedule',
-                                    'origin_id'   => $schedule->id,
-                                    'type'        => NotificationTypeEnum::SCHEDULE_PROVIDER_START->value,
-                                    'user_id'     => $schedule->provider->user_id,
-                                ]);
-                            }
-
-                            break;
-
-                        default:
-                            break;
+                    if ($schedule->provider_id) {
+                
+                        $notificationService->create([
+                            'title'       => __('notifications.payment_confirmed_title'),
+                            'description' => __('notifications.payment_confirmed_description'),
+                            'origin'      => 'schedule',
+                            'origin_id'   => $schedule->id,
+                            'type'        => NotificationTypeEnum::SCHEDULE_PROVIDER_START->value,
+                            'user_id'     => $schedule->provider->user_id,
+                        ]);
                     }
+                    $this->sendClientPaymentPush($schedule);
 
                     $date_cleaned = Carbon::parse($schedule->date)
                         ->format('Y-m-d');
@@ -350,9 +370,25 @@ class ScheduleService
                         'user_id'     => $schedule->client->user_id,
                     ]);
 
+                    $this->sendProviderRefusedPush($schedule);
+
                     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']);
@@ -369,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()
@@ -515,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']);
@@ -550,26 +620,28 @@ class ScheduleService
             $siblingSchedules = $package->items->pluck('schedule')->filter();
 
             $siblingSchedules
-                ->filter(fn (Schedule $sibling) => $sibling->id !== $schedule->id
+                ->filter(fn(Schedule $sibling) => $sibling->id !== $schedule->id
                     && in_array($sibling->status, ['pending', 'accepted', 'paid'], true))
-                ->each(fn (Schedule $sibling) => $sibling->update([
+                ->each(fn(Schedule $sibling) => $sibling->update([
                     'status'       => 'cancelled',
                     'cancel_text'  => $cancelText,
                     'cancelled_by' => $cancelledBy,
                 ]));
 
             $hasRealizedSchedule = $siblingSchedules->contains(
-                fn (Schedule $sibling) => in_array($sibling->status, ['started', 'finished'], true),
+                fn(Schedule $sibling) => in_array($sibling->status, ['started', 'finished'], true),
             );
 
-            if ($package->status === ServicePackageStatusEnum::OPEN
-                || ($package->status === ServicePackageStatusEnum::PAID && ! $hasRealizedSchedule)) {
+            if (
+                $package->status === ServicePackageStatusEnum::OPEN
+                || ($package->status === ServicePackageStatusEnum::PAID && ! $hasRealizedSchedule)
+            ) {
                 $package->update(['status' => ServicePackageStatusEnum::CANCELLED->value]);
             }
         }
     }
 
-    //
+    //Notificações por push do sistema
 
     private function sendProviderAcceptedPush(Schedule $schedule): void
     {
@@ -597,6 +669,147 @@ class ScheduleService
         }
     }
 
+    private function sendProviderRefusedPush(Schedule $schedule): void
+    {
+        $user = $schedule->client->user;
+
+        if (! $user) {
+            Log::warning('Push de recusa ignorada: cliente sem usuário', [
+                'schedule_id' => $schedule->id,
+            ]);
+
+            return;
+        }
+
+        try {
+            app(PushNotificationService::class)->sendToUser(
+                $user,
+                new PrestadorRecusouPush(
+                    $schedule->provider->user->name
+                )
+            );
+        } catch (\Throwable $exception) {
+            Log::error('Falha ao enviar push de recusa do prestador', [
+                'schedule_id' => $schedule->id,
+                'user_id'     => $user->id,
+                'error'       => $exception->getMessage(),
+            ]);
+        }
+    }
+
+    private function sendClientAcceptedPush(Schedule $schedule): void
+    {
+        $user = $schedule->provider?->user;
+
+        if (! $user) {
+            Log::warning('Push de aceite do cliente ignorado: prestador sem usuário', [
+                'schedule_id' => $schedule->id,
+            ]);
+
+            return;
+        }
+
+        try {
+            app(PushNotificationService::class)->sendToUser(
+                $user,
+                new ClienteAceitouPush(
+                    $schedule->client->user->name
+                )
+            );
+        } catch (\Throwable $exception) {
+            Log::error('Falha ao enviar push de aceite do cliente', [
+                'schedule_id' => $schedule->id,
+                'user_id' => $user->id,
+                'error' => $exception->getMessage(),
+            ]);
+        }
+    }
+
+    private function sendClientPaymentPush(Schedule $schedule): void
+    {
+        $user = $schedule->provider?->user;
+        if (! $user) {
+            Log::warning('Push de pagamento ignorado: prestador sem usuário', [
+                'schedule_id' => $schedule->id,
+            ]);
+
+            return;
+        }
+
+        try {
+            app(PushNotificationService::class)->sendToUser(
+                $user,
+                new ClienteEfetuouPagamentoPush(
+                    $schedule->client?->user?->name ?? 'Cliente'
+                )
+
+            );
+        } catch (\Throwable $exception) {
+            Log::error('Falha ao enviar push de pagamento ao prestador', [
+                'schedule_id' => $schedule->id,
+                'provider_id' => $schedule->provider_id,
+                'user_id' => $user->id,
+                'error' => $exception->getMessage(),
+            ]);
+        }
+    }
+
+    private function sendClientCancelledPush(Schedule $schedule): void
+    {
+        $user = $schedule->provider->user;
+
+        if (! $user) {
+            Log::warning('Push de cancelamento ignorado: prestador sem usuário', [
+                'schedule_id' => $schedule->id,
+            ]);
+
+            return;
+        }
+
+        try {
+            app(PushNotificationService::class)->sendToUser(
+                $user,
+                new ClienteCancelouPush(
+                    $schedule->client->user->name
+                )
+            );
+        } catch (\Throwable $exception) {
+            Log::error('Falha ao enviar push de cancelamento pelo cliente', [
+                'schedule_id' => $schedule->id,
+                'user_id'     => $user->id,
+                'error'       => $exception->getMessage(),
+            ]);
+        }
+    }
+
+    private function sendProviderCancelledPush(Schedule $schedule): void
+    {
+        $user = $schedule->client->user;
+
+        if (! $user) {
+            Log::warning('Push de cancelamento ignorado: cliente sem usuário', [
+                'schedule_id' => $schedule->id,
+            ]);
+
+            return;
+        }
+
+        try {
+            app(PushNotificationService::class)->sendToUser(
+                $user,
+                new PrestadorCancelouPush(
+                    $schedule->provider->user->name
+                )
+            );
+        } catch (\Throwable $exception) {
+            Log::error('Falha ao enviar push de cancelamento pelo prestador', [
+                'schedule_id' => $schedule->id,
+                'user_id'     => $user->id,
+                'error'       => $exception->getMessage(),
+            ]);
+        }
+    }
+
     private function calculateAmount(Provider $provider, string $periodType): float
     {
         $hourlyRates = [

+ 54 - 0
app/Services/WebhookService.php

@@ -4,8 +4,10 @@ namespace App\Services;
 
 use App\Models\Payment;
 use App\Models\Webhook;
+use App\Services\Didit\DiditDecisionService;
 use App\Services\Pagarme\PagarmePaymentService;
 use Illuminate\Support\Facades\Log;
+use Throwable;
 
 class WebhookService
 {
@@ -36,6 +38,58 @@ class WebhookService
     ) {
     }
 
+    public function handleDidit(array $payload): void
+    {
+        $hookId = data_get($payload, 'event_id') ?: sha1(implode('|', [
+            data_get($payload, 'session_id'),
+            data_get($payload, 'status'),
+            data_get($payload, 'webhook_type'),
+        ]));
+
+        $webhook = Webhook::query()->firstOrNew([
+            'provider' => 'didit',
+            'hook_id'  => $hookId,
+        ]);
+
+        if ($webhook->exists) {
+            $webhook->increment('attempts_count');
+
+            Log::channel('didit')->info('Webhook duplicado ignorado', ['hook_id' => $hookId]);
+
+            return;
+        }
+
+        $webhook->fill([
+            'event'          => data_get($payload, 'webhook_type'),
+            'status'         => 'received',
+            'attempts_count' => 1,
+            'payload'        => $payload,
+            'received_at'    => now(),
+        ])->save();
+
+        try {
+            $verification = app(DiditDecisionService::class)->handleWebhook($payload);
+
+            $webhook->update([
+                'status'       => $verification ? 'processed' : 'ignored',
+                'processed_at' => now(),
+            ]);
+        } catch (Throwable $e) {
+            $webhook->update([
+                'status'        => 'failed',
+                'processed_at'  => now(),
+                'error_message' => $e->getMessage(),
+            ]);
+
+            Log::channel('didit')->error('Falha ao processar webhook do Didit', [
+                'hook_id'   => $hookId,
+                'exception' => $e->getMessage(),
+            ]);
+
+            throw $e;
+        }
+    }
+
     public function handlePagarme(array $payload): ?Payment
     {
         $event = data_get($payload, 'type');

+ 25 - 0
app/Tasks/SyncStaleDiditVerifications.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace App\Tasks;
+
+use Illuminate\Support\Facades\Artisan;
+use Illuminate\Support\Facades\Log;
+use Throwable;
+
+class SyncStaleDiditVerifications
+{
+    public function __invoke(): void
+    {
+        try {
+            Artisan::call('didit:sync', ['--stale-hours' => 6, '--limit' => 100]);
+
+            Log::channel('didit')->info('Reconciliacao agendada concluida', [
+                'saida' => trim(Artisan::output()),
+            ]);
+        } catch (Throwable $e) {
+            Log::channel('didit')->error('Falha na reconciliacao agendada', [
+                'exception' => $e->getMessage(),
+            ]);
+        }
+    }
+}

+ 16 - 3
bootstrap/app.php

@@ -1,11 +1,13 @@
 <?php
 
 use App\Http\Middleware\CheckPermission;
+use App\Http\Middleware\EnsureClientIsVerified;
 use App\Http\Middleware\EnsureProviderIsAccepted;
 use App\Http\Middleware\PerformanceMonitor;
 use App\Http\Middleware\SetUserLanguage;
 use App\Tasks\DeleteExpiredTokens;
 use App\Tasks\SendPushNotificationsTask;
+use App\Tasks\SyncStaleDiditVerifications;
 use Illuminate\Console\Scheduling\Schedule;
 use Illuminate\Foundation\Application;
 use Illuminate\Foundation\Configuration\Exceptions;
@@ -27,6 +29,7 @@ return Application::configure(basePath: dirname(__DIR__))
             'permission'        => CheckPermission::class,
             'ability'           => CheckForAnyAbility::class,
             'provider.accepted' => EnsureProviderIsAccepted::class,
+            'client.verified'   => EnsureClientIsVerified::class,
         ]);
     })
     ->withSchedule(function (Schedule $schedule) {
@@ -42,9 +45,19 @@ 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');
+
+        $syncDiditVerifications = function () {
+            try {
+                app(SyncStaleDiditVerifications::class)();
+            } catch (Throwable $e) {
+                Log::error('Falha ao resolver SyncStaleDiditVerifications: '.$e->getMessage(), [
+                    'exception' => $e,
+                ]);
+            }
+        };
+
+        $schedule->call($syncDiditVerifications)->hourly();
     })
     ->withExceptions(function (Exceptions $exceptions) {
         //

+ 1 - 0
config/database.php

@@ -92,6 +92,7 @@ return [
             'prefix_indexes' => true,
             'search_path'    => 'public',
             'sslmode'        => 'prefer',
+            'timezone' => env('DB_TIMEZONE', config('app.timezone')),
         ],
 
         'sqlsrv' => [

+ 8 - 0
config/logging.php

@@ -160,6 +160,14 @@ return [
             'replace_placeholders' => true,
         ],
 
+        'didit' => [
+            'driver'               => 'daily',
+            'path'                 => storage_path('logs/integrations/didit.log'),
+            'level'                => 'debug',
+            'days'                 => 14,
+            'replace_placeholders' => true,
+        ],
+
         'google_geocoding' => [
             'driver'               => 'daily',
             'path'                 => storage_path('logs/google-geocoding/google-geocoding.log'),

+ 7 - 0
config/realtime.php

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

+ 16 - 0
config/services.php

@@ -73,4 +73,20 @@ return [
         'platform_service_package_min_3_schedules_credit_card_fee_rate' => env('PAGARME_PLATFORM_SERVICE_PACKAGE_MIN_3_SCHEDULES_CREDIT_CARD_FEE_RATE', 0.12),
         'platform_service_package_min_3_schedules_pix_fee_rate'         => env('PAGARME_PLATFORM_SERVICE_PACKAGE_MIN_3_SCHEDULES_PIX_FEE_RATE', 0.10),
     ],
+
+    'didit' => [
+        'base_url'         => env('DIDIT_BASE_URL', 'https://verification.didit.me'),
+        'api_key'          => env('DIDIT_API_KEY'),
+        'webhook_secret'   => env('DIDIT_WEBHOOK_SECRET'),
+        'workflow_provider' => env('DIDIT_WORKFLOW_PROVIDER'),
+        'workflow_client'  => env('DIDIT_WORKFLOW_CLIENT'),
+        'callback_url'     => env('DIDIT_CALLBACK_URL'),
+        'connect_timeout'  => env('DIDIT_CONNECT_TIMEOUT', 5),
+        'timeout'          => env('DIDIT_TIMEOUT', 15),
+        'max_attempts'     => (int) env('DIDIT_MAX_ATTEMPTS', 3),
+        'webhook_tolerance' => (int) env('DIDIT_WEBHOOK_TOLERANCE', 300),
+
+        'liveness_min_score'   => (float) env('DIDIT_LIVENESS_MIN_SCORE', 80),
+        'face_match_min_score' => (float) env('DIDIT_FACE_MATCH_MIN_SCORE', 85),
+    ],
 ];

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

+ 54 - 0
database/migrations/2026_09_08_100001_create_identity_verifications_table.php

@@ -0,0 +1,54 @@
+<?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::create('identity_verifications', function (Blueprint $table) {
+            $table->id();
+
+            $table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
+
+            // Identificadores da sessao no Didit
+            $table->uuid('session_id')->unique();
+            $table->string('session_token')->nullable();
+            $table->uuid('workflow_id')->nullable();
+
+            // Status literal do Didit (Not Started, In Progress, Approved, Declined, In Review, ...)
+            $table->string('didit_status')->default('Not Started')->index();
+            $table->unsignedTinyInteger('attempt')->default(1);
+
+            // Resumo por feature, para o backoffice filtrar sem abrir o JSON
+            $table->string('id_verification_status')->nullable();
+            $table->string('liveness_status')->nullable();
+            $table->string('face_match_status')->nullable();
+
+            // Escala 0-100 (normalizada na extracao)
+            $table->decimal('liveness_score', 5, 2)->nullable();
+            $table->decimal('face_match_score', 5, 2)->nullable();
+
+            $table->json('warnings')->nullable();
+            $table->json('decision')->nullable();
+
+            $table->foreignId('reviewed_by')->nullable()->constrained('users')->nullOnDelete();
+            $table->timestamp('reviewed_at')->nullable();
+            $table->text('review_comment')->nullable();
+
+            $table->timestamp('started_at')->nullable();
+            $table->timestamp('completed_at')->nullable();
+
+            $table->timestamps();
+
+            $table->index(['user_id', 'didit_status']);
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('identity_verifications');
+    }
+};

+ 46 - 0
database/migrations/2026_09_08_100002_add_identity_verification_to_providers_and_clients.php

@@ -0,0 +1,46 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        foreach (['providers', 'clients'] as $tableName) {
+            Schema::table($tableName, function (Blueprint $table) {
+                $table->string('identity_verification_status')->default('not_started')->index();
+                $table->timestamp('identity_verified_at')->nullable();
+                $table->unsignedTinyInteger('identity_verification_attempts')->default(0);
+
+                $table->boolean('identity_verification_required')->default(true);
+            });
+
+            DB::table($tableName)->update(['identity_verification_required' => false]);
+        }
+
+        Schema::table('clients', function (Blueprint $table) {
+            $table->date('birth_date')->nullable()->after('document');
+        });
+    }
+
+    public function down(): void
+    {
+        foreach (['providers', 'clients'] as $tableName) {
+            Schema::table($tableName, function (Blueprint $table) {
+                $table->dropColumn([
+                    'identity_verification_status',
+                    'identity_verified_at',
+                    'identity_verification_attempts',
+                    'identity_verification_required',
+                ]);
+            });
+        }
+
+        Schema::table('clients', function (Blueprint $table) {
+            $table->dropColumn('birth_date');
+        });
+    }
+};

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels