diff --git a/CHANGELOG.md b/CHANGELOG.md index b9f1d39..eed2e15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,13 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue - Associação do utilizador local ao ID Snipe-IT confirmada durante a entrega. - Bloqueios temporais de equipamento criados a partir de incidentes e considerados pelo motor de disponibilidade. - Tratamento e auditoria de falhas parciais em movimentos com vários equipamentos. +- Gestão administrativa de utilizadores, perfis, estado, departamento, associação Snipe-IT e palavra-passe. +- Regras de reserva editáveis com antecedência, duração, margem, prazo de aprovação e limite de equipamentos. +- Buffers temporais por item e restrição GiST atualizada para garantir margens também sob concorrência. +- Calendário global com identificação do requisitante e autorização por perfil. +- Relatórios operacionais, métricas por dimensão e exportação CSV detalhada. +- Consulta de auditoria filtrável e paginada para administradores e auditores. +- Modelos editáveis para email e notificações internas com variáveis funcionais. ### Alterado @@ -45,12 +52,16 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue - SSR de desenvolvimento ligado internamente de PHP para o serviço `node`, com o hostname Docker autorizado no Vite. - Catálogo sem período passa a distinguir equipamentos apenas requisitáveis; quando um período é selecionado, apresenta exclusivamente equipamentos livres nesse intervalo. - Máquina de estados passa a admitir incidentes durante preparação/utilização, atrasos e resolução por devolução. +- Formulário de requisição apresenta as políticas atuais e o serviço de domínio volta a validá-las antes da transação. +- Notificações passam a obter assunto e mensagem dos modelos administrativos, preservando defaults seguros. ### Segurança - Registo público desativado. - Variáveis para tokens Snipe-IT separados e credenciais Entra ID documentadas sem valores. - Rotas operacionais protegidas por Gate e verificação de perfil também no serviço de domínio; escritas externas não fazem retry automático. +- Exportação CSV neutraliza células iniciadas por caracteres de fórmula e limita cada relatório a 366 dias. +- Gestão administrativa impede auto-desativação e garante pelo menos um administrador ativo. ### Verificado @@ -65,3 +76,4 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue - 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. - Fluxo operacional: autorização, checkout/check-in, entrega parcial, auditoria, incidentes e bloqueios validados com API Snipe-IT simulada. +- Administração: utilizadores, políticas, buffers, calendário global, relatórios, CSV, auditoria e modelos de notificação validados por testes funcionais. diff --git a/README.md b/README.md index 32ad845..eeca940 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 as **Fases 1 a 5**: fundação técnica, integração Snipe-IT, motor de reservas, aprovações e operações físicas. Estão disponíveis autenticação local, catálogo temporal, pré-reservas, conflitos, calendário pessoal, decisões e notificações, checkout/check-in Snipe-IT, área diária do operador, entrega/devolução auditada, incidentes, atrasos e bloqueios. A próxima entrega é a administração e os relatórios. +O projeto concluiu as **Fases 1 a 6**: fundação técnica, integração Snipe-IT, reservas, aprovações, operações físicas e administração/relatórios. Inclui gestão de utilizadores e perfis, regras editáveis com margens garantidas em PostgreSQL, calendário global, métricas, exportação CSV, auditoria e modelos de email. A próxima entrega é o hardening e a entrada em produçã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/Administration/Models/PortalSetting.php b/app/Domain/Administration/Models/PortalSetting.php new file mode 100644 index 0000000..e0290a8 --- /dev/null +++ b/app/Domain/Administration/Models/PortalSetting.php @@ -0,0 +1,29 @@ + 'json']; + } + + /** @return BelongsTo */ + public function updatedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } +} diff --git a/app/Domain/Administration/Services/NotificationTemplateService.php b/app/Domain/Administration/Services/NotificationTemplateService.php new file mode 100644 index 0000000..cb6345f --- /dev/null +++ b/app/Domain/Administration/Services/NotificationTemplateService.php @@ -0,0 +1,122 @@ + */ + public function definitions(): array + { + return [ + 'submitted' => [ + 'label' => 'Nova requisição', + 'subject' => 'Nova requisição {reference}', + 'message' => '{requester} submeteu {reference} para decisão. Período: {starts_at} — {ends_at}.', + ], + 'information_provided' => [ + 'label' => 'Esclarecimentos prestados', + 'subject' => 'Resposta recebida para {reference}', + 'message' => '{requester} prestou esclarecimentos para {reference}.', + ], + 'approved' => [ + 'label' => 'Requisição aprovada', + 'subject' => 'Requisição {reference} aprovada', + 'message' => 'A sua requisição {reference} foi aprovada.', + ], + 'rejected' => [ + 'label' => 'Requisição rejeitada', + 'subject' => 'Requisição {reference} rejeitada', + 'message' => 'A sua requisição {reference} foi rejeitada.', + ], + 'information_requested' => [ + 'label' => 'Pedido de esclarecimentos', + 'subject' => 'Esclarecimentos necessários para {reference}', + 'message' => 'O aprovador pediu informações adicionais para {reference}.', + ], + 'asset_substituted' => [ + 'label' => 'Equipamento substituído', + 'subject' => 'Equipamento alterado em {reference}', + 'message' => 'Um equipamento de {reference} foi substituído por outro equivalente.', + ], + ]; + } + + /** @return array */ + public function templates(): array + { + return Cache::remember('portal.notification_templates', now()->addHour(), function (): array { + $keys = []; + foreach (array_keys($this->definitions()) as $event) { + $keys[] = "notification.{$event}.subject"; + $keys[] = "notification.{$event}.message"; + } + $stored = PortalSetting::query() + ->whereIn('key', $keys) + ->get(['key', 'value']) + ->mapWithKeys(fn (PortalSetting $setting): array => [$setting->key => $setting->value]); + + return collect($this->definitions())->mapWithKeys(function (array $definition, string $event) use ($stored): array { + return [$event => [ + 'subject' => (string) ($stored["notification.{$event}.subject"] ?? $definition['subject']), + 'message' => (string) ($stored["notification.{$event}.message"] ?? $definition['message']), + ]]; + })->all(); + }); + } + + /** @return array{subject:string,message:string} */ + public function render(string $event, Reservation $reservation, ?string $comment = null): array + { + $template = $this->templates()[$event] ?? [ + 'subject' => 'Atualização da requisição {reference}', + 'message' => 'O estado da requisição {reference} foi atualizado.', + ]; + $replacements = [ + '{reference}' => $reservation->reference, + '{requester}' => $reservation->requester->name, + '{starts_at}' => $reservation->starts_at->format('d/m/Y H:i'), + '{ends_at}' => $reservation->ends_at->format('d/m/Y H:i'), + '{comment}' => $comment ?? '', + ]; + + return [ + 'subject' => strtr($template['subject'], $replacements), + 'message' => strtr($template['message'], $replacements), + ]; + } + + /** @param array $templates */ + public function update(array $templates, User $actor): void + { + DB::transaction(function () use ($templates, $actor): void { + $before = $this->templates(); + foreach ($templates as $event => $template) { + foreach (['subject', 'message'] as $field) { + PortalSetting::query()->updateOrCreate( + ['key' => "notification.{$event}.{$field}"], + [ + 'value' => $template[$field], + 'description' => "Modelo {$field} para {$event}", + 'updated_by' => $actor->id, + ], + ); + } + } + AuditLog::create([ + 'actor_id' => $actor->id, + 'event' => 'settings.notification_templates_updated', + 'before' => $before, + 'after' => $templates, + ]); + }, 3); + + Cache::forget('portal.notification_templates'); + } +} diff --git a/app/Domain/Administration/Services/ReservationPolicy.php b/app/Domain/Administration/Services/ReservationPolicy.php new file mode 100644 index 0000000..b63fc4d --- /dev/null +++ b/app/Domain/Administration/Services/ReservationPolicy.php @@ -0,0 +1,100 @@ + */ + public function definitions(): array + { + return [ + 'reservation.min_notice_hours' => [ + 'label' => 'Antecedência mínima', + 'description' => 'Horas mínimas entre a submissão e o início da utilização.', + 'default' => 0, 'min' => 0, 'max' => 720, 'unit' => 'horas', + ], + 'reservation.max_duration_hours' => [ + 'label' => 'Duração máxima', + 'description' => 'Duração máxima permitida para uma requisição.', + 'default' => 168, 'min' => 1, 'max' => 2160, 'unit' => 'horas', + ], + 'reservation.turnaround_minutes' => [ + 'label' => 'Margem entre reservas', + 'description' => 'Tempo bloqueado antes e depois da utilização para preparação e conferência.', + 'default' => 0, 'min' => 0, 'max' => 1440, 'unit' => 'minutos', + ], + 'reservation.approval_deadline_hours' => [ + 'label' => 'Prazo de aprovação', + 'description' => 'Tempo até uma pré-reserva pendente expirar.', + 'default' => 48, 'min' => 1, 'max' => 336, 'unit' => 'horas', + ], + 'reservation.max_assets' => [ + 'label' => 'Equipamentos por requisição', + 'description' => 'Número máximo de equipamentos no mesmo pedido.', + 'default' => 10, 'min' => 1, 'max' => 50, 'unit' => 'equipamentos', + ], + ]; + } + + public function integer(string $key): int + { + $definitions = $this->definitions(); + if (! isset($definitions[$key])) { + throw new \InvalidArgumentException("Política desconhecida: {$key}"); + } + + return $this->values()[$key] ?? $definitions[$key]['default']; + } + + /** @return array */ + public function values(): array + { + return Cache::remember('portal.reservation_policy', now()->addHour(), function (): array { + /** @var array $stored */ + $stored = PortalSetting::query() + ->whereIn('key', array_keys($this->definitions())) + ->pluck('value', 'key') + ->map(fn (mixed $value): int => (int) $value) + ->all(); + + return collect($this->definitions()) + ->mapWithKeys(fn (array $definition, string $key): array => [ + $key => $stored[$key] ?? $definition['default'], + ]) + ->all(); + }); + } + + /** @param array $values */ + public function update(array $values, User $actor): void + { + DB::transaction(function () use ($values, $actor): void { + $before = $this->values(); + foreach ($this->definitions() as $key => $definition) { + PortalSetting::query()->updateOrCreate( + ['key' => $key], + [ + 'value' => $values[$key], + 'description' => $definition['description'], + 'updated_by' => $actor->id, + ], + ); + } + + AuditLog::create([ + 'actor_id' => $actor->id, + 'event' => 'settings.reservation_policy_updated', + 'before' => $before, + 'after' => $values, + ]); + }, 3); + + Cache::forget('portal.reservation_policy'); + } +} diff --git a/app/Domain/Approvals/Services/ApprovalService.php b/app/Domain/Approvals/Services/ApprovalService.php index 2966ef7..b6f25d6 100644 --- a/app/Domain/Approvals/Services/ApprovalService.php +++ b/app/Domain/Approvals/Services/ApprovalService.php @@ -141,6 +141,8 @@ class ApprovalService 'snipe_asset_id' => $newAsset->id, 'starts_at' => $lockedItem->starts_at, 'ends_at' => $lockedItem->ends_at, + 'buffer_starts_at' => $lockedItem->buffer_starts_at, + 'buffer_ends_at' => $lockedItem->buffer_ends_at, 'status' => ReservationItemStatus::Held, 'notes' => "Substitui {$original->asset_tag}: {$reason}", ]); diff --git a/app/Domain/Identity/Services/UserAdministrationService.php b/app/Domain/Identity/Services/UserAdministrationService.php new file mode 100644 index 0000000..af2073c --- /dev/null +++ b/app/Domain/Identity/Services/UserAdministrationService.php @@ -0,0 +1,135 @@ +} $data + */ + public function create(User $actor, array $data): User + { + return DB::transaction(function () use ($actor, $data): User { + $user = User::create([ + 'name' => $data['name'], + 'email' => $data['email'], + 'email_verified_at' => now(), + 'department' => $data['department'], + 'snipe_user_id' => $data['snipe_user_id'], + 'password' => $data['password'], + 'active' => $data['active'], + ]); + $user->roles()->sync($this->roleIds($data['roles'])); + + AuditLog::create([ + 'actor_id' => $actor->id, + 'event' => 'user.created', + 'auditable_type' => $user->getMorphClass(), + 'auditable_id' => $user->id, + 'after' => $this->snapshot($user), + ]); + + return $user->load('roles'); + }, 3); + } + + /** + * @param array{name:string,email:string,department:?string,snipe_user_id:?int,password:?string,active:bool,roles:list} $data + */ + public function update(User $user, User $actor, array $data): User + { + return DB::transaction(function () use ($user, $actor, $data): User { + $locked = User::query()->whereKey($user->id)->lockForUpdate()->firstOrFail(); + $before = $this->snapshot($locked); + $removesAdministrator = ! in_array(RoleName::Administrator->value, $data['roles'], true); + + if ($locked->id === $actor->id && (! $data['active'] || $removesAdministrator)) { + throw ValidationException::withMessages([ + 'roles' => 'Não pode desativar a própria conta nem remover o seu perfil de administrador.', + ]); + } + + if ((! $data['active'] || $removesAdministrator) + && $locked->hasRole(RoleName::Administrator) + && $this->activeAdministratorCount() <= 1) { + throw ValidationException::withMessages([ + 'roles' => 'O portal tem de manter pelo menos um administrador ativo.', + ]); + } + + $attributes = [ + 'name' => $data['name'], + 'email' => $data['email'], + 'department' => $data['department'], + 'snipe_user_id' => $data['snipe_user_id'], + 'active' => $data['active'], + ]; + if (filled($data['password'])) { + $attributes['password'] = $data['password']; + } + + $locked->update($attributes); + $locked->roles()->sync($this->roleIds($data['roles'])); + $locked->load('roles'); + + AuditLog::create([ + 'actor_id' => $actor->id, + 'event' => 'user.updated', + 'auditable_type' => $locked->getMorphClass(), + 'auditable_id' => $locked->id, + 'before' => $before, + 'after' => $this->snapshot($locked), + 'metadata' => ['password_changed' => filled($data['password'])], + ]); + + return $locked; + }, 3); + } + + /** + * @param list $roles + * @return list + */ + private function roleIds(array $roles): array + { + $ids = array_values(Role::query() + ->whereIn('name', $roles) + ->pluck('id') + ->map(fn (int $id): int => $id) + ->all()); + + if (count($ids) !== count(array_unique($roles))) { + throw ValidationException::withMessages(['roles' => 'Um ou mais perfis selecionados não existem.']); + } + + return $ids; + } + + private function activeAdministratorCount(): int + { + return User::query() + ->where('active', true) + ->whereHas('roles', fn ($query) => $query->where('name', RoleName::Administrator->value)) + ->count(); + } + + /** @return array */ + private function snapshot(User $user): array + { + return [ + 'name' => $user->name, + 'email' => $user->email, + 'department' => $user->department, + 'snipe_user_id' => $user->snipe_user_id, + 'active' => $user->active, + 'roles' => $user->roles()->orderBy('name')->pluck('name')->all(), + ]; + } +} diff --git a/app/Domain/Reservations/Models/ReservationItem.php b/app/Domain/Reservations/Models/ReservationItem.php index 4b05948..597962b 100644 --- a/app/Domain/Reservations/Models/ReservationItem.php +++ b/app/Domain/Reservations/Models/ReservationItem.php @@ -19,7 +19,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; class ReservationItem extends Model { protected $fillable = [ - 'reservation_id', 'snipe_asset_id', 'starts_at', 'ends_at', 'status', + 'reservation_id', 'snipe_asset_id', 'starts_at', 'ends_at', 'buffer_starts_at', 'buffer_ends_at', 'status', 'delivered_at', 'returned_at', 'checkout_reference', 'checkin_reference', 'condition_out', 'condition_in', 'notes', ]; @@ -30,6 +30,8 @@ class ReservationItem extends Model 'status' => ReservationItemStatus::class, 'starts_at' => 'immutable_datetime', 'ends_at' => 'immutable_datetime', + 'buffer_starts_at' => 'immutable_datetime', + 'buffer_ends_at' => 'immutable_datetime', 'delivered_at' => 'immutable_datetime', 'returned_at' => 'immutable_datetime', ]; diff --git a/app/Domain/Reservations/Services/AvailabilityService.php b/app/Domain/Reservations/Services/AvailabilityService.php index 218afe1..4f640c9 100644 --- a/app/Domain/Reservations/Services/AvailabilityService.php +++ b/app/Domain/Reservations/Services/AvailabilityService.php @@ -2,24 +2,30 @@ namespace App\Domain\Reservations\Services; +use App\Domain\Administration\Services\ReservationPolicy; use App\Domain\Inventory\Models\SnipeAsset; use App\Domain\Reservations\Enums\ReservationItemStatus; +use Carbon\CarbonImmutable; use DateTimeInterface; use Illuminate\Database\Eloquent\Builder; class AvailabilityService { + public function __construct(private readonly ReservationPolicy $policy) {} + /** @return Builder */ public function availableAssets(DateTimeInterface $startsAt, DateTimeInterface $endsAt): Builder { + [$bufferStartsAt, $bufferEndsAt] = $this->bufferedPeriod($startsAt, $endsAt); + return SnipeAsset::query() ->where('active', true) ->where('requestable', true) ->whereNull('assigned_id') ->whereDoesntHave('reservationItems', fn (Builder $query): Builder => $query ->whereIn('status', ReservationItemStatus::blockingValues()) - ->where('starts_at', '<', $endsAt) - ->where('ends_at', '>', $startsAt)) + ->whereRaw('COALESCE(buffer_starts_at, starts_at) < ?', [$bufferEndsAt]) + ->whereRaw('COALESCE(buffer_ends_at, ends_at) > ?', [$bufferStartsAt])) ->whereDoesntHave('blocks', fn (Builder $query): Builder => $query ->where('starts_at', '<', $endsAt) ->where('ends_at', '>', $startsAt)); @@ -35,11 +41,12 @@ class AvailabilityService return false; } + [$bufferStartsAt, $bufferEndsAt] = $this->bufferedPeriod($startsAt, $endsAt); $hasReservationConflict = $asset->reservationItems() ->whereIn('status', ReservationItemStatus::blockingValues()) ->when($exceptReservationId, fn (Builder $query): Builder => $query->where('reservation_id', '!=', $exceptReservationId)) - ->where('starts_at', '<', $endsAt) - ->where('ends_at', '>', $startsAt) + ->whereRaw('COALESCE(buffer_starts_at, starts_at) < ?', [$bufferEndsAt]) + ->whereRaw('COALESCE(buffer_ends_at, ends_at) > ?', [$bufferStartsAt]) ->exists(); if ($hasReservationConflict) { @@ -51,4 +58,15 @@ class AvailabilityService ->where('ends_at', '>', $startsAt) ->exists(); } + + /** @return array{DateTimeInterface, DateTimeInterface} */ + public function bufferedPeriod(DateTimeInterface $startsAt, DateTimeInterface $endsAt): array + { + $margin = $this->policy->integer('reservation.turnaround_minutes'); + + return [ + CarbonImmutable::instance($startsAt)->subMinutes($margin), + CarbonImmutable::instance($endsAt)->addMinutes($margin), + ]; + } } diff --git a/app/Domain/Reservations/Services/ReservationService.php b/app/Domain/Reservations/Services/ReservationService.php index d066160..2755bad 100644 --- a/app/Domain/Reservations/Services/ReservationService.php +++ b/app/Domain/Reservations/Services/ReservationService.php @@ -2,6 +2,7 @@ namespace App\Domain\Reservations\Services; +use App\Domain\Administration\Services\ReservationPolicy; use App\Domain\Approvals\Models\ApprovalAction; use App\Domain\Audit\Models\AuditLog; use App\Domain\Identity\Enums\RoleName; @@ -20,7 +21,10 @@ use Illuminate\Validation\ValidationException; class ReservationService { - public function __construct(private readonly AvailabilityService $availability) {} + public function __construct( + private readonly AvailabilityService $availability, + private readonly ReservationPolicy $policy, + ) {} /** * @param array{purpose:string, usage_location:string, starts_at:string, ends_at:string, asset_ids:list} $data @@ -29,8 +33,10 @@ class ReservationService { $startsAt = CarbonImmutable::parse($data['starts_at']); $endsAt = CarbonImmutable::parse($data['ends_at']); + $this->validatePolicy($startsAt, $endsAt, count($data['asset_ids'])); + [$bufferStartsAt, $bufferEndsAt] = $this->availability->bufferedPeriod($startsAt, $endsAt); - $reservation = DB::transaction(function () use ($requester, $data, $startsAt, $endsAt): Reservation { + $reservation = DB::transaction(function () use ($requester, $data, $startsAt, $endsAt, $bufferStartsAt, $bufferEndsAt): Reservation { $assets = SnipeAsset::query() ->whereKey($data['asset_ids']) ->lockForUpdate() @@ -58,7 +64,7 @@ class ReservationService 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'status' => ReservationStatus::PendingApproval, - 'approval_deadline' => now()->addHours(48), + 'approval_deadline' => now()->addHours($this->policy->integer('reservation.approval_deadline_hours')), ]); foreach ($assets as $asset) { @@ -66,6 +72,8 @@ class ReservationService 'snipe_asset_id' => $asset->id, 'starts_at' => $startsAt, 'ends_at' => $endsAt, + 'buffer_starts_at' => $bufferStartsAt, + 'buffer_ends_at' => $bufferEndsAt, 'status' => ReservationItemStatus::Held, ]); } @@ -156,7 +164,7 @@ class ReservationService $locked->update([ 'status' => ReservationStatus::PendingApproval, - 'approval_deadline' => now()->addHours(48), + 'approval_deadline' => now()->addHours($this->policy->integer('reservation.approval_deadline_hours')), ]); ApprovalAction::create([ 'reservation_id' => $locked->id, @@ -258,4 +266,21 @@ class ReservationService return $reference; } + + private function validatePolicy(CarbonImmutable $startsAt, CarbonImmutable $endsAt, int $assetCount): void + { + $minNotice = $this->policy->integer('reservation.min_notice_hours'); + $maxDuration = $this->policy->integer('reservation.max_duration_hours'); + $maxAssets = $this->policy->integer('reservation.max_assets'); + + if ($startsAt->lessThan(now()->addHours($minNotice))) { + throw ValidationException::withMessages(['starts_at' => "A requisição exige pelo menos {$minNotice} horas de antecedência."]); + } + if ($startsAt->diffInMinutes($endsAt) > $maxDuration * 60) { + throw ValidationException::withMessages(['ends_at' => "A duração máxima permitida é de {$maxDuration} horas."]); + } + if ($assetCount > $maxAssets) { + throw ValidationException::withMessages(['asset_ids' => "Selecione no máximo {$maxAssets} equipamentos."]); + } + } } diff --git a/app/Http/Controllers/Admin/NotificationTemplateController.php b/app/Http/Controllers/Admin/NotificationTemplateController.php new file mode 100644 index 0000000..39c3bcb --- /dev/null +++ b/app/Http/Controllers/Admin/NotificationTemplateController.php @@ -0,0 +1,36 @@ + $service->definitions(), + 'templates' => $service->templates(), + 'variables' => ['{reference}', '{requester}', '{starts_at}', '{ends_at}', '{comment}'], + ]); + } + + public function update(Request $request, NotificationTemplateService $service): RedirectResponse + { + $rules = []; + foreach (array_keys($service->definitions()) as $event) { + $rules["templates.{$event}.subject"] = ['required', 'string', 'max:200']; + $rules["templates.{$event}.message"] = ['required', 'string', 'max:2000']; + } + /** @var array{templates:array} $data */ + $data = $request->validate($rules); + $service->update($data['templates'], $request->user()); + + return back()->with('success', 'Modelos de notificação atualizados.'); + } +} diff --git a/app/Http/Controllers/Admin/ReservationPolicyController.php b/app/Http/Controllers/Admin/ReservationPolicyController.php new file mode 100644 index 0000000..11ecac0 --- /dev/null +++ b/app/Http/Controllers/Admin/ReservationPolicyController.php @@ -0,0 +1,37 @@ + $policy->definitions(), + 'values' => $policy->values(), + ]); + } + + public function update(Request $request, ReservationPolicy $policy): RedirectResponse + { + $rules = []; + foreach ($policy->definitions() as $key => $definition) { + $rules[str_replace('.', '\\.', $key)] = ['required', 'integer', 'min:'.$definition['min'], 'max:'.$definition['max']]; + } + $request->validate($rules); + /** @var array $values */ + $values = collect(array_keys($policy->definitions())) + ->mapWithKeys(fn (string $key): array => [$key => (int) $request->all()[$key]]) + ->all(); + $policy->update($values, $request->user()); + + return back()->with('success', 'Regras de reserva atualizadas e aplicadas a novos pedidos.'); + } +} diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php new file mode 100644 index 0000000..29e746b --- /dev/null +++ b/app/Http/Controllers/Admin/UserController.php @@ -0,0 +1,83 @@ +validate([ + 'search' => ['nullable', 'string', 'max:100'], + 'role' => ['nullable', Rule::enum(RoleName::class)], + 'status' => ['nullable', Rule::in(['active', 'inactive'])], + ]); + + return Inertia::render('admin/users/index', [ + 'users' => User::query() + ->with('roles:id,name,description') + ->when($filters['search'] ?? null, fn ($query, string $search) => $query + ->where(fn ($query) => $query + ->where('name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%") + ->orWhere('department', 'like', "%{$search}%"))) + ->when($filters['role'] ?? null, fn ($query, string $role) => $query + ->whereHas('roles', fn ($query) => $query->where('name', $role))) + ->when(($filters['status'] ?? null) === 'active', fn ($query) => $query->where('active', true)) + ->when(($filters['status'] ?? null) === 'inactive', fn ($query) => $query->where('active', false)) + ->orderBy('name') + ->paginate(20) + ->withQueryString(), + 'roles' => Role::query()->orderBy('name')->get(['id', 'name', 'description']), + 'filters' => $filters, + ]); + } + + public function store(Request $request, UserAdministrationService $service): RedirectResponse + { + $data = $this->validatedData($request, null); + $user = $service->create($request->user(), $data); + + return back()->with('success', "Utilizador {$user->name} criado."); + } + + public function update(Request $request, User $user, UserAdministrationService $service): RedirectResponse + { + $data = $this->validatedData($request, $user); + $service->update($user, $request->user(), $data); + + return back()->with('success', "Utilizador {$user->name} atualizado."); + } + + /** + * @return array{name:string,email:string,department:?string,snipe_user_id:?int,password:?string,active:bool,roles:list} + */ + private function validatedData(Request $request, ?User $user): array + { + $roleNames = array_map(fn (RoleName $role): string => $role->value, RoleName::cases()); + /** @var array{name:string,email:string,department:?string,snipe_user_id:?int,password:?string,active:bool,roles:list} $data */ + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user?->id)], + 'department' => ['nullable', 'string', 'max:255'], + 'snipe_user_id' => ['nullable', 'integer', 'min:1'], + 'password' => [$user === null ? 'required' : 'nullable', 'string', Password::min(12)], + 'active' => ['required', 'boolean'], + 'roles' => ['required', 'array', 'min:1'], + 'roles.*' => ['required', 'string', 'distinct', Rule::in($roleNames)], + ]); + + return $data; + } +} diff --git a/app/Http/Controllers/AuditController.php b/app/Http/Controllers/AuditController.php new file mode 100644 index 0000000..4842e25 --- /dev/null +++ b/app/Http/Controllers/AuditController.php @@ -0,0 +1,38 @@ +validate([ + 'event' => ['nullable', 'string', 'max:100'], + 'actor_id' => ['nullable', 'integer', 'exists:users,id'], + 'date_from' => ['nullable', 'date'], + 'date_to' => ['nullable', 'date', 'after_or_equal:date_from'], + ]); + + return Inertia::render('audit/index', [ + 'logs' => AuditLog::query() + ->with('actor:id,name,email') + ->when($filters['event'] ?? null, fn ($query, string $event) => $query->where('event', 'like', "%{$event}%")) + ->when($filters['actor_id'] ?? null, fn ($query, int $actorId) => $query->where('actor_id', $actorId)) + ->when($filters['date_from'] ?? null, fn ($query, string $date) => $query->where('created_at', '>=', $date.' 00:00:00')) + ->when($filters['date_to'] ?? null, fn ($query, string $date) => $query->where('created_at', '<', CarbonImmutable::parse($date)->addDay())) + ->latest('created_at') + ->paginate(50) + ->withQueryString(), + 'actors' => User::query()->whereHas('roles')->orderBy('name')->get(['id', 'name']), + 'events' => AuditLog::query()->distinct()->orderBy('event')->pluck('event'), + 'filters' => $filters, + ]); + } +} diff --git a/app/Http/Controllers/GlobalCalendarController.php b/app/Http/Controllers/GlobalCalendarController.php new file mode 100644 index 0000000..885258f --- /dev/null +++ b/app/Http/Controllers/GlobalCalendarController.php @@ -0,0 +1,31 @@ +validate(['month' => ['nullable', 'date_format:Y-m']]); + $month = isset($filters['month']) + ? CarbonImmutable::createFromFormat('!Y-m', $filters['month']) + : now()->startOfMonth(); + + return Inertia::render('reservations/calendar', [ + 'scope' => 'global', + 'month' => $month->format('Y-m'), + 'reservations' => Reservation::query() + ->with(['requester:id,name,department', 'items.asset:id,name,asset_tag']) + ->where('starts_at', '<', $month->addMonth()) + ->where('ends_at', '>', $month) + ->orderBy('starts_at') + ->get(), + ]); + } +} diff --git a/app/Http/Controllers/ReportController.php b/app/Http/Controllers/ReportController.php new file mode 100644 index 0000000..a8dc214 --- /dev/null +++ b/app/Http/Controllers/ReportController.php @@ -0,0 +1,109 @@ +period($request); + $reservations = $this->query($from, $to)->get(); + $items = $reservations->flatMap->items->where('status', '!=', ReservationItemStatus::Cancelled); + + return Inertia::render('reports/index', [ + 'filters' => $filters, + 'summary' => [ + 'total' => $reservations->count(), + 'completed' => $reservations->where('status', ReservationStatus::Completed)->count(), + 'incidents' => AuditLog::query()->where('event', 'reservation.incident_reported')->whereBetween('created_at', [$from, $to])->count(), + 'overdue' => AuditLog::query()->where('event', 'reservation.overdue')->whereBetween('created_at', [$from, $to])->count(), + 'utilizationHours' => round($items->sum(function ($item): float { + $end = $item->returned_at ?? ($item->ends_at->isPast() ? $item->ends_at : now()); + + return $item->delivered_at === null ? 0 : max(0, $item->delivered_at->diffInMinutes($end) / 60); + }), 1), + ], + 'byStatus' => $reservations->countBy(fn ($reservation): string => $reservation->status->value)->sortDesc(), + 'byCategory' => $items->countBy(fn ($item): string => $item->asset->category_name ?? 'Sem categoria')->sortDesc()->take(10), + 'byDepartment' => $reservations->countBy(fn ($reservation): string => $reservation->requester->department ?? 'Sem departamento')->sortDesc()->take(10), + 'topAssets' => $items->countBy(fn ($item): string => "{$item->asset->asset_tag} — {$item->asset->name}")->sortDesc()->take(10), + ]); + } + + public function export(Request $request): StreamedResponse + { + [$filters, $from, $to] = $this->period($request); + $reservations = $this->query($from, $to)->lazy(200); + + return response()->streamDownload(function () use ($reservations): void { + $output = fopen('php://output', 'w'); + if ($output === false) { + return; + } + fwrite($output, "\xEF\xBB\xBF"); + fputcsv($output, ['Referência', 'Requisitante', 'Departamento', 'Estado', 'Início', 'Fim', 'Equipamentos'], ';', '"', ''); + foreach ($reservations as $reservation) { + fputcsv($output, [ + $this->csvCell($reservation->reference), + $this->csvCell($reservation->requester->name), + $this->csvCell($reservation->requester->department), + $this->csvCell($reservation->status->value), + $reservation->starts_at->toIso8601String(), + $reservation->ends_at->toIso8601String(), + $this->csvCell($reservation->items->pluck('asset.asset_tag')->join(', ')), + ], ';', '"', ''); + } + fclose($output); + }, "reservas-{$filters['date_from']}-{$filters['date_to']}.csv", [ + 'Content-Type' => 'text/csv; charset=UTF-8', + ]); + } + + /** @return array{array{date_from:string,date_to:string},CarbonImmutable,CarbonImmutable} */ + private function period(Request $request): array + { + $filters = $request->validate([ + 'date_from' => ['nullable', 'date'], + 'date_to' => ['nullable', 'date', 'after_or_equal:date_from'], + ]); + $from = isset($filters['date_from']) ? CarbonImmutable::parse($filters['date_from'])->startOfDay() : now()->subDays(29)->startOfDay(); + $to = isset($filters['date_to']) ? CarbonImmutable::parse($filters['date_to'])->endOfDay() : now()->endOfDay(); + if ($from->diffInDays($to) > 366) { + throw ValidationException::withMessages(['date_to' => 'O relatório está limitado a um período máximo de 366 dias.']); + } + + return [[ + 'date_from' => $from->toDateString(), + 'date_to' => $to->toDateString(), + ], $from, $to]; + } + + /** @return Builder */ + private function query(CarbonImmutable $from, CarbonImmutable $to): Builder + { + return Reservation::query() + ->with(['requester:id,name,department', 'items.asset:id,name,asset_tag,category_name']) + ->where('starts_at', '<=', $to) + ->where('ends_at', '>=', $from) + ->orderBy('starts_at'); + } + + private function csvCell(?string $value): string + { + $value ??= ''; + + return preg_match('/^\s*[=+\-@]/', $value) === 1 ? "'{$value}" : $value; + } +} diff --git a/app/Http/Controllers/ReservationController.php b/app/Http/Controllers/ReservationController.php index 4c1df2a..50977c8 100644 --- a/app/Http/Controllers/ReservationController.php +++ b/app/Http/Controllers/ReservationController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Domain\Administration\Services\ReservationPolicy; use App\Domain\Inventory\Models\SnipeAsset; use App\Domain\Reservations\Models\Reservation; use App\Domain\Reservations\Services\AvailabilityService; @@ -46,7 +47,7 @@ class ReservationController extends Controller ]); } - public function create(Request $request, AvailabilityService $availability): Response + public function create(Request $request, AvailabilityService $availability, ReservationPolicy $policy): Response { $period = $request->validate([ 'starts_at' => ['nullable', 'date', 'required_with:ends_at', 'after:now'], @@ -73,6 +74,12 @@ class ReservationController extends Controller 'selectedAssetId' => $assets->contains('id', $requestedAssetId) ? $requestedAssetId : 0, 'period' => $period, 'periodSelected' => $periodSelected, + 'policy' => [ + 'minNoticeHours' => $policy->integer('reservation.min_notice_hours'), + 'maxDurationHours' => $policy->integer('reservation.max_duration_hours'), + 'turnaroundMinutes' => $policy->integer('reservation.turnaround_minutes'), + 'maxAssets' => $policy->integer('reservation.max_assets'), + ], ]); } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index b2744d4..2e7b895 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -49,6 +49,9 @@ class HandleInertiaRequests extends Middleware 'administer' => fn (): bool => $request->user()?->can('administer') ?? false, 'approveReservations' => fn (): bool => $request->user()?->can('approve-reservations') ?? false, 'operateInventory' => fn (): bool => $request->user()?->can('operate-inventory') ?? false, + 'viewGlobalCalendar' => fn (): bool => $request->user()?->can('view-global-calendar') ?? false, + 'viewReports' => fn (): bool => $request->user()?->can('view-reports') ?? false, + 'viewAudit' => fn (): bool => $request->user()?->can('view-audit') ?? false, ], 'unreadNotifications' => fn (): int => $request->user()?->unreadNotifications()->count() ?? 0, 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', diff --git a/app/Http/Requests/StoreReservationRequest.php b/app/Http/Requests/StoreReservationRequest.php index cc7fb27..5f28f6a 100644 --- a/app/Http/Requests/StoreReservationRequest.php +++ b/app/Http/Requests/StoreReservationRequest.php @@ -2,7 +2,10 @@ namespace App\Http\Requests; +use App\Domain\Administration\Services\ReservationPolicy; +use Carbon\CarbonImmutable; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Validator; class StoreReservationRequest extends FormRequest { @@ -14,14 +17,39 @@ class StoreReservationRequest extends FormRequest /** @return array> */ public function rules(): array { + $maxAssets = app(ReservationPolicy::class)->integer('reservation.max_assets'); + return [ 'purpose' => ['required', 'string', 'min:10', 'max:2000'], 'usage_location' => ['required', 'string', 'max:255'], 'starts_at' => ['required', 'date', 'after:now'], 'ends_at' => ['required', 'date', 'after:starts_at'], - 'asset_ids' => ['required', 'array', 'min:1', 'max:10'], + 'asset_ids' => ['required', 'array', 'min:1', "max:{$maxAssets}"], 'asset_ids.*' => ['required', 'integer', 'distinct', 'exists:snipe_assets,id'], 'terms_accepted' => ['accepted'], ]; } + + /** @return list */ + public function after(): array + { + return [function (Validator $validator): void { + if ($validator->errors()->hasAny(['starts_at', 'ends_at'])) { + return; + } + + $policy = app(ReservationPolicy::class); + $startsAt = CarbonImmutable::parse((string) $this->input('starts_at')); + $endsAt = CarbonImmutable::parse((string) $this->input('ends_at')); + $minNotice = $policy->integer('reservation.min_notice_hours'); + $maxDuration = $policy->integer('reservation.max_duration_hours'); + + if ($startsAt->lessThan(now()->addHours($minNotice))) { + $validator->errors()->add('starts_at', "A requisição exige pelo menos {$minNotice} horas de antecedência."); + } + if ($startsAt->diffInMinutes($endsAt) > $maxDuration * 60) { + $validator->errors()->add('ends_at', "A duração máxima permitida é de {$maxDuration} horas."); + } + }]; + } } diff --git a/app/Models/User.php b/app/Models/User.php index 04d023d..981d7d4 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -33,7 +33,7 @@ use Laravel\Fortify\TwoFactorAuthenticatable; * @property Carbon|null $created_at * @property Carbon|null $updated_at */ -#[Fillable(['uuid', 'name', 'email', 'password', 'department', 'snipe_user_id', 'active', 'last_login_at'])] +#[Fillable(['uuid', 'name', 'email', 'email_verified_at', 'password', 'department', 'snipe_user_id', 'active', 'last_login_at'])] #[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])] class User extends Authenticatable implements PasskeyUser { diff --git a/app/Notifications/ReservationDecisionNotification.php b/app/Notifications/ReservationDecisionNotification.php index 93788d2..8f40e23 100644 --- a/app/Notifications/ReservationDecisionNotification.php +++ b/app/Notifications/ReservationDecisionNotification.php @@ -2,6 +2,7 @@ namespace App\Notifications; +use App\Domain\Administration\Services\NotificationTemplateService; use App\Domain\Reservations\Models\Reservation; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -28,10 +29,11 @@ class ReservationDecisionNotification extends Notification implements ShouldQueu public function toMail(object $notifiable): MailMessage { + $template = $this->template(); $message = (new MailMessage) - ->subject($this->subject()) + ->subject($template['subject']) ->greeting("Atualização da requisição {$this->reservation->reference}") - ->line($this->message()); + ->line($template['message']); if (filled($this->comment)) { $message->line("Comentário: {$this->comment}"); @@ -43,35 +45,21 @@ class ReservationDecisionNotification extends Notification implements ShouldQueu /** @return array */ public function toArray(object $notifiable): array { + $template = $this->template(); + return [ 'reservation_uuid' => $this->reservation->uuid, 'reference' => $this->reservation->reference, 'event' => $this->event, - 'message' => $this->message(), + 'message' => $template['message'], 'comment' => $this->comment, 'url' => '/requisicoes', ]; } - private function subject(): string + /** @return array{subject:string,message:string} */ + private function template(): array { - 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.', - }; + return app(NotificationTemplateService::class)->render($this->event, $this->reservation, $this->comment); } } diff --git a/app/Notifications/ReservationSubmittedNotification.php b/app/Notifications/ReservationSubmittedNotification.php index d55b98a..06d6cc5 100644 --- a/app/Notifications/ReservationSubmittedNotification.php +++ b/app/Notifications/ReservationSubmittedNotification.php @@ -2,6 +2,7 @@ namespace App\Notifications; +use App\Domain\Administration\Services\NotificationTemplateService; use App\Domain\Reservations\Models\Reservation; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -27,28 +28,27 @@ class ReservationSubmittedNotification extends Notification implements ShouldQue public function toMail(object $notifiable): MailMessage { + $template = app(NotificationTemplateService::class)->render($this->event, $this->reservation); + return (new MailMessage) - ->subject($this->event === 'information_provided' - ? "Resposta recebida para {$this->reservation->reference}" - : "Nova requisição {$this->reservation->reference}") + ->subject($template['subject']) ->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')}.") + ->line($template['message']) ->action('Analisar requisição', url('/aprovacoes')); } /** @return array */ public function toArray(object $notifiable): array { + $template = app(NotificationTemplateService::class)->render($this->event, $this->reservation); + 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.", + 'message' => $template['message'], 'url' => '/aprovacoes', ]; } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 5e4ee98..4d075b1 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -42,6 +42,25 @@ class AppServiceProvider extends ServiceProvider fn (User $user): bool => $user->active && ($user->hasRole(RoleName::InventoryOperator) || $user->hasRole(RoleName::Administrator)), ); + Gate::define( + 'view-global-calendar', + fn (User $user): bool => $user->active && ( + $user->hasRole(RoleName::Approver) + || $user->hasRole(RoleName::InventoryOperator) + || $user->hasRole(RoleName::Administrator) + || $user->hasRole(RoleName::Auditor) + ), + ); + Gate::define( + 'view-reports', + fn (User $user): bool => $user->active + && ($user->hasRole(RoleName::Administrator) || $user->hasRole(RoleName::Auditor)), + ); + Gate::define( + 'view-audit', + fn (User $user): bool => $user->active + && ($user->hasRole(RoleName::Administrator) || $user->hasRole(RoleName::Auditor)), + ); } /** diff --git a/database/migrations/2026_08_04_000700_create_portal_settings_and_reservation_buffers.php b/database/migrations/2026_08_04_000700_create_portal_settings_and_reservation_buffers.php new file mode 100644 index 0000000..4c9cbb5 --- /dev/null +++ b/database/migrations/2026_08_04_000700_create_portal_settings_and_reservation_buffers.php @@ -0,0 +1,65 @@ +string('key')->primary(); + $table->json('value'); + $table->text('description')->nullable(); + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestampsTz(); + }); + + Schema::table('reservation_items', function (Blueprint $table) { + $table->timestampTz('buffer_starts_at')->nullable()->index()->after('ends_at'); + $table->timestampTz('buffer_ends_at')->nullable()->index()->after('buffer_starts_at'); + }); + DB::table('reservation_items')->update([ + 'buffer_starts_at' => DB::raw('starts_at'), + 'buffer_ends_at' => DB::raw('ends_at'), + ]); + + if (DB::getDriverName() === 'pgsql') { + DB::statement('ALTER TABLE reservation_items DROP CONSTRAINT reservation_items_no_blocking_overlap'); + DB::statement('ALTER TABLE reservation_items ALTER COLUMN buffer_starts_at SET NOT NULL'); + DB::statement('ALTER TABLE reservation_items ALTER COLUMN buffer_ends_at SET NOT NULL'); + DB::statement(<<<'SQL' + ALTER TABLE reservation_items + ADD CONSTRAINT reservation_items_no_blocking_overlap + EXCLUDE USING gist ( + snipe_asset_id WITH =, + tstzrange(buffer_starts_at, buffer_ends_at, '[)') WITH && + ) + WHERE (status IN ('held', 'reserved', 'ready', 'delivered')) + SQL); + } + } + + public function down(): void + { + if (DB::getDriverName() === 'pgsql') { + DB::statement('ALTER TABLE reservation_items DROP CONSTRAINT reservation_items_no_blocking_overlap'); + DB::statement(<<<'SQL' + ALTER TABLE reservation_items + ADD CONSTRAINT reservation_items_no_blocking_overlap + EXCLUDE USING gist ( + snipe_asset_id WITH =, + tstzrange(starts_at, ends_at, '[)') WITH && + ) + WHERE (status IN ('held', 'reserved', 'ready', 'delivered')) + SQL); + } + + Schema::table('reservation_items', function (Blueprint $table) { + $table->dropColumn(['buffer_starts_at', 'buffer_ends_at']); + }); + Schema::dropIfExists('portal_settings'); + } +}; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 20d8813..4d729c1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -30,6 +30,7 @@ Nginx ──► Laravel + Inertia/React ## Módulos - `app/Domain/Identity`: perfis, permissões e identidades externas. +- `app/Domain/Administration`: políticas persistentes, modelos de notificação e configuração funcional. - `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, histórico e concorrência de aprovação. @@ -56,6 +57,12 @@ Pré-reservas ficam bloqueantes em `held` enquanto aguardam decisão. `Reservati 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. +`ReservationPolicy` fornece defaults versionados e sobreposições guardadas em `portal_settings`. Antecedência, duração, limite de itens e prazo são validados no pedido e no serviço de domínio. A margem operacional é materializada em `reservation_items.buffer_starts_at`/`buffer_ends_at`; a restrição GiST usa estes campos, mantendo a proteção contra corridas. Uma alteração de política só afeta novas reservas, preservando o contrato temporal das existentes. + +Os relatórios usam consultas de leitura com período máximo de 366 dias. A exportação CSV é transmitida em blocos e neutraliza conteúdos interpretáveis como fórmulas. Auditoria, relatórios e calendário global têm Gates próprios; o perfil `auditor` permanece exclusivamente de leitura. + +`NotificationTemplateService` mantém defaults no código e sobreposições em `portal_settings`. Apenas variáveis conhecidas (`{reference}`, `{requester}`, `{starts_at}`, `{ends_at}`, `{comment}`) são substituídas; a alteração de modelos é auditada e nunca inclui credenciais ou configuração SMTP. + ## 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 57cdfec..72ec4db 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -78,3 +78,17 @@ Notificações funcionais são colocadas na fila apenas depois da confirmação Checkout e check-in usam um token operacional distinto do token de leitura e nunca escrevem diretamente na base de dados do Snipe-IT. Cada equipamento é movimentado remotamente antes de o respetivo `reservation_item` e `handover_record` serem confirmados localmente. Escritas não têm retry automático: uma falha de ligação ou HTTP 5xx pode ser posterior à execução remota e a repetição cega não é segura. Numa operação com vários itens, sucessos confirmados são preservados e uma falha posterior coloca a requisição em `incident`, com IDs concluídos, item falhado e erro em auditoria. O operador reconcilia o ativo no Snipe-IT e repete apenas o movimento ainda pendente. O checkout é dirigido a `checkout_to_type=user`; o ID remoto confirmado pelo operador é guardado em `users.snipe_user_id`. O check-in usa o estado e a localização habitual provenientes da última sincronização. Esta opção segue os contratos oficiais de [checkout](https://snipe-it.readme.io/reference/hardware-checkout), [check-in](https://snipe-it.readme.io/reference/hardware-checkin) e o comportamento documentado de [erros em HTTP 200](https://snipe-it.readme.io/reference/api-overview). + +## ADR-012 — Políticas persistentes e buffers temporais imutáveis + +**Data:** 2026-08-04 +**Estado:** aceite + +Guardar parâmetros funcionais em `portal_settings`, mantendo defaults seguros no código. Antecedência, duração, prazo e limite de itens aplicam-se a novos pedidos. A margem é materializada em cada item através de `buffer_starts_at`/`buffer_ends_at` e protegida pela restrição de exclusão PostgreSQL. Alterar a política não recalcula reservas existentes: o período bloqueante aceite no momento da submissão permanece estável e auditável. + +## ADR-013 — Supervisão de leitura e exportações seguras + +**Data:** 2026-08-04 +**Estado:** aceite + +Separar Gates para calendário global, relatórios e auditoria. Administradores têm acesso integral; auditores consultam relatórios/auditoria sem mutações; aprovadores e operadores consultam o calendário global. Relatórios são limitados a 366 dias, exportados por streaming e neutralizam possíveis fórmulas em CSV. Modelos de notificação são conteúdo funcional persistente, mas credenciais SMTP e tokens continuam exclusivamente no ambiente. diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index afe0d21..0907685 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 a 5 concluídas; próxima entrega: Fase 6 +**Fase:** Fases 1 a 6 concluídas; próxima entrega: Fase 7 **Último agente:** Codex ## Resultado disponível @@ -40,6 +40,15 @@ - Associação persistente entre o utilizador do portal e o respetivo ID no Snipe-IT, confirmada durante uma entrega bem-sucedida. - Tratamento explícito de falhas parciais: movimentos já aceites são preservados, a requisição passa a `incident` e a falha fica em `audit_logs` para reconciliação. - Registo de falta, atraso e incidente; um incidente pode criar um bloqueio temporal do equipamento que entra imediatamente no motor de disponibilidade. +- Gestão administrativa de utilizadores com pesquisa, perfis múltiplos, estado ativo, departamento, ID Snipe-IT e reposição de palavra-passe. +- Proteção contra desativação da própria conta administrativa e contra remoção do último administrador ativo. +- Políticas de reserva editáveis para antecedência, duração, margem operacional, prazo de aprovação e limite de equipamentos. +- Margens temporais persistidas por item e garantidas pela restrição de exclusão PostgreSQL, sem reescrever reservas existentes quando a política muda. +- Calendário institucional global para aprovadores, operadores, administradores e auditores. +- Relatórios por período com totais, horas de utilização, incidentes, atrasos, estados, categorias, departamentos e equipamentos mais requisitados. +- Exportação CSV protegida contra formula injection e limitada a períodos de 366 dias. +- Consulta paginada e filtrável de `audit_logs`, apenas para administradores e auditores. +- Modelos editáveis de assunto/mensagem para os seis eventos de notificação, com variáveis validadas e auditoria das alterações. ## Limites conhecidos @@ -49,36 +58,38 @@ - 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. - O leitor operacional aceita scanners QR/código de barras que funcionem como teclado. A leitura direta pela câmara do navegador ainda não foi adicionada. - Uma falha depois de o Snipe-IT confirmar um movimento e antes do commit local é uma transação distribuída ambígua; não existe retry automático de escritas. O operador deve reconciliar pelos registos Snipe-IT e `audit_logs` conforme o runbook. +- As exportações administrativas estão disponíveis em CSV; XLSX poderá ser acrescentado se o processo institucional o exigir. ## Próxima tarefa recomendada -Implementar a **Fase 6 — administração e relatórios**: +Implementar a **Fase 7 — hardening e entrada em produção**: -1. criar a área administrativa de utilizadores, perfis e associação ao ID Snipe-IT; -2. tornar configuráveis as políticas de antecedência, duração, margens, estados e categorias; -3. criar calendário global e relatórios de utilização, atrasos, incidentes e ativos mais requisitados; -4. adicionar exportação CSV/XLSX e filtros por período, categoria, departamento e estado; -5. criar consulta de auditoria apenas de leitura para o perfil `auditor`; -6. adicionar métricas operacionais e gestão de modelos de email; -7. cobrir autorização, exportações e agregações com testes e atualizar toda a documentação. +1. implementar Microsoft Entra ID/OIDC e associação segura a contas locais; +2. validar a integração contra o Snipe-IT institucional e configurar tokens reais de menor privilégio; +3. configurar SMTP institucional, domínio, HTTPS e gestão externa de segredos; +4. adicionar health checks, monitorização, alertas, backups e teste de restauro; +5. executar testes de segurança, concorrência, desempenho e end-to-end com equipamentos reais; +6. preparar formação, dados iniciais e piloto com um grupo reduzido; +7. corrigir resultados do piloto e preparar a disponibilização geral. ## Testes da última sessão Validação concluída em 4 de agosto de 2026: - `docker compose exec -T app composer test`: passou; -- Laravel Pint: 117 ficheiros sem desvios; -- PHPStan/Larastan nível 7: 89 ficheiros, zero erros; -- Pest: 79 testes passaram, 412 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada; +- Laravel Pint: 132 ficheiros sem desvios; +- PHPStan/Larastan nível 7: 100 ficheiros, zero erros; +- Pest: 92 testes passaram, 522 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, 2309 módulos cliente e 98 módulos SSR transformados; +- Vite: builds cliente e SSR concluídos, 2314 módulos cliente e 103 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; - PostgreSQL 17: migration `snipe_user_id` aplicada e fluxo operacional exercitado contra API simulada; +- PostgreSQL 17: migration de `portal_settings` e buffers temporais aplicada; restrição GiST recriada sobre o intervalo com margem; - 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; diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index 88da11b..5c7fa37 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -128,6 +128,19 @@ Utilizadores com perfil `approver` ou `administrator` acedem a `http://localhost 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. +## Administração e relatórios + +Administradores usam: + +- `http://localhost:8000/administracao/utilizadores` para contas, perfis e IDs Snipe-IT; +- `http://localhost:8000/administracao/regras` para políticas aplicadas a novos pedidos; +- `http://localhost:8000/administracao/notificacoes/modelos` para assuntos e mensagens; +- `http://localhost:8000/administracao/snipe-it` para diagnóstico da integração. + +As alterações são registadas em `audit_logs`. A margem de uma reserva já submetida não muda quando a política é alterada; confirmar `buffer_starts_at` e `buffer_ends_at` do item ao investigar um conflito. + +Administradores e auditores consultam `http://localhost:8000/relatorios` e `http://localhost:8000/auditoria`. O CSV usa `;`, UTF-8 com BOM e neutralização de fórmulas para abertura segura em aplicações de folha de cálculo. Cada pedido está limitado a 366 dias; para análises plurianuais, exportar períodos separados. Aprovadores, operadores, administradores e auditores consultam o planeamento consolidado em `http://localhost:8000/calendario-global`. + ## Produção Antes de disponibilizar: diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index d006a95..2829c22 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -1,6 +1,7 @@ import { Link, usePage } from '@inertiajs/react'; import { Bell, + BarChart3, CalendarDays, CalendarRange, LayoutGrid, @@ -10,6 +11,10 @@ import { PlusCircle, ServerCog, ShieldCheck, + ScrollText, + UsersRound, + SlidersHorizontal, + Mails, } from 'lucide-react'; import AppLogo from '@/components/app-logo'; import { NavFooter } from '@/components/nav-footer'; @@ -82,16 +87,53 @@ export function AppSidebar() { }, ] : roleNavItems; - const visibleNavItems = can.administer + const calendarNavItems = can.viewGlobalCalendar ? [ ...operationalNavItems, + { + title: 'Calendário global', + href: '/calendario-global', + icon: CalendarRange, + }, + ] + : operationalNavItems; + const reportNavItems = can.viewReports + ? [ + ...calendarNavItems, + { title: 'Relatórios', href: '/relatorios', icon: BarChart3 }, + ] + : calendarNavItems; + const oversightNavItems = can.viewAudit + ? [ + ...reportNavItems, + { title: 'Auditoria', href: '/auditoria', icon: ScrollText }, + ] + : reportNavItems; + const visibleNavItems = can.administer + ? [ + ...oversightNavItems, + { + title: 'Utilizadores', + href: '/administracao/utilizadores', + icon: UsersRound, + }, + { + title: 'Regras de reserva', + href: '/administracao/regras', + icon: SlidersHorizontal, + }, + { + title: 'Modelos de email', + href: '/administracao/notificacoes/modelos', + icon: Mails, + }, { title: 'Integração Snipe-IT', href: '/administracao/snipe-it', icon: ServerCog, }, ] - : operationalNavItems; + : oversightNavItems; return ( diff --git a/resources/js/pages/admin/notification-templates.tsx b/resources/js/pages/admin/notification-templates.tsx new file mode 100644 index 0000000..835f29a --- /dev/null +++ b/resources/js/pages/admin/notification-templates.tsx @@ -0,0 +1,144 @@ +import { Head, useForm } from '@inertiajs/react'; +import { Mails, Save } from 'lucide-react'; +import type { FormEvent } from 'react'; +import InputError from '@/components/input-error'; +import { PageHeading } from '@/components/page-heading'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; + +type Template = { subject: string; message: string }; +type Definition = Template & { label: string }; + +export default function NotificationTemplates({ + definitions, + templates, + variables, +}: { + definitions: Record; + templates: Record; + variables: string[]; +}) { + const form = useForm({ templates }); + + function submit(event: FormEvent) { + event.preventDefault(); + form.put('/administracao/notificacoes/modelos', { + preserveScroll: true, + }); + } + + function update(eventName: string, field: keyof Template, value: string) { + form.setData('templates', { + ...form.data.templates, + [eventName]: { ...form.data.templates[eventName], [field]: value }, + }); + } + + return ( + <> + +
+ +

+ Variáveis disponíveis:{' '} + {variables.map((variable) => ( + + {variable} + + ))} +

+
+
+ {Object.entries(definitions).map( + ([eventName, definition]) => ( + + + + + {definition.label} + + + + + +