Bladeren bron

feat(notifications): dispatch in-app, endpoints do sino e gatilhos

- NotificationService::dispatch() cria notification + 1 recipient por
  usuário e emite WebsocketEvent (best-effort); canal in_app.
- Resolvers de destinatários: unitUserIds() e matrizUserIds() (ADMIN).
- Endpoints do sino: /notification/me, /me/unread-count,
  /{id}/read, /read-all (sem permissão, qualquer autenticado).
- NotificationResource com is_read do usuário logado; enum NotificationType.
- Migration: notifications.created_by_user_id nullable (eventos do sistema).
- Gatilhos (chamada direta no controller): SupportTicket/SupportReply e
  Kanban/KanbanReply (criar/responder, direção franqueada<->matriz) e
  fechamento do mês no tbr:generate-monthly (matriz + cada unidade).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ebagabee 3 weken geleden
bovenliggende
commit
23b93ffc1c

+ 61 - 1
app/Console/Commands/GenerateMonthlyTbrCommand.php

@@ -2,6 +2,8 @@
 
 namespace App\Console\Commands;
 
+use App\Enums\NotificationType;
+use App\Services\NotificationService;
 use App\Services\TbrCalculationService;
 use Carbon\Carbon;
 use Illuminate\Console\Command;
@@ -18,7 +20,13 @@ class GenerateMonthlyTbrCommand extends Command
 
     protected $description = 'Gera automaticamente as cobranças do TBR do mês fechado';
 
-    public function handle(TbrCalculationService $service): int
+    private const MONTHS = [
+        1 => 'janeiro', 2 => 'fevereiro', 3 => 'março', 4 => 'abril',
+        5 => 'maio', 6 => 'junho', 7 => 'julho', 8 => 'agosto',
+        9 => 'setembro', 10 => 'outubro', 11 => 'novembro', 12 => 'dezembro',
+    ];
+
+    public function handle(TbrCalculationService $service, NotificationService $notifications): int
     {
         $reference = Carbon::now()->subMonthNoOverflow();
 
@@ -36,6 +44,58 @@ public function handle(TbrCalculationService $service): int
             $result['error_count'],
         ));
 
+        if ($result['generated_count'] > 0) {
+            $this->notifyMonthClosed($notifications, $year, $month, $result['generated']);
+        }
+
         return self::SUCCESS;
     }
+
+    /**
+     * Notifica a matriz (contas a receber) e cada unidade (conta a pagar).
+     */
+    private function notifyMonthClosed(
+        NotificationService $notifications,
+        int $year,
+        int $month,
+        array $generated,
+    ): void {
+        $monthLabel = (self::MONTHS[$month] ?? $month) . "/{$year}";
+        $count      = count($generated);
+
+        // Matriz: visão consolidada no Contas a Receber.
+        $notifications->dispatch([
+            'origin_table'       => 'tbr_calculations',
+            'origin_id'          => $year * 100 + $month,
+            'type'               => NotificationType::TBR_MONTH_CLOSED->value,
+            'title'              => 'Fechamento do mês',
+            'message'            => "As cobranças de {$monthLabel} foram geradas no Contas a Receber "
+                . "({$count} unidade(s)).",
+            'url'                => '/financial/accounts-receivable',
+            'action_text'        => 'Ver Contas a Receber',
+            'priority'           => 'normal',
+            'recipient_user_ids' => $notifications->matrizUserIds(),
+        ]);
+
+        // Cada unidade: sua conta a pagar (TBR) do mês.
+        foreach ($generated as $item) {
+            $unitId = $item['unit_id'] ?? null;
+            if (!$unitId) {
+                continue;
+            }
+
+            $notifications->dispatch([
+                'origin_table'       => 'unit_account_payables',
+                'origin_id'          => $item['receivable_id'] ?? ($year * 100 + $month),
+                'unit_id'            => $unitId,
+                'type'               => NotificationType::TBR_MONTH_CLOSED->value,
+                'title'              => 'Conta a pagar gerada',
+                'message'            => "Sua cobrança (TBR) referente a {$monthLabel} foi gerada em Contas a Pagar.",
+                'url'                => '/financial/accounts-payable',
+                'action_text'        => 'Ver Contas a Pagar',
+                'priority'           => 'normal',
+                'recipient_user_ids' => $notifications->unitUserIds($unitId),
+            ]);
+        }
+    }
 }

+ 32 - 0
app/Enums/NotificationType.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Enums;
+
+use App\Traits\EnumHelper;
+
+/**
+ * Tipos de notificação. O frontend usa o valor para escolher ícone e rota.
+ */
+enum NotificationType: string
+{
+    use EnumHelper;
+
+    case SUPPORT_TICKET_CREATED = 'support_ticket_created';
+    case SUPPORT_TICKET_REPLY   = 'support_ticket_reply';
+    case KANBAN_CREATED         = 'kanban_created';
+    case KANBAN_REPLY           = 'kanban_reply';
+    case KANBAN_STATUS_CHANGED  = 'kanban_status_changed';
+    case TBR_MONTH_CLOSED       = 'tbr_month_closed';
+
+    public function label(): string
+    {
+        return match ($this) {
+            self::SUPPORT_TICKET_CREATED => 'Novo chamado de suporte',
+            self::SUPPORT_TICKET_REPLY   => 'Nova resposta no chamado',
+            self::KANBAN_CREATED         => 'Nova atividade',
+            self::KANBAN_REPLY           => 'Novo comentário na atividade',
+            self::KANBAN_STATUS_CHANGED  => 'Status da atividade atualizado',
+            self::TBR_MONTH_CLOSED       => 'Fechamento do mês',
+        };
+    }
+}

+ 36 - 1
app/Http/Controllers/KanbanController.php

@@ -3,7 +3,9 @@
 namespace App\Http\Controllers;
 
 use App\Http\Controllers\Concerns\ResolvesActiveUnit;
+use App\Enums\NotificationType;
 use App\Services\KanbanService;
+use App\Services\NotificationService;
 use App\Http\Requests\KanbanRequest;
 use App\Http\Requests\KanbanReorderRequest;
 use App\Http\Resources\KanbanResource;
@@ -16,6 +18,7 @@ class KanbanController extends Controller
 
     public function __construct(
         protected KanbanService $service,
+        protected NotificationService $notifications,
     ) {}
 
     public function index(): JsonResponse
@@ -46,9 +49,11 @@ public function store(KanbanRequest $request): JsonResponse
             $unitIds = Unit::query()->pluck('id');
             $created = [];
             foreach ($unitIds as $unitId) {
-                $created[] = $this->service->create(array_merge($data, [
+                $card = $this->service->create(array_merge($data, [
                     'target_unit_id' => $unitId,
                 ]));
+                $this->notifyKanbanCreated($card);
+                $created[] = $card;
             }
             return $this->successResponse(
                 payload: KanbanResource::collection($created),
@@ -70,6 +75,7 @@ public function store(KanbanRequest $request): JsonResponse
         }
 
         $item = $this->service->create($data);
+        $this->notifyKanbanCreated($item);
         return $this->successResponse(
             payload: new KanbanResource($item),
             message: __('messages.created'),
@@ -77,6 +83,35 @@ public function store(KanbanRequest $request): JsonResponse
         );
     }
 
+    /**
+     * Notifica o outro lado quando uma atividade é criada:
+     * franqueada → matriz; matriz → unidade alvo.
+     */
+    private function notifyKanbanCreated(\App\Models\Kanban $card): void
+    {
+        if ($card->origin === 'unit') {
+            $unitId     = $card->unit_id;
+            $recipients = $this->notifications->matrizUserIds();
+        } else {
+            $unitId     = $card->target_unit_id;
+            $recipients = $this->notifications->unitUserIds($unitId);
+        }
+
+        $this->notifications->dispatch([
+            'origin_table'       => 'kanbans',
+            'origin_id'          => $card->id,
+            'unit_id'            => $unitId,
+            'type'               => NotificationType::KANBAN_CREATED->value,
+            'title'              => 'Nova atividade',
+            'message'            => $card->title,
+            'url'                => '/kanban',
+            'action_text'        => 'Ver atividade',
+            'priority'           => $card->priority === 'alta' ? 'high' : 'normal',
+            'created_by_user_id' => $card->created_by_user_id,
+            'recipient_user_ids' => $recipients,
+        ]);
+    }
+
     public function show(int $id): JsonResponse
     {
         $item = $this->service->findById($id);

+ 41 - 0
app/Http/Controllers/KanbanReplyController.php

@@ -2,7 +2,10 @@
 
 namespace App\Http\Controllers;
 
+use App\Enums\NotificationType;
+use App\Models\Kanban;
 use App\Services\KanbanReplyService;
+use App\Services\NotificationService;
 use App\Http\Requests\KanbanReplyRequest;
 use App\Http\Resources\KanbanReplyResource;
 use Illuminate\Http\JsonResponse;
@@ -11,6 +14,7 @@ class KanbanReplyController extends Controller
 {
     public function __construct(
         protected KanbanReplyService $service,
+        protected NotificationService $notifications,
     ) {}
 
     public function index(int $kanbanId): JsonResponse
@@ -22,9 +26,46 @@ public function index(int $kanbanId): JsonResponse
     public function store(KanbanReplyRequest $request, int $kanbanId): JsonResponse
     {
         $reply = $this->service->create($kanbanId, auth()->id(), $request->validated()['reply']);
+        $this->notifyReplyCreated($kanbanId);
         return $this->successResponse(payload: new KanbanReplyResource($reply), message: __('messages.created'), code: 201);
     }
 
+    /**
+     * Notifica o outro lado da atividade quando há um novo comentário.
+     * Autor da matriz → unidade da atividade; autor da unidade → matriz.
+     */
+    private function notifyReplyCreated(int $kanbanId): void
+    {
+        $card = Kanban::find($kanbanId);
+        if (!$card) {
+            return;
+        }
+
+        $author         = auth()->user();
+        $authorIsMatriz = $author->user_type === 'ADMIN';
+        $unitId         = $card->unit_id ?? $card->target_unit_id;
+
+        $recipients = $authorIsMatriz
+            ? $this->notifications->unitUserIds($unitId)
+            : $this->notifications->matrizUserIds();
+
+        // Não notifica o próprio autor do comentário.
+        $recipients = array_values(array_diff($recipients, [$author->id]));
+
+        $this->notifications->dispatch([
+            'origin_table'       => 'kanban_replies',
+            'origin_id'          => $card->id,
+            'unit_id'            => $unitId,
+            'type'               => NotificationType::KANBAN_REPLY->value,
+            'title'              => 'Novo comentário na atividade',
+            'message'            => $card->title,
+            'url'                => '/kanban',
+            'action_text'        => 'Ver atividade',
+            'created_by_user_id' => $author->id,
+            'recipient_user_ids' => $recipients,
+        ]);
+    }
+
     public function update(KanbanReplyRequest $request, int $kanbanId, int $id): JsonResponse
     {
         $reply = $this->service->update($kanbanId, $id, $request->validated()['reply']);

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

@@ -19,6 +19,35 @@ public function index(): JsonResponse
         return $this->successResponse(payload: NotificationResource::collection($items));
     }
 
+    /** Notificações do usuário logado (sino). */
+    public function me(): JsonResponse
+    {
+        $items = $this->service->forUser(auth()->id());
+        return $this->successResponse(payload: NotificationResource::collection($items));
+    }
+
+    /** Contador de não lidas do usuário logado. */
+    public function unreadCount(): JsonResponse
+    {
+        return $this->successResponse(
+            payload: ['count' => $this->service->unreadCount(auth()->id())]
+        );
+    }
+
+    /** Marca uma notificação como lida para o usuário logado. */
+    public function markRead(int $id): JsonResponse
+    {
+        $this->service->markRead($id, auth()->id());
+        return $this->successResponse(message: __('messages.updated'));
+    }
+
+    /** Marca todas as notificações do usuário logado como lidas. */
+    public function markAllRead(): JsonResponse
+    {
+        $this->service->markAllRead(auth()->id());
+        return $this->successResponse(message: __('messages.updated'));
+    }
+
     public function store(NotificationRequest $request): JsonResponse
     {
         $item = $this->service->create($request->validated());

+ 41 - 0
app/Http/Controllers/SupportReplyController.php

@@ -2,6 +2,9 @@
 
 namespace App\Http\Controllers;
 
+use App\Enums\NotificationType;
+use App\Models\SupportTicket;
+use App\Services\NotificationService;
 use App\Services\SupportReplyService;
 use App\Http\Requests\SupportReplyRequest;
 use App\Http\Resources\SupportReplyResource;
@@ -11,6 +14,7 @@ class SupportReplyController extends Controller
 {
     public function __construct(
         protected SupportReplyService $service,
+        protected NotificationService $notifications,
     ) {}
 
     public function index(int $ticketId): JsonResponse
@@ -22,9 +26,46 @@ public function index(int $ticketId): JsonResponse
     public function store(SupportReplyRequest $request, int $ticketId): JsonResponse
     {
         $reply = $this->service->create($ticketId, auth()->id(), $request->validated()['reply']);
+        $this->notifyReplyCreated($ticketId);
         return $this->successResponse(payload: new SupportReplyResource($reply), message: __('messages.created'), code: 201);
     }
 
+    /**
+     * Notifica o outro lado do chamado quando há uma nova resposta.
+     * Autor da matriz → unidade do chamado; autor da unidade → matriz.
+     */
+    private function notifyReplyCreated(int $ticketId): void
+    {
+        $ticket = SupportTicket::find($ticketId);
+        if (!$ticket) {
+            return;
+        }
+
+        $author         = auth()->user();
+        $authorIsMatriz = $author->user_type === 'ADMIN';
+        $unitId         = $ticket->applicant_unit_id ?? $ticket->target_unit_id;
+
+        $recipients = $authorIsMatriz
+            ? $this->notifications->unitUserIds($unitId)
+            : $this->notifications->matrizUserIds();
+
+        // Não notifica o próprio autor da resposta.
+        $recipients = array_values(array_diff($recipients, [$author->id]));
+
+        $this->notifications->dispatch([
+            'origin_table'       => 'support_replies',
+            'origin_id'          => $ticket->id,
+            'unit_id'            => $unitId,
+            'type'               => NotificationType::SUPPORT_TICKET_REPLY->value,
+            'title'              => 'Nova resposta no chamado',
+            'message'            => $ticket->title,
+            'url'                => '/support',
+            'action_text'        => 'Ver chamado',
+            'created_by_user_id' => $author->id,
+            'recipient_user_ids' => $recipients,
+        ]);
+    }
+
     public function update(SupportReplyRequest $request, int $ticketId, int $id): JsonResponse
     {
         $reply = $this->service->update($ticketId, $id, $request->validated()['reply']);

+ 36 - 1
app/Http/Controllers/SupportTicketController.php

@@ -3,6 +3,8 @@
 namespace App\Http\Controllers;
 
 use App\Http\Controllers\Concerns\ResolvesActiveUnit;
+use App\Enums\NotificationType;
+use App\Services\NotificationService;
 use App\Services\SupportTicketService;
 use App\Http\Requests\SupportTicketRequest;
 use App\Http\Resources\SupportTicketResource;
@@ -14,6 +16,7 @@ class SupportTicketController extends Controller
 
     public function __construct(
         protected SupportTicketService $service,
+        protected NotificationService $notifications,
     ) {}
 
     public function index(): JsonResponse
@@ -52,10 +55,12 @@ public function store(SupportTicketRequest $request): JsonResponse
             $unitIds = \App\Models\Unit::query()->pluck('id');
             $created = [];
             foreach ($unitIds as $unitId) {
-                $created[] = $this->service->create(array_merge($data, [
+                $ticket = $this->service->create(array_merge($data, [
                     'target_unit_id' => $unitId,
                     'batch_id' => $batchId,
                 ]));
+                $this->notifyTicketCreated($ticket);
+                $created[] = $ticket;
             }
             return $this->successResponse(
                 payload: SupportTicketResource::collection($created),
@@ -78,6 +83,7 @@ public function store(SupportTicketRequest $request): JsonResponse
         }
 
         $item = $this->service->create($data);
+        $this->notifyTicketCreated($item);
         return $this->successResponse(
             payload: new SupportTicketResource($item),
             message: __('messages.created'),
@@ -85,6 +91,35 @@ public function store(SupportTicketRequest $request): JsonResponse
         );
     }
 
+    /**
+     * Notifica o outro lado quando um chamado é criado:
+     * franqueada → matriz; matriz → unidade alvo.
+     */
+    private function notifyTicketCreated(\App\Models\SupportTicket $ticket): void
+    {
+        if ($ticket->origin === 'unit') {
+            $unitId     = $ticket->applicant_unit_id;
+            $recipients = $this->notifications->matrizUserIds();
+        } else {
+            $unitId     = $ticket->target_unit_id;
+            $recipients = $this->notifications->unitUserIds($unitId);
+        }
+
+        $this->notifications->dispatch([
+            'origin_table'       => 'support_tickets',
+            'origin_id'          => $ticket->id,
+            'unit_id'            => $unitId,
+            'type'               => NotificationType::SUPPORT_TICKET_CREATED->value,
+            'title'              => 'Novo chamado de suporte',
+            'message'            => $ticket->title,
+            'url'                => '/support',
+            'action_text'        => 'Ver chamado',
+            'priority'           => $ticket->severity === 'high' ? 'high' : 'normal',
+            'created_by_user_id' => $ticket->applicant_user_id,
+            'recipient_user_ids' => $recipients,
+        ]);
+    }
+
     public function show(int $id): JsonResponse
     {
         $item = $this->service->findById($id);

+ 18 - 13
app/Http/Resources/NotificationResource.php

@@ -17,20 +17,25 @@ class NotificationResource extends JsonResource
      */
     public function toArray(Request $request): array
     {
-        return [
-            'id' => $this->id,
-            'name' => $this->name,
-            'created_at' => Carbon::parse($this->created_at)->format('Y-m-d H:i:s'),
-            'updated_at' => Carbon::parse($this->updated_at)->format('Y-m-d H:i:s'),
-            // Add your fields here
-
-            // Conditional fields
-            // $this->mergeWhen($request->user()?->isAdmin(), [
-            //     'internal_notes' => $this->internal_notes,
-            // ]),
+        // Quando carregado via forUser(), 'recipients' já vem filtrado pelo
+        // usuário logado — usamos essa linha para o estado de leitura.
+        $recipient = $this->whenLoaded('recipients', fn () => $this->recipients->first());
 
-            // Relationships
-            // 'user' => new UserResource($this->whenLoaded('user')),
+        return [
+            'id'                => $this->id,
+            'notification_type' => $this->notification_type,
+            'title'             => $this->title,
+            'message'           => $this->message,
+            'url'               => $this->url,
+            'action_text'       => $this->action_text,
+            'priority'          => $this->priority,
+            'unit_id'           => $this->unit_id,
+            'franchisee_id'     => $this->franchisee_id,
+            'is_read'           => $recipient ? (bool) $recipient->is_read : false,
+            'read_at'           => $recipient?->read_at
+                ? Carbon::parse($recipient->read_at)->format('Y-m-d H:i:s')
+                : null,
+            'created_at'        => Carbon::parse($this->created_at)->format('Y-m-d H:i:s'),
         ];
     }
 

+ 11 - 1
app/Models/Notification.php

@@ -54,13 +54,23 @@ class Notification extends Model
     ];
 
     protected $casts = [
+        'expires_at' => 'datetime',
         'created_at' => 'datetime',
         'updated_at' => 'datetime',
-        // Add your casts here (e.g., 'is_active' => 'boolean')
     ];
 
     // Relationships
 
+    public function recipients(): \Illuminate\Database\Eloquent\Relations\HasMany
+    {
+        return $this->hasMany(NotificationRecipient::class, 'notification_id');
+    }
+
+    public function creator(): \Illuminate\Database\Eloquent\Relations\BelongsTo
+    {
+        return $this->belongsTo(User::class, 'created_by_user_id');
+    }
+
     // Business Logic Methods
 
     // Custom Finders

+ 16 - 3
app/Models/NotificationRecipient.php

@@ -50,13 +50,26 @@ class NotificationRecipient extends Model
     ];
 
     protected $casts = [
-        'created_at' => 'datetime',
-        'updated_at' => 'datetime',
-        // Add your casts here (e.g., 'is_active' => 'boolean')
+        'is_read'      => 'boolean',
+        'read_at'      => 'datetime',
+        'is_delivered' => 'boolean',
+        'delivered_at' => 'datetime',
+        'created_at'   => 'datetime',
+        'updated_at'   => 'datetime',
     ];
 
     // Relationships
 
+    public function notification(): \Illuminate\Database\Eloquent\Relations\BelongsTo
+    {
+        return $this->belongsTo(Notification::class, 'notification_id');
+    }
+
+    public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
+    {
+        return $this->belongsTo(User::class, 'user_id');
+    }
+
     // Business Logic Methods
 
     // Custom Finders

+ 152 - 3
app/Services/NotificationService.php

@@ -2,15 +2,23 @@
 
 namespace App\Services;
 
+use App\Broadcasting\Entity\WebsocketEventData;
+use App\Broadcasting\Events\WebsocketEvent;
+use App\Enums\UserTypeEnum;
 use App\Models\Notification;
+use App\Models\NotificationRecipient;
+use App\Models\User;
 use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Support\Facades\DB;
 
 class NotificationService
 {
+    // ------------------------------------------------------------------
+    // CRUD genérico (mantido para o controller base / rotas existentes)
+    // ------------------------------------------------------------------
     public function getAll(): Collection
     {
-        return Notification::orderBy('created_at', 'desc')
-            ->get();
+        return Notification::orderBy('created_at', 'desc')->get();
     }
 
     public function findById(int $id): ?Notification
@@ -46,5 +54,146 @@ public function delete(int $id): bool
         return $model->delete();
     }
 
-    // Add custom business logic methods here
+    // ------------------------------------------------------------------
+    // Dispatch: cria a notificação + um recipient por usuário + realtime
+    // ------------------------------------------------------------------
+    /**
+     * @param array{
+     *   origin_table:string, origin_id:int, type:string, title:string, message:string,
+     *   recipient_user_ids:int[], unit_id?:int|null, franchisee_id?:int|null,
+     *   url?:string|null, action_text?:string|null, priority?:string,
+     *   expires_at?:string|null, created_by_user_id?:int|null
+     * } $params
+     */
+    public function dispatch(array $params): ?Notification
+    {
+        $recipientUserIds = array_values(array_unique($params['recipient_user_ids'] ?? []));
+
+        if (empty($recipientUserIds)) {
+            return null; // ninguém para notificar
+        }
+
+        return DB::transaction(function () use ($params, $recipientUserIds) {
+            $notification = Notification::create([
+                'origin_table'       => $params['origin_table'],
+                'origin_id'          => $params['origin_id'],
+                'unit_id'            => $params['unit_id'] ?? null,
+                'franchisee_id'      => $params['franchisee_id'] ?? null,
+                'notification_type'  => $params['type'],
+                'title'              => $params['title'],
+                'message'            => $params['message'],
+                'url'                => $params['url'] ?? null,
+                'action_text'        => $params['action_text'] ?? null,
+                'priority'           => $params['priority'] ?? 'normal',
+                'expires_at'         => $params['expires_at'] ?? null,
+                'created_by_user_id' => $params['created_by_user_id'] ?? null,
+            ]);
+
+            $now = now();
+            $rows = array_map(fn ($userId) => [
+                'notification_id' => $notification->id,
+                'user_id'         => $userId,
+                'unit_id'         => $params['unit_id'] ?? null,
+                'franchisee_id'   => $params['franchisee_id'] ?? null,
+                'is_read'         => false,
+                'channel'         => 'in_app',
+                'is_delivered'    => true,
+                'delivered_at'    => $now,
+                'created_at'      => $now,
+                'updated_at'      => $now,
+            ], $recipientUserIds);
+
+            NotificationRecipient::insert($rows);
+
+            $this->broadcast($notification, $recipientUserIds);
+
+            return $notification;
+        });
+    }
+
+    /**
+     * Emite um evento realtime por usuário destino (best-effort).
+     */
+    private function broadcast(Notification $notification, array $userIds): void
+    {
+        $payload = [
+            'id'    => $notification->id,
+            'type'  => $notification->notification_type,
+            'title' => $notification->title,
+        ];
+
+        foreach ($userIds as $userId) {
+            try {
+                event(new WebsocketEvent(WebsocketEventData::from(
+                    room: "user.{$userId}",
+                    data: $payload,
+                    event: 'notification',
+                )));
+            } catch (\Throwable $e) {
+                // Realtime é complementar: nunca deve quebrar o fluxo de negócio.
+                report($e);
+            }
+        }
+    }
+
+    // ------------------------------------------------------------------
+    // Resolvers de destinatários (fan-out para todos os usuários)
+    // ------------------------------------------------------------------
+    /** Todos os usuários vinculados a uma unidade. */
+    public function unitUserIds(?int $unitId): array
+    {
+        if (!$unitId) {
+            return [];
+        }
+
+        return User::whereHas('units', fn ($q) => $q->where('units.id', $unitId))
+            ->pluck('id')->all();
+    }
+
+    /** Todos os usuários da matriz (franqueadora). */
+    public function matrizUserIds(): array
+    {
+        return User::where('user_type', UserTypeEnum::ADMIN->value)
+            ->pluck('id')->all();
+    }
+
+    // ------------------------------------------------------------------
+    // Consulta do sino (usuário logado)
+    // ------------------------------------------------------------------
+    public function forUser(int $userId, int $limit = 30): Collection
+    {
+        return Notification::query()
+            ->select('notifications.*')
+            ->join('notification_recipients as nr', 'nr.notification_id', '=', 'notifications.id')
+            ->where('nr.user_id', $userId)
+            ->where(fn ($q) => $q
+                ->whereNull('notifications.expires_at')
+                ->orWhere('notifications.expires_at', '>', now()))
+            ->with(['recipients' => fn ($q) => $q->where('user_id', $userId)])
+            ->orderByDesc('notifications.created_at')
+            ->limit($limit)
+            ->get();
+    }
+
+    public function unreadCount(int $userId): int
+    {
+        return NotificationRecipient::where('user_id', $userId)
+            ->where('is_read', false)
+            ->count();
+    }
+
+    public function markRead(int $notificationId, int $userId): void
+    {
+        NotificationRecipient::where('notification_id', $notificationId)
+            ->where('user_id', $userId)
+            ->where('is_read', false)
+            ->update(['is_read' => true, 'read_at' => now()]);
+    }
+
+    public function markAllRead(int $userId): void
+    {
+        NotificationRecipient::where('user_id', $userId)
+            ->where('is_read', false)
+            ->update(['is_read' => true, 'read_at' => now()]);
+    }
 }

+ 27 - 0
database/migrations/2026_07_02_000001_make_created_by_nullable_on_notifications.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
+{
+    /**
+     * Permite notificações geradas pelo sistema (ex.: fechamento do mês via
+     * schedule tbr:generate-monthly) sem um usuário logado como autor.
+     * created_by_user_id nulo = gerado pelo sistema.
+     */
+    public function up(): void
+    {
+        Schema::table('notifications', function (Blueprint $table) {
+            $table->foreignId('created_by_user_id')->nullable()->change();
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('notifications', function (Blueprint $table) {
+            $table->foreignId('created_by_user_id')->nullable(false)->change();
+        });
+    }
+};

+ 7 - 0
routes/authRoutes/notification.php

@@ -4,6 +4,13 @@
 use App\Http\Controllers\NotificationController;
 
 Route::controller(NotificationController::class)->prefix('notification')->group(function () {
+    // Sino do usuário logado (qualquer usuário autenticado acessa as suas).
+    Route::get('/me', 'me');
+    Route::get('/me/unread-count', 'unreadCount');
+    Route::post('/read-all', 'markAllRead');
+    Route::post('/{id}/read', 'markRead');
+
+    // CRUD administrativo genérico.
     Route::get('/', 'index')->middleware('permission:notification,view');
 
     Route::post('/', 'store')->middleware('permission:notification,add');