Procházet zdrojové kódy

fix: service de dashboard agora faz verificao do tipo de user para features que sao especificas para um tipo

Gustavo Mantovani před 2 týdny
rodič
revize
b7d3b5a0a7
100 změnil soubory, kde provedl 4937 přidání a 1030 odebrání
  1. 255 0
      PLANO_PUSH.md
  2. 44 0
      _ide_helper.php
  3. 29 0
      app/Enums/NotificationTypeEnum.php
  4. 15 0
      app/Enums/PushNotificationCategoryEnum.php
  5. 9 0
      app/Enums/PushNotificationTargetEnum.php
  6. 208 208
      app/Http/Controllers/AuthController.php
  7. 2 1
      app/Http/Controllers/ClientPaymentMethodController.php
  8. 26 0
      app/Http/Controllers/DeviceTokenController.php
  9. 80 0
      app/Http/Controllers/NotificationController.php
  10. 14 22
      app/Http/Controllers/ProviderController.php
  11. 2 0
      app/Http/Requests/AddressRequest.php
  12. 17 0
      app/Http/Requests/DeviceTokenRequest.php
  13. 48 0
      app/Http/Requests/ProviderPaymentMethodRequest.php
  14. 16 14
      app/Http/Requests/ProviderRequest.php
  15. 84 87
      app/Http/Requests/RegisterProviderRequest.php
  16. 2 0
      app/Http/Requests/ReviewRequest.php
  17. 1 0
      app/Http/Requests/UpdateMeRequest.php
  18. 2 0
      app/Http/Resources/AddressResource.php
  19. 20 0
      app/Http/Resources/CardsListResource.php
  20. 9 7
      app/Http/Resources/ClientResource.php
  21. 1 0
      app/Http/Resources/DashboardClienteResource.php
  22. 1 0
      app/Http/Resources/DashboardPrestadorResource.php
  23. 2 1
      app/Http/Resources/MediaResource.php
  24. 23 0
      app/Http/Resources/ProviderPaymentMethodResource.php
  25. 5 4
      app/Http/Resources/ProviderResource.php
  26. 10 0
      app/Http/Resources/ReviewResource.php
  27. 3 2
      app/Http/Resources/UserResource.php
  28. 9 0
      app/Models/Client.php
  29. 38 0
      app/Models/DeviceToken.php
  30. 36 0
      app/Models/Notification.php
  31. 72 56
      app/Models/Provider.php
  32. 38 0
      app/Models/PushNotificationLog.php
  33. 7 0
      app/Models/Review.php
  34. 31 0
      app/Models/ReviewMedia.php
  35. 11 0
      app/Models/User.php
  36. 43 0
      app/Notifications/Push/BasePushNotification.php
  37. 34 0
      app/Notifications/Push/Cliente/Contextual/ContextualSegundaPush.php
  38. 34 0
      app/Notifications/Push/Cliente/Contextual/ContextualSextaPush.php
  39. 26 0
      app/Notifications/Push/Cliente/Contextual/ContextualVisitaPush.php
  40. 26 0
      app/Notifications/Push/Cliente/Educativo/Educativo1Push.php
  41. 26 0
      app/Notifications/Push/Cliente/Educativo/Educativo2Push.php
  42. 26 0
      app/Notifications/Push/Cliente/Educativo/Educativo3Push.php
  43. 26 0
      app/Notifications/Push/Cliente/Educativo/Educativo4Push.php
  44. 39 0
      app/Notifications/Push/Cliente/EducativoConversao/EducativoConversao1Push.php
  45. 34 0
      app/Notifications/Push/Cliente/EducativoConversao/EducativoConversao2Push.php
  46. 26 0
      app/Notifications/Push/Cliente/Marketing/Marketing1Push.php
  47. 26 0
      app/Notifications/Push/Cliente/Marketing/Marketing2Push.php
  48. 26 0
      app/Notifications/Push/Cliente/Marketing/Marketing3Push.php
  49. 26 0
      app/Notifications/Push/Cliente/Marketing/Marketing4Push.php
  50. 26 0
      app/Notifications/Push/Cliente/Motivacional/Motivacional1Push.php
  51. 26 0
      app/Notifications/Push/Cliente/Motivacional/Motivacional2Push.php
  52. 26 0
      app/Notifications/Push/Cliente/Motivacional/Motivacional3Push.php
  53. 27 0
      app/Notifications/Push/Cliente/Recorrencia/Recorrencia1Push.php
  54. 27 0
      app/Notifications/Push/Cliente/Recorrencia/Recorrencia2Push.php
  55. 27 0
      app/Notifications/Push/Cliente/Recorrencia/Recorrencia3Push.php
  56. 39 0
      app/Notifications/Push/Cliente/SocialProof/SocialProof1Push.php
  57. 34 0
      app/Notifications/Push/Cliente/SocialProof/SocialProof2Push.php
  58. 54 0
      app/Notifications/Push/Prestador/Motivacional/Motivacional1Push.php
  59. 78 0
      app/Notifications/Push/Prestador/ReforcoEducativo/ReforcoEducativo1Push.php
  60. 71 0
      app/Notifications/Push/Prestador/ReforcoEducativo/ReforcoEducativo2Push.php
  61. 231 254
      app/Services/AuthService.php
  62. 10 3
      app/Services/ClientCalendarService.php
  63. 6 0
      app/Services/ClientPaymentMethodService.php
  64. 49 6
      app/Services/CustomScheduleService.php
  65. 179 33
      app/Services/DashboardService.php
  66. 29 0
      app/Services/DeviceTokenService.php
  67. 61 0
      app/Services/DistanceService.php
  68. 27 0
      app/Services/MediaService.php
  69. 59 0
      app/Services/NotificationService.php
  70. 236 209
      app/Services/ProviderService.php
  71. 138 0
      app/Services/PushNotificationDispatcher.php
  72. 78 0
      app/Services/PushNotificationService.php
  73. 23 1
      app/Services/ReviewService.php
  74. 205 8
      app/Services/ScheduleService.php
  75. 2 22
      app/Services/SearchService.php
  76. 19 3
      app/Services/UserService.php
  77. 23 0
      app/Tasks/SendPushNotificationsTask.php
  78. 8 14
      app/Traits/RemoveArchiveS3.php
  79. 1 3
      app/Traits/UploadsBase64Image.php
  80. 21 0
      app/Traits/UploadsFile.php
  81. 5 0
      bootstrap/app.php
  82. 3 1
      composer.json
  83. 990 11
      composer.lock
  84. 207 0
      config/firebase.php
  85. 41 0
      database/migrations/2026_05_25_102711_create_notifications_table.php
  86. 34 0
      database/migrations/2026_05_27_111754_create_device_tokens_table.php
  87. 35 0
      database/migrations/2026_05_27_111755_create_push_notification_logs_table.php
  88. 27 0
      database/migrations/2026_05_27_142855_add_profile_media_id_to_clients_table.php
  89. 30 0
      database/migrations/2026_05_27_143114_add_document_media_ids_to_providers_table.php
  90. 27 0
      database/migrations/2026_05_27_143114_remove_base64_columns_from_providers_table.php
  91. 33 0
      database/migrations/2026_05_27_143343_create_review_media_table.php
  92. 7 0
      database/seeders/PermissionSeeder.php
  93. 3 0
      database/seeders/UserTypePermissionSeeder.php
  94. 10 10
      lang/en/auth.php
  95. 10 10
      lang/en/messages.php
  96. 10 10
      lang/es/auth.php
  97. 10 10
      lang/es/messages.php
  98. 10 10
      lang/pt/auth.php
  99. 10 10
      lang/pt/messages.php
  100. 7 0
      routes/authRoutes/device_token.php

+ 255 - 0
PLANO_PUSH.md

@@ -0,0 +1,255 @@
+# Plano de Implementação — 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.
+
+---
+
+## 1. Condições de Elegibilidade
+
+### PROVIDER
+
+| 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 |
+
+### CLIENT
+
+| 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 |
+
+---
+
+## 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. |
+
+### CLIENT — Recorrência
+
+| 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. |
+
+### CLIENT — Educativo
+
+| 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. |
+
+### CLIENT — Educativo + Conversão
+
+| 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. |
+
+### 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ê. |
+
+### CLIENT — Motivacional
+
+| 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. |
+
+### 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. |
+
+---
+
+## 3. Estrutura de Arquivos — Backend
+
+```
+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
+```
+
+---
+
+## 4. Banco de Dados
+
+### `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
+```
+
+### `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
+```
+
+---
+
+## 5. Rotas de API
+
+```
+POST   /api/device-tokens            → registra token (login / abertura do app)
+DELETE /api/device-tokens/{token}    → remove token (logout)
+```
+
+---
+
+## 6. Scheduler
+
+Em `bootstrap/app.php`:
+
+```php
+$schedule->call(new SendPushNotificationsTask)->dailyAt('08:00');
+$schedule->call(new SendPushNotificationsTask)->dailyAt('13:00');
+$schedule->call(new SendPushNotificationsTask)->dailyAt('19:00');
+```
+
+---
+
+## 7. Frontend — Ambos os Apps
+
+### Instalações necessárias (prestador e cliente)
+```
+@capacitor/push-notifications
+```
+
+### 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
+```
+
+### 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
+
+---
+
+## 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

+ 44 - 0
_ide_helper.php

@@ -22782,6 +22782,49 @@ namespace Illuminate\Support\Facades {
             }
     }
 
+namespace Kreait\Laravel\Firebase\Facades {
+    /**
+     * @method static AppCheck appCheck()
+     * @method static Auth auth()
+     * @method static Database database()
+     * @method static Firestore firestore()
+     * @method static Messaging messaging()
+     * @method static RemoteConfig remoteConfig()
+     * @method static Storage storage()
+     * @see FirebaseProjectManager
+     * @see FirebaseProject
+     */
+    class Firebase {
+        /**
+         * @static
+         */
+        public static function project($name = null)
+        {
+            /** @var \Kreait\Laravel\Firebase\FirebaseProjectManager $instance */
+            return $instance->project($name);
+        }
+
+        /**
+         * @static
+         */
+        public static function getDefaultProject()
+        {
+            /** @var \Kreait\Laravel\Firebase\FirebaseProjectManager $instance */
+            return $instance->getDefaultProject();
+        }
+
+        /**
+         * @static
+         */
+        public static function setDefaultProject($name)
+        {
+            /** @var \Kreait\Laravel\Firebase\FirebaseProjectManager $instance */
+            return $instance->setDefaultProject($name);
+        }
+
+            }
+    }
+
 namespace Illuminate\Http {
     /**
      */
@@ -27678,6 +27721,7 @@ namespace  {
     class Validator extends \Illuminate\Support\Facades\Validator {}
     class View extends \Illuminate\Support\Facades\View {}
     class Vite extends \Illuminate\Support\Facades\Vite {}
+    class Firebase extends \Kreait\Laravel\Firebase\Facades\Firebase {}
 }
 
 

+ 29 - 0
app/Enums/NotificationTypeEnum.php

@@ -0,0 +1,29 @@
+<?php
+
+namespace App\Enums;
+
+enum NotificationTypeEnum: string
+{
+
+    // Notificação cliente
+    case SCHEDULE_CLIENT_PROVIDER_ACCEPTED = 'schedule_client_provider_accepted';
+
+    case SCHEDULE_CLIENT_PROVIDER_REFUSED = 'schedule_client_provider_refused';
+
+    case SCHEDULE_CLIENT_PROVIDER_CANCELLED = 'schedule_client_provider_cancelled';
+
+    case SCHEDULE_CLIENT_PROVIDER_COMING = 'schedule_client_provider_coming';
+
+    case SCHEDULE_CLIENT_PROVIDER_FINISHED = 'schedule_client_provider_finished';
+
+
+
+        // Notificação PRESTADO
+    case SCHEDULE_PROVIDER_CLIENT_NEW_SOLICITATION = 'schedule_provider_client_new_solicitation';
+
+    case SCHEDULE_PROVIDER_START = 'schedule_provider_start';
+
+    case SCHEDULE_PROVIDER_CLIENT_CANCELLED = 'schedule_provider_client_cancelled';
+
+    case SCHEDULE_PROVIDER_CLIENT_PROPOSAL_ACCEPTED = 'schedule_provider_client_proposal_accepted';
+}

+ 15 - 0
app/Enums/PushNotificationCategoryEnum.php

@@ -0,0 +1,15 @@
+<?php
+
+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';
+}

+ 9 - 0
app/Enums/PushNotificationTargetEnum.php

@@ -0,0 +1,9 @@
+<?php
+
+namespace App\Enums;
+
+enum PushNotificationTargetEnum: string
+{
+    case PRESTADOR = 'prestador';
+    case CLIENTE   = 'cliente';
+}

+ 208 - 208
app/Http/Controllers/AuthController.php

@@ -3,174 +3,174 @@
 namespace App\Http\Controllers;
 
 use App\Http\Requests\AuthRequest;
-use App\Http\Requests\RefreshTokenAppRequest;
 use App\Http\Requests\RefreshTokenRequest;
+use App\Http\Requests\RefreshTokenAppRequest;
 use App\Http\Requests\UserAppsRequest;
 use App\Http\Requests\UserAppsValidateCodeRequest;
+use Illuminate\Http\JsonResponse;
 use App\Http\Resources\AuthResource;
 use App\Services\AuthService;
-use Illuminate\Http\JsonResponse;
+use Illuminate\Support\Facades\Log;
 
 class AuthController extends Controller
 {
-    public function __construct(protected AuthService $authService) {}
+  public function __construct(protected AuthService $authService) {}
 
-    public function login(AuthRequest $request): JsonResponse
-    {
-        $validated = $request->validated();
-
-        $result = $this->authService->login(
-            email: $validated['email'],
-            password: $validated['password'],
-        );
+  public function login(AuthRequest $request): JsonResponse
+  {
+    $validated = $request->validated();
 
-        if (! $result) {
-            return $this->errorResponse(message: __('auth.failed'), code: 401);
-        }
+    $result = $this->authService->login(
+      email: $validated["email"],
+      password: $validated["password"],
+    );
 
-        return $this->successResponse(
-            payload: new AuthResource($result['payload']),
-            message: __('auth.logged_in'),
-        )->withCookie(
-            cookie(
-                'refresh_token',
-                $result['refreshToken'],
-                config('sanctum.rt_expiration') * 60,
-                '/',
-                config('session.domain'),
-                config('session.secure'),
-                true,
-                false,
-                'Lax',
-            ),
-        );
+    if (!$result) {
+      return $this->errorResponse(message: __("auth.failed"), code: 401);
     }
 
-    public function loginApp(AuthRequest $request): JsonResponse
-    {
-        $validated = $request->validated();
-
-        $result = $this->authService->login(
-            email: $validated['email'],
-            password: $validated['password'],
-        );
-
-        if (! $result) {
-            return $this->errorResponse(message: __('auth.failed'), code: 401);
-        }
-
-        return $this->successResponse(
-            payload: new AuthResource([
-                ...$result['payload'],
-                'refresh_token' => $result['refreshToken'],
-            ]),
-            message: __('auth.logged_in'),
-        );
+    return $this->successResponse(
+      payload: new AuthResource($result["payload"]),
+      message: __("auth.logged_in"),
+    )->withCookie(
+      cookie(
+        "refresh_token",
+        $result["refreshToken"],
+        config("sanctum.rt_expiration") * 60,
+        "/",
+        config("session.domain"),
+        config("session.secure"),
+        true,
+        false,
+        "Lax",
+      ),
+    );
+  }
+
+  public function loginApp(AuthRequest $request): JsonResponse
+  {
+    $validated = $request->validated();
+
+    $result = $this->authService->login(
+      email: $validated["email"],
+      password: $validated["password"],
+    );
+
+    if (!$result) {
+      return $this->errorResponse(message: __("auth.failed"), code: 401);
     }
 
-    public function logout(): JsonResponse
-    {
-        $this->authService->logout();
-
-        return $this->successResponse(
-            message: __('auth.logout'),
-        )->withoutCookie('refresh_token');
+    return $this->successResponse(
+      payload: new AuthResource([
+        ...$result["payload"],
+        "refresh_token" => $result["refreshToken"],
+      ]),
+      message: __("auth.logged_in"),
+    );
+  }
+
+  public function logout(): JsonResponse
+  {
+    $this->authService->logout();
+
+    return $this->successResponse(
+      message: __("auth.logout"),
+    )->withoutCookie("refresh_token");
+  }
+
+  public function refresh(RefreshTokenRequest $request): JsonResponse
+  {
+    $refresh_token = $request->cookie("refresh_token");
+
+    if (is_null($refresh_token)) {
+      return $this->errorResponse(
+        code: 403,
+      )->withoutCookie("refresh_token");
     }
 
-    public function refresh(RefreshTokenRequest $request): JsonResponse
-    {
-        $refresh_token = $request->cookie('refresh_token');
-
-        if (is_null($refresh_token)) {
-            return $this->errorResponse(
-                code: 403,
-            )->withoutCookie('refresh_token');
-        }
-
-        $result = $this->authService->refresh(
-            $refresh_token
-        );
-
-        if (is_null($result)) {
-            return $this->errorResponse(
-                message: __('auth.unauthorized'),
-                code: 403,
-            )->withoutCookie('refresh_token');
-        }
+    $result = $this->authService->refresh(
+      $refresh_token
+    );
 
-        return $this->successResponse(
-            payload: new AuthResource($result['payload']),
-        )->withCookie(
-            cookie(
-                'refresh_token',
-                $result['refreshToken'],
-                config('sanctum.rt_expiration') * 60,
-                '/',
-                config('session.domain'),
-                config('session.secure'),
-                true,
-                true,
-                'Lax',
-            ),
-        );
+    if (is_null($result)) {
+      return $this->errorResponse(
+        message: __("auth.unauthorized"),
+        code: 403,
+      )->withoutCookie("refresh_token");
     }
 
-    public function refreshApp(RefreshTokenAppRequest $request): JsonResponse
-    {
-        $refresh_token = $request->validated('refresh_token');
-
-        if (is_null($refresh_token)) {
-            return $this->errorResponse(code: 403);
-        }
-        $result = $this->authService->refresh(
-            $refresh_token
-        );
-
-        if (is_null($result)) {
-            return $this->errorResponse(
-                message: __('auth.unauthorized'),
-                code: 403,
-            );
-        }
-
-        return $this->successResponse(
-            payload: new AuthResource([
-                ...$result['payload'],
-                'refresh_token' => $result['refreshToken'],
-            ]),
-        );
+    return $this->successResponse(
+      payload: new AuthResource($result["payload"]),
+    )->withCookie(
+      cookie(
+        "refresh_token",
+        $result["refreshToken"],
+        config("sanctum.rt_expiration") * 60,
+        "/",
+        config("session.domain"),
+        config("session.secure"),
+        true,
+        true,
+        "Lax",
+      ),
+    );
+  }
+
+  public function refreshApp(RefreshTokenAppRequest $request): JsonResponse
+  {
+    $refresh_token = $request->validated("refresh_token");
+
+    if (is_null($refresh_token)) {
+      return $this->errorResponse(code: 403);
     }
-
-    public function clientSendCode(UserAppsRequest $request): JsonResponse
-    {
-        $result = $this->authService->clientSendCode($request->validated());
-
-        if (is_array($result) && isset($result['error'])) {
-            return $this->errorResponse(message: __("auth.{$result['error']}"), code: 403);
-        }
-
-        return $this->successResponse(
-            message: __('messages.code_sent'),
-            code: 201,
-            payload: ['isLogin' => $result],
-        );
+    $result = $this->authService->refresh(
+      $refresh_token
+    );
+
+    if (is_null($result)) {
+      return $this->errorResponse(
+        message: __("auth.unauthorized"),
+        code: 403,
+      );
+    }
+    return $this->successResponse(
+      payload: new AuthResource([
+        ...$result["payload"],
+        "refresh_token" => $result["refreshToken"],
+      ]),
+    );
+  }
+
+  public function clientSendCode(UserAppsRequest $request): JsonResponse
+  {
+    $result = $this->authService->clientSendCode($request->validated());
+
+    if (is_array($result) && isset($result['error'])) {
+      return $this->errorResponse(message: __("auth.{$result['error']}"), code: 403);
     }
 
-    public function providerSendCode(UserAppsRequest $request): JsonResponse
-    {
-        $result = $this->authService->providerSendCode($request->validated());
+    return $this->successResponse(
+      message: __("messages.code_sent"),
+      code: 201,
+      payload: ['isLogin' => $result],
+    );
+  }
 
-        if (is_array($result) && isset($result['error'])) {
-            return $this->errorResponse(message: __("auth.{$result['error']}"), code: 403);
-        }
+  public function providerSendCode(UserAppsRequest $request): JsonResponse
+  {
+    $result = $this->authService->providerSendCode($request->validated());
 
-        return $this->successResponse(
-            message: __('messages.code_sent'),
-            code: 201,
-            payload: ['isLogin' => $result],
-        );
+    if (is_array($result) && isset($result['error'])) {
+      return $this->errorResponse(message: __("auth.{$result['error']}"), code: 403);
     }
 
+    return $this->successResponse(
+      message: __("messages.code_sent"),
+      code: 201,
+      payload: ['isLogin' => $result],
+    );
+  }
+
     public function validateCodeClient(UserAppsValidateCodeRequest $request): JsonResponse
     {
         $email = $request->input('email');
@@ -178,29 +178,29 @@ class AuthController extends Controller
         $code = $request->input('code');
         $isLogin = (bool) $request->input('isLogin', false);
 
-        $result = $this->authService->validateCodeClient($request->validated(), $isLogin);
-
-        if ($result === false) {
-            return $this->errorResponse(message: __('auth.invalid_code'), code: 400);
-        }
+    $result = $this->authService->validateCodeClient($request->validated(), $isLogin);
 
-        if (is_array($result) && isset($result['error'])) {
-            return $this->errorResponse(message: __("auth.{$result['error']}"), code: 403);
-        }
+    if ($result === false) {
+      return $this->errorResponse(message: __('auth.invalid_code'), code: 400);
+    }
 
-        if ($isLogin) {
-            return $this->successResponse(
-                payload: new AuthResource([...$result['payload'], 'refresh_token' => $result['refreshToken']]),
-                message: __('auth.logged_in'),
-            );
-        }
+    if (is_array($result) && isset($result['error'])) {
+      return $this->errorResponse(message: __("auth.{$result['error']}"), code: 403);
+    }
 
-        return $this->successResponse(
-            payload: ['email' => $email, 'phone' => $phone, 'code' => $code],
-            message: __('auth.valid_code'),
-        );
+    if ($isLogin) {
+      return $this->successResponse(
+        payload: new AuthResource([...$result['payload'], 'refresh_token' => $result['refreshToken']]),
+        message: __('auth.logged_in'),
+      );
     }
 
+    return $this->successResponse(
+      payload: ['email' => $email, 'phone' => $phone, 'code' => $code],
+      message: __('auth.valid_code'),
+    );
+  }
+
     public function validateCodeProvider(UserAppsValidateCodeRequest $request): JsonResponse
     {
         $email = $request->input('email');
@@ -208,29 +208,29 @@ class AuthController extends Controller
         $code = $request->input('code');
         $isLogin = (bool) $request->input('isLogin', false);
 
-        $result = $this->authService->validateCodeProvider($request->validated(), $isLogin);
-
-        if ($result === false) {
-            return $this->errorResponse(message: __('auth.invalid_code'), code: 400);
-        }
+    $result = $this->authService->validateCodeProvider($request->validated(), $isLogin);
 
-        if (is_array($result) && isset($result['error'])) {
-            return $this->errorResponse(message: __("auth.{$result['error']}"), code: 403);
-        }
+    if ($result === false) {
+      return $this->errorResponse(message: __('auth.invalid_code'), code: 400);
+    }
 
-        if ($isLogin) {
-            return $this->successResponse(
-                payload: new AuthResource([...$result['payload'], 'refresh_token' => $result['refreshToken']]),
-                message: __('auth.logged_in'),
-            );
-        }
+    if (is_array($result) && isset($result['error'])) {
+      return $this->errorResponse(message: __("auth.{$result['error']}"), code: 403);
+    }
 
-        return $this->successResponse(
-            payload: ['email' => $email, 'phone' => $phone, 'code' => $code],
-            message: __('auth.valid_code'),
-        );
+    if ($isLogin) {
+      return $this->successResponse(
+        payload: new AuthResource([...$result['payload'], 'refresh_token' => $result['refreshToken']]),
+        message: __('auth.logged_in'),
+      );
     }
 
+    return $this->successResponse(
+      payload: ['email' => $email, 'phone' => $phone, 'code' => $code],
+      message: __('auth.valid_code'),
+    );
+  }
+
     public function validateCode(UserAppsValidateCodeRequest $request): JsonResponse
     {
         try {
@@ -239,36 +239,36 @@ class AuthController extends Controller
             $code = $request->input('code');
             $isLogin = $request->input('isLogin');
 
-            $result = $this->authService->validateCode($request->validated(), $isLogin);
-
-            if (! $result) {
-                return $this->errorResponse(
-                    message: __('auth.invalid_code'),
-                    code: 400,
-                );
-            }
-
-            if ($isLogin) {
-                return $this->successResponse(
-                    payload: new AuthResource([
-                        ...$result['payload'],
-                        'refresh_token' => $result['refreshToken'],
-                    ]),
-                    message: __('auth.logged_in'),
-                );
-            } else {
-                return $this->successResponse(
-                    payload: ['email' => $email, 'phone' => $phone, 'code' => $code],
-                    message: __('auth.valid_code'),
-                    code: 200,
-                );
-            }
-
-        } catch (\Exception $e) {
-            return $this->errorResponse(
-                message: __('auth.validation_error'),
-                code: 500,
-            );
-        }
+      $result = $this->authService->validateCode($request->validated(), $isLogin);
+  
+      if (!$result) {
+        return $this->errorResponse(
+          message: __("auth.invalid_code"),
+          code: 400,
+        );
+      }
+      
+      if($isLogin) {
+        return $this->successResponse(
+          payload: new AuthResource([
+            ...$result["payload"],
+            "refresh_token" => $result["refreshToken"],
+          ]),
+          message: __("auth.logged_in"),
+        );
+      } else {
+        return $this->successResponse(
+          payload: ['email' => $email, 'phone' => $phone, 'code' => $code],
+          message: __("auth.valid_code"),
+          code: 200,
+        );
+      }
+
+    } catch (\Exception $e) {
+      return $this->errorResponse(
+        message: __("auth.validation_error"),
+        code: 500,
+      );
     }
+  }
 }

+ 2 - 1
app/Http/Controllers/ClientPaymentMethodController.php

@@ -3,6 +3,7 @@
 namespace App\Http\Controllers;
 
 use App\Http\Requests\ClientPaymentMethodRequest;
+use App\Http\Resources\CardsListResource;
 use App\Http\Resources\ClientPaymentMethodResource;
 use App\Services\ClientPaymentMethodService;
 use Illuminate\Http\JsonResponse;
@@ -18,7 +19,7 @@ class ClientPaymentMethodController extends Controller
         $paymentMethods = $this->service->getByClientId($clientId);
 
         return $this->successResponse(
-            payload: ClientPaymentMethodResource::collection($paymentMethods)
+            payload: CardsListResource::collection($paymentMethods)
         );
     }
 

+ 26 - 0
app/Http/Controllers/DeviceTokenController.php

@@ -0,0 +1,26 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Http\Requests\DeviceTokenRequest;
+use App\Services\DeviceTokenService;
+use Illuminate\Http\JsonResponse;
+
+class DeviceTokenController extends Controller
+{
+    public function __construct(private DeviceTokenService $deviceTokenService) {}
+
+    public function store(DeviceTokenRequest $request): JsonResponse
+    {
+        $this->deviceTokenService->register($request->validated());
+
+        return $this->successResponse(code: 201);
+    }
+
+    public function destroy(string $token): JsonResponse
+    {
+        $this->deviceTokenService->remove($token);
+
+        return $this->successResponse();
+    }
+}

+ 80 - 0
app/Http/Controllers/NotificationController.php

@@ -0,0 +1,80 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Notification;
+use Carbon\Carbon;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Support\Facades\Auth;
+
+class NotificationController extends Controller
+{
+    public function index(): JsonResponse
+    {
+        $user = Auth::user();
+
+        $notifications = Notification::where('user_id', $user->id)
+            ->orderBy('read', 'asc')
+            ->orderBy('created_at', 'desc')
+            ->limit(50)
+            ->get()
+            ->map(function ($notification) {
+
+                return [
+                    'id' => $notification->id,
+
+                    'title' => $notification->title,
+
+                    'description' => $notification->description,
+
+                    'origin' => $notification->origin,
+
+                    'origin_id' => $notification->origin_id,
+
+                    'type' => $notification->type,
+
+                    'read' => $notification->read,
+
+                    'time' => Carbon::parse(
+                        $notification->created_at
+                    )->diffForHumans(),
+                ];
+            });
+
+        return $this->successResponse(
+            payload: $notifications
+        );
+    }
+
+    public function markAsRead(int $id): JsonResponse
+    {
+        $notification = Notification::where('id', $id)
+            ->where('user_id', Auth::id())
+            ->firstOrFail();
+
+        $notification->update([
+            'read' => true,
+
+            'read_at' => now(),
+        ]);
+
+        return $this->successResponse(
+            message: __('messages.updated')
+        );
+    }
+
+    public function markAllAsRead(): JsonResponse
+    {
+        Notification::where('user_id', Auth::id())
+            ->where('read', false)
+            ->update([
+                'read' => true,
+
+                'read_at' => now(),
+            ]);
+
+        return $this->successResponse(
+            message: __('messages.updated')
+        );
+    }
+}

+ 14 - 22
app/Http/Controllers/ProviderController.php

@@ -18,7 +18,6 @@ class ProviderController extends Controller
     public function index(): JsonResponse
     {
         $items = $this->service->getAll();
-
         return $this->successResponse(
             payload: ProviderResource::collection($items),
         );
@@ -27,10 +26,9 @@ class ProviderController extends Controller
     public function store(ProviderRequest $request): JsonResponse
     {
         $item = $this->service->create($request->validated());
-
         return $this->successResponse(
             payload: new ProviderResource($item),
-            message: __('messages.created'),
+            message: __("messages.created"),
             code: 201,
         );
     }
@@ -38,14 +36,12 @@ class ProviderController extends Controller
     public function show(int $id): JsonResponse
     {
         $item = $this->service->findById($id);
-
         return $this->successResponse(payload: new ProviderResource($item));
     }
 
     public function update(ProviderRequest $request, int $id): JsonResponse
     {
         $item = $this->service->update($id, $request->validated());
-
         return $this->successResponse(
             payload: new ProviderResource($item),
             message: __('messages.updated'),
@@ -65,9 +61,8 @@ class ProviderController extends Controller
     public function destroy(int $id): JsonResponse
     {
         $this->service->delete($id);
-
         return $this->successResponse(
-            message: __('messages.deleted'),
+            message: __("messages.deleted"),
             code: 204,
         );
     }
@@ -89,7 +84,6 @@ class ProviderController extends Controller
     public function approve(int $id): JsonResponse
     {
         $item = $this->service->approve($id);
-
         return $this->successResponse(
             payload: new ProviderResource($item),
             message: __('messages.provider_approved'),
@@ -99,7 +93,6 @@ class ProviderController extends Controller
     public function reject(int $id): JsonResponse
     {
         $item = $this->service->reject($id);
-
         return $this->successResponse(
             payload: new ProviderResource($item),
             message: __('messages.provider_rejected'),
@@ -108,18 +101,17 @@ class ProviderController extends Controller
 
     public function register(RegisterProviderRequest $request): JsonResponse
     {
-        $result = $this->service->register($request->validated());
-
-        if (! $result) {
-            return $this->errorResponse(message: __('auth.failed'), code: 401);
-        }
-
-        return $this->successResponse(
-            payload: new AuthResource([
-                ...$result['payload'],
-                'refresh_token' => $result['refreshToken'],
-            ]),
-            message: __('auth.logged_in'),
-        );
+      $result = $this->service->register($request->validated());
+      if (!$result) {
+        return $this->errorResponse(message: __("auth.failed"), code: 401);
+      }
+
+      return $this->successResponse(
+        payload: new AuthResource([
+          ...$result["payload"],
+          "refresh_token" => $result["refreshToken"],
+        ]),
+        message: __("auth.logged_in"),
+      );
     }
 }

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

@@ -27,6 +27,8 @@ class AddressRequest extends FormRequest
             'state_id'       => 'nullable|integer|exists:states,id',
             'address_type'   => 'sometimes|in:home,commercial,other',
             'is_primary'     => 'sometimes|boolean',
+            'latitude'       => 'nullable|numeric|between:-90,90',
+            'longitude'      => 'nullable|numeric|between:-180,180',
         ];
 
         if ($this->isMethod('post')) {

+ 17 - 0
app/Http/Requests/DeviceTokenRequest.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class DeviceTokenRequest extends FormRequest
+{
+    public function rules(): array
+    {
+        return [
+            'token'    => 'required|string',
+            'platform' => 'required|string|in:android,ios',
+            'app_type' => 'required|string|in:prestador,cliente',
+        ];
+    }
+}

+ 48 - 0
app/Http/Requests/ProviderPaymentMethodRequest.php

@@ -0,0 +1,48 @@
+<?php
+
+namespace App\Http\Requests;
+
+use App\Enums\AccountTypeEnum;
+use App\Enums\BankAccountTypeEnum;
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+
+class ProviderPaymentMethodRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    public function rules(): array
+    {
+
+        if ($this->account_type == AccountTypeEnum::PIX->value) {
+
+            $rules =  [
+                'account_type' => ['sometimes', Rule::in([AccountTypeEnum::PIX->value, AccountTypeEnum::BANK_ACCOUNT->value])],
+                'pix_key' => ['required', 'string', 'max:255', Rule::requiredIf($this->account_type === AccountTypeEnum::PIX->value)],
+                'bank_account_type' => ['nullable', Rule::in([BankAccountTypeEnum::CHECKING->value, BankAccountTypeEnum::SAVINGS->value]), Rule::requiredIf($this->account_type === AccountTypeEnum::BANK_ACCOUNT->value)],
+                'agency' => ['nullable', 'string', 'max:255', Rule::requiredIf($this->account_type === AccountTypeEnum::BANK_ACCOUNT->value)],
+                'account' => ['nullable', 'string', 'max:255', Rule::requiredIf($this->account_type === AccountTypeEnum::BANK_ACCOUNT->value)],
+                'digit' => ['nullable', 'string', 'max:255', Rule::requiredIf($this->account_type === AccountTypeEnum::BANK_ACCOUNT->value)],
+            ];
+        } else {
+            $rules =  [
+                'account_type' => ['sometimes', Rule::in([AccountTypeEnum::PIX->value, AccountTypeEnum::BANK_ACCOUNT->value])],
+                'pix_key' => ['nullable', 'string', 'max:255', Rule::requiredIf($this->account_type === AccountTypeEnum::PIX->value)],
+                'bank_account_type' => ['sometimes', Rule::in([BankAccountTypeEnum::CHECKING->value, BankAccountTypeEnum::SAVINGS->value]), Rule::requiredIf($this->account_type === AccountTypeEnum::BANK_ACCOUNT->value)],
+                'agency' => ['sometimes', 'string', 'max:255', Rule::requiredIf($this->account_type === AccountTypeEnum::BANK_ACCOUNT->value)],
+                'account' => ['sometimes', 'string', 'max:255', Rule::requiredIf($this->account_type === AccountTypeEnum::BANK_ACCOUNT->value)],
+                'digit' => ['sometimes', 'string', 'max:255', Rule::requiredIf($this->account_type === AccountTypeEnum::BANK_ACCOUNT->value)],
+            ];
+        }
+
+        if ($this->isMethod('post')) {
+            $rules['provider_id'] =  ['required', 'exists:providers,id'];
+        };
+
+
+        return $rules;
+    }
+}

+ 16 - 14
app/Http/Requests/ProviderRequest.php

@@ -2,9 +2,10 @@
 
 namespace App\Http\Requests;
 
-use App\Enums\ApprovalStatusEnum;
 use Illuminate\Foundation\Http\FormRequest;
 use Illuminate\Validation\Rule;
+use App\Enums\UserTypeEnum;
+use App\Enums\ApprovalStatusEnum;
 
 class ProviderRequest extends FormRequest
 {
@@ -17,8 +18,8 @@ class ProviderRequest extends FormRequest
                 'sometimes',
                 'string',
                 'regex:/^[0-9]{11}$|^[0-9]{14}$/',
-                function ($attribute, $value, $fail) {
-                    if (! $this->isValidCpfCnpj($value)) {
+                function ($attribute, $value, $fail) use ($providerId) {
+                    if (!$this->isValidCpfCnpj($value)) {
                         $fail(__('validation.custom.document.invalid'));
                     }
                 },
@@ -26,7 +27,7 @@ class ProviderRequest extends FormRequest
                     ->ignore($providerId)
                     ->whereNull('deleted_at'),
             ],
-            'rg'      => 'sometimes|nullable|string|max:20',
+            'rg' => 'sometimes|nullable|string|max:20',
             'user_id' => [
                 'sometimes',
                 'exists:users,id',
@@ -38,7 +39,7 @@ class ProviderRequest extends FormRequest
                         ->where('user_id', $value)
                         ->whereNull('deleted_at')
                         ->exists();
-
+                    
                     if ($clientExists) {
                         $fail(__('validation.custom.user_id.already_linked_to_client'));
                     }
@@ -66,6 +67,7 @@ class ProviderRequest extends FormRequest
             'daily_price_4h'         => 'sometimes|nullable|numeric',
             'daily_price_2h'         => 'sometimes|nullable|numeric',
             'profile_media_id'       => 'sometimes|nullable|exists:media,id',
+            'avatar'                 => 'sometimes|file|image|mimes:jpg,jpeg,png,webp|max:5120',
             'recipient_name'         => 'sometimes|string|max:255',
             'recipient_email'        => 'sometimes|email|max:255',
             'recipient_description'  => 'sometimes|nullable|string',
@@ -82,7 +84,7 @@ class ProviderRequest extends FormRequest
                 'string',
                 'regex:/^[0-9]{11}$|^[0-9]{14}$/',
                 function ($attribute, $value, $fail) {
-                    if (! $this->isValidCpfCnpj($value)) {
+                    if (!$this->isValidCpfCnpj($value)) {
                         $fail(__('validation.custom.document.invalid'));
                     }
                 },
@@ -116,7 +118,7 @@ class ProviderRequest extends FormRequest
                         ->where('user_id', $value)
                         ->whereNull('deleted_at')
                         ->exists();
-
+                    
                     if ($clientExists) {
                         $fail(__('validation.custom.user_id.already_linked_to_client'));
                     }
@@ -186,11 +188,11 @@ class ProviderRequest extends FormRequest
         }
 
         // Valida primeiro dígito verificador
-        $length  = strlen($cnpj) - 2;
+        $length = strlen($cnpj) - 2;
         $numbers = substr($cnpj, 0, $length);
-        $digits  = substr($cnpj, $length);
-        $sum     = 0;
-        $pos     = $length - 7;
+        $digits = substr($cnpj, $length);
+        $sum = 0;
+        $pos = $length - 7;
 
         for ($i = $length; $i >= 1; $i--) {
             $sum += $numbers[$length - $i] * $pos--;
@@ -206,10 +208,10 @@ class ProviderRequest extends FormRequest
         }
 
         // Valida segundo dígito verificador
-        $length  = $length + 1;
+        $length = $length + 1;
         $numbers = substr($cnpj, 0, $length);
-        $sum     = 0;
-        $pos     = $length - 7;
+        $sum = 0;
+        $pos = $length - 7;
 
         for ($i = $length; $i >= 1; $i--) {
             $sum += $numbers[$length - $i] * $pos--;

+ 84 - 87
app/Http/Requests/RegisterProviderRequest.php

@@ -8,93 +8,90 @@ use Illuminate\Validation\Rule;
 
 class RegisterProviderRequest extends FormRequest
 {
-    public function rules(): array
-    {
-        $rules = [
-            'email'      => 'sometimes|email',
-            'phone'      => 'sometimes|string|nullable|max:20',
-            'name'       => 'required|string|max:255',
-            'code'       => 'required|string|max:6',
-            'document'   => ['required', 'string', 'max:20'],
-            'rg'         => 'required|string|max:20',
-            'birth_date' => 'required|date|before:today',
-
-            'recipient_name'        => 'required|string|max:255',
-            'recipient_email'       => 'required|email|max:255',
-            'recipient_description' => 'required|string',
-            'recipient_document'    => 'required|string|max:20',
-            'recipient_type'        => ['required', Rule::in(['individual', 'company'])],
-            'recipient_code'        => 'required|string|max:255',
-
-            'recipient_payment_mode' => ['required', Rule::in(['bank_transfer'])],
-
-            'recipient_default_bank_account'                     => 'required|array',
-            'recipient_default_bank_account.holder_name'         => 'required|string|max:255',
-            'recipient_default_bank_account.holder_type'         => ['required', Rule::in(['individual', 'company'])],
-            'recipient_default_bank_account.holder_document'     => 'required|string|max:20',
-            'recipient_default_bank_account.bank'                => 'required|string|max:20',
-            'recipient_default_bank_account.branch_number'       => 'required|string|max:20',
-            'recipient_default_bank_account.branch_check_digit'  => 'sometimes|nullable|string|max:10',
-            'recipient_default_bank_account.account_number'      => 'required|string|max:20',
-            'recipient_default_bank_account.account_check_digit' => 'required|string|max:10',
-            'recipient_default_bank_account.type'                => ['required', Rule::in(['checking', 'savings'])],
-            'recipient_default_bank_account.metadata'            => 'sometimes|array',
-            'recipient_default_bank_account.pix_key'             => 'sometimes|nullable|string|max:255',
-
-            'recipient_metadata' => 'sometimes|array',
-
-            'zip_code'       => 'required|string|max:20',
-            'address'        => 'required|string|max:255',
-            'has_complement' => 'sometimes|boolean',
-            'complement'     => 'nullable|string|max:255',
-            'nickname'       => 'nullable|string|max:255',
-            'instructions'   => 'nullable|string',
-            'address_type'   => ['required', Rule::in(['home', 'commercial', 'other'])],
-            'city'           => 'nullable|string|max:255',
-            'state'          => 'nullable|string|max:2',
-
-            'daily_price_8h' => 'required|numeric|min:100|max:500',
-            'daily_price_6h' => 'required|numeric|min:0',
-            'daily_price_4h' => 'required|numeric|min:0',
-            'daily_price_2h' => 'required|numeric|min:0',
-
-            'services_types_ids' => 'sometimes|array',
-
-            'services_types_ids.*' => [
-                'integer',
-                Rule::exists('service_types', 'id')->where(function ($query) {
-                    $query->whereNull('deleted_at')->where('is_active', true);
-                }),
-            ],
-
-            'service_types_ids' => 'sometimes|array',
-
-            'service_types_ids.*' => [
-                'integer',
-                Rule::exists('service_types', 'id')->where(function ($query) {
-                    $query->whereNull('deleted_at')->where('is_active', true);
-                }),
-            ],
-
-            'working_days'          => 'required|array|min:1',
-            'working_days.*.day'    => 'required|integer|min:0|max:6',
-            'working_days.*.period' => ['required', Rule::in([WorkingPeriodEnum::MORNING->value, WorkingPeriodEnum::AFTERNOON->value])],
-
-            'selfie_base64'         => 'required|string',
-            'document_front_base64' => 'required|string',
-            'document_back_base64'  => 'required|string',
-        ];
-
-        if (! $this->has('email')) {
-            $rules['phone'] = 'required|string|max:20';
-            $rules['email'] = 'nullable';
-        }
-
-        if (! $this->has('phone')) {
-            $rules['email'] = 'required|email';
-            $rules['phone'] = 'nullable';
-        }
+  public function rules(): array
+  {
+    $rules = [
+      'email' => 'sometimes|email',
+      'phone' => 'sometimes|string|nullable|max:20',
+      'name' => 'required|string|max:255',
+      'code' => 'required|string|max:6',
+      'document' => ['required', 'string', 'max:20'],
+      'rg' => 'required|string|max:20',
+      'birth_date' => 'required|date|before:today',
+
+      'recipient_name'        => 'sometimes|string|max:255',
+      'recipient_email'       => 'sometimes|email|max:255',
+      'recipient_description' => 'sometimes|string',
+      'recipient_document'    => 'sometimes|string|max:20',
+      'recipient_type'        => ['sometimes', Rule::in(['individual', 'company'])],
+      'recipient_code'        => 'sometimes|string|max:255',
+
+      'recipient_payment_mode' => ['sometimes', Rule::in(['bank_transfer'])],
+
+      'recipient_default_bank_account'                     => 'sometimes|array',
+      'recipient_default_bank_account.holder_name'         => 'sometimes|string|max:255',
+      'recipient_default_bank_account.holder_type'         => ['sometimes', Rule::in(['individual', 'company'])],
+      'recipient_default_bank_account.holder_document'     => 'sometimes|string|max:20',
+      'recipient_default_bank_account.bank'                => 'sometimes|string|max:20',
+      'recipient_default_bank_account.branch_number'       => 'sometimes|string|max:20',
+      'recipient_default_bank_account.branch_check_digit'  => 'sometimes|nullable|string|max:10',
+      'recipient_default_bank_account.account_number'      => 'sometimes|string|max:20',
+      'recipient_default_bank_account.account_check_digit' => 'sometimes|string|max:10',
+      'recipient_default_bank_account.type'                => ['sometimes', Rule::in(['checking', 'savings'])],
+      'recipient_default_bank_account.metadata'            => 'sometimes|array',
+      'recipient_default_bank_account.pix_key'             => 'sometimes|nullable|string|max:255',
+
+      'recipient_metadata' => 'sometimes|array',
+
+      'zip_code'       => 'required|string|max:20',
+      'address'        => 'required|string|max:255',
+      'has_complement' => 'sometimes|boolean',
+      'complement'     => 'nullable|string|max:255',
+      'nickname'       => 'nullable|string|max:255',
+      'instructions'   => 'nullable|string',
+      'address_type'   => ['required', Rule::in(['home', 'commercial', 'other'])],
+      'city'           => 'nullable|string|max:255',
+      'state'          => 'nullable|string|max:2',
+
+      'daily_price_8h' => 'required|numeric|min:100|max:500',
+      'daily_price_6h' => 'required|numeric|min:0',
+      'daily_price_4h' => 'required|numeric|min:0',
+      'daily_price_2h' => 'required|numeric|min:0',
+
+      'services_types_ids' => 'sometimes|array',
+      'services_types_ids.*' => [
+        'integer',
+        Rule::exists('service_types', 'id')->where(function ($query) {
+          $query->whereNull('deleted_at')->where('is_active', true);
+        }),
+      ],
+      'service_types_ids' => 'sometimes|array',
+      'service_types_ids.*' => [
+        'integer',
+        Rule::exists('service_types', 'id')->where(function ($query) {
+          $query->whereNull('deleted_at')->where('is_active', true);
+        }),
+      ],
+
+      'working_days' => 'required|array|min:1',
+      'working_days.*.day' => 'required|integer|min:0|max:6',
+      'working_days.*.period' => ['required', Rule::in([WorkingPeriodEnum::MORNING->value, WorkingPeriodEnum::AFTERNOON->value])],
+
+      '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',
+    ];
+
+    if (!$this->has('email')) {
+      $rules['phone'] = 'required|string|max:20';
+      $rules['email'] = 'nullable';
+    }
 
-        return $rules;
+    if (!$this->has('phone')) {
+      $rules['email'] = 'required|email';
+      $rules['phone'] = 'nullable';
     }
+
+    return $rules;
+  }
 }

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

@@ -27,6 +27,8 @@ class ReviewRequest extends FormRequest
             'block_provider'     => ['sometimes', 'boolean'],
             'block_client'       => ['sometimes', 'boolean'],
             'favorite_provider'  => ['sometimes', 'boolean'],
+            'photos'             => ['sometimes', 'array', 'max:5'],
+            'photos.*'           => ['file', 'image', 'mimes:jpg,jpeg,png,webp', 'max:10240'],
         ];
 
         if ($this->isMethod('POST')) {

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

@@ -21,6 +21,7 @@ class UpdateMeRequest extends FormRequest
             'phone'    => 'sometimes|string|nullable',
             'language' => 'sometimes|string|nullable',
             'document' => 'sometimes|string|nullable',
+            'avatar'   => 'sometimes|file|image|mimes:jpg,jpeg,png,webp|max:5120',
         ];
     }
 }

+ 2 - 0
app/Http/Resources/AddressResource.php

@@ -35,6 +35,8 @@ class AddressResource extends JsonResource
                 $this->district,
                 $this->city ? "{$this->city->name}/{$this->state?->code}" : null,
             ])),
+            'latitude'     => $this->latitude,
+            'longitude'    => $this->longitude,
             'city'         => $this->whenLoaded('city'),
             'state'        => $this->whenLoaded('state'),
             'address_type' => $this->address_type,

+ 20 - 0
app/Http/Resources/CardsListResource.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace App\Http\Resources;
+
+use Illuminate\Http\Request;
+use Illuminate\Http\Resources\Json\JsonResource;
+
+class CardsListResource extends JsonResource
+{
+    public function toArray(Request $request): array
+    {
+        return [
+            'id' => $this->id,
+            'brand' => $this->brand,
+            'last_four_digits' => $this->last_four_digits,
+            'card_name' => $this->card_name,
+
+        ];
+    }
+}

+ 9 - 7
app/Http/Resources/ClientResource.php

@@ -15,13 +15,15 @@ class ClientResource extends JsonResource
     public function toArray(Request $request): array
     {
         return [
-            'id'         => $this->id,
-            'document'   => $this->document,
-            'user_id'    => $this->user_id,
-            'user'       => new UserResource($this->whenLoaded('user')),
-            'created_at' => $this->created_at,
-            'updated_at' => $this->updated_at,
-            'deleted_at' => $this->deleted_at,
+            'id'              => $this->id,
+            'document'        => $this->document,
+            'user_id'         => $this->user_id,
+            'profile_media_id' => $this->profile_media_id,
+            'profile_media'   => new MediaResource($this->whenLoaded('profileMedia')),
+            'user'            => new UserResource($this->whenLoaded('user')),
+            'created_at'      => $this->created_at,
+            'updated_at'      => $this->updated_at,
+            'deleted_at'      => $this->deleted_at,
         ];
     }
 }

+ 1 - 0
app/Http/Resources/DashboardClienteResource.php

@@ -24,6 +24,7 @@ class DashboardClienteResource extends JsonResource
             'providersClose'      => $this['providersClose'],
             'schedulesProposals'  => $this['schedulesProposals'],
             'todaySchedules'      => $this['todaySchedules'],
+            'notifications' => $this['notifications'],
             'has_payment_methods' => $this['has_payment_methods'],
         ];
     }

+ 1 - 0
app/Http/Resources/DashboardPrestadorResource.php

@@ -22,6 +22,7 @@ class DashboardPrestadorResource extends JsonResource
             'solicitations'  => $this['solicitations'],
             'nextSchedules'  => $this['nextSchedules'],
             'opportunities'  => $this['opportunities'],
+            'notifications' => $this['notifications'],
         ];
     }
 }

+ 2 - 1
app/Http/Resources/MediaResource.php

@@ -7,6 +7,7 @@ use Carbon\Carbon;
 use Illuminate\Http\Request;
 use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
 use Illuminate\Http\Resources\Json\JsonResource;
+use Illuminate\Support\Facades\Storage;
 
 class MediaResource extends JsonResource
 {
@@ -18,7 +19,7 @@ class MediaResource extends JsonResource
             'source_id'  => $this->source_id,
             'name'       => $this->name,
             'path'       => $this->path,
-            'url'        => $this->url,
+            'url'        => $this->path ? Storage::temporaryUrl($this->path, now()->addMinutes(60)) : null,
             'user_id'    => $this->user_id,
             'user'       => $this->user,
             'created_at' => Carbon::parse($this->created_at)->format('Y-m-d H:i'),

+ 23 - 0
app/Http/Resources/ProviderPaymentMethodResource.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace App\Http\Resources;
+
+use Illuminate\Http\Request;
+use Illuminate\Http\Resources\Json\JsonResource;
+
+class ProviderPaymentMethodResource extends JsonResource
+{
+    public function toArray(Request $request): array
+    {
+        return [
+            'id' => $this->id,
+            'provider_id' => $this->provider_id,
+            'account_type' => $this->account_type?->value,
+            'pix_key' => $this->pix_key,
+            'bank_account_type' => $this->bank_account_type?->value,
+            'agency' => $this->agency,
+            'account' => $this->account,
+            'digit' => $this->digit,
+        ];
+    }
+}

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

@@ -2,11 +2,12 @@
 
 namespace App\Http\Resources;
 
-use App\Models\Provider;
 use Carbon\Carbon;
 use Illuminate\Http\Request;
-use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
 use Illuminate\Http\Resources\Json\JsonResource;
+use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
+use App\Http\Resources\MediaResource;
+use App\Models\Provider;
 
 class ProviderResource extends JsonResource
 {
@@ -31,14 +32,14 @@ class ProviderResource extends JsonResource
             'profile_media_id'               => $this->profile_media_id,
             'recipient_id'                   => $this->recipient_id,
             'recipient_default_bank_account' => $this->recipient_default_bank_account,
-            'profile_media'                  => $this->profileMedia,
+            'profile_media'                  => $this->profileMedia ? new MediaResource($this->profileMedia) : null,
             'created_at'                     => Carbon::parse($this->created_at)->format('d/m/Y H:i'),
             'updated_at'                     => Carbon::parse($this->updated_at)->format('d/m/Y H:i'),
         ];
     }
 
     /**
-     * @param  \Illuminate\Database\Eloquent\Collection<Provider>  $resource
+     * @param \Illuminate\Database\Eloquent\Collection<Provider> $resource
      * @return \Illuminate\Http\Resources\Json\AnonymousResourceCollection<ProviderResource>
      */
     public static function collection($resource): AnonymousResourceCollection

+ 10 - 0
app/Http/Resources/ReviewResource.php

@@ -4,6 +4,7 @@ namespace App\Http\Resources;
 
 use Illuminate\Http\Request;
 use Illuminate\Http\Resources\Json\JsonResource;
+use Illuminate\Support\Facades\Storage;
 
 class ReviewResource extends JsonResource
 {
@@ -34,6 +35,15 @@ class ReviewResource extends JsonResource
                 });
             }),
 
+            'photos'     => $this->whenLoaded('reviewMedia', function () {
+                return $this->reviewMedia->map(fn ($rm) => [
+                    'id'     => $rm->media_id,
+                    'origin' => $rm->origin,
+                    'url'    => $rm->media?->path
+                        ? Storage::temporaryUrl($rm->media->path, now()->addMinutes(60))
+                        : null,
+                ]);
+            }),
             'created_at' => $this->created_at?->format('Y-m-d H:i'),
             'updated_at' => $this->updated_at?->format('Y-m-d H:i'),
         ];

+ 3 - 2
app/Http/Resources/UserResource.php

@@ -6,6 +6,7 @@ use Carbon\Carbon;
 use Illuminate\Http\Request;
 use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
 use Illuminate\Http\Resources\Json\JsonResource;
+use App\Http\Resources\ProviderResource;
 
 class UserResource extends JsonResource
 {
@@ -31,8 +32,8 @@ class UserResource extends JsonResource
             'client_id'               => $this->client?->id,
             'client_document'         => $this->client?->document,
             'registration_complete'   => $this->registration_complete,
-            'provider'                => $this->whenLoaded('provider'),
-            'client'                  => $this->whenLoaded('client'),
+            'provider'                => new ProviderResource($this->whenLoaded('provider')),
+            'client'                  => new ClientResource($this->whenLoaded('client')),
             'created_at'              => Carbon::parse($this->created_at)->format('Y-m-d H:i'),
             'updated_at'              => Carbon::parse($this->updated_at)->format('Y-m-d H:i'),
         ];

+ 9 - 0
app/Models/Client.php

@@ -6,12 +6,15 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\Relations\HasOne;
 use Illuminate\Database\Eloquent\SoftDeletes;
 
 /**
  * @property int $id
  * @property string|null $document
  * @property int $user_id
+ * @property int|null $profile_media_id
+ * @property-read \App\Models\Media|null $profileMedia
  * @property \Illuminate\Support\Carbon|null $created_at
  * @property \Illuminate\Support\Carbon|null $updated_at
  * @property \Illuminate\Support\Carbon|null $deleted_at
@@ -53,6 +56,7 @@ class Client extends Model
         'external_customer_id',
         'external_customer_code',
         'user_id',
+        'profile_media_id',
     ];
 
     protected $casts = [
@@ -66,6 +70,11 @@ class Client extends Model
         return $this->belongsTo(User::class)->select('id', 'name', 'email', 'phone');
     }
 
+    public function profileMedia(): BelongsTo
+    {
+        return $this->belongsTo(Media::class, 'profile_media_id');
+    }
+
     public function blockedByProviders(): HasMany
     {
         return $this->hasMany(ProviderClientBlock::class);

+ 38 - 0
app/Models/DeviceToken.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Models;
+
+use App\Enums\PushNotificationTargetEnum;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+/**
+ * @property int $id
+ * @property int $user_id
+ * @property string $token
+ * @property string $platform
+ * @property PushNotificationTargetEnum $app_type
+ * @property bool $active
+ * @property \Illuminate\Support\Carbon|null $created_at
+ * @property \Illuminate\Support\Carbon|null $updated_at
+ * @property-read \App\Models\User $user
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|DeviceToken whereAppType($value)
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|DeviceToken whereUserId($value)
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|DeviceToken whereToken($value)
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|DeviceToken whereActive($value)
+ * @mixin \Eloquent
+ */
+class DeviceToken extends Model
+{
+    protected $guarded = ['id'];
+
+    protected $casts = [
+        'active'   => 'boolean',
+        'app_type' => PushNotificationTargetEnum::class,
+    ];
+
+    public function user(): BelongsTo
+    {
+        return $this->belongsTo(User::class);
+    }
+}

+ 36 - 0
app/Models/Notification.php

@@ -0,0 +1,36 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+class Notification extends Model
+{
+    use HasFactory, SoftDeletes;
+
+    protected $fillable = [
+        'title',
+        'description',
+        'origin',
+        'origin_id',
+        'type',
+        'read',
+        'read_at',
+        'user_id',
+    ];
+
+    protected $casts = [
+        'read' => 'boolean',
+        'read_at' => 'datetime',
+        'created_at' => 'datetime',
+        'updated_at' => 'datetime',
+        'deleted_at' => 'datetime',
+    ];
+
+    public function user()
+    {
+        return $this->belongsTo(User::class);
+    }
+}

+ 72 - 56
app/Models/Provider.php

@@ -15,22 +15,24 @@ use Illuminate\Database\Eloquent\SoftDeletes;
  * @property string $document
  * @property string|null $rg
  * @property int $user_id
- * @property numeric|null $average_rating
+ * @property float|null $average_rating
  * @property int $total_services
- * @property \Illuminate\Support\Carbon|null $birth_date
+ * @property string|null $birth_date
  * @property bool $selfie_verified
  * @property bool $document_verified
- * @property numeric|null $daily_price_8h
- * @property numeric|null $daily_price_6h
- * @property numeric|null $daily_price_4h
- * @property numeric|null $daily_price_2h
+ * @property string $approval_status
+ * @property float|null $daily_price_8h
+ * @property float|null $daily_price_6h
+ * @property float|null $daily_price_4h
+ * @property float|null $daily_price_2h
  * @property int|null $profile_media_id
+ * @property int|null $document_front_media_id
+ * @property int|null $document_back_media_id
+ * @property-read \App\Models\Media|null $documentFrontMedia
+ * @property-read \App\Models\Media|null $documentBackMedia
  * @property \Illuminate\Support\Carbon|null $created_at
  * @property \Illuminate\Support\Carbon|null $updated_at
  * @property \Illuminate\Support\Carbon|null $deleted_at
- * @property string|null $selfie_media_base64
- * @property string|null $document_front_media_base64
- * @property string|null $document_back_media_base64
  * @property ApprovalStatusEnum $approval_status
  * @property string|null $recipient_id
  * @property string|null $recipient_name
@@ -52,7 +54,6 @@ use Illuminate\Database\Eloquent\SoftDeletes;
  * @property-read int|null $blocked_clients_count
  * @property-read \App\Models\Address|null $primaryAddress
  * @property-read \App\Models\Media|null $profileMedia
- * @property-read \App\Models\User $user
  * @method static \Illuminate\Database\Eloquent\Builder<static>|Provider newModelQuery()
  * @method static \Illuminate\Database\Eloquent\Builder<static>|Provider newQuery()
  * @method static \Illuminate\Database\Eloquent\Builder<static>|Provider onlyTrashed()
@@ -67,8 +68,8 @@ use Illuminate\Database\Eloquent\SoftDeletes;
  * @method static \Illuminate\Database\Eloquent\Builder<static>|Provider whereDailyPrice8h($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|Provider whereDeletedAt($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|Provider whereDocument($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Provider whereDocumentBackMediaBase64($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Provider whereDocumentFrontMediaBase64($value)
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|Provider whereDocumentBackMediaId($value)
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|Provider whereDocumentFrontMediaId($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|Provider whereDocumentVerified($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|Provider whereId($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|Provider whereProfileMediaId($value)
@@ -85,7 +86,6 @@ use Illuminate\Database\Eloquent\SoftDeletes;
  * @method static \Illuminate\Database\Eloquent\Builder<static>|Provider whereRecipientTransferSettings($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|Provider whereRecipientType($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|Provider whereRg($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Provider whereSelfieMediaBase64($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|Provider whereSelfieVerified($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|Provider whereTotalServices($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|Provider whereUpdatedAt($value)
@@ -96,11 +96,11 @@ use Illuminate\Database\Eloquent\SoftDeletes;
  */
 class Provider extends Model
 {
-    use HasFactory, SoftDeletes;
+  use HasFactory, SoftDeletes;
 
-    protected $table = 'providers';
+  protected $table = "providers";
 
-    protected $guarded = ['id'];
+  protected $guarded = ["id"];
 
     /**
      * Get the attributes that should be cast.
@@ -132,11 +132,27 @@ class Provider extends Model
         return $this->belongsTo(User::class, 'user_id');
     }
 
-    public function profileMedia(): BelongsTo
-    {
-        return $this->belongsTo(Media::class, 'profile_media_id');
-    }
-
+  /**
+   * @return BelongsTo
+   */
+  public function profileMedia(): BelongsTo
+  {
+    return $this->belongsTo(Media::class, "profile_media_id");
+  }
+
+  public function documentFrontMedia(): BelongsTo
+  {
+    return $this->belongsTo(Media::class, 'document_front_media_id');
+  }
+
+  public function documentBackMedia(): BelongsTo
+  {
+    return $this->belongsTo(Media::class, 'document_back_media_id');
+  }
+
+  /**
+   * @return HasMany
+     */
     public function addresses(): HasMany
     {
         return $this->hasMany(Address::class, 'source_id')
@@ -155,42 +171,42 @@ class Provider extends Model
 
     /**
      * @return HasMany
-     */
-    public function blockedClients()
-    {
-        return $this->hasMany(ProviderClientBlock::class);
+   */
+  public function blockedClients()
+  {
+    return $this->hasMany(ProviderClientBlock::class);
+  }
+
+  /**
+   * @return \Illuminate\Database\Eloquent\Relations\HasMany
+   */
+  public function blockedByClients()
+  {
+    return $this->hasMany(ClientProviderBlock::class);
+  }
+
+  public function updateAverageRating(float $newRating): void
+  {
+    $totalReviews = Review::where('reviews.origin', 'client')
+      ->leftJoin('schedules', 'schedules.id', '=', 'reviews.schedule_id')
+      ->where('schedules.provider_id', $this->id)
+      ->count();
+
+    if ($totalReviews === 0) {
+      $this->average_rating = $newRating;
+    } else {
+      $currentTotalRating = $this->average_rating * ($totalReviews - 1);
+      $newAverage = ($currentTotalRating + $newRating) / $totalReviews;
+      $this->average_rating = round($newAverage, 2);
     }
 
-    /**
-     * @return \Illuminate\Database\Eloquent\Relations\HasMany
-     */
-    public function blockedByClients()
-    {
-        return $this->hasMany(ClientProviderBlock::class);
-    }
+    $this->save();
+  }
 
-    public function updateAverageRating(float $newRating): void
-    {
-        $totalReviews = Review::where('reviews.origin', 'client')
-            ->leftJoin('schedules', 'schedules.id', '=', 'reviews.schedule_id')
-            ->where('schedules.provider_id', $this->id)
-            ->count();
-
-        if ($totalReviews === 0) {
-            $this->average_rating = $newRating;
-        } else {
-            $currentTotalRating   = $this->average_rating * ($totalReviews - 1);
-            $newAverage           = ($currentTotalRating + $newRating) / $totalReviews;
-            $this->average_rating = round($newAverage, 2);
-        }
-
-        $this->save();
-    }
-
-    public function primaryAddress()
-    {
-        return $this->hasOne(Address::class, 'source_id')
-            ->where('source', 'provider')
-            ->orderBy('is_primary', 'desc');
-    }
+  public function primaryAddress()
+  {
+    return $this->hasOne(Address::class, "source_id")
+      ->where("source", "provider")
+      ->orderBy("is_primary", "desc");
+  }
 }

+ 38 - 0
app/Models/PushNotificationLog.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Models;
+
+use App\Enums\PushNotificationTargetEnum;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+/**
+ * @property int $id
+ * @property string $label
+ * @property int $user_id
+ * @property PushNotificationTargetEnum $target
+ * @property string $category
+ * @property \Illuminate\Support\Carbon $sent_at
+ * @property \Illuminate\Support\Carbon|null $created_at
+ * @property \Illuminate\Support\Carbon|null $updated_at
+ * @property-read \App\Models\User $user
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|PushNotificationLog whereLabel($value)
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|PushNotificationLog whereUserId($value)
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|PushNotificationLog whereTarget($value)
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|PushNotificationLog whereCategory($value)
+ * @mixin \Eloquent
+ */
+class PushNotificationLog extends Model
+{
+    protected $guarded = ['id'];
+
+    protected $casts = [
+        'sent_at' => 'datetime',
+        'target'  => PushNotificationTargetEnum::class,
+    ];
+
+    public function user(): BelongsTo
+    {
+        return $this->belongsTo(User::class);
+    }
+}

+ 7 - 0
app/Models/Review.php

@@ -19,6 +19,8 @@ use Illuminate\Database\Eloquent\SoftDeletes;
  * @property \Illuminate\Support\Carbon|null $created_at
  * @property \Illuminate\Support\Carbon|null $updated_at
  * @property \Illuminate\Support\Carbon|null $deleted_at
+ * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\ReviewMedia> $reviewMedia
+ * @property-read int|null $review_media_count
  * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\ImprovementType> $improvements
  * @property-read int|null $improvements_count
  * @property-read \App\Models\Client|null $originClient
@@ -82,6 +84,11 @@ class Review extends Model
         return $this->hasMany(ReviewImprovement::class);
     }
 
+    public function reviewMedia(): HasMany
+    {
+        return $this->hasMany(ReviewMedia::class);
+    }
+
     public function improvements(): BelongsToMany
     {
         return $this->belongsToMany(

+ 31 - 0
app/Models/ReviewMedia.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+/**
+ * @property int $id
+ * @property int $review_id
+ * @property int $media_id
+ * @property string $origin
+ * @property-read \App\Models\Media $media
+ * @property-read \App\Models\Review $review
+ */
+class ReviewMedia extends Model
+{
+    protected $table = 'review_media';
+
+    protected $fillable = ['review_id', 'media_id', 'origin'];
+
+    public function review(): BelongsTo
+    {
+        return $this->belongsTo(Review::class);
+    }
+
+    public function media(): BelongsTo
+    {
+        return $this->belongsTo(Media::class);
+    }
+}

+ 11 - 0
app/Models/User.php

@@ -7,6 +7,7 @@ use App\Enums\UserTypeEnum;
 use Carbon\Carbon;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
+use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Foundation\Auth\User as Authenticatable;
 use Illuminate\Notifications\Notifiable;
 use Laravel\Sanctum\HasApiTokens;
@@ -96,6 +97,16 @@ class User extends Authenticatable
         return $this->hasOne(Client::class, 'user_id');
     }
 
+    public function deviceTokens()
+    {
+        return $this->hasMany(DeviceToken::class);
+    }
+
+    public function pushNotificationLogs()
+    {
+        return $this->hasMany(PushNotificationLog::class);
+    }
+
     /**
      * Create a new access token for the user.
      */

+ 43 - 0
app/Notifications/Push/BasePushNotification.php

@@ -0,0 +1,43 @@
+<?php
+
+namespace App\Notifications\Push;
+
+use App\Enums\PushNotificationCategoryEnum;
+use App\Enums\PushNotificationTargetEnum;
+use Illuminate\Database\Eloquent\Collection;
+
+abstract class BasePushNotification
+{
+    abstract public function label(): string;
+
+    abstract public function title(): string;
+
+    abstract public function body(): string;
+
+    abstract public function target(): PushNotificationTargetEnum;
+
+    abstract public function category(): PushNotificationCategoryEnum;
+
+    /**
+     * Retorna os usuários elegíveis para receber esta notificação agora.
+     * Não aplica cooldown — isso é responsabilidade do PushNotificationDispatcher.
+     */
+    abstract public function eligibleUsers(): Collection;
+
+    /**
+     * Cooldown em dias para esta notificação específica (nível de msg).
+     */
+    public function notificationCooldownDays(): int
+    {
+        return 28;
+    }
+
+    /**
+     * Cooldown em dias para a categoria desta notificação (nível de categoria).
+     * Retorne 0 para desabilitar o cooldown de categoria.
+     */
+    public function categoryCooldownDays(): int
+    {
+        return 7;
+    }
+}

+ 34 - 0
app/Notifications/Push/Cliente/Contextual/ContextualSegundaPush.php

@@ -0,0 +1,34 @@
+<?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 ContextualSegundaPush extends BasePushNotification
+{
+    public function label(): string { return 'cliente_contextual_segunda'; }
+    public function title(): string { return 'Segunda organizada'; }
+    public function body(): string  { return 'Comece a semana com a casa limpa.'; }
+
+    public function target(): PushNotificationTargetEnum   { return PushNotificationTargetEnum::CLIENTE; }
+    public function category(): PushNotificationCategoryEnum { return PushNotificationCategoryEnum::CONTEXTUAL; }
+
+    public function notificationCooldownDays(): int { return 7; }
+    public function categoryCooldownDays(): int     { return 7; }
+
+    /** Só retorna usuários se hoje for segunda-feira */
+    public function eligibleUsers(): Collection
+    {
+        if (! now()->isMonday()) {
+            return new Collection();
+        }
+
+        return User::whereHas('client')
+            ->where('registration_complete', true)
+            ->get();
+    }
+}

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

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

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

@@ -0,0 +1,26 @@
+<?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();
+    }
+}

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

@@ -0,0 +1,26 @@
+<?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();
+    }
+}

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

@@ -0,0 +1,26 @@
+<?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();
+    }
+}

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

@@ -0,0 +1,26 @@
+<?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();
+    }
+}

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

@@ -0,0 +1,26 @@
+<?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();
+    }
+}

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

@@ -0,0 +1,39 @@
+<?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();
+    }
+}

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

@@ -0,0 +1,34 @@
+<?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();
+    }
+}

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

@@ -0,0 +1,26 @@
+<?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();
+    }
+}

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

@@ -0,0 +1,26 @@
+<?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();
+    }
+}

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

@@ -0,0 +1,26 @@
+<?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();
+    }
+}

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

@@ -0,0 +1,26 @@
+<?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();
+    }
+}

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

@@ -0,0 +1,26 @@
+<?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();
+    }
+}

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

@@ -0,0 +1,26 @@
+<?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();
+    }
+}

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

@@ -0,0 +1,26 @@
+<?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();
+    }
+}

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

@@ -0,0 +1,27 @@
+<?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();
+    }
+}

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

@@ -0,0 +1,27 @@
+<?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();
+    }
+}

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

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

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

@@ -0,0 +1,39 @@
+<?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();
+    }
+}

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

@@ -0,0 +1,34 @@
+<?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();
+    }
+}

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

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

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

@@ -0,0 +1,78 @@
+<?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();
+    }
+}

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

@@ -0,0 +1,71 @@
+<?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();
+    }
+}

+ 231 - 254
app/Services/AuthService.php

@@ -17,117 +17,114 @@ use Illuminate\Support\Str;
 
 class AuthService
 {
-    public function __construct(
-        private readonly EmailService $emailService,
-    ) {}
+  public function __construct(
+    private readonly EmailService $emailService,
+  ) {}
 
-    public function login(string $email, string $password): ?array
-    {
-        $user = User::where('email', $email)->first();
+  public function login(string $email, string $password): ?array
+  {
+    $user = User::where('email', $email)->first();
 
-        if (! $user || ! in_array($user->type, [UserTypeEnum::ADMIN, UserTypeEnum::USER])) {
-            return null;
-        }
+    if (!$user || !in_array($user->type, [UserTypeEnum::ADMIN, UserTypeEnum::USER])) {
+      return null;
+    }
 
-        if (! Auth::attempt(['email' => $email, 'password' => $password])) {
-            return null;
-        }
+    if (!Auth::attempt(['email' => $email, 'password' => $password])) {
+      return null;
+    }
 
-        // $user = User::where('email', $email)->first();
-        $deviceId = Str::uuid()->toString();
+    // $user = User::where('email', $email)->first();
+    $deviceId = Str::uuid()->toString();
 
         $accessToken = $user->createAccessToken($deviceId);
         $refreshToken = $user->createRefreshToken($deviceId);
 
-        return [
-            'payload' => [
-                'access_token' => $accessToken,
-                'user'         => $user,
-            ],
-            'refreshToken' => $refreshToken,
-        ];
+    return [
+      'payload' => [
+        'access_token' => $accessToken,
+        'user'         => $user,
+      ],
+      'refreshToken' => $refreshToken,
+    ];
+  }
+
+  public function refresh(string $refreshToken): ?array
+  {
+    if (!$refreshToken) {
+      return null;
     }
 
-    public function refresh(string $refreshToken): ?array
-    {
-        if (! $refreshToken) {
-            return null;
-        }
-
-        $tokenModel = PersonalAccessToken::findToken($refreshToken);
+    $tokenModel = PersonalAccessToken::findToken($refreshToken);
 
-        if (
-            ! $tokenModel ||
-            ! in_array('refresh', $tokenModel->abilities) ||
-            $tokenModel->expires_at < now()
-        ) {
-            return null;
-        }
+    if (
+      !$tokenModel ||
+      !in_array("refresh", $tokenModel->abilities) ||
+      $tokenModel->expires_at < now()
+    ) {
+      return null;
+    }
 
-        $user = $tokenModel->tokenable;
+    $user = $tokenModel->tokenable;
+    if (!$user) {
+      return null;
+    }
 
-        if (! $user) {
-            return null;
-        }
+    $deviceId = Str::afterLast($tokenModel->name, "_");
 
-        $deviceId = Str::afterLast($tokenModel->name, '_');
+    $tokens = $this->refreshTokenTransaction($tokenModel, $user, $deviceId);
 
-        $tokens = $this->refreshTokenTransaction($tokenModel, $user, $deviceId);
+    return [
+      "payload" => [
+        "access_token" => $tokens["access_token"],
+        "user" => $user,
+      ],
+      "refreshToken" => $tokens["refresh_token"],
+    ];
+  }
 
-        return [
-            'payload' => [
-                'access_token' => $tokens['access_token'],
-                'user'         => $user,
-            ],
-            'refreshToken' => $tokens['refresh_token'],
-        ];
+  public function logout(): void
+  {
+    $user = Auth::user();
+    if (!$user) {
+      return;
     }
 
-    public function logout(): void
-    {
-        $user = Auth::user();
-
-        if (! $user) {
-            return;
-        }
-
         $tokenName = $user->currentAccessToken()->name;
         $deviceId = Str::afterLast($tokenName, '_');
 
-        $user
-            ->tokens()
-            ->where('name', 'like', "%_{$deviceId}")
-            ->delete();
-    }
-
-    protected function refreshTokenTransaction(
-        PersonalAccessToken $tokenModel,
-        User $user,
-        string $deviceId,
+    $user
+      ->tokens()
+      ->where("name", "like", "%_{$deviceId}")
+      ->delete();
+  }
+
+  protected function refreshTokenTransaction(
+    PersonalAccessToken $tokenModel,
+    User $user,
+    string $deviceId,
+  ): array {
+    return DB::transaction(function () use (
+      $tokenModel,
+      $user,
+      $deviceId,
     ): array {
-        return DB::transaction(function () use (
-            $tokenModel,
-            $user,
-            $deviceId,
-        ): array {
-            $tokenModel->update(['expires_at' => Carbon::now()]);
+      $tokenModel->update(["expires_at" => Carbon::now()]);
 
             $accessToken = $user->createAccessToken($deviceId);
             $refreshToken = $user->createRefreshToken($deviceId);
 
-            return [
-                'access_token'  => $accessToken,
-                'refresh_token' => $refreshToken,
-            ];
-        });
-    }
-
-    public function clientSendCode(array $data): bool|array|null
-    {
-        try {
-            DB::beginTransaction();
+      return [
+        "access_token" => $accessToken,
+        "refresh_token" => $refreshToken,
+      ];
+    });
+  }
 
-            $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
+  public function clientSendCode(array $data): bool|array|null
+  {
+    try {
+      DB::beginTransaction();
+      $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
 
             $user = User::where(function ($query) use ($data) {
                 $query->when(! empty($data['email']), function ($q) use ($data) {
@@ -175,111 +172,93 @@ class AuthService
                 Client::create(['user_id' => $user->id]);
             }
 
-            if (! empty($data['email'])) {
-                $this->emailService->sendVerificationCode(
-                    email: $data['email'],
-                    code: $code,
-                    recipientName: $data['name'] ?? '',
-                );
-            } elseif (! empty($data['phone'])) {
-                Log::info('SMS: envio de código por telefone ainda não implementado.', [
-                    'phone' => $data['phone'],
-                ]);
-            }
-
-            DB::commit();
-
-            return $isLogin;
-        } catch (\Exception $e) {
-            DB::rollBack();
-            Log::error('Erro ao enviar código de verificação.', [
-                'error' => $e->getMessage(),
-                'data'  => $data,
-            ]);
-
-            return false;
-        }
+      if (!empty($data['email'])) {
+        $this->emailService->sendVerificationCode(
+          email: $data['email'],
+          code: $code,
+          recipientName: $data['name'] ?? '',
+        );
+      } elseif (!empty($data['phone'])) {
+        Log::info('SMS: envio de código por telefone ainda não implementado.', [
+          'phone' => $data['phone'],
+        ]);
+      }
+
+      DB::commit();
+      return $isLogin;
+    } catch (\Exception $e) {
+      DB::rollBack();
+      Log::error('Erro ao enviar código de verificação.', [
+        'error' => $e->getMessage(),
+        'data' => $data,
+      ]);
+      return false;
     }
+  }
 
-    public function providerSendCode(array $data): bool|array|null
-    {
-        try {
-            DB::beginTransaction();
-
-            $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
-
-            $user = User::where(function ($query) use ($data) {
-                $query->when(! empty($data['email']), function ($q) use ($data) {
-                    $q->where('email', $data['email']);
-                })
-                    ->when(! empty($data['phone']), function ($q) use ($data) {
-                        $q->where('phone', $data['phone']);
-                    });
-            })
-                ->first();
-
-            $isLogin = false;
-
-            if ($user) {
-                if ($user->type->value !== UserTypeEnum::PROVIDER->value) {
-                    DB::rollBack();
-
-                    return ['error' => 'wrong_user_type'];
-                }
-
-                $provider = Provider::where('user_id', $user->id)->first();
-
-                if ($provider && $provider->approval_status && $provider->approval_status->value !== ApprovalStatusEnum::ACCEPTED->value) {
-                    DB::rollBack();
+  public function providerSendCode(array $data): bool|array|null
+  {
+    try {
+      DB::beginTransaction();
+      $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
 
-                    return ['error' => 'provider_not_accepted'];
-                }
-
-                $user->code = $code;
-
-                $user->validated_code = false;
-
-                $user->save();
-
-                $isLogin = true;
-            } else {
-                $user = new User;
-
-                $user->fill($data);
-
-                $user->code = $code;
-                $user->name = $data['name'] ?? 'Usuário';
-                $user->type = $data['type'] ?? UserTypeEnum::PROVIDER->value;
-
-                $user->save();
-            }
-
-            if (! empty($data['email'])) {
-                $this->emailService->sendVerificationCode(
-                    email: $data['email'],
-                    code: $code,
-                    recipientName: $data['name'] ?? '',
-                );
-            } elseif (! empty($data['phone'])) {
-                Log::info('SMS: envio de código por telefone ainda não implementado.', [
-                    'phone' => $data['phone'],
-                ]);
-            }
-
-            DB::commit();
-
-            return $isLogin;
-        } catch (\Exception $e) {
-            DB::rollBack();
-
-            Log::error('Erro ao enviar código de verificação.', [
-                'error' => $e->getMessage(),
-                'data'  => $data,
-            ]);
-
-            return false;
+      $user = User::where(function ($query) use ($data) {
+        $query->when(!empty($data['email']), function ($q) use ($data) {
+          $q->where('email', $data['email']);
+        })
+          ->when(!empty($data['phone']), function ($q) use ($data) {
+            $q->where('phone', $data['phone']);
+          });
+      })
+        ->first();
+      $isLogin = false;
+      if ($user) {
+        if ($user->type->value !== UserTypeEnum::PROVIDER->value) {
+          DB::rollBack();
+          return ['error' => 'wrong_user_type'];
+        }
+        $provider = Provider::where('user_id', $user->id)->first();
+        if($provider && $provider->approval_status->value !== ApprovalStatusEnum::ACCEPTED->value) {
+          DB::rollBack();
+          return ['error' => 'provider_not_accepted'];
         }
+         
+        $user->code = $code;
+        $user->validated_code = false;
+        $user->save();
+        $isLogin = true;
+      } else {
+        $user = new User();
+        $user->fill($data);
+        $user->code = $code;
+        $user->name = $data['name'] ?? 'Usuário';
+        $user->type = $data['type'] ?? UserTypeEnum::PROVIDER->value;
+        $user->save();
+      }
+
+      if (!empty($data['email'])) {
+        $this->emailService->sendVerificationCode(
+          email: $data['email'],
+          code: $code,
+          recipientName: $data['name'] ?? '',
+        );
+      } elseif (!empty($data['phone'])) {
+        Log::info('SMS: envio de código por telefone ainda não implementado.', [
+          'phone' => $data['phone'],
+        ]);
+      }
+
+      DB::commit();
+      return $isLogin;
+    } catch (\Exception $e) {
+      DB::rollBack();
+      Log::error('Erro ao enviar código de verificação.', [
+        'error' => $e->getMessage(),
+        'data' => $data,
+      ]);
+      return false;
     }
+  }
 
     public function validateCodeClient(array $data, bool $isLogin): bool|array
     {
@@ -287,95 +266,93 @@ class AuthService
         $phone = $data['phone'] ?? null;
         $code = $data['code'] ?? '';
 
-        $user = User::where(function ($query) use ($email, $phone) {
-            $query->when($email, fn ($q) => $q->where('email', $email))
-                ->when($phone, fn ($q) => $q->where('phone', $phone));
-        })
-            ->where('code', $code)
-            ->first();
+    $user = User::where(function ($query) use ($email, $phone) {
+        $query->when($email, fn($q) => $q->where('email', $email))
+              ->when($phone,  fn($q) => $q->where('phone', $phone));
+      })
+      ->where('code', $code)
+      ->first();
 
-        if (! $user) {
-            return false;
-        }
-
-        if ($isLogin) {
-            return $this->loginWithEmail($user->email, $code);
-        }
+    if (!$user) {
+      return false;
+    }
 
-        return true;
+    if ($isLogin) {
+      return $this->loginWithEmail($user->email, $code);
     }
 
+    return true;
+  }
+
     public function validateCodeProvider(array $data, bool $isLogin): bool|array
     {
         $email = $data['email'] ?? null;
         $phone = $data['phone'] ?? null;
         $code = $data['code'] ?? '';
 
-        $user = User::where(function ($query) use ($email, $phone) {
-            $query->when($email, fn ($q) => $q->where('email', $email))
-                ->when($phone, fn ($q) => $q->where('phone', $phone));
-        })
-            ->where('code', $code)
-            ->first();
-
-        if (! $user) {
-            return false;
-        }
-
-        if ($isLogin) {
-            $user->load('provider');
+    $user = User::where(function ($query) use ($email, $phone) {
+        $query->when($email, fn($q) => $q->where('email', $email))
+              ->when($phone,  fn($q) => $q->where('phone', $phone));
+      })
+      ->where('code', $code)
+      ->first();
 
-            $provider = $user->provider ?? null;
+    if (!$user) {
+      return false;
+    }
 
-            if ($provider && $provider->approval_status === ApprovalStatusEnum::PENDING->value) {
-                return ['error' => 'provider_pending'];
-            }
+    if ($isLogin) {
+      $user->load('provider');
+      $provider = $user->provider ?? null;
 
-            if ($provider && $provider->approval_status === ApprovalStatusEnum::REJECTED->value) {
-                return ['error' => 'provider_rejected'];
-            }
+      if ($provider && $provider->approval_status === ApprovalStatusEnum::PENDING->value) {
+        return ['error' => 'provider_pending'];
+      }
 
-            return $this->loginWithEmail($user->email, $code);
-        }
+      if ($provider && $provider->approval_status === ApprovalStatusEnum::REJECTED->value) {
+        return ['error' => 'provider_rejected'];
+      }
 
-        return true;
+      return $this->loginWithEmail($user->email, $code);
     }
 
+    return true;
+  }
+
     public function validateCode(array $data, bool $isLogin): bool|array
     {
         $email = $data['email'] ?? null;
         $phone = $data['phone'] ?? null;
         $code = $data['code'] ?? '';
 
-        $user = User::where(function ($query) use ($email, $phone) {
-            $query->when($email, function ($q) use ($email) {
-                $q->where('email', $email);
-            })
-                ->when($phone, function ($q) use ($phone) {
-                    $q->where('phone', $phone);
-                });
-        })
-            ->where('code', $code)
-            ->first();
-
-        if (! $user) {
-            return false;
-        }
-
-        if ($isLogin) {
-            $resultLogin = $this->loginWithEmail($user->email, $code);
+    $user = User::where(function ($query) use ($email, $phone) {
+      $query->when($email, function ($q) use ($email) {
+        $q->where('email', $email);
+      })
+        ->when($phone, function ($q) use ($phone) {
+          $q->where('phone', $phone);
+        });
+    })
+      ->where('code', $code)
+      ->first();
 
-            return $resultLogin;
-        }
+    if (!$user) {
+      return false;
+    }
 
-        return true;
+    if($isLogin) {
+      $resultLogin = $this->loginWithEmail($user->email, $code);
+      return $resultLogin;
     }
 
-    public function loginWithEmail(string $email, string $code): ?array
-    {
-        $user = User::where('email', $email)
-            ->where('code', $code)
-            ->first();
+    return true;
+  }
+
+  public function loginWithEmail(string $email, string $code): ?array
+  {
+    $user = User::where('email', $email)
+      ->where('code', $code)
+      ->first();
 
         if (! $user) {
             return null;
@@ -392,12 +369,12 @@ class AuthService
 
         $user->save();
 
-        return [
-            'payload' => [
-                'access_token' => $accessToken,
-                'user'         => $user,
-            ],
-            'refreshToken' => $refreshToken,
-        ];
-    }
+    return [
+      "payload" => [
+        "access_token" => $accessToken,
+        "user" => $user,
+      ],
+      "refreshToken" => $refreshToken,
+    ];
+  }
 }

+ 10 - 3
app/Services/ClientCalendarService.php

@@ -6,6 +6,7 @@ use App\Models\Client;
 use App\Models\Schedule;
 use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Storage;
 
 class ClientCalendarService
 {
@@ -32,7 +33,7 @@ class ClientCalendarService
             'provider_user.name as provider_name',
             'providers.average_rating',
             'providers.total_services',
-            DB::raw("(SELECT media.url FROM media WHERE media.source_id = providers.id AND media.source = 'provider' AND media.deleted_at IS NULL LIMIT 1) as provider_photo"),
+            DB::raw("(SELECT media.path FROM media WHERE media.source_id = providers.id AND media.source = 'provider' AND media.deleted_at IS NULL LIMIT 1) as provider_photo"),
             DB::raw("EXISTS(
         SELECT 1 FROM reviews
         WHERE reviews.schedule_id = schedules.id
@@ -50,6 +51,10 @@ class ClientCalendarService
       ) as client_stars"),
         ];
 
+        $signPhoto = fn ($item) => tap($item, fn ($i) => $i->provider_photo = $i->provider_photo
+            ? Storage::temporaryUrl($i->provider_photo, now()->addMinutes(60))
+            : null);
+
         $upcomingSchedules = Schedule::with('address:district,address,number,source_id,source,id')
             ->where('schedules.client_id', $client->id)
             ->whereIn('schedules.status', ['pending', 'accepted', 'paid', 'started'])
@@ -60,7 +65,8 @@ class ClientCalendarService
             ->select($selectFields)
             ->orderBy('schedules.date', 'asc')
             ->orderBy('schedules.start_time', 'asc')
-            ->get();
+            ->get()
+            ->map($signPhoto);
 
         $completedSchedules = Schedule::with('address:district,address,number,source_id,source,id')
             ->where('schedules.client_id', $client->id)
@@ -71,7 +77,8 @@ class ClientCalendarService
             ->select($selectFields)
             ->orderBy('schedules.date', 'desc')
             ->orderBy('schedules.start_time', 'desc')
-            ->get();
+            ->get()
+            ->map($signPhoto);
 
         return [
             'upcomingSchedules'  => $upcomingSchedules,

+ 6 - 0
app/Services/ClientPaymentMethodService.php

@@ -16,6 +16,12 @@ class ClientPaymentMethodService
     public function getByClientId(int $clientId): Collection
     {
         return ClientPaymentMethod::where('client_id', $clientId)
+            ->select(
+                'id',
+                'brand',
+                'last_four_digits',
+                'card_name'
+            )
             ->orderBy('is_active', 'desc')
             ->orderBy('created_at', 'desc')
             ->get();

+ 49 - 6
app/Services/CustomScheduleService.php

@@ -2,6 +2,7 @@
 
 namespace App\Services;
 
+use App\Models\Address;
 use App\Models\CustomSchedule;
 use App\Models\CustomScheduleSpeciality;
 use App\Models\Provider;
@@ -9,9 +10,13 @@ use App\Models\Schedule;
 use App\Models\ScheduleProposal;
 use App\Models\ScheduleRefuse;
 use App\Rules\ScheduleBusinessRules;
+use App\Services\DistanceService;
 use Carbon\Carbon;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Storage;
+use App\Enums\NotificationTypeEnum;
+use App\Services\NotificationService;
 
 class CustomScheduleService
 {
@@ -105,7 +110,7 @@ class CustomScheduleService
             return $createdCustomSchedules;
         } catch (\Exception $e) {
             DB::rollBack();
-            Log::error('Error creating custom schedule: '.$e->getMessage());
+            Log::error('Error creating custom schedule: ' . $e->getMessage());
             throw $e;
         }
     }
@@ -197,7 +202,7 @@ class CustomScheduleService
             ]);
         } catch (\Exception $e) {
             DB::rollBack();
-            Log::error('Error updating custom schedule: '.$e->getMessage());
+            Log::error('Error updating custom schedule: ' . $e->getMessage());
             throw $e;
         }
     }
@@ -221,7 +226,7 @@ class CustomScheduleService
             return $customSchedule;
         } catch (\Exception $e) {
             DB::rollBack();
-            Log::error('Error deleting custom schedule: '.$e->getMessage());
+            Log::error('Error deleting custom schedule: ' . $e->getMessage());
             throw $e;
         }
     }
@@ -240,11 +245,19 @@ class CustomScheduleService
 
     public function getAvailableOpportunities($providerId)
     {
-
         $provider = Provider::find($providerId);
 
+        $providerAddress = Address::where('source', 'provider')
+            ->where('source_id', $providerId)
+            ->orderBy('is_primary', 'desc')
+            ->first();
+
+        $providerLat = $providerAddress?->latitude !== null ? (float) $providerAddress->latitude : null;
+        $providerLng = $providerAddress?->longitude !== null ? (float) $providerAddress->longitude : null;
+
         $opportunities = Schedule::with([
             'client.user',
+            'client.profileMedia',
             'address',
             'customSchedule.serviceType',
             'customSchedule.specialities',
@@ -285,6 +298,15 @@ class CustomScheduleService
             }
         });
 
+        $availableOpportunities->each(function ($opportunity) use ($providerLat, $providerLng) {
+            $opportunity->distance_km = DistanceService::calculate(
+                $providerLat,
+                $providerLng,
+                $opportunity->address?->latitude !== null ? (float) $opportunity->address->latitude : null,
+                $opportunity->address?->longitude !== null ? (float) $opportunity->address->longitude : null,
+            );
+        });
+
         return $availableOpportunities->values();
     }
 
@@ -382,6 +404,23 @@ class CustomScheduleService
                 'status'      => 'accepted',
             ]);
 
+            $notificationService = app(NotificationService::class);
+
+            $notificationService->create([
+                'title' => 'Proposta aceita!',
+
+                'description' =>
+                'O cliente aceitou sua proposta de diária.',
+
+                'origin' => 'schedule',
+
+                'origin_id' => $schedule->id,
+
+                'type' => NotificationTypeEnum::SCHEDULE_PROVIDER_CLIENT_PROPOSAL_ACCEPTED->value,
+
+                'user_id' => $provider->user_id,
+            ]);
+
             ScheduleProposal::where('schedule_id', $schedule->id)
                 ->where('id', '!=', $proposalId)
                 ->delete();
@@ -503,9 +542,13 @@ class CustomScheduleService
         $grouped = $schedules->groupBy('client_id')->map(function ($clientSchedules) {
             $firstSchedule = $clientSchedules->first();
 
+            $clientPhotoPath = $firstSchedule->client->profileMedia?->path;
             return [
-                'client_id'   => $firstSchedule->client_id,
-                'client_name' => $firstSchedule->client->user->name ?? 'N/A',
+                'client_id'      => $firstSchedule->client_id,
+                'client_name'    => $firstSchedule->client->user->name ?? 'N/A',
+                'customer_photo' => $clientPhotoPath
+                    ? Storage::temporaryUrl($clientPhotoPath, now()->addMinutes(60))
+                    : null,
                 'schedules'   => $clientSchedules->map(function ($schedule) {
                     $customSchedule = $schedule->customSchedule;
 

+ 179 - 33
app/Services/DashboardService.php

@@ -13,10 +13,13 @@ use App\Models\Review;
 use App\Models\Schedule;
 use App\Models\ScheduleProposal;
 use App\Models\Speciality;
+use App\Models\Notification;
 use App\Rules\ScheduleBusinessRules;
+use App\Services\DistanceService;
 use Illuminate\Auth\Access\AuthorizationException;
 use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Storage;
 
 class DashboardService
 {
@@ -32,7 +35,7 @@ class DashboardService
             throw new AuthorizationException('Apenas clientes podem acessar este recurso.');
         }
 
-        $cliente = Client::where('user_id', $user->id)->firstOrFail();
+        $cliente = Client::with('profileMedia')->where('user_id', $user->id)->first();
 
         $headerBar = [
             'rating'        => $cliente->average_rating,
@@ -51,6 +54,9 @@ class DashboardService
 
         $summaryInfos = [
             'name'             => $user->name,
+            'profile_photo'    => $cliente->profileMedia?->path
+                ? Storage::temporaryUrl($cliente->profileMedia->path, now()->addMinutes(60))
+                : null,
             'address'          => $address,
             'pending_services' => Schedule::where('client_id', $cliente->id)
                 ->where('status', 'pending')
@@ -64,6 +70,7 @@ class DashboardService
             ->leftJoin('providers', 'providers.id', '=', 'schedules.provider_id')
             ->leftJoin('users as provider_user', 'provider_user.id', '=', 'providers.user_id')
             ->leftJoin('custom_schedules', 'custom_schedules.schedule_id', '=', 'schedules.id')
+            ->leftJoin('media as provider_media', 'provider_media.id', '=', 'providers.profile_media_id')
             ->where('schedules.date', '>=', now()->toDateString())
             ->select(
                 'schedules.id',
@@ -77,11 +84,19 @@ class DashboardService
                 'schedules.schedule_type',
                 'schedules.address_id',
                 'custom_schedules.address_type as custom_address_type',
+                'provider_media.path as provider_photo_path',
             )
             ->orderBy('schedules.date', 'asc')
             ->limit(5)
             ->get();
 
+        $nextSchedules->each(function ($item) {
+            $item->provider_photo = $item->provider_photo_path
+                ? Storage::temporaryUrl($item->provider_photo_path, now()->addMinutes(60))
+                : null;
+            unset($item->provider_photo_path);
+        });
+
         $lastDoneSchedules = Schedule::where('schedules.client_id', $cliente->id)
             ->where('schedules.status', 'finished')
             ->leftJoin('providers', 'providers.id', '=', 'schedules.provider_id')
@@ -91,16 +106,25 @@ class DashboardService
                     ->where('provider_address.source', 'provider')
                     ->orderBy('provider_address.is_primary', 'desc');
             })
+            ->leftJoin('media as provider_media', 'provider_media.id', '=', 'providers.profile_media_id')
             ->select(
                 'schedules.id',
                 'schedules.provider_id',
                 'provider_user.name as provider_name',
                 'provider_address.district as provider_district',
+                'provider_media.path as provider_photo_path',
             )
             ->orderBy('schedules.date', 'desc')
             ->limit(5)
             ->get();
 
+        $lastDoneSchedules->each(function ($item) {
+            $item->provider_photo = $item->provider_photo_path
+                ? Storage::temporaryUrl($item->provider_photo_path, now()->addMinutes(60))
+                : null;
+            unset($item->provider_photo_path);
+        });
+
         $favoriteProviders = ClientFavoriteProvider::where('client_favorite_providers.client_id', $cliente->id)
             ->leftJoin('providers', 'providers.id', '=', 'client_favorite_providers.provider_id')
             ->leftJoin('users as provider_user', 'provider_user.id', '=', 'providers.user_id')
@@ -109,17 +133,26 @@ class DashboardService
                     ->where('provider_address.source', 'provider')
                     ->orderBy('provider_address.is_primary', 'desc');
             })
+            ->leftJoin('media as provider_media', 'provider_media.id', '=', 'providers.profile_media_id')
             ->select(
                 'providers.id as provider_id',
                 'provider_user.name as provider_name',
                 'providers.average_rating',
                 'provider_address.district as provider_district',
+                'provider_media.path as provider_photo_path',
             )
             ->orderBy('client_favorite_providers.created_at', 'desc')
             ->limit(5)
             ->get();
 
-        $blockedProviderIds = ScheduleBusinessRules::getBlockedProviderIdsForClient($cliente->id);
+        $favoriteProviders->each(function ($item) {
+            $item->provider_photo = $item->provider_photo_path
+                ? Storage::temporaryUrl($item->provider_photo_path, now()->addMinutes(60))
+                : null;
+            unset($item->provider_photo_path);
+        });
+
+        $blockedProviderIds       = ScheduleBusinessRules::getBlockedProviderIdsForClient($cliente->id);
         $providersWithWorkingDays = ScheduleBusinessRules::getProviderIdsWithWorkingDays();
 
         $clientPrimaryAddress = Address::where('source', 'client')
@@ -143,6 +176,7 @@ class DashboardService
           ORDER BY source_id, is_primary DESC
         ) as provider_address
       "), 'provider_address.source_id', '=', 'providers.id')
+            ->leftJoin('media as provider_media', 'provider_media.id', '=', 'providers.profile_media_id')
             ->whereNotNull('provider_address.id')
             ->where('provider_address.city_id', $clientPrimaryAddress?->city_id)
             ->whereNotIn('providers.id', $blockedProviderIds)
@@ -169,15 +203,24 @@ class DashboardService
           AND schedules.provider_id = providers.id
         ) as total_reviews"),
                 $providersCloseDistanceSelect,
+                'provider_media.path as provider_photo_path',
             )
             ->get();
 
+        $providersClose->each(function ($item) {
+            $item->provider_photo = $item->provider_photo_path
+                ? Storage::temporaryUrl($item->provider_photo_path, now()->addMinutes(60))
+                : null;
+            unset($item->provider_photo_path);
+        });
+
         $pendingSchedules = Schedule::with('address:district,address,number,source_id,source,id,address_type')
             ->where('schedules.client_id', $cliente->id)
             ->whereIn('schedules.status', ['pending', 'accepted'])
             ->where('schedules.schedule_type', 'default')
             ->leftJoin('providers', 'providers.id', '=', 'schedules.provider_id')
             ->leftJoin('users as provider_user', 'provider_user.id', '=', 'providers.user_id')
+            ->leftJoin('media as provider_media', 'provider_media.id', '=', 'providers.profile_media_id')
             ->select(
                 'schedules.id',
                 'schedules.provider_id',
@@ -192,15 +235,37 @@ class DashboardService
           WHEN (now() - schedules.created_at) < INTERVAL '1 hour' THEN CONCAT(ROUND(EXTRACT(EPOCH FROM (now() - schedules.created_at)) / 60), 'min')
           WHEN (now() - schedules.created_at) < INTERVAL '1 day' THEN CONCAT(ROUND(EXTRACT(EPOCH FROM (now() - schedules.created_at)) / 3600), 'h')
           ELSE CONCAT(ROUND(EXTRACT(EPOCH FROM (now() - schedules.created_at)) / 86400), 'd')
-        END as time_since_request")
+        END as time_since_request"),
+                'provider_media.path as provider_photo_path',
             )
             ->orderBy('schedules.date', 'asc')
             ->get();
 
+        $pendingSchedules->each(function ($item) {
+            $item->provider_photo = $item->provider_photo_path
+                ? Storage::temporaryUrl($item->provider_photo_path, now()->addMinutes(60))
+                : null;
+            unset($item->provider_photo_path);
+        });
+
+        $proposalsDistanceSelect = DistanceService::sqlExpression(
+            $clientPrimaryAddress?->latitude !== null ? (float) $clientPrimaryAddress->latitude : null,
+            $clientPrimaryAddress?->longitude !== null ? (float) $clientPrimaryAddress->longitude : null,
+        );
+
         $schedulesProposals = ScheduleProposal::query()
             ->leftJoin('schedules', 'schedule_proposals.schedule_id', '=', 'schedules.id')
             ->leftJoin('providers', 'schedule_proposals.provider_id', '=', 'providers.id')
             ->leftJoin('users', 'providers.user_id', '=', 'users.id')
+            ->leftJoin(DB::raw("(
+                SELECT DISTINCT ON (source_id)
+                    *
+                FROM addresses
+                WHERE source = 'provider'
+                AND deleted_at IS NULL
+                ORDER BY source_id, is_primary DESC
+            ) as provider_address"), 'provider_address.source_id', '=', 'providers.id')
+            ->leftJoin('media as provider_media', 'provider_media.id', '=', 'providers.profile_media_id')
             ->where('schedules.client_id', $cliente->id)
             ->where('schedules.schedule_type', 'custom')
             ->where('schedules.status', 'pending')
@@ -223,9 +288,18 @@ class DashboardService
                 'providers.total_services',
 
                 'users.name as provider_name',
+                $proposalsDistanceSelect,
+                'provider_media.path as provider_photo_path',
             ])
             ->get();
 
+        $schedulesProposals->each(function ($item) {
+            $item->provider_photo = $item->provider_photo_path
+                ? Storage::temporaryUrl($item->provider_photo_path, now()->addMinutes(60))
+                : null;
+            unset($item->provider_photo_path);
+        });
+
         $todaySchedules = Schedule::with('address:district,address,number,source_id,source,id,address_type')
             ->where('schedules.client_id', $cliente->id)
             ->whereIn('schedules.status', ['accepted', 'paid', 'started', 'finished'])
@@ -247,7 +321,7 @@ class DashboardService
                 'schedules.status',
                 'schedules.code_verified',
                 'schedules.code',
-                'media.url as provider_photo',
+                'media.path as provider_photo',
                 DB::raw("EXISTS(
           SELECT 1 FROM reviews
           WHERE reviews.schedule_id = schedules.id
@@ -257,7 +331,29 @@ class DashboardService
         ) as client_reviewed"),
             )
             ->orderBy('schedules.start_time', 'asc')
-            ->get();
+            ->get()
+            ->map(function ($item) {
+                $item->provider_photo = $item->provider_photo
+                    ? Storage::temporaryUrl($item->provider_photo, now()->addMinutes(60))
+                    : null;
+                return $item;
+            });
+
+        $notifications = Notification::where('user_id', $user->id)
+            ->orderBy('read', 'asc')
+            ->orderBy('created_at', 'desc')
+            ->limit(10)
+            ->get()
+            ->map(function ($notification) {
+                return [
+                    'id' => $notification->id,
+                    'title' => $notification->title,
+                    'description' => $notification->description,
+                    'time' => $notification->created_at->diffForHumans(),
+                    'read' => $notification->read,
+                    'avatar' => '/icons/avatar.svg',
+                ];
+            });
 
         $hasPaymentMethods = ClientPaymentMethod::where('client_id', $cliente->id)->exists();
 
@@ -271,6 +367,7 @@ class DashboardService
             'providersClose'      => $providersClose,
             'todaySchedules'      => $todaySchedules,
             'schedulesProposals'  => $schedulesProposals,
+            'notifications'       => $notifications,
             'has_payment_methods' => $hasPaymentMethods,
         ];
     }
@@ -291,7 +388,7 @@ class DashboardService
                 'schedules.provider_id',
                 'provider_user.name as provider_name',
                 'providers.birth_date as provider_birth_date',
-                'media.url as provider_photo',
+                'media.path as provider_photo',
                 'custom_schedules.offers_meal',
             )
             ->firstOrFail();
@@ -305,7 +402,7 @@ class DashboardService
             ->pluck('speciality_id')
             ->all();
 
-        $specialities = $allSpecialities->map(fn ($sp) => [
+        $specialities = $allSpecialities->map(fn($sp) => [
             'id'             => $sp->id,
             'description'    => $sp->description,
             'has_speciality' => in_array($sp->id, $providerSpecialityIds),
@@ -314,7 +411,9 @@ class DashboardService
         return [
             'provider_name'       => $schedule->provider_name,
             'provider_birth_date' => $schedule->provider_birth_date,
-            'provider_photo'      => $schedule->provider_photo,
+            'provider_photo'      => $schedule->provider_photo
+                ? Storage::temporaryUrl($schedule->provider_photo, now()->addMinutes(60))
+                : null,
             'offers_meal'         => $schedule->offers_meal,
             'specialities'        => $specialities,
         ];
@@ -328,7 +427,7 @@ class DashboardService
             throw new AuthorizationException('Apenas prestadores podem acessar este recurso.');
         }
 
-        $provider = Provider::where('user_id', $user->id)->firstOrFail();
+        $provider = Provider::with('profileMedia')->where('user_id', $user->id)->first();
 
         $headerBar = [
             'rating'         => $provider->average_rating,
@@ -340,6 +439,9 @@ class DashboardService
 
         $summaryInfos = [
             'name'             => $user->name,
+            'profile_photo'    => $provider->profileMedia?->path
+                ? Storage::temporaryUrl($provider->profileMedia->path, now()->addMinutes(60))
+                : null,
             'address'          => $address,
             'pending_services' => Schedule::where('provider_id', $provider->id)->where('status', 'pending')->count(),
         ];
@@ -356,11 +458,12 @@ class DashboardService
             'your_price'    => $priceActual,
         ];
 
-        $solicitations = Schedule::with('address:district,source_id,source,id')
+        $solicitations = Schedule::with('address:district,source_id,source,id,latitude,longitude')
             ->where('schedules.provider_id', $provider->id)
             ->where('schedules.status', 'pending')
             ->leftJoin('clients', 'clients.id', '=', 'schedules.client_id')
             ->leftJoin('users as client_user', 'client_user.id', '=', 'clients.user_id')
+            ->leftJoin('media as client_media', 'client_media.id', '=', 'clients.profile_media_id')
             ->leftJoin('custom_schedules', 'custom_schedules.schedule_id', '=', 'schedules.id')
             ->select(
                 'schedules.id',
@@ -376,6 +479,7 @@ class DashboardService
                 'schedules.address_id',
                 'schedules.status',
                 'custom_schedules.offers_meal',
+                'client_media.path as customer_photo_path',
                 DB::raw("CASE
           WHEN (now() - schedules.created_at) < INTERVAL '1 day' THEN CONCAT(ROUND(EXTRACT(EPOCH FROM (now() - schedules.created_at)) / 3600), ' hours ago')
           ELSE CONCAT(ROUND(EXTRACT(EPOCH FROM (now() - schedules.created_at)) / 86400), ' days ago')
@@ -384,12 +488,29 @@ class DashboardService
             ->orderBy('schedules.date', 'asc')
             ->get();
 
+        $providerLat = $address?->latitude !== null ? (float) $address->latitude : null;
+        $providerLng = $address?->longitude !== null ? (float) $address->longitude : null;
+
+        $solicitations->each(function ($solicitation) use ($providerLat, $providerLng) {
+            $solicitation->customer_photo = $solicitation->customer_photo_path
+                ? Storage::temporaryUrl($solicitation->customer_photo_path, now()->addMinutes(60))
+                : null;
+            unset($solicitation->customer_photo_path);
+            $solicitation->distance_km = DistanceService::calculate(
+                $providerLat,
+                $providerLng,
+                $solicitation->address?->latitude !== null ? (float) $solicitation->address->latitude : null,
+                $solicitation->address?->longitude !== null ? (float) $solicitation->address->longitude : null,
+            );
+        });
+
         $todayServices = Schedule::with('address:district,address,number,source_id,source,id')
             ->where('schedules.provider_id', $provider->id)
             ->whereIn('schedules.status', ['accepted', 'paid', 'started', 'finished'])
             ->whereDate('schedules.date', now()->toDateString())
             ->leftJoin('clients', 'clients.id', '=', 'schedules.client_id')
             ->leftJoin('users as client_user', 'client_user.id', '=', 'clients.user_id')
+            ->leftJoin('media as client_media', 'client_media.id', '=', 'clients.profile_media_id')
             ->leftJoin('custom_schedules', 'custom_schedules.schedule_id', '=', 'schedules.id')
             ->select(
                 'schedules.id',
@@ -406,6 +527,7 @@ class DashboardService
                 'schedules.code_verified',
                 'schedules.code',
                 'custom_schedules.offers_meal',
+                'client_media.path as customer_photo_path',
                 DB::raw("EXISTS(
           SELECT 1 FROM reviews
           WHERE reviews.schedule_id = schedules.id
@@ -417,12 +539,20 @@ class DashboardService
             ->orderBy('schedules.start_time', 'asc')
             ->get();
 
-        $nextSchedules = Schedule::with('address:district,address,number,source_id,source,id')
+        $todayServices->each(function ($s) {
+            $s->customer_photo = $s->customer_photo_path
+                ? Storage::temporaryUrl($s->customer_photo_path, now()->addMinutes(60))
+                : null;
+            unset($s->customer_photo_path);
+        });
+
+        $nextSchedules = Schedule::with('address:district,address,number,source_id,source,id,latitude,longitude')
             ->where('schedules.provider_id', $provider->id)
             ->whereIn('schedules.status', ['accepted', 'paid'])
             ->whereDate('schedules.date', '>=', now()->toDateString())
             ->leftJoin('clients', 'clients.id', '=', 'schedules.client_id')
             ->leftJoin('users as client_user', 'client_user.id', '=', 'clients.user_id')
+            ->leftJoin('media as client_media', 'client_media.id', '=', 'clients.profile_media_id')
             ->leftJoin('custom_schedules', 'custom_schedules.schedule_id', '=', 'schedules.id')
             ->select(
                 'schedules.id',
@@ -436,10 +566,24 @@ class DashboardService
                 'schedules.schedule_type',
                 'schedules.status',
                 'custom_schedules.offers_meal',
+                'client_media.path as customer_photo_path',
             )
             ->orderBy('schedules.date', 'asc')
             ->get();
 
+        $nextSchedules->each(function ($schedule) use ($providerLat, $providerLng) {
+            $schedule->customer_photo = $schedule->customer_photo_path
+                ? Storage::temporaryUrl($schedule->customer_photo_path, now()->addMinutes(60))
+                : null;
+            unset($schedule->customer_photo_path);
+            $schedule->distance_km = DistanceService::calculate(
+                $providerLat,
+                $providerLng,
+                $schedule->address?->latitude !== null ? (float) $schedule->address->latitude : null,
+                $schedule->address?->longitude !== null ? (float) $schedule->address->longitude : null,
+            );
+        });
+
         // $opportunities = Schedule::with('address:district,source_id,source,id')
         //   ->where('schedules.schedule_type', 'custom')
         //   ->where('schedules.status', 'pending')
@@ -470,8 +614,30 @@ class DashboardService
         //   ->orderBy('schedules.date', 'asc')
         //   ->get();
 
+        $notifications = Notification::where('user_id', $user->id)
+            ->orderBy('read', 'asc')
+            ->orderBy('created_at', 'desc')
+            ->limit(10)
+            ->get()
+            ->map(function ($notification) {
+                return [
+                    'id' => $notification->id,
+                    'title' => $notification->title,
+                    'description' => $notification->description,
+                    'time' => $notification->created_at->diffForHumans(),
+                    'read' => $notification->read,
+                    'avatar' => '/icons/avatar.svg',
+                ];
+            });
+
         $opportunities = $this->customScheduleService->getAvailableOpportunities($provider->id);
 
+        $opportunities->each(function ($o) {
+            $o->customer_photo = $o->client?->profileMedia?->path
+                ? Storage::temporaryUrl($o->client->profileMedia->path, now()->addMinutes(60))
+                : null;
+        });
+
         return [
             'headerBar'      => $headerBar,
             'summaryInfos'   => $summaryInfos,
@@ -480,32 +646,12 @@ class DashboardService
             'todayServices'  => $todayServices,
             'nextSchedules'  => $nextSchedules,
             'opportunities'  => $opportunities,
+            'notifications' => $notifications,
         ];
     }
 
     private function distanceSelect(?float $clientLatitude, ?float $clientLongitude): \Illuminate\Contracts\Database\Query\Expression
     {
-        if ($clientLatitude === null || $clientLongitude === null) {
-            return DB::raw('NULL as distance_km');
-        }
-
-        return DB::raw("
-            CASE
-                WHEN provider_address.latitude IS NOT NULL
-                AND provider_address.longitude IS NOT NULL
-                THEN ROUND((
-                    6371 * acos(
-                        least(1, greatest(-1,
-                            cos(radians({$clientLatitude}))
-                            * cos(radians(provider_address.latitude))
-                            * cos(radians(provider_address.longitude) - radians({$clientLongitude}))
-                            + sin(radians({$clientLatitude}))
-                            * sin(radians(provider_address.latitude))
-                        ))
-                    )
-                )::numeric, 1)
-                ELSE NULL
-            END as distance_km
-        ");
+        return DistanceService::sqlExpression($clientLatitude, $clientLongitude);
     }
 }

+ 29 - 0
app/Services/DeviceTokenService.php

@@ -0,0 +1,29 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\DeviceToken;
+use Illuminate\Support\Facades\Auth;
+
+class DeviceTokenService
+{
+    public function register(array $data): DeviceToken
+    {
+        return DeviceToken::updateOrCreate(
+            ['token' => $data['token']],
+            [
+                'user_id'  => Auth::id(),
+                'platform' => $data['platform'],
+                'app_type' => $data['app_type'],
+                'active'   => true,
+            ]
+        );
+    }
+
+    public function remove(string $token): void
+    {
+        DeviceToken::where('token', $token)
+            ->where('user_id', Auth::id())
+            ->update(['active' => false]);
+    }
+}

+ 61 - 0
app/Services/DistanceService.php

@@ -0,0 +1,61 @@
+<?php
+
+namespace App\Services;
+
+use Illuminate\Contracts\Database\Query\Expression;
+use Illuminate\Support\Facades\DB;
+
+class DistanceService
+{
+    private const EARTH_RADIUS_KM = 6371;
+
+    public static function calculate(?float $lat1, ?float $lng1, ?float $lat2, ?float $lng2): ?float
+    {
+        if ($lat1 === null || $lng1 === null || $lat2 === null || $lng2 === null) {
+            return null;
+        }
+
+        $lat1Rad = deg2rad($lat1);
+        $lat2Rad = deg2rad($lat2);
+        $lng1Rad = deg2rad($lng1);
+        $lng2Rad = deg2rad($lng2);
+
+        $cosValue = cos($lat1Rad) * cos($lat2Rad) * cos($lng2Rad - $lng1Rad)
+            + sin($lat1Rad) * sin($lat2Rad);
+
+        $cosValue = min(1.0, max(-1.0, $cosValue));
+
+        return round(self::EARTH_RADIUS_KM * acos($cosValue), 1);
+    }
+
+    public static function sqlExpression(
+        ?float $clientLatitude,
+        ?float $clientLongitude,
+        string $targetLatCol = 'provider_address.latitude',
+        string $targetLngCol = 'provider_address.longitude',
+        string $alias = 'distance_km'
+    ): Expression {
+        if ($clientLatitude === null || $clientLongitude === null) {
+            return DB::raw("NULL as {$alias}");
+        }
+
+        return DB::raw("
+            CASE
+                WHEN {$targetLatCol} IS NOT NULL
+                AND {$targetLngCol} IS NOT NULL
+                THEN ROUND((
+                    " . self::EARTH_RADIUS_KM . " * acos(
+                        least(1, greatest(-1,
+                            cos(radians({$clientLatitude}))
+                            * cos(radians({$targetLatCol}))
+                            * cos(radians({$targetLngCol}) - radians({$clientLongitude}))
+                            + sin(radians({$clientLatitude}))
+                            * sin(radians({$targetLatCol}))
+                        ))
+                    )
+                )::numeric, 1)
+                ELSE NULL
+            END as {$alias}
+        ");
+    }
+}

+ 27 - 0
app/Services/MediaService.php

@@ -3,10 +3,15 @@
 namespace App\Services;
 
 use App\Models\Media;
+use App\Traits\RemoveArchiveS3;
+use App\Traits\UploadsFile;
 use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Http\UploadedFile;
 
 class MediaService
 {
+    use UploadsFile, RemoveArchiveS3;
+
     public function getAll(): Collection
     {
         return Media::query()
@@ -48,4 +53,26 @@ class MediaService
 
         return $model->delete();
     }
+
+    public function createFromFile(UploadedFile $file, string $folder, string $source, int $sourceId, ?string $filename = null): Media
+    {
+        $path = $this->uploadFile($file, $folder, $filename);
+
+        return Media::create([
+            'source'    => $source,
+            'source_id' => $sourceId,
+            'name'      => $file->getClientOriginalName(),
+            'path'      => $path,
+        ]);
+    }
+
+    public function replaceFile(UploadedFile $newFile, string $folder, string $source, int $sourceId, ?Media $old = null, ?string $filename = null): Media
+    {
+        if ($old) {
+            $this->removeArchiveByPath($old->path);
+            $old->delete();
+        }
+
+        return $this->createFromFile($newFile, $folder, $source, $sourceId, $filename);
+    }
 }

+ 59 - 0
app/Services/NotificationService.php

@@ -0,0 +1,59 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\Notification;
+use Illuminate\Database\Eloquent\Collection;
+
+class NotificationService
+{
+    public function getByUser(int $userId): Collection
+    {
+        return Notification::where('user_id', $userId)
+            ->orderBy('read', 'asc')
+            ->orderBy('created_at', 'desc')
+            ->get();
+    }
+
+    public function findById(int $id): Notification
+    {
+        return Notification::findOrFail($id);
+    }
+
+    public function create(array $data): Notification
+    {
+        return Notification::create($data);
+    }
+
+    public function markAsRead(Notification $notification): Notification
+    {
+        $notification->update([
+            'read' => true,
+            'read_at' => now(),
+        ]);
+
+        return $notification;
+    }
+
+    public function markAllAsRead(int $userId): void
+    {
+        Notification::where('user_id', $userId)
+            ->where('read', false)
+            ->update([
+                'read' => true,
+                'read_at' => now(),
+            ]);
+    }
+
+    public function unreadCount(int $userId): int
+    {
+        return Notification::where('user_id', $userId)
+            ->where('read', false)
+            ->count();
+    }
+
+    public function delete(Notification $notification): void
+    {
+        $notification->delete();
+    }
+}

+ 236 - 209
app/Services/ProviderService.php

@@ -16,28 +16,30 @@ use Illuminate\Database\Eloquent\Collection;
 use Illuminate\Pagination\LengthAwarePaginator;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
+use Illuminate\Http\UploadedFile;
 
 class ProviderService
 {
     public function __construct(
         private readonly AuthService $authService,
         private readonly PagarmeRecipientService $pagarmeRecipientService,
+        private readonly MediaService $mediaService,
     ) {}
 
-    public function getAll(): Collection
-    {
-        $providers = Provider::query()
-            ->with(['user', 'profileMedia'])
-            ->orderBy('created_at', 'desc')
-            ->get();
+  public function getAll(): Collection
+  {
+    $providers = Provider::query()
+      ->with(['user', 'profileMedia'])
+      ->orderBy('created_at', 'desc')
+      ->get();
 
-        return $providers;
-    }
+    return $providers;
+  }
 
-    public function findById(int $id): ?Provider
-    {
-        return Provider::with(['user', 'profileMedia'])->find($id);
-    }
+  public function findById(int $id): ?Provider
+  {
+    return Provider::with(['user', 'profileMedia'])->find($id);
+  }
 
     public function create(array $data): Provider
     {
@@ -52,19 +54,31 @@ class ProviderService
         });
     }
 
-    public function update(int $id, array $data): ?Provider
-    {
-        $model = $this->findById($id);
+  public function update(int $id, array $data): ?Provider
+  {
+    $model = $this->findById($id);
 
-        if (! $model) {
-            return null;
-        }
-
-        $model->update($data);
+    if (!$model) {
+      return null;
+    }
 
-        return $model->fresh(['user', 'profileMedia']);
+    if (isset($data['avatar']) && $data['avatar'] instanceof UploadedFile) {
+      $media = $this->mediaService->replaceFile(
+        newFile:  $data['avatar'],
+        folder:   "provider/avatar/{$model->id}",
+        source:   'provider',
+        sourceId: $model->id,
+        old:      $model->profileMedia,
+      );
+      $data['profile_media_id'] = $media->id;
+      unset($data['avatar']);
     }
 
+    $model->update($data);
+
+    return $model->fresh(['user', 'profileMedia']);
+  }
+
     public function updateBankAccount(int $id, array $bankAccountData): ?Provider
     {
         $provider = $this->findById($id);
@@ -78,226 +92,239 @@ class ProviderService
         return $provider->fresh(['user', 'profileMedia']);
     }
 
-    public function delete(int $id): bool
-    {
-        $model = $this->findById($id);
-
-        if (! $model) {
-            return false;
-        }
-
-        return $model->delete();
-    }
-
-    public function getPending(int $page = 1, int $perPage = 10): LengthAwarePaginator
-    {
-        return Provider::query()
-            ->where('approval_status', ApprovalStatusEnum::PENDING->value)
-            ->with(['user', 'profileMedia'])
-            ->orderBy('created_at', 'asc')
-            ->paginate($perPage, ['*'], 'page', $page);
-    }
-
-    public function approve(int $id): Provider
-    {
-        return DB::transaction(function () use ($id) {
-            $provider = Provider::findOrFail($id);
-
-            $provider->update(['approval_status' => ApprovalStatusEnum::ACCEPTED->value]);
-
-            return $provider->fresh(['user', 'profileMedia']);
-        });
-    }
-
-    public function reject(int $id): Provider
-    {
-        return DB::transaction(function () use ($id) {
-            $provider = Provider::findOrFail($id);
-
-            $provider->update(['approval_status' => ApprovalStatusEnum::REJECTED->value]);
+  public function delete(int $id): bool
+  {
+    $model = $this->findById($id);
 
-            return $provider->fresh(['user', 'profileMedia']);
-        });
+    if (!$model) {
+      return false;
     }
 
-    public function register(array $data): ?array
-    {
-        try {
-            DB::beginTransaction();
+    return $model->delete();
+  }
+
+  public function getPending(int $page = 1, int $perPage = 10): LengthAwarePaginator
+  {
+    return Provider::query()
+      ->where('approval_status', ApprovalStatusEnum::PENDING->value)
+      ->with(['user', 'profileMedia'])
+      ->orderBy('created_at', 'asc')
+      ->paginate($perPage, ['*'], 'page', $page);
+  }
+
+  public function approve(int $id): Provider
+  {
+    return DB::transaction(function () use ($id) {
+      $provider = Provider::findOrFail($id);
+      $provider->update(['approval_status' => ApprovalStatusEnum::ACCEPTED->value]);
+      return $provider->fresh(['user', 'profileMedia']);
+    });
+  }
+
+  public function reject(int $id): Provider
+  {
+    return DB::transaction(function () use ($id) {
+      $provider = Provider::findOrFail($id);
+      $provider->update(['approval_status' => ApprovalStatusEnum::REJECTED->value]);
+      return $provider->fresh(['user', 'profileMedia']);
+    });
+  }
+
+  public function register(array $data): ?array
+  {
+    try {
+      DB::beginTransaction();
 
             $email = $data['email'] ?? null;
             $phone = $data['phone'] ?? null;
             $code = $data['code'] ?? null;
 
-            $user = User::query()
-                ->where('type', UserTypeEnum::PROVIDER->value)
-                ->where('code', $code)
-                ->where(function ($query) use ($email, $phone) {
-                    if (! empty($email)) {
-                        $query->orWhere('email', $email);
-                    }
-
-                    if (! empty($phone)) {
-                        $query->orWhere('phone', $phone);
-                    }
-                })
-                ->latest('id')
-                ->first();
-
-            if (! $user) {
-                throw new \Exception(__('messages.user_not_found_or_code_not_validated'));
-            }
-
-            $user->name = $data['name'];
-
-            if (empty($user->email) && ! empty($email)) {
-                $user->email = $email;
-            }
-
-            if (empty($user->phone) && ! empty($phone)) {
-                $user->phone = $phone;
-            }
-
-            $user->save();
-
-            $provider = new Provider;
-
-            $provider->user_id = $user->id;
-            $provider->rg = $data['rg'] ?? null;
-            $provider->document = $this->sanitizeDigits($data['document'] ?? null);
-            $provider->birth_date = $data['birth_date'] ?? null;
-
-            $provider->daily_price_8h = $data['daily_price_8h'] ?? null;
-            $provider->daily_price_6h = $data['daily_price_6h'] ?? null;
-            $provider->daily_price_4h = $data['daily_price_4h'] ?? null;
-            $provider->daily_price_2h = $data['daily_price_2h'] ?? null;
-
-            $provider->approval_status = ApprovalStatusEnum::PENDING->value;
-
-            $provider->selfie_media_base64 = $data['selfie_base64'] ?? null;
-            $provider->document_front_media_base64 = $data['document_front_base64'] ?? null;
-            $provider->document_back_media_base64 = $data['document_back_base64'] ?? null;
-
-            $provider->save();
-            $provider->refresh();
-
-            $this->pagarmeRecipientService->createRecipientForProvider($provider, $data);
-            $this->createProviderAddress($provider->id, $data);
-            $this->createProviderServicesTypes($provider->id, $data);
-            $this->createProviderWorkingDays($provider->id, $data);
-
-            if (empty($user->email) || empty($user->code)) {
-                throw new \Exception(__('messages.user_not_found_or_code_not_validated'));
-            }
-
-            $result = $this->authService->loginWithEmail(
-                email: $user->email,
-                code: $user->code,
-            );
-
-            DB::commit();
-
-            return $result;
-        } catch (\Exception $e) {
-            DB::rollBack();
-            Log::error('Error registering provider: '.$e->getMessage(), [
-                'data' => $data,
-            ]);
-            throw $e;
-        }
+      $user = User::query()
+        ->where('type', UserTypeEnum::PROVIDER->value)
+        ->where('code', $code)
+        ->where(function ($query) use ($email, $phone) {
+          if (!empty($email)) {
+            $query->orWhere('email', $email);
+          }
+
+          if (!empty($phone)) {
+            $query->orWhere('phone', $phone);
+          }
+        })
+        ->latest('id')
+        ->first();
+
+      if (!$user) {
+        throw new \Exception(__('messages.user_not_found_or_code_not_validated'));
+      }
+
+      $user->name = $data['name'];
+
+      if (empty($user->email) && !empty($email)) {
+        $user->email = $email;
+      }
+
+      if (empty($user->phone) && !empty($phone)) {
+        $user->phone = $phone;
+      }
+
+      $user->save();
+
+      $provider = new Provider();
+      $provider->user_id = $user->id;
+      $provider->rg = $data['rg'] ?? null;
+      $provider->document = $this->sanitizeDigits($data['document'] ?? null);
+      $provider->birth_date = $data['birth_date'] ?? null;
+      $provider->daily_price_8h = $data['daily_price_8h'] ?? null;
+      $provider->daily_price_6h = $data['daily_price_6h'] ?? null;
+      $provider->daily_price_4h = $data['daily_price_4h'] ?? null;
+      $provider->daily_price_2h = $data['daily_price_2h'] ?? null;
+      $provider->approval_status = ApprovalStatusEnum::PENDING->value;
+      $provider->save();
+      $provider->refresh();
+
+      $selfie = $this->mediaService->createFromFile(
+          file:     $data['selfie'],
+          folder:   "provider/avatar/{$provider->id}",
+          source:   'provider',
+          sourceId: $provider->id,
+      );
+      $provider->profile_media_id = $selfie->id;
+
+      $front = $this->mediaService->createFromFile(
+          file:     $data['document_front'],
+          folder:   "provider/documentos/{$provider->id}",
+          source:   'provider_document',
+          sourceId: $provider->id,
+          filename: 'frente.'.$data['document_front']->getClientOriginalExtension(),
+      );
+      $provider->document_front_media_id = $front->id;
+
+      $back = $this->mediaService->createFromFile(
+          file:     $data['document_back'],
+          folder:   "provider/documentos/{$provider->id}",
+          source:   'provider_document',
+          sourceId: $provider->id,
+          filename: 'verso.'.$data['document_back']->getClientOriginalExtension(),
+      );
+      $provider->document_back_media_id = $back->id;
+
+      $provider->save();
+
+      if (!empty($data['recipient_code'])) {
+          $this->pagarmeRecipientService->createRecipientForProvider($provider, $data);
+      }
+      $this->createProviderAddress($provider->id, $data);
+      $this->createProviderServicesTypes($provider->id, $data);
+      $this->createProviderWorkingDays($provider->id, $data);
+
+      if (empty($user->email) || empty($user->code)) {
+        throw new \Exception(__('messages.user_not_found_or_code_not_validated'));
+      }
+
+      $result = $this->authService->loginWithEmail(
+        email: $user->email,
+        code: $user->code,
+      );
+
+      DB::commit();
+      return $result;
+    } catch (\Exception $e) {
+      DB::rollBack();
+      Log::error('Error registering provider: ' . $e->getMessage(), [
+        'data' => $data,
+      ]);
+      throw $e;
     }
+  }
 
     private function createProviderAddress(int $providerId, array $data): void
     {
         $state = null;
         $city = null;
 
-        if (! empty($data['state'])) {
-            $state = State::query()
-                ->whereRaw('LOWER(code) = ?', [mb_strtolower($data['state'])])
-                ->first();
-        }
-
-        if (! empty($data['city'])) {
-            $cityQuery = City::query()
-                ->whereRaw('LOWER(name) = ?', [mb_strtolower($data['city'])]);
-
-            if ($state) {
-                $cityQuery->where('state_id', $state->id);
-            }
-
-            $city = $cityQuery->first();
-        }
-
-        $address = new Address;
-
-        $address->source = 'provider';
-        $address->source_id = $providerId;
-        $address->zip_code = $this->sanitizeDigits($data['zip_code'] ?? null);
-        $address->address = $data['address'] ?? null;
-        $address->has_complement = (bool) ($data['has_complement'] ?? false);
-        $address->complement = $data['complement'] ?? null;
-        $address->nickname = $data['nickname'] ?? null;
-        $address->instructions = $data['instructions'] ?? null;
-        $address->address_type = $data['address_type'] ?? 'home';
-        $address->state_id = $state?->id;
-        $address->city_id = $city?->id;
-
-        $address->save();
+    if (!empty($data['state'])) {
+      $state = State::query()
+        ->whereRaw('LOWER(code) = ?', [mb_strtolower($data['state'])])
+        ->first();
     }
 
-    private function createProviderServicesTypes(int $providerId, array $data): void
-    {
-        $serviceTypeIds = $data['services_types_ids'] ?? $data['service_types_ids'] ?? [];
+    if (!empty($data['city'])) {
+      $cityQuery = City::query()
+        ->whereRaw('LOWER(name) = ?', [mb_strtolower($data['city'])]);
 
-        $uniqueIds = array_values(array_unique(array_map('intval', $serviceTypeIds)));
+      if ($state) {
+        $cityQuery->where('state_id', $state->id);
+      }
 
-        foreach ($uniqueIds as $serviceTypeId) {
-            ProviderServicesType::create([
-                'provider_id'     => $providerId,
-                'service_type_id' => $serviceTypeId,
-            ]);
-        }
+      $city = $cityQuery->first();
     }
 
-    private function createProviderWorkingDays(int $providerId, array $data): void
-    {
-        $workingDays = $data['working_days'] ?? [];
+    $address = new Address();
+    $address->source = 'provider';
+    $address->source_id = $providerId;
+    $address->zip_code = $this->sanitizeDigits($data['zip_code'] ?? null);
+    $address->address = $data['address'] ?? null;
+    $address->has_complement = (bool) ($data['has_complement'] ?? false);
+    $address->complement = $data['complement'] ?? null;
+    $address->nickname = $data['nickname'] ?? null;
+    $address->instructions = $data['instructions'] ?? null;
+    $address->address_type = $data['address_type'] ?? 'home';
+    $address->state_id = $state?->id;
+    $address->city_id = $city?->id;
+    $address->save();
+  }
+
+  private function createProviderServicesTypes(int $providerId, array $data): void
+  {
+    $serviceTypeIds = $data['services_types_ids'] ?? $data['service_types_ids'] ?? [];
+
+    $uniqueIds = array_values(array_unique(array_map('intval', $serviceTypeIds)));
+
+    foreach ($uniqueIds as $serviceTypeId) {
+      ProviderServicesType::create([
+        'provider_id' => $providerId,
+        'service_type_id' => $serviceTypeId,
+      ]);
+    }
+  }
 
-        $seen = [];
+  private function createProviderWorkingDays(int $providerId, array $data): void
+  {
+    $workingDays = $data['working_days'] ?? [];
+    $seen = [];
 
         foreach ($workingDays as $workingDay) {
             $day = (int) ($workingDay['day'] ?? -1);
             $period = $workingDay['period'] ?? null;
 
-            if ($day < 0 || $day > 6 || ! in_array($period, ['morning', 'afternoon'], true)) {
-                continue;
-            }
-
-            $uniqueKey = $day.'-'.$period;
+      if ($day < 0 || $day > 6 || !in_array($period, ['morning', 'afternoon'], true)) {
+        continue;
+      }
 
-            if (isset($seen[$uniqueKey])) {
-                continue;
-            }
+      $uniqueKey = $day . '-' . $period;
+      if (isset($seen[$uniqueKey])) {
+        continue;
+      }
 
-            $seen[$uniqueKey] = true;
+      $seen[$uniqueKey] = true;
 
-            ProviderWorkingDay::create([
-                'provider_id' => $providerId,
-                'day'         => $day,
-                'period'      => $period,
-            ]);
-        }
+      ProviderWorkingDay::create([
+        'provider_id' => $providerId,
+        'day' => $day,
+        'period' => $period,
+      ]);
     }
+  }
 
-    private function sanitizeDigits(?string $value): ?string
-    {
-        if ($value === null) {
-            return null;
-        }
+  private function sanitizeDigits(?string $value): ?string
+  {
+    if ($value === null) {
+      return null;
+    }
 
-        $digits = preg_replace('/\D+/', '', $value);
+    $digits = preg_replace('/\D+/', '', $value);
 
-        return $digits === '' ? null : $digits;
-    }
+    return $digits === '' ? null : $digits;
+  }
 }

+ 138 - 0
app/Services/PushNotificationDispatcher.php

@@ -0,0 +1,138 @@
+<?php
+
+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\EducativoConversao\EducativoConversao1Push;
+use App\Notifications\Push\Cliente\EducativoConversao\EducativoConversao2Push;
+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\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 Illuminate\Support\Collection;
+
+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(),
+        ];
+    }
+
+    /**
+     * Processa todas as notificações: aplica cooldowns e envia para elegíveis.
+     */
+    public function dispatch(): void
+    {
+        foreach ($this->all() as $notification) {
+            $users = $notification->eligibleUsers();
+
+            if ($users->isEmpty()) {
+                continue;
+            }
+
+            $filtered = $this->applyCooldowns($users, $notification);
+
+            if ($filtered->isEmpty()) {
+                continue;
+            }
+
+            $this->pushService->sendToUsers($filtered, $notification);
+        }
+    }
+
+    /**
+     * Remove da coleção os usuários que ainda estão em cooldown
+     * (tanto de categoria quanto de notificação específica).
+     */
+    private function applyCooldowns(Collection $users, BasePushNotification $notification): Collection
+    {
+        $userIds = $users->pluck('id');
+
+        $blockedByCategory = collect();
+        if ($notification->categoryCooldownDays() > 0) {
+            $blockedByCategory = PushNotificationLog::whereIn('user_id', $userIds)
+                ->where('target', $notification->target()->value)
+                ->where('category', $notification->category()->value)
+                ->where('sent_at', '>=', now()->subDays($notification->categoryCooldownDays()))
+                ->pluck('user_id')
+                ->unique();
+        }
+
+        $blockedByNotification = PushNotificationLog::whereIn('user_id', $userIds)
+            ->where('label', $notification->label())
+            ->where('sent_at', '>=', now()->subDays($notification->notificationCooldownDays()))
+            ->pluck('user_id')
+            ->unique();
+
+        $blocked = $blockedByCategory->merge($blockedByNotification)->unique();
+
+        return $users->whereNotIn('id', $blocked)->values();
+    }
+}

+ 78 - 0
app/Services/PushNotificationService.php

@@ -0,0 +1,78 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\DeviceToken;
+use App\Models\PushNotificationLog;
+use App\Models\User;
+use App\Notifications\Push\BasePushNotification;
+use Illuminate\Support\Collection;
+use Kreait\Firebase\Contract\Messaging;
+use Kreait\Firebase\Messaging\CloudMessage;
+use Kreait\Firebase\Messaging\Notification;
+use Throwable;
+
+class PushNotificationService
+{
+    public function __construct(private Messaging $messaging) {}
+
+    /**
+     * Envia uma push notification para todos os tokens ativos de um usuário.
+     * Registra o envio no log e desativa tokens inválidos automaticamente.
+     */
+    public function sendToUser(User $user, BasePushNotification $notification): void
+    {
+        $tokens = DeviceToken::where('user_id', $user->id)
+            ->where('app_type', $notification->target()->value)
+            ->where('active', true)
+            ->pluck('token')
+            ->toArray();
+
+        if (empty($tokens)) {
+            return;
+        }
+
+        $message = CloudMessage::new()->withNotification(
+            Notification::create($notification->title(), $notification->body())
+        );
+
+        $report = $this->messaging->sendMulticast($message, $tokens);
+
+        $this->deactivateInvalidTokens($report->invalidTokens());
+
+        if ($report->successes()->count() > 0) {
+            $this->log($user, $notification);
+        }
+    }
+
+    /**
+     * Envia para uma coleção de usuários, respeitando cooldowns.
+     * Usuários sem tokens ativos são ignorados silenciosamente.
+     */
+    public function sendToUsers(Collection $users, BasePushNotification $notification): void
+    {
+        foreach ($users as $user) {
+            $this->sendToUser($user, $notification);
+        }
+    }
+
+    private function log(User $user, BasePushNotification $notification): void
+    {
+        PushNotificationLog::create([
+            'label'    => $notification->label(),
+            'user_id'  => $user->id,
+            'target'   => $notification->target()->value,
+            'category' => $notification->category()->value,
+            'sent_at'  => now(),
+        ]);
+    }
+
+    private function deactivateInvalidTokens(array $tokens): void
+    {
+        if (empty($tokens)) {
+            return;
+        }
+
+        DeviceToken::whereIn('token', $tokens)->update(['active' => false]);
+    }
+}

+ 23 - 1
app/Services/ReviewService.php

@@ -8,6 +8,7 @@ use App\Models\ClientProviderBlock;
 use App\Models\Provider;
 use App\Models\ProviderClientBlock;
 use App\Models\Review;
+use App\Models\ReviewMedia;
 use App\Models\Schedule;
 use Exception;
 use Illuminate\Support\Facades\DB;
@@ -15,6 +16,7 @@ use Illuminate\Support\Facades\Log;
 
 class ReviewService
 {
+    public function __construct(private readonly MediaService $mediaService) {}
     public function getAll()
     {
         return Review::with(['schedule.client.user', 'schedule.provider.user', 'reviewsImprovements.improvementType'])
@@ -119,9 +121,29 @@ class ReviewService
                 }
             }
 
+            if (! empty($data['photos'])) {
+                $schedule  = Schedule::find($data['schedule_id']);
+                $origin    = $data['origin'];
+
+                foreach ($data['photos'] as $photo) {
+                    $media = $this->mediaService->createFromFile(
+                        file:     $photo,
+                        folder:   "review/{$schedule->id}/{$origin}",
+                        source:   'review',
+                        sourceId: $review->id,
+                    );
+
+                    ReviewMedia::create([
+                        'review_id' => $review->id,
+                        'media_id'  => $media->id,
+                        'origin'    => $origin,
+                    ]);
+                }
+            }
+
             DB::commit();
 
-            return $review;
+            return $review->load('reviewMedia.media');
         } catch (Exception $e) {
             DB::rollBack();
             Log::error('Error creating review: '.$e->getMessage());

+ 205 - 8
app/Services/ScheduleService.php

@@ -6,6 +6,8 @@ use App\Jobs\StartScheduleJob;
 use App\Models\Provider;
 use App\Models\Schedule;
 use App\Rules\ScheduleBusinessRules;
+use App\Services\NotificationService;
+use App\Enums\NotificationTypeEnum;
 use Carbon\Carbon;
 use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\DB;
@@ -41,22 +43,54 @@ class ScheduleService
     public function createSingleOrMultiple(array $baseData, array $schedules)
     {
         try {
+
             DB::beginTransaction();
+
             $createdSchedules = [];
 
+
             foreach ($schedules as $schedule) {
+
                 $datasMerged = array_merge($baseData, $schedule);
 
+
                 $this->validateProviderAvailability($datasMerged, null);
 
+
                 $scheduleData = array_merge($datasMerged, [
                     'code' => str_pad(random_int(0, 9999), 4, '0', STR_PAD_LEFT),
                 ]);
 
-                $createdSchedules[] = Schedule::create($scheduleData);
+
+                $newSchedule = Schedule::create($scheduleData);
+
+                /*NOTIFICAÇÃO PRESTADOR*/
+                if ($newSchedule->provider_id) {
+
+                    $notificationService = app(NotificationService::class);
+
+                    $notificationService->create([
+                        'title' => 'Nova solicitação de diária!',
+
+                        'description' =>
+                        'Você recebeu uma nova solicitação de diária.',
+
+                        'origin' => 'schedule',
+
+                        'origin_id' => $newSchedule->id,
+
+                        'type' => NotificationTypeEnum::SCHEDULE_PROVIDER_CLIENT_NEW_SOLICITATION->value,
+
+                        'user_id' => $newSchedule->provider->user_id,
+                    ]);
+                }
+
+                $createdSchedules[] = $newSchedule;
             }
+
             DB::commit();
         } catch (\Exception $e) {
+
             DB::rollBack();
 
             throw new \Exception(__($e->getMessage()));
@@ -270,26 +304,189 @@ class ScheduleService
             $schedule->update(['status' => $status]);
 
             switch ($status) {
+
                 case 'pending':
+
                     break;
+
                 case 'accepted':
+
+                    $notificationService = app(NotificationService::class);
+
+                    // CLIENTE
+                    $notificationService->create([
+                        'title' => 'Agendamento aceito!',
+
+                        'description' =>
+                        $schedule->provider->user->name .
+                            ' aceitou sua solicitação de diária.',
+
+                        'origin' => 'schedule',
+
+                        'origin_id' => $schedule->id,
+
+                        'type' => NotificationTypeEnum::SCHEDULE_CLIENT_PROVIDER_ACCEPTED->value,
+
+                        'user_id' => $schedule->client->user_id,
+                    ]);
+
                     break;
+
                 case 'rejected':
+
+                    $notificationService = app(NotificationService::class);
+
+                    // CLIENTE
+                    $notificationService->create([
+                        'title' => 'Agendamento recusado!',
+
+                        'description' =>
+                        'O diarista não poderá atender. Veja outros profissionais disponíveis.',
+
+                        'origin' => 'schedule',
+
+                        'origin_id' => $schedule->id,
+
+                        'type' => NotificationTypeEnum::SCHEDULE_CLIENT_PROVIDER_REFUSED->value,
+
+                        'user_id' => $schedule->client->user_id,
+                    ]);
+
                     break;
+
                 case 'paid':
-                    $date_cleaned = Carbon::parse($schedule->date)->format('Y-m-d');
-                    $date_time_dispatch = Carbon::parse($date_cleaned.' '.$schedule->start_time);
 
-                    // StartScheduleJob::dispatch($schedule->id)->delay($date_time_dispatch);
+                    $notificationService = app(NotificationService::class);
+
+                    // PRESTADOR
+                    if ($schedule->provider_id) {
+
+                        $notificationService->create([
+                            'title' => 'Pagamento confirmado!',
+
+                            'description' =>
+                            'O cliente confirmou o pagamento da diária.',
+
+                            'origin' => 'schedule',
+
+                            'origin_id' => $schedule->id,
+
+                            'type' => NotificationTypeEnum::SCHEDULE_PROVIDER_START->value,
+
+                            'user_id' => $schedule->provider->user_id,
+                        ]);
+                    }
+
+                    $date_cleaned = Carbon::parse($schedule->date)
+                        ->format('Y-m-d');
+
+                    $date_time_dispatch = Carbon::parse(
+                        $date_cleaned . ' ' . $schedule->start_time
+                    )->subHour();
+
+                    StartScheduleJob::dispatch($schedule->id)
+                        ->delay($date_time_dispatch);
 
-                    // dispatch de teste em local
-                    StartScheduleJob::dispatch($schedule->id)->delay(now()->addSeconds(15));
                     break;
+
                 case 'cancelled':
+
+                    $notificationService = app(NotificationService::class);
+
+
+                    switch (Auth::user()->type) {
+                        case 'client':
+
+                            // PRESTADOR
+                            if ($schedule->provider_id) {
+
+                                $notificationService->create(['title' => 'Agendamento cancelado!',
+
+                                    'description' =>'O cliente cancelou a diária.',
+
+                                    'origin' => 'schedule',
+
+                                    'origin_id' => $schedule->id,
+
+                                    'type' => NotificationTypeEnum::SCHEDULE_PROVIDER_CLIENT_CANCELLED->value,
+
+                                    'user_id' => $schedule->provider->user_id,
+                                ]);
+                            }
+
+                            break;
+
+                        case 'provider':
+
+                            // CLIENTE
+                            $notificationService->create([
+                                'title' => 'Agendamento cancelado!',
+
+                                'description' =>
+                                $schedule->provider->user->name .
+                                    ' cancelou a diária.',
+
+                                'origin' => 'schedule',
+
+                                'origin_id' => $schedule->id,
+
+                                'type' => NotificationTypeEnum::SCHEDULE_CLIENT_PROVIDER_CANCELLED->value,
+
+                                'user_id' => $schedule->client->user_id,
+                            ]);
+
+                            break;
+
+                        default:
+                            break;
+                    }
+
                     break;
+
                 case 'started':
+
+                    $notificationService = app(NotificationService::class);
+
+                    // CLIENTE
+                    $notificationService->create([
+                        'title' => 'Diarista a caminho!',
+
+                        'description' =>
+                        'Informe o código ' .
+                            $schedule->code .
+                            ' para confirmar a chegada da diarista e liberar o início do serviço.',
+
+                        'origin' => 'schedule',
+
+                        'origin_id' => $schedule->id,
+
+                        'type' => NotificationTypeEnum::SCHEDULE_CLIENT_PROVIDER_COMING->value,
+
+                        'user_id' => $schedule->client->user_id,
+                    ]);
+
                     break;
+
                 case 'finished':
+
+                    $notificationService = app(NotificationService::class);
+
+                    // CLIENTE
+                    $notificationService->create([
+                        'title' => 'Diária finalizada!',
+
+                        'description' =>
+                        'Avalie o serviço feito pelo diarista e conte-nos como foi sua experiência.',
+
+                        'origin' => 'schedule',
+
+                        'origin_id' => $schedule->id,
+
+                        'type' => NotificationTypeEnum::SCHEDULE_CLIENT_PROVIDER_FINISHED->value,
+
+                        'user_id' => $schedule->client->user_id,
+                    ]);
+
                     break;
             }
 
@@ -298,7 +495,7 @@ class ScheduleService
             return $schedule->fresh(['client.user', 'provider.user', 'address']);
         } catch (\Exception $e) {
             DB::rollBack();
-            Log::error('Erro ao atualizar status do agendamento: '.$e->getMessage());
+            Log::error('Erro ao atualizar status do agendamento: ' . $e->getMessage());
             throw new \Exception('Não foi possível atualizar o status do agendamento.');
         }
     }
@@ -330,7 +527,7 @@ class ScheduleService
         } catch (\Exception $e) {
             DB::rollBack();
 
-            Log::error('Erro ao cancelar agendamento: '.$e->getMessage());
+            Log::error('Erro ao cancelar agendamento: ' . $e->getMessage());
 
             throw new \Exception('Não foi possível cancelar o agendamento.');
         }

+ 2 - 22
app/Services/SearchService.php

@@ -6,6 +6,7 @@ use App\Models\Address;
 use App\Models\Client;
 use App\Models\Provider;
 use App\Rules\ScheduleBusinessRules;
+use App\Services\DistanceService;
 use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\DB;
 
@@ -103,27 +104,6 @@ class SearchService
 
     private function distanceSelect(?float $clientLatitude, ?float $clientLongitude): \Illuminate\Contracts\Database\Query\Expression
     {
-        if ($clientLatitude === null || $clientLongitude === null) {
-            return DB::raw('NULL as distance_km');
-        }
-
-        return DB::raw("
-            CASE
-                WHEN provider_address.latitude IS NOT NULL
-                AND provider_address.longitude IS NOT NULL
-                THEN ROUND((
-                    6371 * acos(
-                        least(1, greatest(-1,
-                            cos(radians({$clientLatitude}))
-                            * cos(radians(provider_address.latitude))
-                            * cos(radians(provider_address.longitude) - radians({$clientLongitude}))
-                            + sin(radians({$clientLatitude}))
-                            * sin(radians(provider_address.latitude))
-                        ))
-                    )
-                )::numeric, 1)
-                ELSE NULL
-            END as distance_km
-        ");
+        return DistanceService::sqlExpression($clientLatitude, $clientLongitude);
     }
 }

+ 19 - 3
app/Services/UserService.php

@@ -7,15 +7,18 @@ use App\Models\Address;
 use App\Models\Client;
 use App\Models\User;
 use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Http\UploadedFile;
 use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
 
 class UserService
 {
+    public function __construct(private readonly MediaService $mediaService) {}
+
     public function me(): ?User
     {
-        return User::with(['provider', 'client'])->find(Auth::id());
+        return User::with(['provider.profileMedia', 'client.profileMedia'])->find(Auth::id());
     }
 
     public function getAll(): Collection
@@ -62,7 +65,7 @@ class UserService
         try {
             DB::beginTransaction();
 
-            $user = User::with(['client'])->findOrFail(Auth::id());
+            $user = User::with(['client.profileMedia'])->findOrFail(Auth::id());
 
             $userFields = array_filter(
                 array_intersect_key($data, array_flip(['name', 'email', 'phone', 'language'])),
@@ -81,6 +84,19 @@ class UserService
                 $client->save();
             }
 
+            if (isset($data['avatar']) && $data['avatar'] instanceof UploadedFile) {
+                $client  = $user->client ?? Client::create(['user_id' => $user->id]);
+                $media   = $this->mediaService->replaceFile(
+                    newFile:   $data['avatar'],
+                    folder:    "client/avatar/{$client->id}",
+                    source:    'client',
+                    sourceId:  $client->id,
+                    old:       $client->profileMedia,
+                );
+                $client->profile_media_id = $media->id;
+                $client->save();
+            }
+
             $user->load('client');
 
             $registrationComplete = ! empty($user->name)
@@ -95,7 +111,7 @@ class UserService
 
             DB::commit();
 
-            return $user->fresh(['client']);
+            return $user->fresh(['client.profileMedia']);
         } catch (\Exception $e) {
             DB::rollBack();
 

+ 23 - 0
app/Tasks/SendPushNotificationsTask.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace App\Tasks;
+
+use App\Services\PushNotificationDispatcher;
+use Illuminate\Support\Facades\Log;
+use Throwable;
+
+class SendPushNotificationsTask
+{
+    public function __construct(private PushNotificationDispatcher $dispatcher) {}
+
+    public function __invoke(): void
+    {
+        try {
+            $this->dispatcher->dispatch();
+        } catch (Throwable $e) {
+            Log::error('SendPushNotificationsTask failed: ' . $e->getMessage(), [
+                'exception' => $e,
+            ]);
+        }
+    }
+}

+ 8 - 14
app/Traits/RemoveArchiveS3.php

@@ -12,22 +12,16 @@ trait RemoveArchiveS3
      *
      * @throws \Exception If the file as not found or if the deletion failed.
      */
-    public function removeArchiveByUrl(string $url): bool
+    public function removeArchiveByPath(string $path): bool
     {
-        // Extrair o caminho relativo da URL completa
-        $imagePath = parse_url($url, PHP_URL_PATH);
-        $imagePath = ltrim($imagePath, '/'); // remover a barra inicial
+        if (!Storage::exists($path)) {
+            return false;
+        }
 
-        // Excluir a imagem antiga
-        if (Storage::exists($imagePath)) {
-            $success = Storage::delete($imagePath);
-            if ($success) {
-                return true;
-            } else {
-                throw new Exception('Deletion was not possible');
-            }
-        } else {
-            throw new Exception('File not found');
+        if (!Storage::delete($path)) {
+            throw new Exception('Deletion was not possible');
         }
+
+        return true;
     }
 }

+ 1 - 3
app/Traits/UploadsBase64Image.php

@@ -39,10 +39,8 @@ trait UploadsBase64Image
         // Define the full path where the file will be stored
         $filePath = $folder.'/'.$filename;
 
-        // Save the file
         Storage::put($filePath, $image);
 
-        // Return the URL of the uploaded image
-        return Storage::url($filePath);
+        return $filePath;
     }
 }

+ 21 - 0
app/Traits/UploadsFile.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Traits;
+
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
+
+trait UploadsFile
+{
+    public function uploadFile(UploadedFile $file, string $folder, ?string $filename = null): string
+    {
+        $extension = $file->getClientOriginalExtension();
+        $name      = $filename ?? Str::random(20).'.'.$extension;
+        $path      = $folder.'/'.$name;
+
+        Storage::disk('s3')->put($path, file_get_contents($file));
+
+        return $path;
+    }
+}

+ 5 - 0
bootstrap/app.php

@@ -4,6 +4,7 @@ use App\Http\Middleware\CheckPermission;
 use App\Http\Middleware\PerformanceMonitor;
 use App\Http\Middleware\SetUserLanguage;
 use App\Tasks\DeleteExpiredTokens;
+use App\Tasks\SendPushNotificationsTask;
 use Illuminate\Console\Scheduling\Schedule;
 use Illuminate\Foundation\Application;
 use Illuminate\Foundation\Configuration\Exceptions;
@@ -27,6 +28,10 @@ return Application::configure(basePath: dirname(__DIR__))
     })
     ->withSchedule(function (Schedule $schedule) {
         $schedule->call(new DeleteExpiredTokens)->everyMinute();
+
+        $schedule->call(fn () => app(SendPushNotificationsTask::class)())->dailyAt('08:00');
+        $schedule->call(fn () => app(SendPushNotificationsTask::class)())->dailyAt('13:00');
+        $schedule->call(fn () => app(SendPushNotificationsTask::class)())->dailyAt('19:00');
     })
     ->withExceptions(function (Exceptions $exceptions) {
         //

+ 3 - 1
composer.json

@@ -10,9 +10,11 @@
     "require": {
         "php": "^8.3",
         "kalnoy/nestedset": "^6.0",
+        "kreait/laravel-firebase": "^7.2",
         "laravel/framework": "^12.0",
         "laravel/sanctum": "^4.0",
-        "laravel/tinker": "^2.9"
+        "laravel/tinker": "^2.9",
+        "league/flysystem-aws-s3-v3": "^3.0"
     },
     "require-dev": {
         "barryvdh/laravel-ide-helper": "^3.6",

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 990 - 11
composer.lock


+ 207 - 0
config/firebase.php

@@ -0,0 +1,207 @@
+<?php
+
+declare(strict_types=1);
+
+return [
+    /*
+     * ------------------------------------------------------------------------
+     * Default Firebase project
+     * ------------------------------------------------------------------------
+     */
+
+    'default' => env('FIREBASE_PROJECT', 'app'),
+
+    /*
+     * ------------------------------------------------------------------------
+     * Firebase project configurations
+     * ------------------------------------------------------------------------
+     */
+
+    'projects' => [
+        'app' => [
+
+            /*
+             * ------------------------------------------------------------------------
+             * Credentials / Service Account
+             * ------------------------------------------------------------------------
+             *
+             * In order to access a Firebase project and its related services using a
+             * server SDK, requests must be authenticated. For server-to-server
+             * communication this is done with a Service Account.
+             *
+             * If you don't already have generated a Service Account, you can do so by
+             * following the instructions from the official documentation pages at
+             *
+             * https://firebase.google.com/docs/admin/setup#initialize_the_sdk
+             *
+             * Once you have downloaded the Service Account JSON file, you can use it
+             * to configure the package.
+             *
+             * If you don't provide credentials, the Firebase Admin SDK will try to
+             * auto-discover them
+             *
+             * - by checking the environment variable FIREBASE_CREDENTIALS
+             * - by checking the environment variable GOOGLE_APPLICATION_CREDENTIALS
+             * - by trying to find Google's well known file
+             * - by checking if the application is running on GCE/GCP
+             *
+             * If no credentials file can be found, an exception will be thrown the
+             * first time you try to access a component of the Firebase Admin SDK.
+             *
+             */
+
+            'credentials' => env('FIREBASE_CREDENTIALS', env('GOOGLE_APPLICATION_CREDENTIALS')),
+
+            /*
+             * ------------------------------------------------------------------------
+             * Firebase Auth Component
+             * ------------------------------------------------------------------------
+             */
+
+            'auth' => [
+                'tenant_id' => env('FIREBASE_AUTH_TENANT_ID'),
+            ],
+
+            /*
+             * ------------------------------------------------------------------------
+             * Firestore Component
+             * ------------------------------------------------------------------------
+             */
+
+            'firestore' => [
+
+                /*
+                 * If you want to access a Firestore database other than the default database,
+                 * enter its name here.
+                 *
+                 * By default, the Firestore client will connect to the `(default)` database.
+                 *
+                 * https://firebase.google.com/docs/firestore/manage-databases
+                 */
+
+                // 'database' => env('FIREBASE_FIRESTORE_DATABASE'),
+            ],
+
+            /*
+             * ------------------------------------------------------------------------
+             * Firebase Realtime Database
+             * ------------------------------------------------------------------------
+             */
+
+            'database' => [
+
+                /*
+                 * In most of the cases the project ID defined in the credentials file
+                 * determines the URL of your project's Realtime Database. If the
+                 * connection to the Realtime Database fails, you can override
+                 * its URL with the value you see at
+                 *
+                 * https://console.firebase.google.com/u/1/project/_/database
+                 *
+                 * Please make sure that you use a full URL like, for example,
+                 * https://my-project-id.firebaseio.com
+                 */
+
+                'url' => env('FIREBASE_DATABASE_URL'),
+
+                /*
+                 * As a best practice, a service should have access to only the resources it needs.
+                 * To get more fine-grained control over the resources a Firebase app instance can access,
+                 * use a unique identifier in your Security Rules to represent your service.
+                 *
+                 * https://firebase.google.com/docs/database/admin/start#authenticate-with-limited-privileges
+                 */
+
+                // 'auth_variable_override' => [
+                //     'uid' => 'my-service-worker'
+                // ],
+
+            ],
+
+            /*
+             * ------------------------------------------------------------------------
+             * Firebase Cloud Storage
+             * ------------------------------------------------------------------------
+             */
+
+            'storage' => [
+
+                /*
+                 * Your project's default storage bucket usually uses the project ID
+                 * as its name. If you have multiple storage buckets and want to
+                 * use another one as the default for your application, you can
+                 * override it here.
+                 */
+
+                'default_bucket' => env('FIREBASE_STORAGE_DEFAULT_BUCKET'),
+
+            ],
+
+            /*
+             * ------------------------------------------------------------------------
+             * Caching
+             * ------------------------------------------------------------------------
+             *
+             * The Firebase Admin SDK can cache some data returned from the Firebase
+             * API, for example Google's public keys used to verify ID tokens.
+             *
+             */
+
+            'cache_store' => env('FIREBASE_CACHE_STORE', 'file'),
+
+            /*
+             * ------------------------------------------------------------------------
+             * Logging
+             * ------------------------------------------------------------------------
+             *
+             * Enable logging of HTTP interaction for insights and/or debugging.
+             *
+             * Log channels are defined in config/logging.php
+             *
+             * Successful HTTP messages are logged with the log level 'info'.
+             * Failed HTTP messages are logged with the log level 'notice'.
+             *
+             * Note: Using the same channel for simple and debug logs will result in
+             * two entries per request and response.
+             */
+
+            'logging' => [
+                'http_log_channel' => env('FIREBASE_HTTP_LOG_CHANNEL'),
+                'http_debug_log_channel' => env('FIREBASE_HTTP_DEBUG_LOG_CHANNEL'),
+            ],
+
+            /*
+             * ------------------------------------------------------------------------
+             * HTTP Client Options
+             * ------------------------------------------------------------------------
+             *
+             * Behavior of the HTTP Client performing the API requests
+             */
+
+            'http_client_options' => [
+
+                /*
+                 * Use a proxy that all API requests should be passed through.
+                 * (default: none)
+                 */
+
+                'proxy' => env('FIREBASE_HTTP_CLIENT_PROXY'),
+
+                /*
+                 * Set the maximum amount of seconds (float) that can pass before
+                 * a request is considered timed out
+                 *
+                 * The default time out can be reviewed at
+                 * https://github.com/beste/firebase-php/blob/6.x/src/Firebase/Http/HttpClientOptions.php
+                 */
+
+                'timeout' => env('FIREBASE_HTTP_CLIENT_TIMEOUT'),
+
+                'guzzle_middlewares' => [
+                    // MyInvokableMiddleware::class,
+                    // [MyMiddleware::class, 'static_method'],
+                ],
+            ],
+        ],
+    ],
+];

+ 41 - 0
database/migrations/2026_05_25_102711_create_notifications_table.php

@@ -0,0 +1,41 @@
+<?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('notifications', function (Blueprint $table) {
+
+            $table->id();
+
+            $table->string('title');
+
+            $table->text('description');
+
+            $table->string('origin');
+
+            $table->unsignedBigInteger('origin_id');
+
+            $table->string('type');
+
+            $table->boolean('read')->default(false);
+
+            $table->dateTime('read_at')->nullable();
+
+           $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
+
+            $table->softDeletes();
+
+            $table->timestamps();
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('notifications');
+    }
+};

+ 34 - 0
database/migrations/2026_05_27_111754_create_device_tokens_table.php

@@ -0,0 +1,34 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('device_tokens', function (Blueprint $table) {
+            $table->id();
+            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
+            $table->string('token')->unique();
+            $table->enum('platform', ['android', 'ios']);
+            $table->enum('app_type', ['prestador', 'cliente']);
+            $table->boolean('active')->default(true);
+            $table->timestamps();
+
+            $table->index(['user_id', 'app_type', 'active']);
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('device_tokens');
+    }
+};

+ 35 - 0
database/migrations/2026_05_27_111755_create_push_notification_logs_table.php

@@ -0,0 +1,35 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('push_notification_logs', function (Blueprint $table) {
+            $table->id();
+            $table->string('label');
+            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
+            $table->string('target');
+            $table->string('category');
+            $table->timestamp('sent_at');
+            $table->timestamps();
+
+            $table->index(['user_id', 'label']);
+            $table->index(['user_id', 'target', 'category']);
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('push_notification_logs');
+    }
+};

+ 27 - 0
database/migrations/2026_05_27_142855_add_profile_media_id_to_clients_table.php

@@ -0,0 +1,27 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('clients', function (Blueprint $table) {
+            $table->unsignedBigInteger('profile_media_id')->nullable()->after('user_id');
+            $table->foreign('profile_media_id')->references('id')->on('media')->nullOnDelete();
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('clients', function (Blueprint $table) {
+            $table->dropForeign(['profile_media_id']);
+            $table->dropColumn('profile_media_id');
+        });
+    }
+};

+ 30 - 0
database/migrations/2026_05_27_143114_add_document_media_ids_to_providers_table.php

@@ -0,0 +1,30 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('providers', function (Blueprint $table) {
+            $table->unsignedBigInteger('document_front_media_id')->nullable()->after('profile_media_id');
+            $table->unsignedBigInteger('document_back_media_id')->nullable()->after('document_front_media_id');
+            $table->foreign('document_front_media_id')->references('id')->on('media')->nullOnDelete();
+            $table->foreign('document_back_media_id')->references('id')->on('media')->nullOnDelete();
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('providers', function (Blueprint $table) {
+            $table->dropForeign(['document_front_media_id']);
+            $table->dropForeign(['document_back_media_id']);
+            $table->dropColumn(['document_front_media_id', 'document_back_media_id']);
+        });
+    }
+};

+ 27 - 0
database/migrations/2026_05_27_143114_remove_base64_columns_from_providers_table.php

@@ -0,0 +1,27 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('providers', function (Blueprint $table) {
+            $table->dropColumn(['selfie_media_base64', 'document_front_media_base64', 'document_back_media_base64']);
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('providers', function (Blueprint $table) {
+            $table->longText('selfie_media_base64')->nullable();
+            $table->longText('document_front_media_base64')->nullable();
+            $table->longText('document_back_media_base64')->nullable();
+        });
+    }
+};

+ 33 - 0
database/migrations/2026_05_27_143343_create_review_media_table.php

@@ -0,0 +1,33 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('review_media', function (Blueprint $table) {
+            $table->id();
+            $table->unsignedBigInteger('review_id');
+            $table->unsignedBigInteger('media_id');
+            $table->string('origin'); // client | provider
+            $table->timestamps();
+
+            $table->foreign('review_id')->references('id')->on('reviews')->cascadeOnDelete();
+            $table->foreign('media_id')->references('id')->on('media')->cascadeOnDelete();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('review_media');
+    }
+};

+ 7 - 0
database/seeders/PermissionSeeder.php

@@ -196,6 +196,13 @@ class PermissionSeeder extends Seeder
                         'bits'        => 271,
                         'children'    => [],
                     ],
+
+                    [
+                        'scope'       => 'notification',
+                        'description' => 'Notificações',
+                        'bits'        => 271,
+                        'children'    => [],
+                    ],
                 ],
             ],
         ];

+ 3 - 0
database/seeders/UserTypePermissionSeeder.php

@@ -52,6 +52,7 @@ class UserTypePermissionSeeder extends Seeder
                         ['scope' => 'config.speciality', 'bits' => 271],
                         ['scope' => 'config.review', 'bits' => 271],
                         ['scope' => 'config.review_improvement', 'bits' => 271],
+                        ['scope' => 'notification', 'bits' => 271],
                     ];
                     $this->seedUserTypePermissions($userPermissions, UserTypeEnum::USER->value);
                     break;
@@ -72,6 +73,7 @@ class UserTypePermissionSeeder extends Seeder
                         ['scope' => 'config.improvement_type', 'bits' => 1],
                         ['scope' => 'config.review', 'bits' => 271],
                         ['scope' => 'config.provider_client_block', 'bits' => 9],
+                        ['scope' => 'notification', 'bits' => 271],
                     ];
                     $this->seedUserTypePermissions($providerPermissions, UserTypeEnum::PROVIDER->value);
                     break;
@@ -93,6 +95,7 @@ class UserTypePermissionSeeder extends Seeder
                         ['scope' => 'config.custom_schedule', 'bits' => 271],
                         ['scope' => 'config.speciality', 'bits' => 271],
                         ['scope' => 'config.service_type', 'bits' => 271],
+                        ['scope' => 'notification', 'bits' => 271],
                     ];
                     $this->seedUserTypePermissions($clientPermissions, UserTypeEnum::CLIENT->value);
                     break;

+ 10 - 10
lang/en/auth.php

@@ -13,17 +13,17 @@ return [
     |
     */
 
-    'failed'             => 'Invalid credentials',
-    'password'           => 'The provided password is incorrect.',
-    'throttle'           => 'Too many login attempts. Please try again in :seconds seconds.',
-    'logout'             => 'Logged out successfully',
-    'logged_in'          => 'Logged in successfully',
+    'failed' => 'Invalid credentials',
+    'password' => 'The provided password is incorrect.',
+    'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
+    'logout' => 'Logged out successfully',
+    'logged_in' => 'Logged in successfully',
     'already_logged_out' => 'User already logged out',
-    'unauthorized'       => 'Unauthorized',
-    'session_expired'    => 'Session expired',
-    'invalid_code'       => 'Invalid code',
-    'valid_code'         => 'The provided code is valid.',
-    'wrong_user_type'    => 'This email cannot access this application. Please contact support for more information.',
+    'unauthorized' => 'Unauthorized',
+    'session_expired' => 'Session expired',
+    'invalid_code' => 'Invalid code',
+    'valid_code' => 'The provided code is valid.',
+    'wrong_user_type' => 'This email cannot access this application. Please contact support for more information.',
     // 'provider_pending' => 'Your registration is awaiting approval. We will contact you soon.',
     // 'provider_rejected' => 'Your registration was rejected. Please contact support.',
     'provider_not_accepted' => 'Your registration has not been approved yet. Please wait or contact support for more information.',

+ 10 - 10
lang/en/messages.php

@@ -1,16 +1,16 @@
 <?php
 
 return [
-    'welcome'                              => 'Welcome to our application',
-    'created'                              => 'Created successfully',
-    'updated'                              => 'Updated successfully',
-    'deleted'                              => 'Deleted successfully',
-    'email_sent'                           => 'Email sent successfully',
-    'email_not_sent'                       => 'Email not sent',
-    'imported'                             => 'Imported successfully',
-    'import_error'                         => 'Error importing',
-    'buyer_not_allowed'                    => 'Buyer not allowed',
-    'code_sent'                            => 'Verification code sent successfully',
+    'welcome' => 'Welcome to our application',
+    'created' => 'Created successfully',
+    'updated' => 'Updated successfully',
+    'deleted' => 'Deleted successfully',
+    'email_sent' => 'Email sent successfully',
+    'email_not_sent' => 'Email not sent',
+    'imported' => 'Imported successfully',
+    'import_error' => 'Error importing',
+    'buyer_not_allowed' => 'Buyer not allowed',
+    'code_sent' => 'Verification code sent successfully',
     'user_not_found_or_code_not_validated' => 'User not found or invalid code.',
     'provider_approved'                    => 'Provider approved successfully.',
     'provider_rejected'                    => 'Provider rejected.',

+ 10 - 10
lang/es/auth.php

@@ -13,17 +13,17 @@ return [
     |
     */
 
-    'failed'             => 'Estas credenciales no coinciden con nuestros registros.',
-    'password'           => 'La contraseña proporcionada es incorrecta.',
-    'throttle'           => 'Demasiados intentos de acceso. Por favor, inténtelo de nuevo en :seconds segundos.',
-    'logout'             => 'Cerró sesión correctamente',
-    'logged_in'          => 'Inició sesión correctamente',
+    'failed' => 'Estas credenciales no coinciden con nuestros registros.',
+    'password' => 'La contraseña proporcionada es incorrecta.',
+    'throttle' => 'Demasiados intentos de acceso. Por favor, inténtelo de nuevo en :seconds segundos.',
+    'logout' => 'Cerró sesión correctamente',
+    'logged_in' => 'Inició sesión correctamente',
     'already_logged_out' => 'El usuario ya ha cerrado sesión',
-    'unauthorized'       => 'No autorizado',
-    'session_expired'    => 'Sesión caducada',
-    'invalid_code'       => 'Código inválido',
-    'valid_code'         => 'El código proporcionado es válido.',
-    'wrong_user_type'    => 'Este correo electrónico no puede acceder a esta aplicación. Contacte al soporte para más información.',
+    'unauthorized' => 'No autorizado',
+    'session_expired' => 'Sesión caducada',
+    'invalid_code' => 'Código inválido',
+    'valid_code' => 'El código proporcionado es válido.',
+    'wrong_user_type' => 'Este correo electrónico no puede acceder a esta aplicación. Contacte al soporte para más información.',
     // 'provider_pending' => 'Su registro está pendiente de aprobación. Nos pondremos en contacto pronto.',
     // 'provider_rejected' => 'Su registro fue rechazado. Póngase en contacto con el soporte.',
     'provider_not_accepted' => 'Su registro aún no ha sido aprobado. Por favor, espere o póngase en contacto con el soporte para más información.',

+ 10 - 10
lang/es/messages.php

@@ -1,16 +1,16 @@
 <?php
 
 return [
-    'welcome'                              => 'Bienvenido a nuestra aplicación',
-    'created'                              => 'Creado exitosamente',
-    'updated'                              => 'Actualizado exitosamente',
-    'deleted'                              => 'Eliminado exitosamente',
-    'email_sent'                           => 'Email enviado exitosamente',
-    'email_not_sent'                       => 'Email no enviado',
-    'imported'                             => 'Importado exitosamente',
-    'import_error'                         => 'Error al importar',
-    'buyer_not_allowed'                    => 'Comprador no permitido',
-    'code_sent'                            => 'Código de verificación enviado exitosamente',
+    'welcome' => 'Bienvenido a nuestra aplicación',
+    'created' => 'Creado exitosamente',
+    'updated' => 'Actualizado exitosamente',
+    'deleted' => 'Eliminado exitosamente',
+    'email_sent' => 'Email enviado exitosamente',
+    'email_not_sent' => 'Email no enviado',
+    'imported' => 'Importado exitosamente',
+    'import_error' => 'Error al importar',
+    'buyer_not_allowed' => 'Comprador no permitido',
+    'code_sent' => 'Código de verificación enviado exitosamente',
     'user_not_found_or_code_not_validated' => 'Usuario no encontrado o código inválido.',
     'provider_approved'                    => 'Prestador aprobado exitosamente.',
     'provider_rejected'                    => 'Prestador rechazado.',

+ 10 - 10
lang/pt/auth.php

@@ -13,17 +13,17 @@ return [
     |
     */
 
-    'failed'             => 'Essas credenciais não correspondem aos nossos registros.',
-    'password'           => 'A senha fornecida está incorreta.',
-    'throttle'           => 'Muitas tentativas de login. Por favor, tente novamente em :seconds segundos.',
-    'logout'             => 'Desconectado com sucesso',
-    'logged_in'          => 'Conectado com sucesso',
+    'failed' => 'Essas credenciais não correspondem aos nossos registros.',
+    'password' => 'A senha fornecida está incorreta.',
+    'throttle' => 'Muitas tentativas de login. Por favor, tente novamente em :seconds segundos.',
+    'logout' => 'Desconectado com sucesso',
+    'logged_in' => 'Conectado com sucesso',
     'already_logged_out' => 'Usuário já desconectado',
-    'unauthorized'       => 'Não autorizado',
-    'session_expired'    => 'Sessão expirada',
-    'invalid_code'       => 'Código inválido',
-    'valid_code'         => 'O código fornecido é válido.',
-    'wrong_user_type'    => 'Este e-mail não pode acessar esse aplicativo. Entre em contato com o suporte para mais informações.',
+    'unauthorized' => 'Não autorizado',
+    'session_expired' => 'Sessão expirada',
+    'invalid_code' => 'Código inválido',
+    'valid_code' => 'O código fornecido é válido.',
+    'wrong_user_type' => 'Este e-mail não pode acessar esse aplicativo. Entre em contato com o suporte para mais informações.',
     // 'provider_pending' => 'Seu cadastro está aguardando aprovação. Em breve entraremos em contato.',
     // 'provider_rejected' => 'Seu cadastro foi recusado. Entre em contato com o suporte.',
     'provider_not_accepted' => 'Seu cadastro ainda não foi aprovado. Por favor, aguarde ou entre em contato com o suporte para mais informações.',

+ 10 - 10
lang/pt/messages.php

@@ -1,16 +1,16 @@
 <?php
 
 return [
-    'welcome'                              => 'Bem-vindo à nossa aplicação',
-    'created'                              => 'Criado com sucesso',
-    'updated'                              => 'Atualizado com sucesso',
-    'deleted'                              => 'Excluído com sucesso',
-    'email_sent'                           => 'Email enviado com sucesso',
-    'email_not_sent'                       => 'Email não enviado',
-    'imported'                             => 'Importado com sucesso',
-    'import_error'                         => 'Erro ao importar',
-    'buyer_not_allowed'                    => 'Compra não permitida, tente outro ingresso ou comprador',
-    'code_sent'                            => 'Código de verificação enviado com sucesso',
+    'welcome' => 'Bem-vindo à nossa aplicação',
+    'created' => 'Criado com sucesso',
+    'updated' => 'Atualizado com sucesso',
+    'deleted' => 'Excluído com sucesso',
+    'email_sent' => 'Email enviado com sucesso',
+    'email_not_sent' => 'Email não enviado',
+    'imported' => 'Importado com sucesso',
+    'import_error' => 'Erro ao importar',
+    'buyer_not_allowed' => 'Compra não permitida, tente outro ingresso ou comprador',
+    'code_sent' => 'Código de verificação enviado com sucesso',
     'user_not_found_or_code_not_validated' => 'Usuário não encontrado ou código inválido.',
     'provider_approved'                    => 'Prestador aprovado com sucesso.',
     'provider_rejected'                    => 'Prestador recusado.',

+ 7 - 0
routes/authRoutes/device_token.php

@@ -0,0 +1,7 @@
+<?php
+
+use App\Http\Controllers\DeviceTokenController;
+use Illuminate\Support\Facades\Route;
+
+Route::post('/device-tokens',          [DeviceTokenController::class, 'store']);
+Route::delete('/device-tokens/{token}', [DeviceTokenController::class, 'destroy']);

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů