Gustavo Zanatta 1 тиждень тому
батько
коміт
eab28a61fa

+ 3 - 1
.env.example

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

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

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

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

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

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

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

+ 36 - 0
app/Broadcasting/RealtimeEvent.php

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

+ 50 - 0
app/Broadcasting/RealtimeRoom.php

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

+ 72 - 0
app/Broadcasting/RealtimeService.php

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

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

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

+ 43 - 0
app/Observers/NotificationObserver.php

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

+ 47 - 0
app/Observers/ServicePackageObserver.php

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

+ 6 - 0
app/Providers/AppServiceProvider.php

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

+ 87 - 1
app/Services/CustomScheduleService.php

@@ -2,6 +2,9 @@
 
 namespace App\Services;
 
+use App\Broadcasting\RealtimeEvent;
+use App\Broadcasting\RealtimeRoom;
+use App\Broadcasting\RealtimeService;
 use App\Enums\NotificationTypeEnum;
 use App\Models\Address;
 use App\Models\CustomSchedule;
@@ -30,6 +33,7 @@ class CustomScheduleService
 
     public function __construct(
         private readonly ZipCodeCoordinatesService $zipCodeCoordinatesService,
+        private readonly RealtimeService $realtime,
     ) {}
 
     public function getAll()
@@ -464,10 +468,22 @@ class CustomScheduleService
 
         $this->sendProposalReceivedPush($schedule, $provider->user->name);
 
-        return ScheduleProposal::create([
+        $proposal = ScheduleProposal::create([
             'schedule_id' => $scheduleId,
             'provider_id' => $providerId,
         ]);
+
+        $this->realtime->emit(
+            RealtimeEvent::PROPOSAL_CREATED,
+            $this->proposalRooms($schedule, $provider),
+            [
+                'entity'      => 'schedule_proposal',
+                'id'          => $proposal->id,
+                'schedule_id' => $schedule->id,
+            ],
+        );
+
+        return $proposal;
     }
 
     private function sendProposalReceivedPush(Schedule $schedule, string $providerName): void
@@ -538,9 +554,43 @@ class CustomScheduleService
             }
         }
 
+        $this->realtime->emit(
+            RealtimeEvent::PROPOSAL_REFUSED,
+            $this->proposalRooms($schedule, $provider),
+            [
+                'entity'      => 'schedule_refuse',
+                'id'          => $schedule_refuse->id,
+                'schedule_id' => $schedule->id,
+                'actor'       => 'provider',
+            ],
+        );
+
         return $schedule_refuse;
     }
 
+    /**
+     * Salas dos dois lados de uma proposta, mais quem estiver com o
+     * agendamento aberto.
+     *
+     * @return RealtimeRoom[]
+     */
+    private function proposalRooms(Schedule $schedule, Provider $provider): array
+    {
+        $rooms = [
+            RealtimeRoom::schedule($schedule->id),
+        ];
+
+        if ($schedule->client?->user_id) {
+            $rooms[] = RealtimeRoom::user($schedule->client->user_id);
+        }
+
+        if ($provider->user_id) {
+            $rooms[] = RealtimeRoom::user($provider->user_id);
+        }
+
+        return $rooms;
+    }
+
     //
 
     public function acceptProposal($proposalId)
@@ -607,6 +657,17 @@ class CustomScheduleService
                 'schedule_id' => $schedule->id,
             ]);
 
+            $this->realtime->emit(
+                RealtimeEvent::PROPOSAL_ACCEPTED,
+                $this->proposalRooms($schedule, $provider),
+                [
+                    'entity'             => 'schedule_proposal',
+                    'id'                 => $proposalId,
+                    'schedule_id'        => $schedule->id,
+                    'service_package_id' => $servicePackage->id,
+                ],
+            );
+
             return $servicePackage->fresh([
                 'items.schedule.client.user',
                 'items.schedule.provider.user',
@@ -672,8 +733,33 @@ class CustomScheduleService
                 }
             }
 
+            $scheduleId = $proposal->schedule_id;
+
+            $rooms = [
+                RealtimeRoom::schedule($scheduleId),
+            ];
+
+            if ($proposal->schedule?->client?->user_id) {
+                $rooms[] = RealtimeRoom::user($proposal->schedule->client->user_id);
+            }
+
+            if ($proposal->provider?->user_id) {
+                $rooms[] = RealtimeRoom::user($proposal->provider->user_id);
+            }
+
             $proposal->delete();
 
+            $this->realtime->emit(
+                RealtimeEvent::PROPOSAL_REFUSED,
+                $rooms,
+                [
+                    'entity'      => 'schedule_proposal',
+                    'id'          => $proposalId,
+                    'schedule_id' => $scheduleId,
+                    'actor'       => 'client',
+                ],
+            );
+
             return true;
         });
     }

+ 17 - 1
app/Services/NotificationService.php

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

+ 102 - 36
app/Services/ScheduleService.php

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

+ 7 - 0
config/realtime.php

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