<p align="center">
  <img src="docs/assets/tresipunt_logo.svg" alt="" width="280">
</p>

<h1 align="center">Tresipunt Manager</h1>

<p align="center">
  <img src="https://img.shields.io/badge/version-2.0.0-informational" alt="Version">
  <img src="https://img.shields.io/badge/Laravel-12-FF2D20?logo=laravel&logoColor=white" alt="Laravel">
  <img src="https://img.shields.io/badge/Livewire-3-4E56A6?logo=livewire&logoColor=white" alt="Livewire">
  <img src="https://img.shields.io/badge/PHP-8.4%2B-777BB4?logo=php&logoColor=white" alt="PHP">
  <img src="https://img.shields.io/badge/tests-1008%20passing-success" alt="Tests">
  <img src="https://img.shields.io/badge/License-Proprietary-lightgrey" alt="License">
  <a href="https://tresipunt.com"><img src="https://img.shields.io/badge/made%20by-Tresipunt-F84015" alt="Made by Tresipunt"></a>
</p>

<p align="center"><b>El hub de control de todos los productos Tresipunt que corren en un sitio de cliente.</b></p>

<p align="center"><a href="README.md">🇬🇧 English</a> · <b>🇪🇸 Español</b> · <a href="README.ca.md">Català</a></p>

El Manager emite licencias, sirve contenido versionado y recoge telemetría de los plugins
Tresipunt instalados en las plataformas de los clientes. Cada sitio habla con un **único
endpoint** de API usando su propio token de licencia; el panel web es donde el equipo
gestiona clientes, entornos, productos y contenido, y donde mira qué está haciendo el parque.

**Qué no es:** no es un plugin de Moodle y no corre dentro del Moodle de nadie. La parte
cliente vive en `local_tresipunt`, que es otro repositorio. El Manager **nunca envía nada a
un sitio por su cuenta**: el sitio pregunta y el Manager responde.

---

## ✨ Qué hace

- **Licencias** — un token por licencia (`3IP-XXXX-XXXX-XXXX-XXXX`), de un cliente, con sus
  productos, sus fechas de vigencia y sus límites de peticiones. Una licencia puede servir a
  varios entornos.
- **Contenido versionado, 7 tipos** — setups (YAML), SCSS, JS, bundles SCSS-CDN,
  funcionalidades, tutoriales y recursos descargables. Las versiones se numeran `YYYYMMDDXX`
  y a cada sitio se le sirve **la versión más alta menor o igual** a la que pide su plugin.
- **Entornos** — los sitios del cliente, identificados por su dominio y separados por uso
  (`local`, `develop`, `pre`, `pro`). Apagar uno le corta el servicio, con motivo y rastro.
- **Telemetría** — inventario de plugins (`sync`) y foto diaria de uso (`data`) de cada
  sitio, para saber qué corre de verdad cada cliente y cómo evoluciona.
- **Monitorización** — cada petición de la API se registra y se clasifica. Límites de
  peticiones, bloqueos de IP, resumen diario e histórico de errores por sitio.
- **Llamadas hacia fuera** — el panel puede pedirle a un Moodle que sincronice ahora, o que
  envíe su foto de uso ahora, a través del servicio web del plugin. Es el único tráfico que
  sale del Manager.
- **Roles y permisos** — 6 roles y 132 permisos (Spatie), comprobados **en la ruta y en el
  componente**.

## 🧭 Casos de uso

### Dar de alta el sitio de un cliente nuevo

Un cliente contrata dos productos. Se crea el cliente, su licencia con esos dos productos, y
el entorno con su dominio. El token se pega en el plugin del sitio y el sitio empieza a
pedir contenido en su siguiente petición. Si no se quiere esperar a la tarea nocturna del
plugin, se guarda una vez el token de servicios web del sitio y se trae el inventario desde
el panel en el momento.

### Publicar una versión nueva de contenido sin romperle a nadie

Alguien crea la versión `2026030100` de las funcionalidades de un producto y la publica. Los
sitios cuyo plugin esté en ese número o por encima reciben el contenido nuevo en su siguiente
petición; los más antiguos siguen recibiendo la versión que les encaja. No se envía nada y no
hay que coordinar nada con el cliente.

### Averiguar por qué un sitio ha dejado de recibir contenido

Soporte abre la ficha del entorno: da el **veredicto** de ese sitio —apagado, con errores,
sin sincronizar, sin señal, sin licencia o normal—, más lo último que envió y lo último que
pidió. El visor de peticiones enseña cada llamada con su severidad, y el detalle explica qué
pasó y si pasa siempre.

### Retirar contenido que ya no usa nadie

Una versión solo se puede borrar cuando **ningún entorno la recibe y está vacía**. El panel
enseña el botón con un candado hasta que se cumplen las dos condiciones, y dice cuál falta y
cómo resolverla.

## 🏗️ Cómo está montado

Esto es lo que hay que leer antes de tocar nada.

- **Un solo endpoint de API para los plugins.** `POST /api/v1`, con la acción en el cuerpo
  y el token de licencia como bearer. 13 acciones y ninguna ruta por acción. La única otra
  ruta pública es `GET /cdn/scss/{token}/{filename}`, que sirve los ficheros de un bundle
  SCSS-CDN.
- **El dominio es la llave.** Una petición se asocia a un entorno por el host que envía,
  buscado **solo entre los entornos de la licencia que llama**. Cambiar un dominio cambia la
  identidad de ese sitio.
- **La resolución de versiones es regla de negocio y vive en un solo sitio.**
  `ResuelveVersionCompatible` lo usan los siete tipos de contenido. El panel resuelve con el
  mismo código que la API, así que lo que dice la pantalla es lo que recibe el sitio.
- **No todo lo que no es 200 es un fallo.** Las respuestas se clasifican en cuatro
  severidades: `ok`, `error`, `sin_contenido` —a ese sitio no le toca ese contenido— y
  `negocio` —su licencia no lo cubre—. Solo `error` es una avería. Tratar las otras tres como
  fallos deja la monitorización ilegible: es una **norma del proyecto**, no una preferencia.
- **El contenido todavía no tiene borrador.** Toda versión que se crea se sirve al instante.
  Es una limitación conocida y va en la 2.1.
- **El contenido versionado se borra de verdad.** Ninguno de los modelos de versión usa
  borrado lógico, y por eso el borrado está condicionado y auditado.
- **El middleware de permisos de una ruta no se reaplica en Livewire.** El permiso de la ruta
  no se vuelve a comprobar en `/livewire/update`, así que cada método que muta autoriza
  también por su cuenta. Las dos comprobaciones son deliberadas.

## 📋 Requisitos

| Requisito | Versión | Nota |
|---|---|---|
| PHP | **8.4+** | Lo imponen las dependencias instaladas, no es una preferencia: con 8.3 la aplicación aborta en `platform_check.php` |
| Composer | actual | |
| Node.js | 18+ | La compilación del front **no es opcional**: el panel usa Tailwind y sin compilar la maquetación sale rota |
| Base de datos | MySQL 8 / MariaDB 10.6+ | Usa una columna generada `STORED` y un índice único parcial |
| Extensiones PHP | BCMath, Ctype, cURL, DOM, Fileinfo, JSON, Mbstring, OpenSSL, PCRE, PDO, Tokenizer, XML | |

## 🚀 Puesta en marcha

```bash
composer install
npm install

cp .env.example .env
php artisan key:generate          # nunca en un despliegue existente: ver Seguridad

# Define antes al menos SEED_ADMIN_EMAIL en el .env, o no se crea ningún
# usuario y nadie podrá entrar: la aplicación no permite crear el primero.
php artisan migrate --seed
npm run build                     # o `npm run dev` mientras se trabaja el front
php artisan serve
```

El seeder crea los roles y los 132 permisos siempre. **Los usuarios salen del entorno y solo
de ahí** (`config/seed.php`): si no hay ningún `SEED_*_EMAIL` no crea ninguno y lo dice por
consola. No hay cuentas por defecto — un correo por defecto decidiría de quién es la cuenta
de administrador de una instalación que todavía no existe. Si la contraseña se deja vacía se
genera una aleatoria fuerte y se muestra una sola vez.

## ⚙️ Configuración

Son dos clases distintas y no son intercambiables:

**Decisiones del servidor — `.env`.** Base de datos, correo, driver de sesión, canales de
log, llamadas salientes a los Moodles de los clientes (`MOODLE_SYNC_*`) y los valores por
defecto de los límites de la API. Cambiar esto exige desplegar.

**Decisiones de uso — el panel.** *Configuraciones › Configuraciones del Manager*:
destinatarios de los avisos, retención de logs, umbrales de los límites y si esos límites
**cortan o solo observan**, bloqueos manuales de IP. Viven en la base de datos para que
soporte pueda cambiarlos sin desplegar, y cada uno explica su consecuencia antes de guardar.

Los límites de subida se leen del entorno y se enseñan en el panel, incluida la capa que PHP
no puede ver —el tope de cuerpo del propio servidor web—.

## 🔐 Seguridad

- **Los tokens de licencia** autentican cada llamada a la API. Un token está atado a un
  cliente y, a través de él, a los entornos y productos que puede servir.
- **Los tokens de servicios web de los Moodles de los clientes están cifrados en reposo** y
  no se muestran de vuelta de ninguna forma. Se pueden reemplazar o borrar, no leer.
- **Nunca ejecutar `key:generate` en un despliegue existente.** La clave de aplicación
  descifra esas credenciales de terceros: una clave nueva las destruye todas.
- **Los límites y los bloqueos** protegen la API por IP y por licencia, y arrancan en modo
  observación para poder afinar los umbrales contra tráfico real antes de rechazar nada.
- **El contenido enriquecido se sanea al guardar** con una lista blanca estricta. Las subidas
  rechazan SVG en el editor porque puede llevar código ejecutable.
- Recuperar contraseña y verificar correo tienen tope por IP.

## 🚢 Despliegue

Manual, y tiene un procedimiento escrito con su copia de seguridad, su lista ordenada de
pasos y las comprobaciones de producción posteriores. Pídelo al equipo antes de desplegar:
tres de sus pasos no tienen vuelta atrás si se los salta.

## 📄 Ejemplo de `.env`

Las variables que importan. La lista completa está en `.env.example`; estas son las que
un despliegue se equivoca:

```dotenv
APP_NAME="Tresipunt Manager"
APP_ENV=production          # local | production
APP_KEY=                    # php artisan key:generate — SOLO la primera vez
APP_DEBUG=false             # nunca true en producción
APP_URL=https://manager.example.com
APP_LOCALE=es

DB_CONNECTION=mysql         # no sqlite: columna generada STORED + índice parcial
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=manager
DB_USERNAME=
DB_PASSWORD=

SESSION_DRIVER=database     # de aquí sale «está dentro ahora» en Usuarios
SESSION_LIFETIME=120

LOG_CHANNEL=stack
LOG_API_LEVEL=info          # canal propio: storage/logs/api.log
LOG_API_DAYS=30

MAIL_MAILER=smtp            # sin esto los avisos no salen; el panel lo dice en ámbar
MAIL_HOST=
MAIL_PORT=587
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_FROM_ADDRESS=
MAIL_FROM_NAME="${APP_NAME}"

# Llamadas del Manager HACIA el Moodle del cliente. El único tráfico saliente.
MOODLE_SYNC_ENABLED=true    # interruptor general
MOODLE_SYNC_TIMEOUT=30
MOODLE_SYNC_CONNECT_TIMEOUT=5
MOODLE_SYNC_COOLDOWN=60
MOODLE_SYNC_VERIFY_SSL=true # en local se pone false por el certificado autofirmado

# Usuarios iniciales. Sin correo no hay usuario: no hay cuentas por defecto.
SEED_ADMIN_EMAIL=
SEED_ADMIN_PASSWORD=
SEED_WS_EMAIL=
SEED_WS_PASSWORD=
```

Dos merecen leerse dos veces. **`APP_KEY` se genera una vez y nunca más**: descifra los
tokens de servicios web de los Moodles de los clientes, así que una clave nueva los
destruye todos. Y **`MOODLE_SYNC_VERIFY_SSL` tiene que quedarse en `true`**: en local se
pone a `false` por el certificado autofirmado, y lo que viaja en esa llamada es el token de
administración de un cliente.

## 🔁 CI/CD

Hay pipeline para **pre** y producción es **manual a propósito**. Lo de abajo es la
propuesta de lo que debería ejecutar el pipeline; el actual difiere en tres puntos que no
son cosméticos:

| Actual | Propuesta | Por qué |
|---|---|---|
| `key:generate` en cada despliegue | **quitarlo** | No recifra nada e invalida todas las credenciales de terceros guardadas |
| `composer install` con dependencias de desarrollo | `--no-dev --optimize-autoloader` | Faker, Pint y la suite de tests no tienen nada que hacer en un servidor |
| `npm install` | `npm ci` | `install` puede resolver un árbol distinto del que se probó |
| sin `optimize:clear` | después de copiar el `.env` | Si no, la configuración cacheada mantiene los valores anteriores |

```bash
rsync -avzd --delete --exclude 'storage' $WORKSPACE/ $DESTINO
cp /home/.env $DESTINO
cd $DESTINO && composer install --no-dev --optimize-autoloader
php artisan storage:link
npm ci && npm run build
php artisan migrate --force
php artisan optimize:clear
chown -R www-data:www-data $DESTINO
```

**`migrate --force` solo salta la confirmación por teclado** que Laravel pide cuando
`APP_ENV` vale exactamente `production`, y en un pipeline nadie contesta. No cambia nada de
lo que se ejecuta. No confundir con `migrate:fresh`, `migrate:refresh` ni
`migrate:rollback`, que sí son destructivos.

El planificador necesita **una sola** línea de cron: las cuatro tareas programadas ya están
declaradas en `routes/console.php` con su hora.

```cron
* * * * * cd /ruta/al/manager && php artisan schedule:run >> /dev/null 2>&1
```

## 🧪 Tests

```bash
php artisan test                       # 1008 tests
php artisan test --filter=Nombre
```

Los tests corren sobre SQLite en memoria y producción es MariaDB, así que todo lo que depende
del motor —columnas generadas, índices parciales, tipos estrictos— se comprueba de forma
explícita.

## 📄 Licencia

Software propietario. 2026 [Tresipunt](https://tresipunt.com) — todos los derechos
reservados.

---

<p align="center">
  <a href="https://tresipunt.com"><img src="docs/assets/tresipunt_logo.svg" alt="Tresipunt" width="160"></a>
</p>
