<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 tots els productes Tresipunt que corren en un lloc de client.</b></p>

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

El Manager emet llicències, serveix contingut versionat i recull telemetria dels connectors
Tresipunt instal·lats a les plataformes dels clients. Cada lloc parla amb un **únic endpoint**
d'API fent servir el seu propi token de llicència; el tauler web és on l'equip gestiona
clients, entorns, productes i contingut, i on mira què està fent el parc.

**Què no és:** no és un connector de Moodle i no corre dins del Moodle de ningú. La part
client viu a `local_tresipunt`, que és un altre repositori. El Manager **mai no envia res a un
lloc pel seu compte**: el lloc pregunta i el Manager respon.

---

## ✨ Què fa

- **Llicències** — un token per llicència (`3IP-XXXX-XXXX-XXXX-XXXX`), d'un client, amb els
  seus productes, les seves dates de vigència i els seus límits de peticions. Una llicència
  pot servir diversos entorns.
- **Contingut versionat, 7 tipus** — setups (YAML), SCSS, JS, bundles SCSS-CDN,
  funcionalitats, tutorials i recursos descarregables. Les versions es numeren `YYYYMMDDXX` i
  a cada lloc se li serveix **la versió més alta menor o igual** que la que demana el seu
  connector.
- **Entorns** — els llocs del client, identificats pel seu domini i separats per ús (`local`,
  `develop`, `pre`, `pro`). Apagar-ne un li talla el servei, amb motiu i rastre.
- **Telemetria** — inventari de connectors (`sync`) i foto diària d'ús (`data`) de cada lloc,
  per saber què corre de veritat cada client i com evoluciona.
- **Monitoratge** — cada petició de l'API es registra i es classifica. Límits de peticions,
  blocatges d'IP, resum diari i històric d'errors per lloc.
- **Trucades cap enfora** — el tauler pot demanar a un Moodle que sincronitzi ara, o que enviï
  la seva foto d'ús ara, a través del servei web del connector. És l'únic trànsit que surt del
  Manager.
- **Rols i permisos** — 6 rols i 132 permisos (Spatie), comprovats **a la ruta i al
  component**.

## 🧭 Casos d'ús

### Donar d'alta el lloc d'un client nou

Un client contracta dos productes. Es crea el client, la seva llicència amb aquests dos
productes, i l'entorn amb el seu domini. El token s'enganxa al connector del lloc i el lloc
comença a demanar contingut en la seva petició següent. Si no es vol esperar a la tasca
nocturna del connector, es guarda un cop el token de serveis web del lloc i es porta
l'inventari des del tauler a l'instant.

### Publicar una versió nova de contingut sense trencar res a ningú

Algú crea la versió `2026030100` de les funcionalitats d'un producte i la publica. Els llocs
amb el connector en aquest número o per sobre reben el contingut nou en la seva petició
següent; els més antics continuen rebent la versió que els encaixa. No s'envia res i no cal
coordinar res amb el client.

### Esbrinar per què un lloc ha deixat de rebre contingut

Suport obre la fitxa de l'entorn: dóna el **veredicte** d'aquell lloc —apagat, amb errors,
sense sincronitzar, sense senyal, sense llicència o normal—, més l'últim que va enviar i
l'últim que va demanar. El visor de peticions mostra cada trucada amb la seva severitat, i el
detall explica què va passar i si passa sempre.

### Retirar contingut que ja no fa servir ningú

Una versió només es pot esborrar quan **cap entorn la rep i és buida**. El tauler mostra el
botó amb un cadenat fins que es compleixen les dues condicions, i diu quina falta i com
resoldre-la.

## 🏗️ Com està muntat

Això és el que cal llegir abans de tocar res.

- **Un sol endpoint d'API per als connectors.** `POST /api/v1`, amb l'acció al cos i el
  token de llicència com a bearer. 13 accions i cap ruta per acció. L'única altra ruta
  pública és `GET /cdn/scss/{token}/{filename}`, que serveix els fitxers d'un bundle
  SCSS-CDN.
- **El domini és la clau.** Una petició s'associa a un entorn pel host que envia, cercat
  **només entre els entorns de la llicència que truca**. Canviar un domini canvia la identitat
  d'aquell lloc.
- **La resolució de versions és regla de negoci i viu en un sol lloc.**
  `ResuelveVersionCompatible` el fan servir els set tipus de contingut. El tauler resol amb el
  mateix codi que l'API, així que el que diu la pantalla és el que rep el lloc.
- **No tot el que no és 200 és una fallada.** Les respostes es classifiquen en quatre
  severitats: `ok`, `error`, `sin_contenido` —a aquell lloc no li toca aquell contingut— i
  `negocio` —la seva llicència no ho cobreix—. Només `error` és una avaria. Tractar les altres
  tres com a fallades deixa el monitoratge il·legible: és una **norma del projecte**, no una
  preferència.
- **El contingut encara no té esborrany.** Tota versió que es crea es serveix a l'instant. És
  una limitació coneguda i va a la 2.1.
- **El contingut versionat s'esborra de veritat.** Cap dels models de versió fa servir esborrat
  lògic, i per això l'esborrat està condicionat i auditat.
- **El middleware de permisos d'una ruta no es reaplica a Livewire.** El permís de la ruta no
  es torna a comprovar a `/livewire/update`, així que cada mètode que muta autoritza també pel
  seu compte. Les dues comprovacions són deliberades.

## 📋 Requisits

| Requisit | Versió | Nota |
|---|---|---|
| PHP | **8.4+** | L'imposen les dependències instal·lades, no és una preferència: amb 8.3 l'aplicació avorta a `platform_check.php` |
| Composer | actual | |
| Node.js | 18+ | La compilació del front **no és opcional**: el tauler fa servir Tailwind i sense compilar la maquetació surt trencada |
| Base de dades | MySQL 8 / MariaDB 10.6+ | Fa servir una columna generada `STORED` i un índex únic parcial |
| Extensions PHP | BCMath, Ctype, cURL, DOM, Fileinfo, JSON, Mbstring, OpenSSL, PCRE, PDO, Tokenizer, XML | |

## 🚀 Posada en marxa

```bash
composer install
npm install

cp .env.example .env
php artisan key:generate          # mai en un desplegament existent: veure Seguretat

# Defineix abans com a mínim SEED_ADMIN_EMAIL al .env, o no es crea cap
# usuari i ningú no podrà entrar: l'aplicació no permet crear el primer.
php artisan migrate --seed
npm run build                     # o `npm run dev` mentre es treballa el front
php artisan serve
```

El seeder crea els rols i els 132 permisos sempre. **Els usuaris surten de l'entorn i només
d'allà** (`config/seed.php`): si no hi ha cap `SEED_*_EMAIL` no en crea cap i ho diu per
consola. No hi ha comptes per defecte — un correu per defecte decidiria de qui és el compte
d'administrador d'una instal·lació que encara no existeix. Si la contrasenya es deixa buida
se'n genera una aleatòria forta i es mostra una sola vegada.

## ⚙️ Configuració

Són dues classes diferents i no són intercanviables:

**Decisions del servidor — `.env`.** Base de dades, correu, driver de sessió, canals de log,
trucades sortints als Moodles dels clients (`MOODLE_SYNC_*`) i els valors per defecte dels
límits de l'API. Canviar això exigeix desplegar.

**Decisions d'ús — el tauler.** *Configuracions › Configuracions del Manager*: destinataris
dels avisos, retenció de logs, llindars dels límits i si aquests límits **tallen o només
observen**, blocatges manuals d'IP. Viuen a la base de dades perquè suport pugui canviar-los
sense desplegar, i cadascun explica la seva conseqüència abans de guardar.

Els límits de pujada es llegeixen de l'entorn i es mostren al tauler, inclosa la capa que PHP
no pot veure —el límit de cos del propi servidor web—.

## 🔐 Seguretat

- **Els tokens de llicència** autentiquen cada trucada a l'API. Un token està lligat a un
  client i, a través d'ell, als entorns i productes que pot servir.
- **Els tokens de serveis web dels Moodles dels clients estan xifrats en repòs** i no es
  mostren de tornada de cap manera. Es poden reemplaçar o esborrar, no llegir.
- **Mai executar `key:generate` en un desplegament existent.** La clau d'aplicació desxifra
  aquestes credencials de tercers: una clau nova les destrueix totes.
- **Els límits i els blocatges** protegeixen l'API per IP i per llicència, i arrenquen en mode
  observació per poder afinar els llindars contra trànsit real abans de rebutjar res.
- **El contingut enriquit es saneja al guardar** amb una llista blanca estricta. Les pujades
  rebutgen SVG a l'editor perquè pot portar codi executable.
- Recuperar contrasenya i verificar correu tenen límit per IP.

## 🚢 Desplegament

Manual, i té un procediment escrit amb la seva còpia de seguretat, la seva llista ordenada de
passos i les comprovacions de producció posteriors. Demana'l a l'equip abans de desplegar:
tres dels seus passos no tenen marxa enrere si te'ls saltes.

## 📄 Exemple de `.env`

Les variables que importen. La llista completa és a `.env.example`; aquestes són les que
un desplegament s'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

# Usuaris inicials. Sense correu no hi ha usuari: no hi ha comptes per defecte.
SEED_ADMIN_EMAIL=
SEED_ADMIN_PASSWORD=
SEED_WS_EMAIL=
SEED_WS_PASSWORD=
```

Dues mereixen llegir-se dues vegades. **`APP_KEY` es genera un cop i mai més**: desxifra
els tokens de serveis web dels Moodles dels clients, així que una clau nova els destrueix
tots. I **`MOODLE_SYNC_VERIFY_SSL` s'ha de quedar a `true`**: en local es posa a `false` pel
certificat autosignat, i el que viatja en aquella trucada és el token d'administració d'un
client.

## 🔁 CI/CD

Hi ha pipeline per a **pre** i producció és **manual a propòsit**. El de sota és la
proposta del que hauria d'executar el pipeline; l'actual difereix en tres punts que no són
cosmètics:

| Actual | Proposta | Per què |
|---|---|---|
| `key:generate` a cada desplegament | **treure'l** | No rexifra res i invalida totes les credencials de tercers guardades |
| `composer install` amb dependències de desenvolupament | `--no-dev --optimize-autoloader` | Faker, Pint i la suite de tests no hi tenen res a fer en un servidor |
| `npm install` | `npm ci` | `install` pot resoldre un arbre diferent del que es va provar |
| sense `optimize:clear` | després de copiar el `.env` | Si no, la configuració en memòria cau manté els valors anteriors |

```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` només salta la confirmació per teclat** que Laravel demana quan
`APP_ENV` val exactament `production`, i en un pipeline ningú no contesta. No canvia res del
que s'executa. No confondre amb `migrate:fresh`, `migrate:refresh` ni `migrate:rollback`,
que sí que són destructius.

El planificador necessita **una sola** línia de cron: les quatre tasques programades ja
estan declarades a `routes/console.php` amb la seva 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=Nom
```

Els tests corren sobre SQLite en memòria i producció és MariaDB, així que tot el que depèn del
motor —columnes generades, índexs parcials, tipus estrictes— es comprova de manera explícita.

## 📄 Llicència

Programari propietari. 2026 [Tresipunt](https://tresipunt.com) — tots els drets reservats.

---

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