From 809eab9d4abbd49f48e08dec7b7e78dfcebb799c Mon Sep 17 00:00:00 2001 From: joaomiranda Date: Tue, 4 Aug 2026 16:51:51 +0100 Subject: [PATCH] feat: complete approval workflow --- CHANGELOG.md | 8 +- README.md | 2 +- .../Approvals/Services/ApprovalService.php | 100 ++++++- .../Reservations/Models/Reservation.php | 7 + .../Services/ReservationService.php | 74 ++++- app/Http/Controllers/ApprovalController.php | 46 ++- .../Controllers/NotificationController.php | 35 +++ .../Controllers/ReservationController.php | 10 + app/Http/Middleware/HandleInertiaRequests.php | 1 + .../ReservationDecisionNotification.php | 77 +++++ .../ReservationSubmittedNotification.php | 55 ++++ ...8_04_000500_create_notifications_table.php | 25 ++ docs/ARCHITECTURE.md | 5 +- docs/DECISIONS.md | 7 + docs/PROJECT_STATUS.md | 34 ++- docs/RUNBOOK.md | 2 + resources/js/components/app-sidebar.tsx | 13 +- resources/js/pages/approvals/index.tsx | 280 +++++++++++++++++- resources/js/pages/notifications/index.tsx | 123 ++++++++ resources/js/pages/reservations/index.tsx | 95 +++++- resources/js/types/global.d.ts | 1 + resources/js/types/portal.ts | 8 +- routes/web.php | 6 + tests/Feature/ApprovalWorkflowTest.php | 129 ++++++++ tests/Feature/NotificationCenterTest.php | 45 +++ tests/Feature/ReservationCreationTest.php | 9 + 26 files changed, 1156 insertions(+), 41 deletions(-) create mode 100644 app/Http/Controllers/NotificationController.php create mode 100644 app/Notifications/ReservationDecisionNotification.php create mode 100644 app/Notifications/ReservationSubmittedNotification.php create mode 100644 database/migrations/2026_08_04_000500_create_notifications_table.php create mode 100644 resources/js/pages/notifications/index.tsx create mode 100644 tests/Feature/NotificationCenterTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f16a07..2143825 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,11 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue - Calendário mensal pessoal com isolamento por utilizador e navegação entre meses. - Núcleo de aprovações com fila pesquisável, Gate por perfil, aprovação, rejeição e pedido de esclarecimentos. - Revalidação transacional da disponibilidade antes da aprovação, promoção de itens para `reserved` e histórico em `approval_actions`/auditoria. +- Notificações assíncronas por email e caixa interna para submissões, decisões, esclarecimentos e substituições. +- Centro de notificações isolado por utilizador, com contador e marcação de leitura. +- Resposta auditada a pedidos de esclarecimento e renovação do prazo de decisão. +- Substituição controlada por equipamento disponível da mesma categoria, preservando o item original no histórico. +- Filtros da fila de aprovação por categoria/período e histórico de ações apresentado na interface. ### Alterado @@ -42,7 +47,7 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue ### Verificado - Migrations e seeders executados em PostgreSQL 17 real. -- Suite backend: Pint em 106 ficheiros, PHPStan nível 7 em 80 ficheiros e 65 testes Pest (289 asserções). +- Suite backend: Pint em 111 ficheiros, PHPStan nível 7 em 84 ficheiros e 70 testes Pest (340 asserções). - Frontend: TypeScript, ESLint, Prettier e build Vite de produção. - Stack Docker operacional e endpoint de login acessível através do Nginx. - Renderização SSR real do login validada, incluindo o conteúdo “Entrar no Portal de Reservas”. @@ -50,3 +55,4 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue - Migration `last_seen_sync_run_id` aplicada em PostgreSQL 17. - Comando de expiração executado em PostgreSQL e agendamento a cada cinco minutos confirmado. - Builds Vite cliente e SSR com as páginas de disponibilidade, cancelamento e calendário concluídos. +- Migration da caixa interna de notificações aplicada em PostgreSQL 17. diff --git a/README.md b/README.md index ac784c3..a7ea847 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Portal interno para consultar, requisitar, aprovar, entregar e devolver equipame ## Estado atual -O projeto concluiu a **Fase 1 — Fundação técnica**, a **Fase 2 — Integração de leitura Snipe-IT** e a **Fase 3 — Motor de reservas**. A **Fase 4 — Aprovações** está em curso, já com fila protegida por perfil e decisões transacionais. Estão também disponíveis autenticação local, catálogo sincronizável com pesquisa temporal, pré-reservas, conflitos, cancelamento auditado, expiração automática, calendário pessoal, dashboard e diagnóstico administrativo da integração. +O projeto concluiu as **Fases 1 a 4**: fundação técnica, integração de leitura Snipe-IT, motor de reservas e aprovações. Estão disponíveis autenticação local, catálogo temporal, pré-reservas, conflitos, cancelamento e expiração, calendário pessoal, decisões transacionais, esclarecimentos, substituição equivalente, notificações por email/caixa interna, dashboard e diagnóstico administrativo. A próxima entrega implementa o fluxo físico de entrega e devolução. Consulte sempre [docs/PROJECT_STATUS.md](docs/PROJECT_STATUS.md) antes de alterar o projeto. Esse documento contém o ponto de situação, riscos e próxima tarefa recomendada. diff --git a/app/Domain/Approvals/Services/ApprovalService.php b/app/Domain/Approvals/Services/ApprovalService.php index e5a9d2c..2966ef7 100644 --- a/app/Domain/Approvals/Services/ApprovalService.php +++ b/app/Domain/Approvals/Services/ApprovalService.php @@ -9,8 +9,10 @@ use App\Domain\Inventory\Models\SnipeAsset; use App\Domain\Reservations\Enums\ReservationItemStatus; use App\Domain\Reservations\Enums\ReservationStatus; use App\Domain\Reservations\Models\Reservation; +use App\Domain\Reservations\Models\ReservationItem; use App\Domain\Reservations\Services\AvailabilityService; use App\Models\User; +use App\Notifications\ReservationDecisionNotification; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -23,7 +25,7 @@ class ApprovalService { $this->authorize($actor); - return DB::transaction(function () use ($reservation, $actor, $comment): Reservation { + $approved = DB::transaction(function () use ($reservation, $actor, $comment): Reservation { $locked = $this->lockPending($reservation); $items = $locked->items()->with('asset')->get(); SnipeAsset::query()->whereKey($items->pluck('snipe_asset_id'))->orderBy('id')->lockForUpdate()->get(); @@ -51,13 +53,17 @@ class ApprovalService return $locked->load('requester', 'items.asset'); }, 3); + + $approved->requester->notify(new ReservationDecisionNotification($approved, 'approved', $comment)); + + return $approved; } public function reject(Reservation $reservation, User $actor, string $reason): Reservation { $this->authorize($actor); - return DB::transaction(function () use ($reservation, $actor, $reason): Reservation { + $rejected = DB::transaction(function () use ($reservation, $actor, $reason): Reservation { $locked = $this->lockPending($reservation); $this->transition($locked, $actor, ReservationStatus::Rejected, 'rejected', $reason, [ 'rejected_by' => $actor->id, @@ -70,13 +76,17 @@ class ApprovalService return $locked->load('requester', 'items.asset'); }, 3); + + $rejected->requester->notify(new ReservationDecisionNotification($rejected, 'rejected', $reason)); + + return $rejected; } public function requestInformation(Reservation $reservation, User $actor, string $comment): Reservation { $this->authorize($actor); - return DB::transaction(function () use ($reservation, $actor, $comment): Reservation { + $awaitingInformation = DB::transaction(function () use ($reservation, $actor, $comment): Reservation { $locked = $this->lockPending($reservation); $this->transition($locked, $actor, ReservationStatus::AwaitingInformation, 'information_requested', $comment, [ 'decision_reason' => $comment, @@ -85,6 +95,81 @@ class ApprovalService return $locked->load('requester', 'items.asset'); }, 3); + + $awaitingInformation->requester->notify( + new ReservationDecisionNotification($awaitingInformation, 'information_requested', $comment) + ); + + return $awaitingInformation; + } + + public function substituteAsset( + Reservation $reservation, + ReservationItem $item, + SnipeAsset $replacement, + User $actor, + string $reason, + ): Reservation { + $this->authorize($actor); + + $updated = DB::transaction(function () use ($reservation, $item, $replacement, $actor, $reason): Reservation { + $locked = $this->lockPending($reservation); + $lockedItem = ReservationItem::query()->whereKey($item->id)->lockForUpdate()->firstOrFail(); + if ($lockedItem->reservation_id !== $locked->id || $lockedItem->status !== ReservationItemStatus::Held) { + throw ValidationException::withMessages(['replacement_asset_id' => 'O item já não pode ser substituído.']); + } + + $assets = SnipeAsset::query() + ->whereKey([$lockedItem->snipe_asset_id, $replacement->id]) + ->orderBy('id') + ->lockForUpdate() + ->get() + ->keyBy('id'); + $original = $assets->get($lockedItem->snipe_asset_id); + $newAsset = $assets->get($replacement->id); + + if ($original === null || $newAsset === null || ! $this->sameCategory($original, $newAsset)) { + throw ValidationException::withMessages(['replacement_asset_id' => 'Selecione um equipamento da mesma categoria.']); + } + if ($locked->items()->where('snipe_asset_id', $newAsset->id)->exists() + || ! $this->availability->isAvailable($newAsset, $lockedItem->starts_at, $lockedItem->ends_at)) { + throw ValidationException::withMessages(['replacement_asset_id' => 'O equipamento substituto não está disponível.']); + } + + $lockedItem->update(['status' => ReservationItemStatus::Cancelled]); + $locked->items()->create([ + 'snipe_asset_id' => $newAsset->id, + 'starts_at' => $lockedItem->starts_at, + 'ends_at' => $lockedItem->ends_at, + 'status' => ReservationItemStatus::Held, + 'notes' => "Substitui {$original->asset_tag}: {$reason}", + ]); + ApprovalAction::create([ + 'reservation_id' => $locked->id, + 'actor_id' => $actor->id, + 'action' => 'asset_substituted', + 'previous_status' => $locked->status->value, + 'new_status' => $locked->status->value, + 'comment' => $reason, + ]); + AuditLog::create([ + 'actor_id' => $actor->id, + 'event' => 'reservation.asset_substituted', + 'auditable_type' => $locked->getMorphClass(), + 'auditable_id' => $locked->id, + 'metadata' => [ + 'original_asset_id' => $original->id, + 'replacement_asset_id' => $newAsset->id, + 'reason' => $reason, + ], + ]); + + return $locked->load('requester', 'items.asset'); + }, 3); + + $updated->requester->notify(new ReservationDecisionNotification($updated, 'asset_substituted', $reason)); + + return $updated; } private function authorize(User $actor): void @@ -94,6 +179,15 @@ class ApprovalService } } + private function sameCategory(SnipeAsset $original, SnipeAsset $replacement): bool + { + if ($original->category_id !== null && $replacement->category_id !== null) { + return $original->category_id === $replacement->category_id; + } + + return filled($original->category_name) && $original->category_name === $replacement->category_name; + } + private function lockPending(Reservation $reservation): Reservation { $locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail(); diff --git a/app/Domain/Reservations/Models/Reservation.php b/app/Domain/Reservations/Models/Reservation.php index 176e2ee..132e04c 100644 --- a/app/Domain/Reservations/Models/Reservation.php +++ b/app/Domain/Reservations/Models/Reservation.php @@ -2,6 +2,7 @@ namespace App\Domain\Reservations\Models; +use App\Domain\Approvals\Models\ApprovalAction; use App\Domain\Reservations\Enums\ReservationStatus; use App\Models\User; use Carbon\CarbonImmutable; @@ -57,4 +58,10 @@ class Reservation extends Model { return $this->hasMany(ReservationItem::class); } + + /** @return HasMany */ + public function approvalActions(): HasMany + { + return $this->hasMany(ApprovalAction::class)->latest('created_at'); + } } diff --git a/app/Domain/Reservations/Services/ReservationService.php b/app/Domain/Reservations/Services/ReservationService.php index c522cff..d066160 100644 --- a/app/Domain/Reservations/Services/ReservationService.php +++ b/app/Domain/Reservations/Services/ReservationService.php @@ -2,15 +2,19 @@ namespace App\Domain\Reservations\Services; +use App\Domain\Approvals\Models\ApprovalAction; use App\Domain\Audit\Models\AuditLog; +use App\Domain\Identity\Enums\RoleName; use App\Domain\Inventory\Models\SnipeAsset; use App\Domain\Reservations\Enums\ReservationItemStatus; use App\Domain\Reservations\Enums\ReservationStatus; use App\Domain\Reservations\Models\Reservation; use App\Models\User; +use App\Notifications\ReservationSubmittedNotification; use Carbon\CarbonImmutable; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Notification; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; @@ -26,7 +30,7 @@ class ReservationService $startsAt = CarbonImmutable::parse($data['starts_at']); $endsAt = CarbonImmutable::parse($data['ends_at']); - return DB::transaction(function () use ($requester, $data, $startsAt, $endsAt): Reservation { + $reservation = DB::transaction(function () use ($requester, $data, $startsAt, $endsAt): Reservation { $assets = SnipeAsset::query() ->whereKey($data['asset_ids']) ->lockForUpdate() @@ -75,8 +79,19 @@ class ReservationService 'metadata' => ['asset_ids' => $assets->modelKeys()], ]); - return $reservation->load('items.asset'); + return $reservation->load('requester', 'items.asset'); }, 3); + + $approvers = User::query() + ->where('active', true) + ->whereHas('roles', fn ($query) => $query->whereIn('name', [ + RoleName::Approver->value, + RoleName::Administrator->value, + ])) + ->get(); + Notification::send($approvers, new ReservationSubmittedNotification($reservation)); + + return $reservation; } public function cancel(Reservation $reservation, User $actor, string $reason): Reservation @@ -124,6 +139,61 @@ class ReservationService }, 3); } + public function respondToInformation(Reservation $reservation, User $actor, string $response): Reservation + { + if ($reservation->requester_id !== $actor->id) { + throw new AuthorizationException('Apenas o requisitante pode responder a este pedido.'); + } + + $updated = DB::transaction(function () use ($reservation, $actor, $response): Reservation { + $locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail(); + if (! $locked->status->canTransitionTo(ReservationStatus::PendingApproval) + || $locked->status !== ReservationStatus::AwaitingInformation) { + throw ValidationException::withMessages([ + 'response' => 'Esta requisição não aguarda esclarecimentos.', + ]); + } + + $locked->update([ + 'status' => ReservationStatus::PendingApproval, + 'approval_deadline' => now()->addHours(48), + ]); + ApprovalAction::create([ + 'reservation_id' => $locked->id, + 'actor_id' => $actor->id, + 'action' => 'information_provided', + 'previous_status' => ReservationStatus::AwaitingInformation->value, + 'new_status' => ReservationStatus::PendingApproval->value, + 'comment' => $response, + ]); + AuditLog::create([ + 'actor_id' => $actor->id, + 'event' => 'reservation.information_provided', + 'auditable_type' => $locked->getMorphClass(), + 'auditable_id' => $locked->id, + 'before' => ['status' => ReservationStatus::AwaitingInformation->value], + 'after' => ['status' => ReservationStatus::PendingApproval->value], + 'metadata' => ['response' => $response], + ]); + + return $locked->load('requester', 'items.asset'); + }, 3); + + $approvers = User::query() + ->where('active', true) + ->whereHas('roles', fn ($query) => $query->whereIn('name', [ + RoleName::Approver->value, + RoleName::Administrator->value, + ])) + ->get(); + Notification::send( + $approvers, + new ReservationSubmittedNotification($updated, 'information_provided'), + ); + + return $updated; + } + public function expirePending(int $limit = 100): int { $reservationIds = Reservation::query() diff --git a/app/Http/Controllers/ApprovalController.php b/app/Http/Controllers/ApprovalController.php index 63a0dce..69e0879 100644 --- a/app/Http/Controllers/ApprovalController.php +++ b/app/Http/Controllers/ApprovalController.php @@ -3,8 +3,11 @@ namespace App\Http\Controllers; use App\Domain\Approvals\Services\ApprovalService; +use App\Domain\Inventory\Models\SnipeAsset; use App\Domain\Reservations\Enums\ReservationStatus; use App\Domain\Reservations\Models\Reservation; +use App\Domain\Reservations\Models\ReservationItem; +use Carbon\CarbonImmutable; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; @@ -14,20 +17,46 @@ class ApprovalController extends Controller { public function index(Request $request): Response { - $filters = $request->validate(['search' => ['nullable', 'string', 'max:100']]); + $filters = $request->validate([ + 'search' => ['nullable', 'string', 'max:100'], + 'category' => ['nullable', 'string', 'max:100'], + 'date_from' => ['nullable', 'date'], + 'date_to' => ['nullable', 'date', 'after_or_equal:date_from'], + ]); return Inertia::render('approvals/index', [ 'reservations' => Reservation::query() - ->with(['requester:id,name,email,department', 'items.asset:id,name,asset_tag,category_name']) + ->with([ + 'requester:id,name,email,department', + 'items.asset:id,name,asset_tag,category_id,category_name', + 'approvalActions.actor:id,name', + ]) ->whereIn('status', [ReservationStatus::PendingApproval, ReservationStatus::AwaitingInformation]) ->when($filters['search'] ?? null, fn ($query, string $search) => $query ->where(fn ($query) => $query ->where('reference', 'like', "%{$search}%") ->orWhereHas('requester', fn ($query) => $query->where('name', 'like', "%{$search}%")))) + ->when($filters['category'] ?? null, fn ($query, string $category) => $query + ->whereHas('items.asset', fn ($query) => $query->where('category_name', $category))) + ->when($filters['date_from'] ?? null, fn ($query, string $date) => $query + ->where('ends_at', '>', CarbonImmutable::parse($date)->startOfDay())) + ->when($filters['date_to'] ?? null, fn ($query, string $date) => $query + ->where('starts_at', '<', CarbonImmutable::parse($date)->addDay()->startOfDay())) ->oldest('starts_at') ->paginate(20) ->withQueryString(), 'filters' => $filters, + 'categories' => SnipeAsset::query() + ->whereNotNull('category_name') + ->distinct() + ->orderBy('category_name') + ->pluck('category_name'), + 'replacementAssets' => SnipeAsset::query() + ->where('active', true) + ->where('requestable', true) + ->whereNull('assigned_id') + ->orderBy('name') + ->get(['id', 'name', 'asset_tag', 'category_id', 'category_name']), ]); } @@ -54,4 +83,17 @@ class ApprovalController extends Controller return back()->with('success', "Pedido de esclarecimentos enviado para {$reservation->reference}."); } + + public function substitute(Request $request, Reservation $reservation, ReservationItem $item, ApprovalService $service): RedirectResponse + { + $data = $request->validate([ + 'replacement_asset_id' => ['required', 'integer', 'exists:snipe_assets,id'], + 'comment' => ['required', 'string', 'min:5', 'max:1000'], + ]); + /** @var SnipeAsset $replacement */ + $replacement = SnipeAsset::query()->findOrFail($data['replacement_asset_id']); + $service->substituteAsset($reservation, $item, $replacement, $request->user(), $data['comment']); + + return back()->with('success', "Equipamento substituído na requisição {$reservation->reference}."); + } } diff --git a/app/Http/Controllers/NotificationController.php b/app/Http/Controllers/NotificationController.php new file mode 100644 index 0000000..714438a --- /dev/null +++ b/app/Http/Controllers/NotificationController.php @@ -0,0 +1,35 @@ + $request->user()->notifications()->latest()->paginate(20), + ]); + } + + public function read(Request $request, string $notification): RedirectResponse + { + $item = $request->user()->notifications()->whereKey($notification)->firstOrFail(); + $item->markAsRead(); + + $url = is_string($item->data['url'] ?? null) ? $item->data['url'] : '/notificacoes'; + + return redirect()->to($url); + } + + public function readAll(Request $request): RedirectResponse + { + $request->user()->unreadNotifications->markAsRead(); + + return back()->with('success', 'Notificações marcadas como lidas.'); + } +} diff --git a/app/Http/Controllers/ReservationController.php b/app/Http/Controllers/ReservationController.php index d45b6d6..4c1df2a 100644 --- a/app/Http/Controllers/ReservationController.php +++ b/app/Http/Controllers/ReservationController.php @@ -95,4 +95,14 @@ class ReservationController extends Controller return back()->with('success', "Requisição {$reservation->reference} cancelada. Os equipamentos ficaram novamente disponíveis."); } + + public function respondToInformation(Request $request, Reservation $reservation, ReservationService $service): RedirectResponse + { + $data = $request->validate([ + 'response' => ['required', 'string', 'min:5', 'max:2000'], + ]); + $service->respondToInformation($reservation, $request->user(), $data['response']); + + return back()->with('success', "Esclarecimentos enviados para a requisição {$reservation->reference}."); + } } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 5f9872b..0c26e1e 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -49,6 +49,7 @@ class HandleInertiaRequests extends Middleware 'administer' => fn (): bool => $request->user()?->can('administer') ?? false, 'approveReservations' => fn (): bool => $request->user()?->can('approve-reservations') ?? false, ], + 'unreadNotifications' => fn (): int => $request->user()?->unreadNotifications()->count() ?? 0, 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', ]; } diff --git a/app/Notifications/ReservationDecisionNotification.php b/app/Notifications/ReservationDecisionNotification.php new file mode 100644 index 0000000..93788d2 --- /dev/null +++ b/app/Notifications/ReservationDecisionNotification.php @@ -0,0 +1,77 @@ +afterCommit(); + } + + /** @return list */ + public function via(object $notifiable): array + { + return ['mail', 'database']; + } + + public function toMail(object $notifiable): MailMessage + { + $message = (new MailMessage) + ->subject($this->subject()) + ->greeting("Atualização da requisição {$this->reservation->reference}") + ->line($this->message()); + + if (filled($this->comment)) { + $message->line("Comentário: {$this->comment}"); + } + + return $message->action('Consultar requisições', url('/requisicoes')); + } + + /** @return array */ + public function toArray(object $notifiable): array + { + return [ + 'reservation_uuid' => $this->reservation->uuid, + 'reference' => $this->reservation->reference, + 'event' => $this->event, + 'message' => $this->message(), + 'comment' => $this->comment, + 'url' => '/requisicoes', + ]; + } + + private function subject(): string + { + return match ($this->event) { + 'approved' => "Requisição {$this->reservation->reference} aprovada", + 'rejected' => "Requisição {$this->reservation->reference} rejeitada", + 'information_requested' => "Esclarecimentos necessários para {$this->reservation->reference}", + 'asset_substituted' => "Equipamento alterado em {$this->reservation->reference}", + default => "Atualização da requisição {$this->reservation->reference}", + }; + } + + private function message(): string + { + return match ($this->event) { + 'approved' => 'A sua requisição foi aprovada.', + 'rejected' => 'A sua requisição foi rejeitada.', + 'information_requested' => 'O aprovador pediu informações adicionais.', + 'asset_substituted' => 'Um equipamento da sua requisição foi substituído por outro equivalente.', + default => 'O estado da sua requisição foi atualizado.', + }; + } +} diff --git a/app/Notifications/ReservationSubmittedNotification.php b/app/Notifications/ReservationSubmittedNotification.php new file mode 100644 index 0000000..d55b98a --- /dev/null +++ b/app/Notifications/ReservationSubmittedNotification.php @@ -0,0 +1,55 @@ +afterCommit(); + } + + /** @return list */ + public function via(object $notifiable): array + { + return ['mail', 'database']; + } + + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject($this->event === 'information_provided' + ? "Resposta recebida para {$this->reservation->reference}" + : "Nova requisição {$this->reservation->reference}") + ->greeting($this->event === 'information_provided' + ? 'O requisitante respondeu ao pedido de esclarecimentos' + : 'Nova requisição para aprovação') + ->line("{$this->reservation->requester->name} submeteu a requisição {$this->reservation->reference} para decisão.") + ->line("Período: {$this->reservation->starts_at->format('d/m/Y H:i')} — {$this->reservation->ends_at->format('d/m/Y H:i')}.") + ->action('Analisar requisição', url('/aprovacoes')); + } + + /** @return array */ + public function toArray(object $notifiable): array + { + return [ + 'reservation_uuid' => $this->reservation->uuid, + 'reference' => $this->reservation->reference, + 'event' => $this->event, + 'message' => $this->event === 'information_provided' + ? "Foram prestados esclarecimentos para {$this->reservation->reference}." + : "Nova requisição {$this->reservation->reference} aguarda decisão.", + 'url' => '/aprovacoes', + ]; + } +} diff --git a/database/migrations/2026_08_04_000500_create_notifications_table.php b/database/migrations/2026_08_04_000500_create_notifications_table.php new file mode 100644 index 0000000..29d1ec8 --- /dev/null +++ b/database/migrations/2026_08_04_000500_create_notifications_table.php @@ -0,0 +1,25 @@ +uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestampTz('read_at')->nullable(); + $table->timestampsTz(); + }); + } + + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e58d7e8..6097256 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -32,7 +32,6 @@ Nginx ──► Laravel + Inertia/React - `app/Domain/Identity`: perfis, permissões e identidades externas. - `app/Domain/Inventory`: cache local de ativos e consultas do catálogo. - `app/Domain/Reservations`: disponibilidade, estados, regras e requisições. -- `app/Domain/Approvals`: decisões e concorrência de aprovação (a criar). - `app/Domain/Approvals`: decisões, histórico e concorrência de aprovação. - `app/Domain/Handovers`: entrega, devolução e incidentes (a criar). - `app/Domain/Audit`: logs imutáveis pela interface. @@ -53,6 +52,10 @@ Uma criação ou aprovação deve bloquear as linhas dos ativos, voltar a valida Pré-reservas ficam bloqueantes em `held` enquanto aguardam decisão. `ReservationService` centraliza criação, cancelamento e expiração: todas as transições bloqueiam a requisição, atualizam os itens bloqueantes e criam um `AuditLog`. Um job único procura prazos vencidos a cada cinco minutos. A restrição GiST continua a proteger contra corridas entre estes ciclos de vida. +`ApprovalService` bloqueia a requisição e os ativos antes de aprovar ou substituir, revalida a disponibilidade e regista cada passo em `approval_actions` e `audit_logs`. Uma substituição cancela o item original e cria um novo `held`, apenas na mesma categoria. Pedidos e respostas de esclarecimento conservam o histórico e renovam explicitamente o prazo. + +Notificações implementam `ShouldQueue`, usam `afterCommit` e seguem dois canais: email e tabela `notifications`. A caixa interna consulta exclusivamente a relação polimórfica do utilizador autenticado, evitando acesso horizontal a notificações de terceiros. + ## Fluxo de dados Snipe-IT 1. Um job consulta a API paginada com token de leitura. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index e942267..5e12794 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -62,3 +62,10 @@ Uma requisição submetida cria itens em `held` e bloqueia o período durante 48 **Estado:** aceite A autorização HTTP e a verificação no serviço exigem perfil de aprovador ou administrador ativo. Antes de aprovar, o serviço bloqueia a requisição e os ativos por ordem estável, ignora apenas os itens da própria pré-reserva e volta a validar estado, atribuição, bloqueios e conflitos externos. A decisão altera requisição e itens na mesma transação e escreve tanto `approval_actions` como `audit_logs`. Rejeição liberta os itens; um pedido de esclarecimentos mantém a pré-reserva e renova o prazo por 48 horas. + +## ADR-010 — Notificações após commit e substituição equivalente + +**Data:** 2026-08-04 +**Estado:** aceite + +Notificações funcionais são colocadas na fila apenas depois da confirmação da transação e enviadas por email e caixa interna. A submissão e uma resposta a esclarecimentos notificam aprovadores ativos; decisões e substituições notificam o requisitante. Uma substituição não altera automaticamente a decisão: exige justificação, equipamento livre da mesma categoria, bloqueio de ambas as linhas, novo item `held` e preservação do item original como `cancelled` para auditoria. diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index 574a4dd..3fe641d 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -1,7 +1,7 @@ # Estado do projeto e handoff **Atualizado em:** 4 de agosto de 2026 -**Fase:** Fases 1, 2 e 3 concluídas; Fase 4 em curso +**Fase:** Fases 1, 2, 3 e 4 concluídas **Último agente:** Codex ## Resultado disponível @@ -29,26 +29,31 @@ - Área de aprovação protegida por perfil, com pesquisa por referência/requisitante. - Aprovação, rejeição e pedido de esclarecimentos transacionais, registados em `approval_actions` e `audit_logs`. - Aprovação revalida disponibilidade e estado operacional do ativo antes de promover itens `held` para `reserved`. +- Notificações assíncronas por email e base de dados para submissões, decisões, esclarecimentos e substituições. +- Caixa interna de notificações, contador de não lidas, marcação individual/coletiva e isolamento por utilizador. +- Resposta auditada do requisitante a esclarecimentos, com regresso a `pending_approval` e novo prazo de 48 horas. +- Substituição de equipamento apenas por ativo livre da mesma categoria, mantendo a pré-reserva e todo o histórico. +- Fila de aprovação com filtros por referência, requisitante, categoria e período, incluindo histórico de ações. ## Limites conhecidos - A integração está implementada e testada contra respostas simuladas; falta configurar e validar URL, versão e token da instância institucional. Até lá, o catálogo utiliza dados fictícios do seeder. - Microsoft Entra ID ainda não está implementado; as variáveis de ambiente já estão definidas. - Entrega e devolução têm tabelas preparadas, mas ainda não possuem ações ou interface. -- As notificações de decisão, a resposta do requisitante aos esclarecimentos e a substituição por equipamento equivalente ainda não foram implementadas. +- As notificações estão implementadas com Mailpit em desenvolvimento; falta validar SMTP institucional antes do piloto. - As políticas de autorização finas ainda não foram ligadas às rotas; nesta fase todas as rotas funcionais exigem utilizador autenticado, verificado e ativo. - O calendário atual é a vista mensal pessoal do MVP; vistas diária, semanal, global e por equipamento/categoria/localização ficam para as áreas operacional e administrativa. ## Próxima tarefa recomendada -Completar a **Fase 4 — aprovações**: +Implementar a **Fase 5 — entrega e devolução**: -1. abrir `app/Domain/Approvals/Services/ApprovalService.php` e criar notificações assíncronas para cada decisão; -2. permitir ao requisitante responder ao pedido de esclarecimentos e voltar a colocar o pedido em `pending_approval`; -3. acrescentar filtros da fila por data e categoria e uma vista de detalhe com histórico; -4. permitir substituição explícita por equipamento equivalente sem contornar a deteção de conflitos; -5. testar notificações, resposta, substituição e concorrência em PostgreSQL; -6. concluir a documentação da Fase 4; +1. abrir `app/Integrations/SnipeIt/SnipeItClient.php` e acrescentar checkout/check-in com o token operacional separado; +2. criar `app/Domain/Handovers/Services/HandoverService.php` com entrega e devolução transacionais; +3. criar área do operador para reservas do dia, pesquisa por referência/utilizador e confirmação do estado físico; +4. tratar falhas parciais da API Snipe-IT sem declarar entrega/devolução concluída; +5. implementar incidentes, atrasos e bloqueios associados a dano; +6. testar API simulada, autorização, idempotência e auditoria; 7. atualizar este documento e o changelog. ## Testes da última sessão @@ -56,20 +61,21 @@ Completar a **Fase 4 — aprovações**: Validação concluída em 4 de agosto de 2026: - `docker compose exec -T app composer test`: passou; -- Laravel Pint: 106 ficheiros sem desvios; -- PHPStan/Larastan nível 7: 80 ficheiros, zero erros; -- Pest: 65 testes passaram, 289 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada; +- Laravel Pint: 111 ficheiros sem desvios; +- PHPStan/Larastan nível 7: 84 ficheiros, zero erros; +- Pest: 70 testes passaram, 340 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada; - TypeScript (`tsc --noEmit`): passou; - ESLint: passou; - Prettier: passou; -- Vite: builds cliente e SSR concluídos, 2307 módulos cliente e 96 módulos SSR transformados; +- Vite: builds cliente e SSR concluídos, 2308 módulos cliente e 97 módulos SSR transformados; - PostgreSQL 17: `migrate:fresh --seed` concluído, incluindo extensão `btree_gist` e restrição de exclusão temporal; - PostgreSQL 17: migration incremental do marcador `last_seen_sync_run_id` aplicada com sucesso; - PostgreSQL 17: nenhuma migration pendente; comando de expiração executado e agendamentos horário/a cada cinco minutos confirmados; +- PostgreSQL 17: migration `notifications` aplicada com sucesso; - smoke test HTTP: `/login` respondeu com estado 200 através do Nginx; - Vite em Docker: URL pública, HMR e CORS verificados em `localhost:5173`; corrigida a página em branco causada pela publicação anterior de `0.0.0.0:5173`; - Inertia SSR: renderização do login validada através do serviço `node`; corrigido o componente de mensagens flash que usava `usePage()` fora do contexto Inertia; -- verificação visual no browser integrado: a inspeção controlada de `localhost` continua impedida por uma política administrativa temporariamente indisponível; catálogo, cancelamento e calendário foram validados por testes Inertia, TypeScript e builds cliente/SSR. +- verificação visual no browser integrado: a inspeção controlada de `localhost` continua impedida por uma política administrativa temporariamente indisponível; aprovações, esclarecimentos e notificações foram validados por testes Inertia, TypeScript e builds cliente/SSR. ## Decisões pendentes do contexto institucional diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index 23b4bf7..c818325 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -101,6 +101,8 @@ Uma expiração ou cancelamento muda itens `held`, `reserved`, `ready` e `delive Utilizadores com perfil `approver` ou `administrator` acedem a `http://localhost:8000/aprovacoes`. Uma decisão válida deve criar uma linha em `approval_actions` e outra em `audit_logs`. Se a aprovação indicar que a disponibilidade mudou, confirmar primeiro a atribuição/estado sincronizado do ativo e conflitos entretanto criados; não alterar o estado diretamente na base de dados. +As decisões geram jobs de notificação. Em desenvolvimento, confirmar o email em `http://localhost:8025` e a notificação interna em `http://localhost:8000/notificacoes`. Se uma mensagem não chegar, consultar primeiro `docker compose logs --tail=100 queue` e a tabela `failed_jobs`; não repetir a decisão já confirmada. + ## Produção Antes de disponibilizar: diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index f3375e6..312cf06 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -1,5 +1,6 @@ import { Link, usePage } from '@inertiajs/react'; import { + Bell, CalendarDays, CalendarRange, LayoutGrid, @@ -42,6 +43,7 @@ const mainNavItems: NavItem[] = [ href: '/requisicoes/calendario', icon: CalendarRange, }, + { title: 'Notificações', href: '/notificacoes', icon: Bell }, ]; const footerNavItems: NavItem[] = [ @@ -53,17 +55,22 @@ const footerNavItems: NavItem[] = [ ]; export function AppSidebar() { - const { can } = usePage().props; + const { can, unreadNotifications } = usePage().props; + const userNavItems = mainNavItems.map((item) => + item.href === '/notificacoes' && unreadNotifications > 0 + ? { ...item, title: `Notificações (${unreadNotifications})` } + : item, + ); const roleNavItems = can.approveReservations ? [ - ...mainNavItems, + ...userNavItems, { title: 'Aprovações', href: '/aprovacoes', icon: ListChecks, }, ] - : mainNavItems; + : userNavItems; const visibleNavItems = can.administer ? [ ...roleNavItems, diff --git a/resources/js/pages/approvals/index.tsx b/resources/js/pages/approvals/index.tsx index 8670182..9ecf216 100644 --- a/resources/js/pages/approvals/index.tsx +++ b/resources/js/pages/approvals/index.tsx @@ -1,5 +1,5 @@ -import { Head, router, usePage } from '@inertiajs/react'; -import { Check, CircleHelp, Search, X } from 'lucide-react'; +import { Head, Link, router, usePage } from '@inertiajs/react'; +import { Check, CircleHelp, RefreshCw, Search, X } from 'lucide-react'; import type { FormEvent } from 'react'; import { useState } from 'react'; import InputError from '@/components/input-error'; @@ -8,7 +8,7 @@ import { ReservationStatus } from '@/components/reservation-status'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; -import type { Paginated, Reservation } from '@/types'; +import type { Asset, Paginated, Reservation } from '@/types'; type ApprovalReservation = Reservation & { requester: { @@ -16,22 +16,49 @@ type ApprovalReservation = Reservation & { email: string; department: string | null; }; + approval_actions: { + id: number; + action: string; + comment: string | null; + created_at: string; + actor: { name: string }; + }[]; }; export default function ApprovalsIndex({ reservations, filters, + replacementAssets, + categories, }: { reservations: Paginated; - filters: { search?: string }; + filters: { + search?: string; + category?: string; + date_from?: string; + date_to?: string; + }; + replacementAssets: Asset[]; + categories: string[]; }) { const [search, setSearch] = useState(filters.search ?? ''); + const [category, setCategory] = useState(filters.category ?? ''); + const [dateFrom, setDateFrom] = useState(filters.date_from ?? ''); + const [dateTo, setDateTo] = useState(filters.date_to ?? ''); const [comments, setComments] = useState>({}); + const [replacements, setReplacements] = useState>( + {}, + ); const { errors } = usePage().props; function submitSearch(event: FormEvent) { event.preventDefault(); - router.get('/aprovacoes', { search: search || undefined }); + router.get('/aprovacoes', { + search: search || undefined, + category: category || undefined, + date_from: dateFrom || undefined, + date_to: dateTo || undefined, + }); } function decide(reservation: ApprovalReservation, action: string) { @@ -42,6 +69,17 @@ export default function ApprovalsIndex({ ); } + function substitute(reservation: ApprovalReservation, itemId: number) { + router.post( + `/aprovacoes/${reservation.uuid}/itens/${itemId}/substituir`, + { + replacement_asset_id: Number(replacements[itemId]), + comment: comments[reservation.id] ?? '', + }, + { preserveScroll: true }, + ); + } + return ( <> @@ -51,8 +89,11 @@ export default function ApprovalsIndex({ title="Requisições por decidir" description="Reveja o período, a finalidade e os equipamentos antes de tomar uma decisão." /> -
-
+ +
+ + setDateFrom(event.target.value)} + /> + setDateTo(event.target.value)} + /> @@ -90,11 +155,131 @@ export default function ApprovalsIndex({

Equipamentos

-

+

{reservation.items - .map((item) => item.asset.name) - .join(', ')} -

+ .filter( + (item) => + item.status !== + 'cancelled', + ) + .map((item) => { + const candidates = + replacementAssets.filter( + (asset) => + asset.id !== + item.asset + .id && + (item.asset + .category_id != + null + ? asset.category_id === + item.asset + .category_id + : asset.category_name === + item.asset + .category_name), + ); + + return ( +
+

+ { + item.asset + .name + }{' '} + + { + item + .asset + .asset_tag + } + +

+ {reservation.status === + 'pending_approval' && + candidates.length > + 0 && ( +
+ + +
+ )} +
+ ); + })} +

@@ -175,6 +360,38 @@ export default function ApprovalsIndex({ A aguardar resposta do requisitante.

)} + {reservation.approval_actions.length > 0 && ( +
+ + Histórico de aprovação ( + { + reservation.approval_actions + .length + } + ) + +
+ {reservation.approval_actions.map( + (action) => ( +
+

+ {actionLabel( + action.action, + )}{' '} + ·{' '} + {action.actor.name} +

+ {action.comment && ( +

+ {action.comment} +

+ )} +
+ ), + )} +
+
+ )} ))} @@ -185,6 +402,35 @@ export default function ApprovalsIndex({
)} + {reservations.last_page > 1 && ( + + )} ); @@ -199,6 +445,18 @@ function formatPeriod(reservation: Reservation): string { return `${format.format(new Date(reservation.starts_at))} — ${format.format(new Date(reservation.ends_at))}`; } +function actionLabel(action: string): string { + return ( + { + approved: 'Aprovada', + rejected: 'Rejeitada', + information_requested: 'Esclarecimentos pedidos', + information_provided: 'Esclarecimentos recebidos', + asset_substituted: 'Equipamento substituído', + }[action] ?? action + ); +} + ApprovalsIndex.layout = { breadcrumbs: [{ title: 'Aprovações', href: '/aprovacoes' }], }; diff --git a/resources/js/pages/notifications/index.tsx b/resources/js/pages/notifications/index.tsx new file mode 100644 index 0000000..d1bf218 --- /dev/null +++ b/resources/js/pages/notifications/index.tsx @@ -0,0 +1,123 @@ +import { Head, Link, router } from '@inertiajs/react'; +import { Bell, CheckCheck } from 'lucide-react'; +import { PageHeading } from '@/components/page-heading'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import type { Paginated } from '@/types'; + +type PortalNotification = { + id: string; + data: { + message: string; + url?: string; + reference?: string; + }; + read_at: string | null; + created_at: string; +}; + +export default function NotificationsIndex({ + notifications, +}: { + notifications: Paginated; +}) { + function openNotification(notification: PortalNotification) { + router.post(`/notificacoes/${notification.id}/ler`); + } + + return ( + <> + +
+ + router.post('/notificacoes/ler-todas') + } + > + Marcar todas como lidas + + } + /> + +
+ {notifications.data.map((notification) => ( + + ))} + {notifications.data.length === 0 && ( +
+ Não existem notificações. +
+ )} +
+ + {notifications.last_page > 1 && ( + + )} +
+ + ); +} + +NotificationsIndex.layout = { + breadcrumbs: [{ title: 'Notificações', href: '/notificacoes' }], +}; diff --git a/resources/js/pages/reservations/index.tsx b/resources/js/pages/reservations/index.tsx index b0bd4d0..e1ebf4d 100644 --- a/resources/js/pages/reservations/index.tsx +++ b/resources/js/pages/reservations/index.tsx @@ -1,5 +1,5 @@ import { Head, Link, useForm } from '@inertiajs/react'; -import { Ban, CalendarDays, Plus } from 'lucide-react'; +import { Ban, CalendarDays, MessageSquareText, Plus } from 'lucide-react'; import { useState } from 'react'; import InputError from '@/components/input-error'; import { PageHeading } from '@/components/page-heading'; @@ -22,7 +22,11 @@ export default function ReservationsIndex({ reservations: Paginated; }) { const [selected, setSelected] = useState(null); + const [clarification, setClarification] = useState( + null, + ); const cancelForm = useForm({ reason: '' }); + const clarificationForm = useForm({ response: '' }); const format = new Intl.DateTimeFormat('pt-PT', { dateStyle: 'medium', timeStyle: 'short', @@ -54,6 +58,25 @@ export default function ReservationsIndex({ }); } + function openClarification(reservation: Reservation) { + clarificationForm.reset(); + clarificationForm.clearErrors(); + setClarification(reservation); + } + + function submitClarification(event: React.FormEvent) { + event.preventDefault(); + + if (!clarification) { + return; + } + + clarificationForm.post(`/requisicoes/${clarification.uuid}/responder`, { + preserveScroll: true, + onSuccess: () => setClarification(null), + }); + } + return ( <> @@ -87,7 +110,20 @@ export default function ReservationsIndex({ {reservation.reference} -
+
+ {reservation.status === + 'awaiting_information' && ( + + )}

{reservation.items .map((item) => item.asset.name) @@ -232,6 +268,61 @@ export default function ReservationsIndex({

+ + !open && setClarification(null)} + > + +
+ + + Prestar esclarecimentos + + + {clarification?.decision_reason ?? + 'O aprovador pediu informação adicional para poder decidir.'} + + +
+ +