Sfoglia il codice sorgente

push notifications wip (interrompido pela tarefa do s3 pois recebi acesso)

Gustavo Zanatta 2 settimane fa
parent
commit
2707ecbbc0
50 ha cambiato i file con 2884 aggiunte e 31 eliminazioni
  1. 255 0
      PLANO_PUSH.md
  2. 44 0
      _ide_helper.php
  3. 15 0
      app/Enums/PushNotificationCategoryEnum.php
  4. 9 0
      app/Enums/PushNotificationTargetEnum.php
  5. 26 0
      app/Http/Controllers/DeviceTokenController.php
  6. 17 0
      app/Http/Requests/DeviceTokenRequest.php
  7. 2 1
      app/Http/Resources/MediaResource.php
  8. 38 0
      app/Models/DeviceToken.php
  9. 38 0
      app/Models/PushNotificationLog.php
  10. 11 0
      app/Models/User.php
  11. 43 0
      app/Notifications/Push/BasePushNotification.php
  12. 34 0
      app/Notifications/Push/Cliente/Contextual/ContextualSegundaPush.php
  13. 34 0
      app/Notifications/Push/Cliente/Contextual/ContextualSextaPush.php
  14. 26 0
      app/Notifications/Push/Cliente/Contextual/ContextualVisitaPush.php
  15. 26 0
      app/Notifications/Push/Cliente/Educativo/Educativo1Push.php
  16. 26 0
      app/Notifications/Push/Cliente/Educativo/Educativo2Push.php
  17. 26 0
      app/Notifications/Push/Cliente/Educativo/Educativo3Push.php
  18. 26 0
      app/Notifications/Push/Cliente/Educativo/Educativo4Push.php
  19. 39 0
      app/Notifications/Push/Cliente/EducativoConversao/EducativoConversao1Push.php
  20. 34 0
      app/Notifications/Push/Cliente/EducativoConversao/EducativoConversao2Push.php
  21. 26 0
      app/Notifications/Push/Cliente/Marketing/Marketing1Push.php
  22. 26 0
      app/Notifications/Push/Cliente/Marketing/Marketing2Push.php
  23. 26 0
      app/Notifications/Push/Cliente/Marketing/Marketing3Push.php
  24. 26 0
      app/Notifications/Push/Cliente/Marketing/Marketing4Push.php
  25. 26 0
      app/Notifications/Push/Cliente/Motivacional/Motivacional1Push.php
  26. 26 0
      app/Notifications/Push/Cliente/Motivacional/Motivacional2Push.php
  27. 26 0
      app/Notifications/Push/Cliente/Motivacional/Motivacional3Push.php
  28. 27 0
      app/Notifications/Push/Cliente/Recorrencia/Recorrencia1Push.php
  29. 27 0
      app/Notifications/Push/Cliente/Recorrencia/Recorrencia2Push.php
  30. 27 0
      app/Notifications/Push/Cliente/Recorrencia/Recorrencia3Push.php
  31. 39 0
      app/Notifications/Push/Cliente/SocialProof/SocialProof1Push.php
  32. 34 0
      app/Notifications/Push/Cliente/SocialProof/SocialProof2Push.php
  33. 54 0
      app/Notifications/Push/Prestador/Motivacional/Motivacional1Push.php
  34. 78 0
      app/Notifications/Push/Prestador/ReforcoEducativo/ReforcoEducativo1Push.php
  35. 71 0
      app/Notifications/Push/Prestador/ReforcoEducativo/ReforcoEducativo2Push.php
  36. 10 3
      app/Services/ClientCalendarService.php
  37. 13 4
      app/Services/DashboardService.php
  38. 29 0
      app/Services/DeviceTokenService.php
  39. 138 0
      app/Services/PushNotificationDispatcher.php
  40. 78 0
      app/Services/PushNotificationService.php
  41. 23 0
      app/Tasks/SendPushNotificationsTask.php
  42. 3 8
      app/Traits/RemoveArchiveS3.php
  43. 1 3
      app/Traits/UploadsBase64Image.php
  44. 5 0
      bootstrap/app.php
  45. 3 1
      composer.json
  46. 990 11
      composer.lock
  47. 207 0
      config/firebase.php
  48. 34 0
      database/migrations/2026_05_27_111754_create_device_tokens_table.php
  49. 35 0
      database/migrations/2026_05_27_111755_create_push_notification_logs_table.php
  50. 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 {}
 }
 
 

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

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

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

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

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

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

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

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

+ 13 - 4
app/Services/DashboardService.php

@@ -16,6 +16,7 @@ use App\Rules\ScheduleBusinessRules;
 use App\Services\DistanceService;
 use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Storage;
 
 class DashboardService
 {
@@ -256,7 +257,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
@@ -266,7 +267,13 @@ 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;
+            });
 
         $hasPaymentMethods = ClientPaymentMethod::where('client_id', $cliente->id)->exists();
 
@@ -300,7 +307,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();
@@ -323,7 +330,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,
         ];

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

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

+ 3 - 8
app/Traits/RemoveArchiveS3.php

@@ -12,15 +12,10 @@ 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
-
-        // Excluir a imagem antiga
-        if (Storage::exists($imagePath)) {
-            $success = Storage::delete($imagePath);
+        if (Storage::exists($path)) {
+            $success = Storage::delete($path);
             if ($success) {
                 return true;
             } else {

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

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

File diff suppressed because it is too large
+ 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'],
+                ],
+            ],
+        ],
+    ],
+];

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

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

Some files were not shown because too many files changed in this diff