# Permisos, roles y auditoría de acceso

> **Estado:** ✅ **CÓDIGO COMPLETO Y COMMITEADO** (2026-07-28, commits `6f56fc7` → `aa6579f`).
> ✅ **YA EJECUTADO EN PRODUCCIÓN** (verificado el 2026-09-18 contra un dump real: 54 permisos, todos
> con `description`, 7 roles incluido `superadmin`, y `model_has_permissions` con la fila de
> `machinery@group-itg.com` → `receive_ticket_admin_emails`) — ver
> [Pendientes](#-pendientes--comandos-a-correr).
> ✅ **SIN PENDIENTES** — las 4 acciones manuales están hechas, incluida la decisión sobre los 3
> usuarios `admin` (se quedan como están) — ver [Acciones manuales obligatorias](#-acciones-manuales-obligatorias).

Sesión de trabajo sobre el sistema de permisos: se pasó todo de *rol* a *permiso*, se cerró una auditoría
de control de acceso, se creó el rol `superadmin` y se construyó la pantalla de roles/permisos.

---

## 🧭 Índice

1. [Qué se hizo](#1-qué-se-hizo)
2. [Pendientes — comandos a correr](#-pendientes--comandos-a-correr)
3. [Despliegue en producción](#-despliegue-en-producción)
4. [Cola de correos (worker)](#-cola-de-correos-worker)
5. [Acciones manuales obligatorias](#-acciones-manuales-obligatorias)
6. [Checklist de pruebas](#-checklist-de-pruebas)
7. [Decisiones cerradas](#-decisiones-cerradas-no-re-preguntar)
8. [Notas técnicas clave](#-notas-técnicas-clave)
9. [Deuda técnica detectada y NO tocada](#-deuda-técnica-detectada-y-no-tocada)

---

## 1. Qué se hizo

### `6f56fc7` — Movimientos de tickets por permiso (carriles) en vez de por rol

**El problema:** `config/sale_ticket_stages.php` y `config/purchase_ticket_stages.php` estaban indexados por
**nombre de rol**, así que cada rol nuevo quedaba sin poder mover tickets hasta editar los dos archivos a mano
(fue exactamente lo que le pasó a `machinery_manager`). Y `getRoleNames()->first()` tomaba **un solo** rol,
descartando los demás.

- Las claves de nivel superior de los dos configs pasaron de rol a **permiso** ("carriles"):
  `move_{sale,purchase}_ticket_{full,validation,marketing,commercial}`. Los mapas de transiciones quedaron intactos.
- `App\Services\Tickets\StageTransitions::for($user, $config)` devuelve la **unión** de los carriles del usuario.
- `SaleTicketPolicy` y `PurchaseTicketPolicy`: `edit`/`archived`/`move` pasaron de 6 bloques duplicados a 1 línea cada uno.
- `PermissionSeeder`: 8 permisos de carril + `access_web_editor`. Los roles se resuelven con `Role::firstOrCreate`
  (⚠️ **arreglo de un bloqueador de deploy**: `deploy.sh` solo corre `PermissionSeeder`, y `machinery_manager`
  no existiría en prod → `syncPermissions()` sobre `null` → el deploy moría después de las migraciones).
- `User::canAccessPanel()` usa `can('access_web_editor')` en vez de `hasRole('admin')`; `isAdmin()` borrado (sin usos).
- Breadcrumbs de vistas compartidas staff/customer armados por permiso (el customer ya no queda sin migas).

**Resultado (en su momento):** `hasRole`/`hasAnyRole` quedó sin usos en `app/`, `routes/` ni `resources/`. Un
rol nuevo solo necesita permisos en el seeder — nunca tocar los configs de stages.
⚠️ **Reapareció después** en el módulo de backups del Editor Web — ver la sección de 2026-09-17 más abajo.

### 2026-09-17 — `manage_site_backups`: el módulo de Backup gateaba por `hasRole('admin')`

**El problema:** un superadmin en producción no veía "Site Backups" en el Editor Web. `SiteBackupPage::canAccess()`
(`app/Filament/Pages/SiteBackupPage.php`) y `SiteBackupDownloadController` comparaban `hasRole('admin')` en vez
de un permiso — el único sobreviviente de ese patrón en toda la app (violaba la regla de este documento sin que
nada lo marcara, porque `PermissionSeeder` nunca tuvo un permiso de backup). `superadmin` tiene el rol
`superadmin`, no `admin`, y la jerarquía de `config/roles.php` no hace que `hasRole()` "herede" por nombre — de
ahí que ni el usuario de mayor rango del sistema pudiera entrar.

- Permiso nuevo `manage_site_backups` en `PermissionSeeder`, asignado a `admin` (explícito) y `superadmin`
  (automático, vía el `syncPermissions(array_diff(...))` sobre todo el array). Deliberadamente NO se le dio a
  `machinery_manager` ni a ningún otro rol: restaurar un backup **sobreescribe la base de datos y los archivos en
  vivo**, así que se dejó tan restringido como estaba (antes solo `admin` podía).
- `SiteBackupPage::canAccess()` y `SiteBackupDownloadController` (ruta `admin.site-backup.download`) pasaron a
  `->can('manage_site_backups')`.
- `config/permission_groups.php`: agregado a `Users & access` y a `sensitive` (restaura y overwritea en vivo).
- Tras el deploy: `permission:cache-reset` + reiniciar el worker, como cualquier cambio de permisos.

### 2026-09-30 — Quedaba un `hasRole('admin')` en la vista de backups: el botón Restore no salía a superadmin

**El problema:** la sección de 2026-09-17 decía que `SiteBackupPage::canAccess()` y `SiteBackupDownloadController`
eran "el único sobreviviente" del patrón, pero la vista `resources/views/filament/pages/site-backup.blade.php`
seguía con `$isAdmin = auth()->user()?->hasRole('admin')` y mostraba **Restore** solo con
`@if ($isAdmin && $backup['file_exists'])`. Un `superadmin` veía la página, y Create/Download/Delete, pero no
Restore. Era un error de código, no de permisos del usuario.

- La vista ahora usa `$canRestore = auth()->user()?->can('manage_site_backups')` (lo tienen `admin` y
  `superadmin`, así que `admin` no pierde nada). Restore sigue exigiendo que el archivo exista (`file_exists`).
- **El permiso se valida también en el servidor**: las acciones `createBackup`, `restoreBackup` y `deleteBackup`
  empiezan con `abort_unless(static::canAccess(), 403)`. Antes solo protegía el botón oculto, y una acción de
  Filament/Livewire se puede disparar por petición aunque el botón no se pinte.
- Auditoría completa: no queda ningún `hasRole`, `hasAnyRole`, `@role` ni middleware `role:` que controle acceso en
  `app/`, `routes/`, `resources/`, `config/`, `database/` ni `bootstrap/`. Lo que sí queda y es legítimo:
  `User::roleRank()` (jerarquía, `config/roles.php`), mostrar el nombre del rol en el perfil y la barra
  (`getRoleNames()->first()`, solo visual), el filtro por rol de `UserTable`, `assignRole('customer')` al
  registrarse, `$type_user === 'customer'` (tipo de formulario) y el alias `role` de `bootstrap/app.php`, sin usos.
- Sin cambios de `PermissionSeeder`: no hay permiso nuevo, por lo que no hace falta `permission:cache-reset`.

### `fef64e8` — Auditoría de control de acceso (IDOR / vistas vulnerables)

Los tickets ya estaban bien (las policies validan `user_id`). Lo que se corrigió:

- **Ficha técnica** (`SaleUsedMachineController::downloadTechSheet`): solo máquinas publicadas
  (`abort_unless($machine->is_published, 404)`).
- **Catálogo público** (`FrontendUsedMachineController::detail`): filtra `is_published` igual que `index()`.
  Antes se veía el detalle de una máquina en pipeline adivinando el id.
- **Modales de máquina** (`UsedMachineEditMediaForm`, `UsedMachineEditPromoFlyerForm`): un modal se abre por
  **evento del navegador con argumentos del cliente**, así que esconder el trigger en el blade no protegía nada.
  Ahora autorizan en `mount()` con el mismo gate de su página (`SaleTicketPolicy@edit`) + `abort_if` de ticket
  huérfano, y `$machineId` lleva `#[Locked]`.
- `#[Locked]` en los ids de `SaleTicketCommentList` y `PurchaseTicketCommentList`.

**Código muerto eliminado** (verificado: 0 referencias en vistas, JS y PHP):
- Rutas + controller + service de `update-stage` (sale y purchase). Era una vía paralela al kanban que escribía
  la etapa **sin** validar la policy `move`, o sea sin respetar los carriles. El kanban nunca la usó (mueve con
  su propio `$ticket->update()` y sí valida `move`). **Al borrarla, esa validación ya no tiene cómo saltearse.**
- Rutas `mark-as-completed`: apuntaban a métodos que **nunca existieron** ni en controller ni en service (daban
  500, no 404).
- Permisos `finalize_sale_ticket` y `finalize_purchase_ticket`: sin un solo uso en el código.

### `ed8813c` — Rol `superadmin` + cambio de rol desde la gestión de usuarios

- `superadmin` (rank 100 en `config/roles.php`) recibe **todos** los permisos con
  `syncPermissions(array_keys($permissions))` — sincronizado contra el array del propio seeder, así cualquier
  permiso nuevo le queda incluido automáticamente.
- Permiso `manage_user_roles`, **solo** superadmin: habilita el select de rol en `EditUser`.
- Las opciones salen de `User::assignableRoles()` (roles de rank **estrictamente menor**), así que nadie puede
  asignar su propio nivel ni superior → **`superadmin` no es asignable desde la interfaz** sin código especial.
- `update()` usa `syncRoles()`, que **reemplaza** el rol y nunca agrega un segundo (el sistema asume rol único).
- ⚠️ `type_user` es escribible desde el cliente aunque el select esté `disabled`, así que se repone al rol
  guardado cuando el actor no tiene el permiso (si no, podía forzar `type_user=customer` y disparar la creación
  de filas `Customer`/`Company` sobre un usuario de staff).
- `UserSeeder`: cuenta `lcastellanos+superadminitg@tacostech.com`.

### `70af1a2` — Descripción por permiso + correos internos por usuario

- Migración idempotente: columna `description` (nullable) en `permissions`. El nombre de la tabla se lee de
  `config/permission.php` en vez de hardcodearse.
- `PermissionSeeder`: el array `$permissions` pasó de lista a mapa **`nombre => descripción`** con las 51
  descripciones en inglés. `firstOrCreate` → `updateOrCreate`.
  > ⚠️ **El prune (`whereNotIn`) y el `syncPermissions` del superadmin usan `array_keys($permissions)`.**
  > Sin eso, el primero compara nombres contra descripciones y **borra los 51 permisos**. Hay una nota en el código.
- **Correos internos ahora por usuario:** `receive_ticket_admin_emails` salió del rol `machinery_manager` (con N
  usuarios en ese rol salían N correos, y sumar a alguien al rol lo suscribía sin querer). Ahora **no está en
  ningún rol**: se concede por usuario (`model_has_permissions`).
- `User::ticketAdminRecipients()` pasó de `->permission(...)` a `whereHas('permissions', ...)`, o sea **directos
  solamente**. Era obligatorio: ese scope también matchea permisos de rol y el **superadmin tiene los 51 por rol**,
  con lo cual habría recibido todos los correos internos.
- **Efecto extra:** al ser un permiso directo, ahora la **baja también funciona** (`revokePermissionTo`). Cuando
  venía del rol no había forma de quitárselo a una sola persona.

### La pantalla de roles es SOLO LECTURA (y por qué)

**Decisión cerrada: el `PermissionSeeder` es la única fuente de verdad del reparto de permisos.**
`deploy/deploy.sh` lo corre en **cada deploy**, y usa `syncPermissions()`, que **reemplaza** lo que tiene cada
rol (además de podar permisos huérfanos con el `whereNotIn`). Es decir: cualquier cambio hecho desde la UI
**se revertía en el siguiente deploy, en silencio**.

Se evaluó mover la fuente de verdad a la DB (seeder aditivo / bootstrap-only) y se **descartó**:

- La UI solo la puede usar el `superadmin`, que es la misma persona que corre los deploys → la UI no daba
  autonomía a nadie, solo velocidad, a cambio de perder git, revisión y **paridad entre entornos**.
- Los permisos son **contratos con el código**: quitar `view_purchase_tickets` a un rol de un click rompe la
  app para esos usuarios sin que nadie revise el cambio.
- Con el reparto en DB, local / staging / prod divergen en semanas y aparecen los bugs de *"en prod no me
  deja"* imposibles de reproducir.
- Son 7 roles estables, no un SaaS donde cada cliente arma los suyos: cambian cuando cambia el producto,
  o sea cuando ya hay un deploy de por medio.

**Lo que se hizo, entonces:**

1. **`RolePermissionsForm` → `RolePermissionsList`** (renombrado; el blade también). Cero superficie de
   escritura: no quedan `save()`, `toggleGroup()`, `resetChanges()`, `syncPermissions()`, `activity()`, ni
   un solo `wire:model`/`wire:click`/`wire:submit`. Los switches son ahora **iconos** (✓ concedido /
   ✗ no concedido) y las que el rol **no** tiene se muestran atenuadas — el punto es comparar.
2. **Banner permanente en la pantalla** explicando que el reparto vive en el seeder y que el deploy lo
   reaplica. La pantalla ahora dice la verdad sobre sí misma.
3. **`superadmin` ya es visible.** El `abort_if` existía porque *editarlo* podía dejar sin acceso a la única
   cuenta que llega a la pantalla; sin edición ese riesgo desaparece. Se ve que tiene 50 de 53 permisos y
   le faltan exactamente las 3 suscripciones de notificación (útil como verificación visual).
4. **Ruta y controller:** `editPermissions()` → `showPermissions()`. Botón de la tabla: *"Edit permissions"*
   → *"View permissions"* (ícono de ojo). No hay ni un POST en `/roles`.

### Diff de permisos en el deploy — `permissions:snapshot` + `permissions:diff`

El defecto real no era que el deploy revirtiera, sino que **revertía sin avisar**. Ahora `deploy.sh` toma una
foto antes del seeder y muestra el diff después:

```bash
php artisan permissions:snapshot          # antes del seeder → storage/app/permissions-snapshot.json
php artisan db:seed --class=PermissionSeeder --force
php artisan permissions:diff              # después → imprime +/- por rol y borra la foto
```

Salida real de una simulación (se le quitó un permiso a `validator` y se le agregó uno de más a `marketing`
a mano, y el seeder los revirtió):

```
  marketing (24 → 23)
    - access_web_editor
  validator (26 → 27)
    + receive_ticket_intake_notifications

2 role(s) changed: 1 permission(s) granted, 1 revoked.
Revoked permissions were either changed in the seeder or reverted from a manual tweak.
```

- **Snapshot real antes/después, no comparación contra el código**: reporta lo que *pasó*, no lo que el
  seeder *pretendía*. Además detecta roles creados o borrados.
- **Nunca corta el deploy** (exit 0 aunque haya cambios): que un release agregue permisos es lo normal.
  Existe `--fail-on-change` si algún día se quiere bloquear. El `snapshot` fallido tampoco corta: el seeder
  corre igual y el diff avisa que no había con qué comparar.
- Loguea a `Log::info` solo cuando hubo cambios, con el detalle por rol.
- Opciones: `--path`, `--keep` (no borrar la foto), `--fail-on-change`.

⚠️ **La foto vive en `storage/app/`.** Si el deploy usara releases con `storage` NO compartido, el snapshot
se perdería entre pasos y el diff saldría vacío (avisa, no falla). En el `deploy.sh` actual, que trabaja
sobre el mismo directorio, no aplica.

---

### `aa6579f` — Pantalla de roles y checklist de permisos (solo superadmin)

> ⚠️ **Parcialmente superado:** esta pantalla ya **no edita** permisos, es solo lectura. Ver la sección
> *"La pantalla de roles es SOLO LECTURA"* más arriba. Lo de abajo describe cómo se construyó originalmente.

- `config/permission_groups.php`: agrupa los 51 permisos en 7 secciones y marca `sensitive`, `essential` y
  `per_user`. **Solo organiza la UI, no concede nada.** Un permiso que no esté en ningún grupo cae en
  *"Not grouped yet"* para que nunca desaparezca de la pantalla.
- `RoleController` + rutas `roles.index` y `roles.permissions` + ítem en el sidebar.
- Livewire `RolePermissionsForm`: checklist agrupado, select/clear por grupo, descarte de cambios y guardado con
  `syncPermissions`. **Registra en activitylog** quién cambió qué permisos, en qué rol y a cuántos usuarios afectó.
- Vistas Vuexy: tabla de roles (rol, rank, usuarios, permisos) y checklist con switches, barra de acción sticky
  con el diff en vivo, alerta con la cantidad de usuarios afectados y la **descripción** de cada permiso.

**Protecciones de la pantalla:**
- **El rol `superadmin` NO es editable**: sin botón en la tabla, y `abort_if` en el controller, en `mount()` y en
  `save()`. Como es el único rol con `manage_user_roles` y no se puede tocar, **la pantalla no puede quitarse su
  propio acceso**.
- `receive_ticket_admin_emails` queda **fuera** del checklist de roles (grupo `per_user`): se concede por usuario,
  así que tildarlo para un rol no notificaría a nadie y el checkbox mentiría. Se asigna en **Editar usuario →
  Permisos especiales** (ver abajo).
- `save()` filtra el payload con `array_intersect` contra el catálogo real menos los `per_user`.
- `forgetCachedPermissions()` explícito al guardar.

### Permisos especiales por usuario (Editar usuario)

Los permisos del grupo `per_user` de `config/permission_groups.php` **no van en ningún rol** y se asignan uno a uno
desde la sección **"Special permissions"** del modal `EditUser`. Hoy solo está `receive_ticket_admin_emails`.

- **Solo superadmin.** Gateado por `manage_user_roles` (igual que el cambio de rol y la pantalla de roles): sin ese
  permiso la sección **no se renderiza** (`render()` pasa una colección vacía) y `update()` **pisa la propiedad**
  con lo que ya estaba guardado, porque `$specialPermissions` es escribible desde el cliente. Encima sigue
  aplicando la jerarquía de `mount()` (`canManageUser`).
- **Se conceden como permisos DIRECTOS** (`givePermissionTo` / `revokePermissionTo`), uno por uno.
  ⚠️ **A propósito NO se usa `$user->syncPermissions()`**: ese método reemplaza *todos* los permisos directos del
  usuario y borraría cualquiera concedido fuera de esta lista (por tinker o por una pantalla futura).
- Solo se muestran los `per_user` que **existen en la tabla** `permissions`: `givePermissionTo` lanza
  `PermissionDoesNotExist` con un nombre desconocido, y así la sección queda vacía (no rota) si `PermissionSeeder`
  no ha corrido.
- Los switches reflejan **solo permisos directos**: uno que venga de un rol no se muestra tildado, porque esta
  pantalla no podría revocarlo (Spatie no sabe denegar).
- `save()` filtra el payload contra los ofrecidos, hace `forgetCachedPermissions()` y **registra en activitylog**
  (`user-special-permissions`) quién suscribió/dio de baja a quién.
- Cada permiso muestra su **descripción** (columna `description`, en inglés como en la pantalla de roles) más una
  **nota de advertencia** propia, tomada de `permission_groups.per_user_notes`.
- ⚠️ **El superadmin no puede editarse a sí mismo** (`canManageUser` es rank *estrictamente* mayor), así que no
  puede suscribirse solo a los correos internos — hace falta otro superadmin o `givePermissionTo` por tinker.
- Tras cambiar la suscripción hay que **reiniciar el worker** (`queue:restart`): los correos internos van encolados
  y el worker mantiene su propia copia del mapa de permisos.
- `#[Locked]` en `$role` y `$original`; `toggleGroup` recibe el **índice** del grupo y no su nombre (los nombres
  tienen `&` y paréntesis, que se rompen en un `wire:click`).

**i18n:** las 41 keys van **solo en `en.json`** a propósito. La pantalla es solo para superadmin y queda en
inglés; los otros 5 idiomas caen al fallback, que es el propio texto. Las descripciones de permisos, también en inglés.

---

## Notificaciones de campanita por permiso (no por rol)

**El problema:** las notificaciones in-app se enviaban con `User::role(['validator','admin'])` — listas de roles
escritas a mano en 5 sitios, con el helper `notifyRoles()` duplicado en 3 archivos. Consecuencias:
`machinery_manager` **nunca recibió una sola notificación** (se creó el rol después y nadie tocó esas listas), y
`superadmin` tampoco (su rol no es `admin`). Eran el mismo problema que los `hasRole()` ya eliminados, pero con
otra API de Spatie, así que la limpieza anterior no los cazó.

**Los 3 permisos nuevos** (`config/permission_groups.php` → `notification_subscriptions`, grupo de UI
*"Bell notifications"*, editables en `/roles` como cualquier otro):

| Permiso | Avisa de | Roles |
|---|---|---|
| `receive_ticket_intake_notifications` | Ticket nuevo, ticket corregido por el cliente | `validator`, `machinery_manager` |
| `receive_ticket_pipeline_notifications` | Ticket aprobado (pasa a MKT / comercial) | `marketing`, `commercial`, `machinery_manager` |
| `receive_used_machine_request_notifications` | Solicitud de máquina usada del catálogo | `marketing`, `commercial`, `machinery_manager` |

**Son SUSCRIPCIONES, no capacidades:** deciden a quién se avisa, nunca qué puede hacer alguien.

- **Por rol** (a diferencia de `receive_ticket_admin_emails`, que es por usuario). La campanita es in-app y
  barata de ignorar, así que "el rol que trabaja este carril se entera de este carril" es el default correcto.
  El correo va al inbox y por eso sigue siendo opt-in por persona.
- **Gruesos por público, no espejo del carril.** Uno por carril serían 8 (4 × sale/purchase) y habría dos
  registros que mantener sincronizados. No se separa sale/purchase porque hoy los mismos roles trabajan ambos.
  Los carriles del kanban se usaron solo como **guía del default del seeder**, no como fuente de verdad en runtime.
- **`admin` NO los tiene** (decisión explícita): es rol de supervisión y recibiría un aviso por cada evento del
  sistema. `machinery_manager` es el dueño operativo y se los queda. Un admin concreto que los quiera se los
  puede conceder directo (`givePermissionTo`).
- **`superadmin` tampoco**, y esto se logra **restándolos en el seeder**:
  ```php
  $superadmin->syncPermissions(array_diff(array_keys($permissions), config('permission_groups.notification_subscriptions')));
  ```
  Quitarle una suscripción no le quita poder (no es una capacidad), y hacerlo **ahí** es lo que permite que el
  código de envío no tenga ni un `hasRole()`. Sigue teniendo los otros 51 permisos.

**`App\Services\Notifications\NotifiesTicketStaff`** (trait) reemplaza los 3 `notifyRoles()` duplicados:

- `notifySubscribers(string $permission, Notification $n)` → `User::permission($permission)`.
- ⚠️ **Usa el scope `permission()`, que matchea vía-rol Y directo. Es lo correcto acá y es lo OPUESTO a
  `User::ticketAdminRecipients()`**, que necesita `whereHas('permissions')` (solo directos) para que ningún rol
  suscriba a nadie por correo. Son dos criterios contrarios a tres archivos de distancia: **no "alinearlos".**
- **Excluye al actor** (`whereKeyNot(auth()->id())`): antes, quien aprobaba un ticket se notificaba a sí mismo.
  En el form público de catálogo no hay actor, así que no filtra nada.

**Sitios migrados (5):** `SaleTicketService` (×3), `PurchaseTicketService` (×3),
`SaleTicketCustomerController`, `PurchaseTicketCustomerController`, `ContactUsedMachinePost`.
No queda ningún `User::role(` en `app/`.

### Escalabilidad de la campanita

- El dropdown ya trae **solo 7** (`NotificationBell::$limit` + `latest()->limit()`), y `unreadCount` es un
  `COUNT()` en la DB → **no crashea** con miles de notificaciones. No hay "ver más": las viejas son inaccesibles.
- **Corregido:** `markAllAsRead()` hacía `Auth::user()->unreadNotifications->markAsRead()` (sin paréntesis) →
  hidrataba **toda** la colección y hacía **un UPDATE por fila**. Con `machinery_manager` recibiendo todo eso
  eran cientos de queries en un request. Ahora es `unreadNotifications()->update(['read_at' => now()])`: 1 query.
### Purga automática — `notifications:prune`

`App\Console\Commands\PruneNotifications`, **agendado a diario a las 03:10** en `routes/console.php` (con
`withoutOverlapping()`, y fuera de la ventana del `backup:run` de las 02:00). Retención en
**`config/notifications.php`**, con overrides por `.env`:

| Config | Default | Env |
|---|---|---|
| `prune.read_after_days` | 60 (2 meses) | `NOTIFICATIONS_PRUNE_READ_AFTER_DAYS` |
| `prune.unread_after_days` | 180 (6 meses), `0` = nunca | `NOTIFICATIONS_PRUNE_UNREAD_AFTER_DAYS` |
| `prune.chunk` | 1000 | `NOTIFICATIONS_PRUNE_CHUNK` |

- **Leídas se miden desde `read_at`**, no desde `created_at`: una que leíste ayer sobrevive aunque el evento
  sea viejo. **No leídas se miden desde `created_at`** (no hay fecha de lectura) y tienen más margen, porque
  borrar algo que el usuario nunca vio es perder información.
- **Borra por chunks.** Selecciona los ids y borra esos, en vez de `->limit()->delete()`: eso genera
  `DELETE ... LIMIT`, que MySQL acepta pero Postgres y SQLite no. Un DELETE masivo dejaría un lock largo sobre
  una tabla que la campanita lee en cada carga de página.
- Opciones: `--dry-run` (cuenta sin borrar), `--read-days`, `--unread-days`, `--chunk`. Devuelve exit 1 si
  `--read-days` o `--chunk` son < 1. Solo escribe en el log cuando borró algo, para que la corrida diaria no
  ensucie los logs.
- Output en inglés a propósito (herramienta de operaciones, como los logs), no pasa por `__()`.

**Verificado en local:** dry-run detecta sin borrar; con 6 filas viejas de prueba (3 leídas + 3 no leídas) y
`--chunk=2` (para forzar varias vueltas del bucle) borró exactamente esas 6 y dejó las 57 reales intactas.

ℹ️ **Depende del scheduler, y está CONFIRMADO en producción (2026-09-17): `schedule:run` corre cada minuto.**
Con eso las cuatro tareas agendadas en `routes/console.php` se ejecutan de verdad:

```
 0  2 26 * *   backup:run              ← respaldo mensual
 0  3  *  * *   sitemap:generate
10  3  *  * *   notifications:prune
20  3  *  * *   slugs:prune-history
```

Si alguna vez hay dudas, se comprueba en el servidor con `sudo crontab -u www-data -l | grep schedule`
(debe haber una línea `* * * * * … php artisan schedule:run`) y, del lado de Laravel, con
`php artisan schedule:list`. Señal indirecta: si los sitemaps de `public/sitemaps/` tienen la fecha del
último deploy y no la de las 03:00 de hoy, el cron no está corriendo.

---

## ⏳ Pendientes — comandos a correr — ✅ YA CORRIDO EN PRODUCCIÓN (verificado 2026-09-18)

> Esta sección quedó como bitácora de lo que había que correr en su momento (2026-07-28). Confirmado
> contra un dump real de producción (`itg_production_2026`): los 6 pasos de abajo ya corrieron —
> 54 permisos (todos con `description`), 7 roles, y `model_has_permissions` con exactamente la fila
> esperada (`machinery@group-itg.com` → `receive_ticket_admin_emails`, permiso id 67, usuario id 49).
> No hace falta re-correr nada de esto.

### LOCAL (Sail) — en este orden

```bash
# 1. Migración: columna `description` en permissions
./vendor/bin/sail artisan migrate

# 2. Rol superadmin  (PermissionSeeder también lo crea por su firstOrCreate, pero este es el canónico)
./vendor/bin/sail artisan db:seed --class=RoleSeeder

# 3. Permisos: crea los 51 + descripciones, sincroniza los 7 roles, poda los que ya no están
./vendor/bin/sail artisan db:seed --class=PermissionSeeder

# 4. Cuentas machinery@ y superadmin + el permiso DIRECTO de correos a machinery@
./vendor/bin/sail artisan db:seed --class=UserSeeder

# 5. Caches: se borraron 4 rutas y hay blades nuevas
./vendor/bin/sail artisan route:clear
./vendor/bin/sail artisan view:clear
./vendor/bin/sail artisan config:clear      # cambiaron las claves de los configs de stages

# 6. Worker (los correos se encolan; sin esto no llegan a Mailpit)
./vendor/bin/sail artisan queue:work --tries=3
```

**Verificación rápida de que quedó bien:**
```bash
# permisos sin descripción → debe salir vacío
./vendor/bin/sail artisan tinker --execute="dump(Spatie\Permission\Models\Permission::whereNull('description')->pluck('name'));"

# destinatarios de correos internos → debe salir SOLO machinery@group-itg.com
./vendor/bin/sail artisan tinker --execute="dump(App\Models\User::ticketAdminRecipients()->pluck('email'));"
```

> 📌 **Estado de la DB local al 2026-07-28** (medido antes de correr nada): 43 permisos, 6 roles,
> `model_has_permissions` con **0 filas**. Después de los seeders deberían quedar **51 permisos, 7 roles y 1 fila**
> en `model_has_permissions` (la de machinery@).

### ⚠️ Sobre el prune del `PermissionSeeder`

`Permission::whereNotIn('name', array_keys($permissions))->delete()` **borra de la DB** todo permiso que no esté
en el array, y el FK cascade lo desasigna de roles y usuarios. Al correrlo se van a borrar
`finalize_sale_ticket` y `finalize_purchase_ticket`. **Es lo buscado**, pero es irreversible sin volver a crearlos.

---

## 🚀 Despliegue en producción

`deploy/deploy.sh` ya hace, en este orden: pull → `composer install --no-dev` → `npm ci && npm run build` →
`migrate --force` → `db:seed --class=PermissionSeeder --force` → `cache/config/route/view clear` + `cache` →
**`queue:restart`** → blindaje de permisos de archivos.

### Lo que el deploy cubre solo
- ✅ La migración de `description`.
- ✅ Los 51 permisos + descripciones + la sincronización de los 7 roles.
- ✅ El rol `superadmin` y `machinery_manager` (por el `Role::firstOrCreate` del `PermissionSeeder` — este fue
  justamente el bloqueador que se arregló; sin él el deploy moría).
- ✅ Los caches y el reinicio de los workers.

### ⚠️ Lo que el deploy NO hace y hay que hacer a mano en prod

1. **`RoleSeeder` y `UserSeeder` no se corren.** Y **NO conviene correr `UserSeeder` en prod**: hace
   `firstOrCreate` de `admin@example.com` con password `password` → crearía un admin con contraseña conocida.
2. **Crear la cuenta superadmin** a mano (o con un comando puntual), con password real.
3. **Crear/verificar la cuenta `machinery@group-itg.com`** y, sobre todo:
4. 🔴 **Asignarle el permiso directo de correos.** Como `receive_ticket_admin_emails` ya **no está en ningún
   rol**, si nadie lo tiene asignado **NADIE recibe los correos internos** (solo llegaría la copia de archivo en
   inglés a devs). En prod:
   ```bash
   php artisan tinker
   >>> App\Models\User::where('email','machinery@group-itg.com')->first()->givePermissionTo('receive_ticket_admin_emails');
   ```
5. **Verificar qué usuarios tienen rol `admin`** y decidir si migran a `machinery_manager` (admin sin Filament ni
   sandbox). Con tinker: `$u->syncRoles(['machinery_manager'])` (⚠️ `syncRoles`, **no** `assignRole`: reemplaza en
   vez de sumar, y el sistema asume rol único). Después: `php artisan permission:cache-reset` y `queue:restart`.
6. ⚠️ **Consecuencia de la jerarquía estricta:** un `admin` (rank 90) **no puede editar a otro admin**. Hasta que
   exista un usuario `superadmin` en prod, nadie puede gestionar cuentas admin desde el panel.
7. Detalle menor: `CLAUDE_DOCS` en `deploy.sh:94` no incluye los docs `I18N-*` ni este archivo, así que quedan en
   el servidor. Si molesta, agregarlos a esa variable.

### 🔔 Notificaciones por permiso — qué correr en prod

El `PermissionSeeder` del deploy crea los 3 permisos nuevos y los reparte solo, **pero** hay que refrescar el
cache y el worker (mantiene su propia copia del mapa de permisos en memoria):

```bash
php artisan db:seed --class=PermissionSeeder --force
php artisan permission:cache-reset
php artisan queue:restart
```

Verificación rápida de que quedó bien (esperado: `admin` y `superadmin` sin ninguna):

```bash
php artisan tinker
>>> $n = config('permission_groups.notification_subscriptions');
>>> Spatie\Permission\Models\Role::with('permissions')->get()->mapWithKeys(fn($r) => [$r->name => $r->permissions->pluck('name')->intersect($n)->values()->all()]);
```

⚠️ Si en prod hay usuarios con rol `admin` que **sí** deben recibir campanita, hay dos caminos: pasarlos a
`machinery_manager` (punto 5 de arriba) o concederles el permiso directo con `givePermissionTo`.

---

## 📬 Cola de correos (worker)

- `QUEUE_CONNECTION=database`. Los correos de `app/Services/EmailTicket/**` usan `Mail::to(...)->queue(...)`,
  así que **se encolan**: sin worker se quedan en la tabla `jobs` y **nunca llegan**.
  - Local: `./vendor/bin/sail artisan queue:work --tries=3` y Mailpit en http://localhost:8025
  - Prod: systemd (`laravel-worker.service`), y `deploy.sh` hace `queue:restart`.
- 🔴 **Tras cambiar permisos hay que reiniciar el worker.** Un worker de larga duración mantiene **su propia copia
  en memoria** del mapa rol↔permiso de Spatie. `forgetCachedPermissions()` limpia el store y el proceso actual,
  **no** los otros procesos. Si editás permisos en la pantalla nueva y hay un `queue:work` levantado, ese worker
  puede seguir con el mapa viejo.
  - Local: matar y relanzar el `queue:work`.
  - Prod: `php artisan queue:restart` (el deploy ya lo hace).
- Este fue el origen del bug **`[Undisclosed recipients]`**: caché stale de Spatie dejaba
  `ticketAdminRecipients()` vacío. Por eso `PermissionSeeder` termina con `forgetCachedPermissions()`.
- Las **notificaciones campanita** (`app/Notifications/**`) NO implementan `ShouldQueue` → son síncronas y no
  dependen del worker.

---

## 🔴 Acciones manuales obligatorias

- [x] **Cambiar la password de `machinery@group-itg.com`** — hecho en producción el 2026-09-08.
- [x] **Cambiar la password de `lcastellanos+superadminitg@tacostech.com`** — hecho en producción el
      2026-09-08.
- [x] **Asignar `receive_ticket_admin_emails`** — verificado el 2026-09-18: `machinery@group-itg.com`
      es la única fila de `model_has_permissions` en producción, con exactamente ese permiso.
- [x] **Verificar en PRODUCCIÓN los usuarios con rol `admin`** — decisión del usuario (2026-09-18):
      los 3 se quedan como `admin`, ninguno migra a `machinery_manager`:
      `lcastellanos+adminitg@tacostech.com` (id 11), `admin@groupitg.com` (id 12),
      `m.jimenez@group-itg.com` (id 47).

---

## ✅ Checklist de pruebas

**Permisos y roles**
- [ ] `admin`: entra a Filament, mueve todo el kanban, ve el breadcrumb completo.
- [ ] `machinery_manager`: **NO** entra a Filament, sí mueve todo el kanban.
- [ ] `validator` / `marketing` / `commercial`: idénticos a antes (un solo carril = el mismo mapa que tenían).
- [ ] `customer`: breadcrumb nuevo en el detalle de purchase y en el de máquina usada.
- [ ] Login por rol cae en su dashboard (admin/machinery_manager→admin, staff→staff, customer→customer,
      rol sin permiso→default).

**Pantalla de roles** (`/roles`, como superadmin)
- [ ] Se ve el ítem **Roles and permissions** en el sidebar.
- [ ] El rol `superadmin` aparece **sin botón** de editar; entrar a `/roles/{id}/permissions` de ese rol da 403.
- [ ] Editar `commercial`, guardar, y verificar con un usuario commercial que el cambio aplicó.
- [ ] `receive_ticket_admin_emails` **no aparece** en el checklist.
- [ ] Queda registro en activitylog (log_name `role-permissions`).

**Cambio de rol** (`EditUser`)
- [ ] Como superadmin: el select de rol está **habilitado**, con todos los roles **menos** superadmin.
- [ ] Como admin: el select está **disabled** y no puede editar a otro admin.

**Seguridad (auditoría)**
- [ ] `/download-tech-sheet/1` a pelo → **403** (falta firma).
- [ ] Ficha técnica desde el form del catálogo → descarga OK. Recargar esa URL firmada → sigue funcionando.
- [ ] Detalle público de una máquina **no publicada** → 404 (web y ficha).
- [ ] Productos y maquinaria nueva → su catálogo baja como siempre (no pasan por la ruta firmada).
- [ ] Staff: los dos modales de máquina (imágenes, video, imagen principal, flyer) funcionan igual que antes.
- [ ] Customer: su tab "Media" del form de edición sigue intacto.

**Correos**
- [ ] Con el worker corriendo: mover un ticket → llega **una sola** copia interna (a machinery@) + la de archivo
      en inglés a devs.
- [ ] Los correos de cliente llegan en el idioma del perfil.

---

## 🔒 Decisiones cerradas (no re-preguntar)

- **Todo se gatea por PERMISO, nunca por rol.** No queda ningún `hasRole` en `app/`, `routes/` ni `resources/`.
  La única excepción legítima es `config/roles.php → hierarchy`: la jerarquía **es** un concepto de rol.
- **`superadmin`**: todos los permisos vía `syncPermissions(array_keys($permissions))`. No se puede crear desde la
  interfaz (`assignableRoles()` solo devuelve rank estrictamente menor) y **no** hace falta que exista uno solo:
  basta con que no se pueda crear desde la UI.
- **La pantalla de roles NO crea ni borra roles ni permisos.** Un permiso es un contrato con el código: crear
  `super_poder` desde una UI no hace nada porque ningún `can()` lo consulta. El catálogo lo define el seeder.
- **`PermissionSeeder` es la fuente de verdad y el punto de restauración.** Correrlo revierte cualquier ajuste
  hecho a mano en los permisos **de un rol** (no toca los permisos directos de usuarios).
- **Correos internos por usuario**, nunca por rol.
- **La ficha técnica exige pasar por el form** (URL firmada), pero **sin vencimiento**: el cliente puede recargar
  el PDF cuando quiera.
- **La pantalla de roles y las descripciones de permisos quedan en inglés** (es solo para superadmin).
- **`edit_profile`** lo tienen los 6 roles; quitarlo deja a esos usuarios sin poder editar su perfil.

---

## 🧠 Notas técnicas clave

### Spatie no puede "denegar" un permiso a un usuario

`HasPermissions::hasPermissionTo()` es `hasDirectPermission($p) || hasPermissionViaRole($p)` — un **OR evaluado
en cada chequeo**. Asignar un rol **no copia** sus permisos al usuario: escribe un registro en `model_has_roles`
y los permisos se **derivan por join** contra `role_has_permissions`, que es un pivote **compartido por todos**
los usuarios de ese rol.

Consecuencias:
- `$user->syncPermissions([...])` vacía **solo** `model_has_permissions` → un permiso que venga del rol **sigue
  concedido**. **No existe una resta por usuario.**
- **Sumar** un permiso a un usuario puntual → ✅ nativo (`givePermissionTo`). **Restar** uno que da el rol → ❌ imposible.
- Los permisos **directos sobreviven un `db:seed`** (el seeder solo sobrescribe los de cada rol). Solo se pierden
  si el permiso sale del array `$permissions` y el prune borra la fila (FK cascade).
- **No hace falta limpiar caché** al cambiar permisos directos de un usuario: `revokePermissionTo()` llama a
  `forgetCachedPermissions()` **solo si el modelo es un Role**. La caché guarda el tramo roles↔permisos.

### Las 5 tablas
```
permissions            ← catálogo (name, description)
roles                  ← catálogo
role_has_permissions   ← rol ↔ permiso      (COMPARTIDO por todos los del rol)
model_has_roles        ← usuario ↔ rol
model_has_permissions  ← usuario ↔ permiso  (los DIRECTOS)
```

### ⚠️ Si algún día se intenta "materializar" permisos por usuario (quitar el rol y dar todo directo)
Se rompe: `UserTable.php:70` y `user-detail.blade.php:40,63` hacen `roles->first()->name` **sin null-safe**
→ fatal, la tabla de usuarios deja de cargar. `EditUser` no puede guardar (regla `required` +
`Rule::in(assignableRoles())` con rol vacío). Y lo más grave: `User::roleRank()` devuelve **0** →
**regresión de seguridad**, un ex-admin materializado pasa a ser editable por cualquiera con `edit_user`.

**Alternativa si vuelve a hacer falta:** columna JSON `denied_permissions` en `users` + hook
`Gate::before(fn ($user, $ability) => in_array($ability, $user->denied_permissions ?? []) ? false : null)` en
`AppServiceProvider`. Mantiene rol y jerarquía intactos.
**Agujero conocido:** el scope `User::permission('x')` es SQL y **no pasa por el Gate** —
`User::ticketAdminRecipients()` lo usaba; si alguien vuelve a usar ese scope, el deny no aplica ahí.

### Carriles de tickets
Los mapas de `config/{sale,purchase}_ticket_stages.php` están indexados por **permiso**. `StageTransitions::for()`
devuelve la **unión** de los carriles del usuario. Las etapas **de origen** de un carril definen además qué
tickets puede abrir/editar y archivar (las policies `edit`/`archived` chequean que la etapa actual sea una clave
del mapa). **Un rol nuevo solo necesita el permiso, nunca tocar los configs.**

### Permisos que cambian COMPORTAMIENTO, no solo visibilidad
Marcados como `sensitive` en `config/permission_groups.php`:
- `edit_sale_used_machine` → `SaleTicket::isEditableBy()` decide staff-vs-customer **por este permiso**. Dárselo a
  un customer lo convierte en "staff" para la editabilidad. **La trampa más grande.**
- `sale_used_machine_view` y `view_purchase_ticket_info` → alimentan el flag `$isStaff` de las vistas de detalle.
- `view_dashboard_admin` / `view_staff_dashboard` / `view_customer_dashboard` → `dashboardRoute()` es
  permission-driven y por prioridad: **cambian dónde cae el usuario al loguearse**.
- `move_*` → mover tarjetas + abrir/editar/archivar tickets en esas etapas.
- `access_web_editor` → entra al panel Filament. `access_sandbox` → preview de contenido web.

---

## 🧹 Deuda técnica detectada y NO tocada

1. **PDFs de catálogo servidos por Nginx.** Los catálogos de productos y maquinaria nueva viven en
   `public_path()`, así que quien sepa la ruta del archivo (`/pdf/x.pdf`) se lo baja **sin pasar por el form ni
   dejar lead** — Laravel no se entera. Cerrarlo implica moverlos fuera de `public/` y servirlos por un controller.
   (La ficha técnica de máquinas usadas **sí** quedó cerrada, porque se genera al vuelo.)
2. **`roles->first()->name` sin null-safe** en `app/Livewire/Tables/UserTable.php:70` y
   `resources/views/users/user-detail.blade.php:40,63` → **fatal** con un usuario sin rol. Fix de 3 caracteres (`?->`).
3. **`=== 'customer'`** en los forms de perfil y usuarios (`update-profile-information-form.blade.php:46,71,91,189`,
   `EditUser.php`, `CreateUser.php`) usa el rol como proxy de "tiene ficha `Customer`". Lo correcto es preguntar
   por el dato: `$user->customer !== null`.
4. **`update-status` / `update-channels`** siguen siendo endpoints HTTP vivos (usados por los forms de
   `admin/edit.blade.php`). No se auditó si sus transiciones de *status* necesitan una policy como la de *stage*.
5. **Tabla de permisos editable como fuente de verdad** (tarea pospuesta): hoy la pantalla edita permisos **de
   roles** y el seeder sigue siendo la fuente de verdad. Si algún día la UI debe mandar, hay que resolver que el
   prune y los `syncPermissions` del seeder no pisen los cambios manuales (idea: flag
   `permissions_source = seeder|database` en `config/roles.php`).
6. **`User::isAdmin()`** se borró por no tener usos; si aparece código viejo que lo llame, migrarlo a un permiso.
