55 Commits

Author SHA1 Message Date
3d73b1e6a7 Mise à jour de la config du switcher pour prendre les crédit du GHCR + sh plus verbeux en cas de bugs
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 59s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build-switcher (push) Successful in 43s
Build & Push Images / build (web) (push) Successful in 1m36s
2026-05-19 18:25:54 +02:00
759e47fc1f Mise à jour vers 0.8.5 ; ajout de la bascule entre le canal bêta et le canal stable
Some checks failed
E2E Tests / e2e (push) Failing after 21s
Build & Push Images / build (brain) (push) Successful in 1m0s
Build & Push Images / build (core) (push) Successful in 1m34s
Build & Push Images / build-switcher (push) Successful in 39s
Build & Push Images / build (web) (push) Successful in 1m40s
2026-05-19 18:05:17 +02:00
f71bf3fcad Ajout d'un globalExceptionHandler pour intercepter toutes les erreurs possibles et avoir un peu plus de détails.
All checks were successful
Build & Push Images / build (brain) (push) Successful in 2m27s
Build & Push Images / build (core) (push) Successful in 3m15s
Build & Push Images / build (web) (push) Successful in 2m49s
Suppression du détail de la mise à jour de chaque composant : l'utilisateur ce fiche de savoir composant x / y à jour car on fera la mise à jour pour tout à chaque fois
(même montée en version pour chaque composant même si composant y non touché par exemple... c'est la montée en version de l'appli qui compte)
2026-05-19 14:38:38 +02:00
0cd99dfb32 Mise à jour du pom coté pore pour intégrer une dépendance vers crypto.tink ; sinon la vérification de la licence patreon ne marche pas.
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Build & Push Images / build (brain) (push) Successful in 1m51s
Build & Push Images / build (core) (push) Successful in 1m52s
Build & Push Images / build (web) (push) Successful in 1m39s
2026-05-19 14:06:57 +02:00
f24ef0891e Ajout de tests playwright et correction de tests non passant (pour les tests ajoutés : partie game system ).
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m36s
Build & Push Images / build (core) (push) Successful in 2m53s
Build & Push Images / build (web) (push) Successful in 2m36s
Correction de plusieurs anomalies : problème de switch entre 2 templates (par exemple si on était sur un template 1 et qu'on voulait passer directement au 2, ce dernier ne chargeait pas) ;
correction du soucis d'apparition de la sidebar à gauche qui disparaissait sans explication ; problème de redirection : lorsqu'on terminait de créer un PJ / PNJ ; on arrivait sur l'accueil de la campagne au lieu de voir le résultat de la création.
Problème de redirection également lors du clique sur un PNJ / PJ sur le coté : on arrivait sur l'édition au lieu de la présentation. Correction de la première lettre stylisée : tout est au même style comme ça plus de probleme de lecture.

Nouveautées : stylisation des modales (notamment suppression, warning.....) avec en prime l'ajout d'un warning lors du changement de système pour avertir que les fiches persos ne sont pas conservées.
Ajout d'une option pour créer un game system directement à la création d'une campagne afin de faciliter la mise en place de cette dernière.
Ajout d'un bouton pour créer un nouveau template directement lorsqu'on créer une page : ça permet de créer un template et de revenir sur la page qu'on était en train de créer sans perdre le titre.

Passage en bêta 0.8.4
2026-05-19 13:37:22 +02:00
7c74c12f3e Changement du readme
Some checks failed
E2E Tests / e2e (push) Failing after 58s
2026-05-17 18:04:24 +02:00
86836ad81c Refonte de toute la partie fiche de personnage avec mise en place d'un nouveau bloc de liste d'attribut (pour tout ce qui sera statistiques, compétences etc....)
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m5s
Build & Push Images / build (core) (push) Successful in 1m38s
Build & Push Images / build (web) (push) Successful in 1m36s
Passage V0.8.3
2026-04-30 15:53:38 +02:00
7c4a42327d Mise en place du picker d'image pour la partie header / illustration des fiches personnage
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m1s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Successful in 1m42s
Migration pour l'ancienne partie des fiches perso vers les nouvelles pages
Vue retravaillée pour les fiches perso
2026-04-30 10:54:27 +02:00
52e389db24 Refonte du système JDR + système de personnage joueurs / non joueurs :
Some checks failed
E2E Tests / e2e (push) Failing after 21s
- Système de templating dans le game system : en effet, les templates sont liés au game system car les fiches personnages ne sont pas forcément les même selon les jeux (perso Dnd possède + de compétences que Nimble par exemple)
- changement des fiches personnages pour adapter le templating au niveau des campagnes et remplir des pages de perso
2026-04-30 10:42:09 +02:00
efaf5a3794 Mise en place d'un composant permettant d'améliorer l'experience de mise à jour (via un rafraichissement de l'appli).
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m0s
Build & Push Images / build (core) (push) Successful in 1m47s
Build & Push Images / build (web) (push) Successful in 1m38s
Modification de la partie web pour prendre la modification en compte
2026-04-29 14:39:30 +02:00
4fe93b5ff3 Correction problème mise à jour : l'application ne voyait pas les mises à jour quand on lançait docker après avoir push la dernière version.
Some checks failed
E2E Tests / e2e (push) Failing after 21s
Build & Push Images / build (brain) (push) Successful in 1m4s
Build & Push Images / build (core) (push) Successful in 1m31s
Build & Push Images / build (web) (push) Successful in 1m38s
Effectivement : au demarrage, docker ce mettait automatiquement sur la dernière version alors qu'il n'avait pas necessairement récupérer, ducoup comparaison faisait true et on arrivait pas à avoir la derniere version du code.
Push de la clé jwt publique : sinon pas incluse dans le jar finale et la section patreon n'apparaissait pas.
2026-04-29 10:56:37 +02:00
0f2d1b1efe Correction updateCheckServiceTest qui faisait planter le build gitea
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (core) (push) Successful in 1m27s
Build & Push Images / build (web) (push) Successful in 1m34s
Build & Push Images / build (brain) (push) Successful in 53s
2026-04-28 19:12:09 +02:00
5ff05242a8 Mise en place de la connexion au canal privé pour la bêta avec Patreon et passage en v0.8.0
Some checks failed
E2E Tests / e2e (push) Failing after 16s
Build & Push Images / build (brain) (push) Failing after 48s
Build & Push Images / build (core) (push) Failing after 1m18s
Build & Push Images / build (web) (push) Successful in 1m35s
2026-04-28 19:04:11 +02:00
b06c77a1eb Autre patch dockerfile
Some checks failed
E2E Tests / e2e (push) Failing after 20s
Build & Push Images / build (brain) (push) Successful in 58s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Successful in 1m30s
2026-04-27 22:11:43 +02:00
03bc669efe Patch dockerfile bookworm a lieu de alpine pour corriger le problème de build
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 58s
Build & Push Images / build (core) (push) Successful in 1m39s
Build & Push Images / build (web) (push) Failing after 1m48s
2026-04-27 21:56:04 +02:00
c3873ddd84 Patch dockerfile pour ne plus que le build plante
Some checks failed
E2E Tests / e2e (push) Failing after 16s
Build & Push Images / build (brain) (push) Successful in 1m8s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Failing after 1m42s
2026-04-27 21:43:13 +02:00
d7ceeac1b0 Correction package-lock
Some checks failed
E2E Tests / e2e (push) Failing after 20s
Build & Push Images / build (brain) (push) Successful in 57s
Build & Push Images / build (core) (push) Successful in 1m34s
Build & Push Images / build (web) (push) Failing after 1m41s
2026-04-27 19:17:01 +02:00
cdbd3cd9b4 Modification lors de la création d'élément de campagne : quand on créer un nouvel élément, on arrive sur la modification et non le résumé de l'élément
Some checks failed
E2E Tests / e2e (push) Failing after 23s
Build & Push Images / build (brain) (push) Successful in 58s
Build & Push Images / build (core) (push) Successful in 1m33s
Build & Push Images / build (web) (push) Failing after 1m39s
2026-04-27 19:03:58 +02:00
a708c74425 Correction du soucis de mise à jour via l'application
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 59s
Build & Push Images / build (core) (push) Successful in 1m39s
Build & Push Images / build (web) (push) Successful in 1m36s
2026-04-27 16:19:56 +02:00
9ad7651c44 Passage V0.7.0
Some checks failed
E2E Tests / e2e (push) Failing after 17s
Build & Push Images / build (brain) (push) Successful in 1m13s
Build & Push Images / build (core) (push) Successful in 1m37s
Build & Push Images / build (web) (push) Successful in 1m43s
2026-04-27 15:51:13 +02:00
389392fd1d Changement sur le Readme
Ajout d'une partie spécifique pour des PNJ dans la partie campagne
2026-04-27 15:48:04 +02:00
aaebeaa547 Mise à jour du readme d'accueil pour l'accès à la documentation
Some checks failed
E2E Tests / e2e (push) Failing after 17s
2026-04-27 08:27:38 +02:00
03ee3855f5 Passage version 0.6.14 + résolution d'un soucis sur l'updater depuis la migration sur git
Some checks failed
E2E Tests / e2e (push) Failing after 24s
Build & Push Images / build (brain) (push) Successful in 59s
Build & Push Images / build (core) (push) Successful in 1m36s
Build & Push Images / build (web) (push) Successful in 1m47s
2026-04-26 19:08:49 +02:00
94a39cf3b4 Mise en place de la pipeline pour github plutot que gitea ; mise en place des images docker sur GHCR plutôt que gitea
Some checks failed
E2E Tests / e2e (push) Failing after 22s
Build & Push Images / build (brain) (push) Successful in 58s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Successful in 1m40s
Passage version v0.6.13
2026-04-26 10:46:46 +02:00
efe6f6c2b0 Empêche la modale de ce fermer tant que le llm n'est pas télécharger
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m20s
Build & Push Images / build (web) (push) Successful in 1m30s
2026-04-26 09:12:36 +02:00
73a9d15786 Forçage HTTP/1.1 pour la partie python et passage en v0.6.11
Some checks failed
E2E Tests / e2e (push) Failing after 20s
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m17s
Build & Push Images / build (web) (push) Successful in 1m27s
2026-04-26 01:55:02 +02:00
dfe05cf2d2 Correction d'un bug lors de tentative de téléchargement de llm pour ollama
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 50s
Build & Push Images / build (core) (push) Successful in 1m22s
Build & Push Images / build (web) (push) Successful in 1m28s
2026-04-26 01:45:39 +02:00
fcba907438 Passage version 0.6.9
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 47s
Build & Push Images / build (core) (push) Successful in 1m18s
Build & Push Images / build (web) (push) Successful in 1m24s
2026-04-26 01:30:35 +02:00
5739602702 Changement du watchtower pour une version plus récente : projet originel abandonné, repris par un fork.
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Failing after 20s
Build & Push Images / build (web) (push) Failing after 21s
Build & Push Images / build (core) (push) Failing after 22s
2026-04-26 01:19:58 +02:00
addf78f01d Mise en place v0.6.8
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 52s
Build & Push Images / build (core) (push) Successful in 1m20s
Build & Push Images / build (web) (push) Successful in 1m30s
Amélioration de l'installation automatique
Ajout de la possibilité de télécharger le llm que l'on veut à l'interieur de l'application en communicant avec ollama
2026-04-26 01:11:04 +02:00
5e04e84ee4 Mise à jour de la conf pour être sur que le cache angular est bien refresh
Some checks failed
E2E Tests / e2e (push) Failing after 20s
Mise à jour des installeurs
Mise en place de secure-host pour ne pas exposer Ollama à l'exterieur
2026-04-26 00:18:49 +02:00
8d5c2e2b7f Correction pour éviter que la fenêtre ce ferme sans qu'on voit le message d'erreur
Some checks failed
E2E Tests / e2e (push) Failing after 16s
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m18s
Build & Push Images / build (web) (push) Successful in 1m24s
2026-04-25 18:37:40 +02:00
788d2c12f2 Ajout d'un .bat pour l'exécution du .ps1
Some checks failed
E2E Tests / e2e (push) Failing after 16s
2026-04-25 18:34:52 +02:00
b25a9746cf Changement sur l'installation automatique : réduction des patterns suspects dans l'installation pour les antivirus (par exemple, monter automatiquement les privilèges en admin...),
Some checks failed
E2E Tests / e2e (push) Failing after 17s
afin d'éviter que l'appli ne soit détectée comme un virus
2026-04-25 18:24:44 +02:00
41fda9aeee Ajout d'un script pour installation automatique du produit
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 45s
Build & Push Images / build (core) (push) Successful in 1m16s
Build & Push Images / build (web) (push) Successful in 1m26s
Ajout d'une partie mise à jour automatique : plus besoin de docker pull en ligne de commande ; on peut passer par l'interface
Refactoring partie Java pour respecter d'avantage le DDD : plus de jackson dans la partie domain

Passage version 0.6.6
2026-04-25 13:24:32 +02:00
550078268c Evolutions :
Some checks failed
Build & Push Images / build (brain) (push) Successful in 55s
Build & Push Images / build (core) (push) Successful in 1m35s
E2E Tests / e2e (push) Failing after 4m10s
Build & Push Images / build (web) (push) Successful in 2m0s
- Ajout d'icônes dans la scène, chapitre et arc
- Possibilité de bouger les cases dans la partie graphe et les textes associés si ces derniers ne sont pas visibles
- Changement sur le thème du graphe : mode sombre et plus blanc
- Barre d'action en haut, même pour la partie scène
- Mode sticky corrigé : plus de trou entre le haut du navigateur web et de la barre d'action

Passage version 0.6.5
2026-04-25 11:41:14 +02:00
0582690dca Correction d'un test unitaire
Some checks failed
E2E Tests / e2e (push) Failing after 3m43s
Ajout d'un champs image dans les templates par défaut en + du champs nom, description pour avoir un exemple
Correction du visuel du champs d'ajout lors de la modification d'un template (apparition ligne pleine au lieu de texte en pointillé)
Ajout d'un intercepteur pour la partie démo de l'application afin de bien rafraichir le cache angular lorsque le temps de démo est expiré
2026-04-25 09:23:56 +02:00
88278bd1dd Fix : problème d'ascenseur en bas de la page au niveau des templates
Some checks failed
Build & Push Images / build (brain) (push) Successful in 1m1s
Build & Push Images / build (core) (push) Successful in 1m32s
E2E Tests / e2e (push) Failing after 4m13s
Build & Push Images / build (web) (push) Successful in 1m53s
Sélection du template par défaut lors de la création d'une page en fonction du dossier
Passage v0.6.2
2026-04-25 01:39:05 +02:00
d24d6459a0 Ajout de test, correctif d'un problème d'horloge pour le workflow gitea actions pour le e2e
Some checks failed
E2E Tests / e2e (push) Failing after 3m33s
2026-04-25 00:51:32 +02:00
4b866e5212 Fix workflow gitea action pour e2e (tests automatisés via playwright) + correction d'une incohérence dans l'API coté java. Ajout d'autres tests utilisateur
Some checks failed
E2E Tests / e2e (push) Failing after 2m31s
2026-04-25 00:45:04 +02:00
6c6bd20f0d Mise en place de tests utilisateurs avec playwright pour la partie angular + corrections au niveau des labels avec for et id pour cliquer dessus
Some checks failed
E2E Tests / e2e (push) Failing after 5m30s
2026-04-25 00:25:53 +02:00
2764228abf Fix rate limit derriere Cloudflare + CORS sur POST demo 2026-04-24 08:55:40 +02:00
f95d69c915 Fix CORS 403 sur POST : passer APP_CORS_ALLOWED_ORIGINS au core démo 2026-04-24 08:46:26 +02:00
70351e9d9a Remplace docker SDK par appels HTTP directs (zero deps)
All checks were successful
Build & Push Images / build (brain) (push) Successful in 52s
Build & Push Images / build (core) (push) Successful in 1m28s
Build & Push Images / build (web) (push) Successful in 1m34s
2026-04-24 07:39:49 +02:00
ff4905126d Docker SDK v28 pour resoudre les conflits transitifs 2026-04-24 07:33:48 +02:00
0e5b5a7de4 Correction d'une dépendance go 2026-04-24 07:30:20 +02:00
c8c032336b Mise à jour du dockerfile suite à une dépendance trop ancienne sur go 2026-04-24 07:26:42 +02:00
dda27e55fc Orchestrateur go pour lancer la démo et mettre en place plusieurs instances docker 2026-04-23 17:49:26 +02:00
83ac67471e Changement dans la config pour éviter les url en dur + mise en place d'un mode démo 2026-04-23 17:15:08 +02:00
e3c8232e38 Version 0.6.1
All checks were successful
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m19s
Build & Push Images / build (web) (push) Successful in 1m31s
2026-04-23 14:36:09 +02:00
a4df9fc759 Ajout des personnage dans la sidebar de la campagne 2026-04-23 14:34:07 +02:00
f1989c1d77 Mutualisation de la version pour ne pas l'oublier dans le footer du front ;
All checks were successful
Build & Push Images / build (brain) (push) Successful in 50s
Build & Push Images / build (core) (push) Successful in 1m29s
Build & Push Images / build (web) (push) Successful in 1m24s
Passage en version v 0.6.0
2026-04-23 14:12:24 +02:00
8efdf5d0e0 Correction bug suppression complète coté lore (et suppression dans tout ce qui est campagne de la partie lore liée).
Améliorations ux :
- Bandeau en haut qui reste accessible lors de la création d'un élément (chapitre, page, scène etc...)
- Mise en place d'un surlignage pour voir su quel élément on est positionné
2026-04-23 14:06:50 +02:00
96bc5de942 Mise en place de la possibilité de supprimer des lores / campagnes d'un seul coup 2026-04-23 11:51:03 +02:00
84ccdd53ad Corrections d'ordre graphique / ergonomique :
- Lorsqu'on part de zéro : la création de dossier / page / template ce fait de manière plus fluide à la création d'un lore (par exemple création de page sans template et dossier : parcours facilité)
- Ajout d'un bouton "+" dans le header templates
- Harmonisation création / modification template

Correction de tests unitaires
2026-04-23 11:25:58 +02:00
378 changed files with 20518 additions and 3273 deletions

View File

@@ -38,3 +38,15 @@ LLM_MODEL=gemma4:26b
# 1min.ai (si LLM_PROVIDER=onemin)
ONEMIN_API_KEY=
ONEMIN_MODEL=gpt-4o-mini
# --- Mises a jour automatiques (Watchtower) ------------------------------
# Watchtower verifie les nouvelles versions de core/brain/web et permet
# le declenchement manuel via l'UI (bouton "Mettre a jour"). Postgres et
# MinIO sont exclus volontairement.
#
# Activer : COMPOSE_PROFILES=autoupdate + WATCHTOWER_TOKEN non vide.
# COMPOSE_PROFILES=autoupdate
# WATCHTOWER_TOKEN=change-me-use-openssl-rand-hex-32
# WATCHTOWER_MONITOR_ONLY=false # true = detecter sans appliquer
# WATCHTOWER_SCHEDULE=0 0 4 * * *
# TZ=Europe/Paris

95
.gitea/workflows/e2e.yml Normal file
View File

@@ -0,0 +1,95 @@
name: E2E Tests
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Create .env for stack
run: |
cat > .env <<'EOF'
POSTGRES_PASSWORD=ci-postgres-pass
BRAIN_INTERNAL_SECRET=ci-brain-secret
ADMIN_USERNAME=admin
ADMIN_PASSWORD=ci-admin-pass
WEB_PORT=8081
LLM_PROVIDER=ollama
EOF
- name: Build & start stack
run: |
docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d --build
- name: Attach runner to compose network
run: |
NET=$(docker network ls --format '{{.Name}}' | grep -E '(^|_)loremind(_|$)' | grep -i default | head -1)
if [ -z "$NET" ]; then
echo "Compose network not found" >&2
docker network ls
exit 1
fi
echo "Connecting $(hostname) to network $NET"
docker network connect "$NET" "$(hostname)"
- name: Wait for web to be ready
run: |
timeout 180 bash -c 'until curl -sf http://web/ > /dev/null; do echo "waiting..."; sleep 3; done'
- name: Install web deps
working-directory: web
run: npm ci
- name: Work around runner clock skew for apt
run: |
sudo tee /etc/apt/apt.conf.d/99no-check-valid-until >/dev/null <<'EOF'
Acquire::Check-Valid-Until "false";
Acquire::Check-Date "false";
EOF
- name: Install Playwright browsers
working-directory: web
run: npx playwright install --with-deps chromium
- name: Run Playwright tests
working-directory: web
env:
E2E_BASE_URL: http://web
CI: 'true'
run: npm run e2e
- name: Dump container logs on failure
if: failure()
run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml logs --no-color
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: web/playwright-report/
retention-days: 14
- name: Stop stack
if: always()
run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml down -v

View File

@@ -6,8 +6,10 @@ on:
- 'v*'
env:
REGISTRY: git.igmlcreation.fr
REGISTRY_USER: ietm64
GITEA_REGISTRY: git.igmlcreation.fr
GITEA_REGISTRY_USER: ietm64
GHCR_REGISTRY: ghcr.io
GHCR_NAMESPACE: igmlcreation
jobs:
build:
@@ -26,19 +28,114 @@ jobs:
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.REGISTRY_USER }}
registry: ${{ env.GITEA_REGISTRY }}
username: ${{ env.GITEA_REGISTRY_USER }}
password: ${{ secrets.DOCKER_PAT }}
- name: Extract version
id: meta
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
# Login to GHCR (GitHub Container Registry) pour distribuer les images
# publiquement aux utilisateurs finaux. Reputation domaine plus elevee
# que git.igmlcreation.fr (mieux pour les antivirus / SmartScreen).
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ env.GHCR_NAMESPACE }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Build & push ${{ matrix.component }}
# Detection du canal :
# - tag vX.Y.Z -> stable (push :latest + :version sur les repos publics)
# - tag vX.Y.Z-beta* -> beta (push :beta + :version sur les repos GHCR prives
# loremind-beta-<component> ; backup Gitea avec :version)
- name: Extract version & channel
id: meta
run: |
VERSION="${GITHUB_REF_NAME#v}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
if [[ "${VERSION}" == *-beta* ]]; then
echo "channel=beta" >> $GITHUB_OUTPUT
else
echo "channel=stable" >> $GITHUB_OUTPUT
fi
# Build & push canal STABLE
- name: Build & push ${{ matrix.component }} (stable)
if: steps.meta.outputs.channel == 'stable'
uses: docker/build-push-action@v5
with:
context: ./${{ matrix.component }}
push: true
tags: |
${{ env.REGISTRY }}/${{ env.REGISTRY_USER }}/${{ matrix.component }}:latest
${{ env.REGISTRY }}/${{ env.REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:latest
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:latest
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:${{ steps.meta.outputs.version }}
# Build & push canal BETA
# GHCR : repos prives loremind-beta-<component> (gated par PAT distribue
# via le relais Patreon aux tiers Compagnon).
# Gitea : backup prive avec :version uniquement (pas de :latest pour ne
# pas faire upgrader les installs branchees sur Gitea).
- name: Build & push ${{ matrix.component }} (beta)
if: steps.meta.outputs.channel == 'beta'
uses: docker/build-push-action@v5
with:
context: ./${{ matrix.component }}
push: true
tags: |
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:beta
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:${{ steps.meta.outputs.version }}
# Job separe pour le sidecar `switcher`.
# Pourquoi separe : le switcher est volontairement HORS de IMAGE_NAMESPACE
# (cf. docker-compose.yml). Il est toujours pulle depuis le repo public
# `loremind-switcher`, quel que soit le canal de l'instance. On le build
# donc uniquement sur les releases stables — pas la peine de re-publier
# une variante beta du switcher, c'est une infrastructure neutre.
build-switcher:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Detect channel
id: meta
run: |
VERSION="${GITHUB_REF_NAME#v}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
if [[ "${VERSION}" == *-beta* ]]; then
echo "channel=beta" >> $GITHUB_OUTPUT
else
echo "channel=stable" >> $GITHUB_OUTPUT
fi
- name: Login to Gitea Registry
if: steps.meta.outputs.channel == 'stable'
uses: docker/login-action@v3
with:
registry: ${{ env.GITEA_REGISTRY }}
username: ${{ env.GITEA_REGISTRY_USER }}
password: ${{ secrets.DOCKER_PAT }}
- name: Login to GHCR
if: steps.meta.outputs.channel == 'stable'
uses: docker/login-action@v3
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ env.GHCR_NAMESPACE }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Build & push switcher (stable only)
if: steps.meta.outputs.channel == 'stable'
uses: docker/build-push-action@v5
with:
context: ./switcher
push: true
tags: |
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/switcher:latest
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/switcher:${{ steps.meta.outputs.version }}
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-switcher:latest
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-switcher:${{ steps.meta.outputs.version }}

18
.gitignore vendored
View File

@@ -7,6 +7,11 @@
brain/data/settings.json
*.key
*.pem
# Exception : la cle PUBLIQUE JWT du relais Patreon est destinee a etre
# embarquee dans le binaire. Pas de risque a la committer (c'est une cle
# publique par construction). Sans cette exception, le module licensing
# est silencieusement desactive dans les builds CI.
!core/src/main/resources/licensing/jwt-public-key.pem
# ============================================================================
# Java / Spring Boot / Maven
@@ -53,6 +58,12 @@ yarn-error.log*
.pnpm-debug.log*
coverage/
# Playwright (E2E)
web/test-results/
web/playwright-report/
web/blob-report/
web/playwright/.cache/
# ============================================================================
# IDE / Editeurs
# ============================================================================
@@ -85,8 +96,15 @@ Thumbs.db
# Documentation hors-code (conservee hors du repo)
# ============================================================================
docs/
loremind-docs/
# ============================================================================
# Docker Compose override (dev uniquement, non-distribue aux end users)
# ============================================================================
docker-compose.override.yml
# ============================================================================
# Relais OAuth Patreon (repo Gitea separe, clone localement pour facilite)
# ============================================================================
relay/
scripts/bump-version.mjs

View File

@@ -1,311 +0,0 @@
# Installation de LoreMindMJ
Ce document decrit la procedure d'installation de LoreMindMJ. Temps estime :
5 a 10 minutes selon la qualite de la connexion reseau.
## 1. Prerequis
- **Docker Desktop** ([Windows](https://www.docker.com/products/docker-desktop/) /
[Mac](https://www.docker.com/products/docker-desktop/)) ou
**Docker Engine + Compose v2** (Linux). Verification :
```
docker --version
docker compose version
```
Compose v2 est requis : la commande est `docker compose`, non `docker-compose`.
- **Un fournisseur LLM**, au choix :
- **[Ollama](https://ollama.com/)** installe sur la machine hote (gratuit,
local, necessite environ 6 Go de RAM libre pour les modeles recommandes).
- **Une cle API [1min.ai](https://1min.ai)** (hebergement cloud, facturation
a l'usage, aucune installation supplementaire requise).
- Environ **2 Go d'espace disque** pour les images Docker, auxquels s'ajoute
la taille des modeles Ollama si l'option locale est retenue.
## 2. Recuperation des fichiers
Telecharger les deux fichiers suivants depuis la
[derniere release](https://git.igmlcreation.fr/ietm64/LoreMindMJ/releases) et
les placer dans un dossier dedie (par exemple `~/loremind/` ou
`C:\Programs\loremind\`) :
- `docker-compose.yml`
- `.env.example`
Le code source n'est pas necessaire : les images sont pre-construites et
publiees sur le registry Gitea `git.igmlcreation.fr` (non Docker Hub). Le
premier `docker compose pull` les telechargera automatiquement.
## 3. Configuration du fichier `.env`
Renommer `.env.example` en `.env` et l'ouvrir dans un editeur de texte. **Trois
variables sont obligatoires** ; sans elles, `docker compose up` refusera de
demarrer. Ce comportement est volontaire afin d'eviter tout deploiement
non-securise par defaut.
### `POSTGRES_PASSWORD`
Mot de passe de la base de donnees PostgreSQL. Choisir une valeur robuste.
Seuls les conteneurs utilisent cette valeur : il n'est pas necessaire de la
memoriser au-dela du fichier `.env`.
### `ADMIN_PASSWORD`
Protege l'ecran **Parametres** de l'application via HTTP Basic. Cette valeur
sera demandee par le navigateur lors de toute modification de la configuration
(changement de modele LLM, saisie de cle API, etc.). Le nom d'utilisateur par
defaut est `admin`, modifiable via la variable `ADMIN_USERNAME`.
### `BRAIN_INTERNAL_SECRET`
Secret partage entre le service Java (`core`) et le service Python (`brain`).
Empeche toute requete externe d'atteindre directement le service Brain.
Generer une valeur aleatoire de 64 caracteres hexadecimaux :
```
openssl rand -hex 32
```
Sous Windows sans `openssl`, utiliser PowerShell :
```powershell
-join ((48..57) + (97..102) | Get-Random -Count 64 | % {[char]$_})
```
### Variables optionnelles
- `WEB_PORT` (defaut `8081`) : port d'ecoute de l'interface web.
- `ADMIN_USERNAME` (defaut `admin`) : identifiant de la popup Parametres.
- `LLM_PROVIDER` (defaut `ollama`) : choix du fournisseur LLM (voir
section 5).
Les autres variables (`MINIO_USER`/`MINIO_PASSWORD`, `POSTGRES_DB`,
`POSTGRES_USER`) disposent de valeurs par defaut adaptees a un deploiement
personnel et peuvent etre conservees en l'etat.
## 4. Lancement de la stack
Depuis le dossier contenant `docker-compose.yml` et `.env` :
```
docker compose up -d
```
Le premier demarrage telecharge les images (environ 1 a 2 Go au total) et
initialise la base. Compter 2 a 5 minutes selon la qualite de la connexion.
La progression peut etre suivie via :
```
docker compose logs -f
```
(`Ctrl+C` pour quitter l'affichage ; les services continuent de fonctionner
en arriere-plan.)
Une fois les services en etat `healthy`, ouvrir **http://localhost:8081**
dans un navigateur.
### Verification du fonctionnement
```
docker compose ps
```
Cinq conteneurs doivent apparaitre en etat `Up` ou `healthy` :
`loremind-postgres`, `loremind-minio`, `loremind-core`, `loremind-brain`,
`loremind-web`. Le conteneur `loremind-minio-init` s'arrete automatiquement
apres creation du bucket d'images : ce comportement est normal.
## 5. Configuration du fournisseur LLM
### Ollama (local, gratuit)
Installer Ollama sur la machine hote (pas dans Docker), puis telecharger un
modele :
```
ollama pull gemma4:26b
```
Dans `.env` :
```
LLM_PROVIDER=ollama
LLM_MODEL=gemma4:26b
OLLAMA_BASE_URL=http://host.docker.internal:11434
```
L'adresse `host.docker.internal` permet au conteneur `brain` d'atteindre
Ollama sur la machine hote. Cette resolution est native sous Docker Desktop
(Mac / Windows). Sous Linux, le fichier `docker-compose.yml` declare un
`extra_hosts` equivalent.
### 1min.ai (cloud, paye)
Dans `.env` :
```
LLM_PROVIDER=onemin
ONEMIN_API_KEY=sk-...
ONEMIN_MODEL=gpt-4o-mini
```
### Modification a chaud
Le fournisseur, le modele et la cle API peuvent etre modifies a chaud depuis
l'ecran **Parametres** de l'application. Les modifications sont persistees
dans un volume Docker et survivent aux redemarrages. Les variables d'env du
fichier `.env` sont uniquement utilisees comme valeurs initiales au premier
demarrage.
## 6. Mise a jour
```
docker compose pull
docker compose up -d
```
Les donnees (base PostgreSQL, images MinIO, configuration Brain) sont
stockees dans des volumes Docker et survivent aux mises a jour.
## 7. Sauvegarde
Les donnees sont reparties dans trois volumes Docker :
- `loremindmj_postgres-data` — ensemble des donnees applicatives (lores,
campagnes, pages, templates, branches narratives, etc.).
- `loremindmj_minio-data` — images uploadees.
- `loremindmj_brain-data` — parametres IA (fournisseur courant, cle API
1min.ai).
### Export SQL de la base
```
docker compose exec postgres pg_dump -U loremind loremind > backup.sql
```
### Sauvegarde complete des volumes
Arreter la stack au prealable afin de garantir la coherence des donnees :
```
docker compose stop
docker run --rm -v loremindmj_postgres-data:/data -v $(pwd):/backup alpine tar czf /backup/postgres-data.tar.gz -C /data .
docker run --rm -v loremindmj_minio-data:/data -v $(pwd):/backup alpine tar czf /backup/minio-data.tar.gz -C /data .
docker compose start
```
Sous Windows PowerShell, remplacer `$(pwd)` par `${PWD}`.
## 8. Resolution des problemes
### Port 8081 deja utilise
Modifier `WEB_PORT=8082` (ou toute autre valeur libre) dans `.env`, puis
relancer :
```
docker compose up -d
```
### Erreur "set POSTGRES_PASSWORD in .env" (ou variable equivalente) au lancement
Une des trois variables obligatoires de l'etape 3 est manquante. Verifier le
contenu du fichier `.env`.
### Popup "Ce site vous demande de vous connecter" sur l'ecran Parametres
Comportement attendu : il s'agit de l'authentification HTTP Basic. Utiliser
la valeur de `ADMIN_USERNAME` (par defaut `admin`) et celle de
`ADMIN_PASSWORD`.
### Erreurs `password authentication failed` en boucle dans les logs Postgres
Si la variable `POSTGRES_PASSWORD` a ete modifiee apres un premier lancement,
le volume Postgres conserve l'ancien mot de passe (initialise une seule fois).
Deux options :
- **Redemarrer avec un volume vierge** (entraine la perte des donnees) :
```
docker compose down -v
docker compose up -d
```
- **Modifier le mot de passe en base** sans toucher au volume :
```
docker compose exec postgres psql -U postgres
```
Puis dans le prompt `psql` :
```sql
ALTER USER loremind WITH PASSWORD 'valeur_exacte_du_env';
\q
```
Redemarrer ensuite le Core : `docker compose restart core`.
### Erreur "502 Bad Gateway" ou message d'erreur IA dans l'interface
Le service Brain ne parvient pas a contacter le fournisseur LLM. Verifier :
- **Ollama** : `ollama serve` est-il actif ? Le modele est-il telecharge
(`ollama list`) ? La valeur de `LLM_MODEL` correspond-elle exactement au
nom d'un modele liste ?
- **1min.ai** : la cle API est-elle valide ? Le modele existe-t-il ?
- Consulter les logs du Brain :
```
docker compose logs brain
```
### Un service ne demarre pas ou reste en etat `unhealthy`
Consulter les logs du service concerne :
```
docker compose logs <service>
```
Services disponibles : `postgres`, `minio`, `core`, `brain`, `web`.
### Redemarrage d'un service apres modification du `.env`
```
docker compose up -d <service>
```
Redemarrage complet : `docker compose restart`.
### Remise a zero complete (PERTE DES DONNEES)
```
docker compose down -v
```
L'option `-v` supprime les volumes. L'ensemble des lores, campagnes, images
et parametres est perdu de maniere definitive.
### "No such image" ou "pull access denied" au premier lancement
Le registry Gitea peut necessiter une authentification selon la visibilite
configuree pour les images. Contacter l'editeur du projet.
## 9. Exposition reseau des services
- **Interface web** : http://localhost:8081 (port configurable via
`WEB_PORT`).
- **PostgreSQL** : accessible uniquement via le reseau Docker interne, non
expose vers l'hote.
- **MinIO** : accessible uniquement via le reseau Docker interne. Les images
transitent par le reverse-proxy Java sur `/api/images/{id}/content`. Le
binding `127.0.0.1:9000/9001` defini dans `docker-compose.override.yml`
n'est actif qu'en developpement.
- **Brain Python** : accessible uniquement via le reseau Docker interne.
Toute requete doit porter l'en-tete `X-Internal-Secret`, injectee
automatiquement par le Core Java et jamais exposee au navigateur.
## 10. Desinstallation
```
docker compose down -v
docker image rm git.igmlcreation.fr/ietm64/core git.igmlcreation.fr/ietm64/brain git.igmlcreation.fr/ietm64/web
```
Supprimer ensuite le dossier contenant `docker-compose.yml` et `.env`.

View File

@@ -1,75 +1,66 @@
# LoreMind
Application web d'aide aux Maîtres de Jeu (JDR) pour centraliser la gestion de l'univers (Lore) et le suivi des campagnes, avec un moteur IA intégré pour générer du contenu structuré.
> Application web auto-hébergeable pour MJ qui veulent centraliser leur univers, leurs campagnes et leurs personnages avec un assistant IA contextuel.
## Fonctionnalités
[![Licence: AGPL v3](https://img.shields.io/badge/Licence-AGPL%20v3-blue.svg)](LICENSE)
[![Documentation](https://img.shields.io/badge/docs-loremind--docs-green)](https://loremind-docs.igmlcreation.fr/)
[![Démo](https://img.shields.io/badge/d%C3%A9mo-en%20ligne-orange)](https://loremind-demo.igmlcreation.fr/)
[![Patreon](https://img.shields.io/badge/Patreon-soutenir-red)](https://www.patreon.com/c/IGMLCreation)
[![Discord](https://img.shields.io/badge/Discord-rejoindre-5865F2)](https://discord.gg/cPpFzCjEzQ)
- Gestion centralisée du Lore : Lieux, Factions, PNJ, et tous les éléments de votre univers
- Suivi de campagnes : Sessions, actions des joueurs, chronologie
- Moteur IA intégré : Génération automatique de contenu (PNJ, Villes, Quêtes) à partir de templates
- Export vers FoundryVTT : Transfert structuré des données vers votre VTT préféré (en développement)
## Découvrir LoreMind en vidéo
## Captures d'écran
[![Présentation LoreMind](https://img.youtube.com/vi/llJkmlotbB8/maxresdefault.jpg)](https://www.youtube.com/watch?v=llJkmlotbB8)
### Page d'accueil
![Accueil](docs/maquettes/général/Accueil.png)
![Tableau de bord](https://raw.githubusercontent.com/IGMLcreation/loremind-docs/main/static/img/screenshots/dashboard.png)
### Recherche
![Recherche](docs/maquettes/général/Ecran de recherche.png)
## Ce que ça fait
## Stack Technologique
LoreMind regroupe ce qu'un MJ utilise habituellement éparpillé entre plusieurs outils. L'application s'articule autour de trois modules principaux, augmentés par un assistant IA qui exploite tout votre contenu.
LoreMind utilise une architecture distribuée pour séparer les responsabilités :
### Lore
- **Frontend** : Angular (Interface utilisateur, affichage du lore, formulaires de templates)
- **Backend Core** : Java (Spring Boot) - Orchestration, persistance, export VTT
- **Backend IA** : Python - Traitement des LLM et génération de contenu
- **Base de données** : PostgreSQL avec JSONB pour les templates flexibles
Construire votre univers avec une arborescence de pages templatées : lieux, factions, PNJ, événements, organisations... Chaque type de page suit un template configurable, ce qui garantit la cohérence et facilite la navigation dans des univers riches.
## Architecture
### Game System
### Backend Java (Domain-Driven Design & Hexagonal)
Stocker les règles de votre système de jeu (D&D, Nimble, créations maison...) et définir les modèles de fiches de personnages associés. Les règles indexées peuvent être injectées dans le contexte de l'IA pour des réponses fidèles à votre système.
Le Backend Core respecte strictement :
- **Domain-Driven Design (DDD)** : Séparation en Bounded Contexts autonomes
- **Architecture Hexagonale (Ports et Adaptateurs)** : Domaine pur sans dépendances techniques
### Campaign
#### Bounded Contexts
- **LoreContext** : Gestion de l'encyclopédie de l'univers
- **CampaignContext** : Suivi des sessions et chronologie
- **GenerationContext** : Gestion des requêtes IA et templates
Structurer vos campagnes en Arcs → Chapitres → Scènes avec séparation claire du contenu MJ et du contenu joueurs. Gérer les PJ et PNJ via des fiches dynamiques basées sur les templates du game system retenu.
#### Couches
- **Domaine (Core)** : Entités métier pures et interfaces (Ports)
- **Application** : Orchestration des flux (Use Cases)
- **Infrastructure** : Implémentation technique (Adapters)
### Assistant IA
## Installation
Un assistant contextuel qui pioche dans votre Lore, vos règles et vos campagnes pour répondre à vos questions, suggérer du contenu cohérent, ou rebondir sur une situation improvisée en table.
Pour installer LoreMind chez vous (Docker requis), suivez le guide **[INSTALL.md](INSTALL.md)** — 3 étapes, 5 minutes chrono :
L'IA s'exécute **en local via [Ollama](https://ollama.com/)** ou via **[1min.ai](https://1min.ai/)**. D'autres moteurs seront supportés à l'avenir.
1. Télécharger `docker-compose.yml` + `.env.example` depuis la [dernière release](https://git.igmlcreation.fr/ietm64/LoreMindMJ/releases)
2. Renommer `.env.example``.env` et changer `POSTGRES_PASSWORD`
3. `docker compose up -d` → ouvrir http://localhost:8081
## Documentation
Mise à jour : `docker compose pull && docker compose up -d`.
Toute la documentation (installation, configuration, prise en main) est sur **[loremind-docs.igmlcreation.fr](https://loremind-docs.igmlcreation.fr/)**.
## Développement (contributeurs)
## Démo en ligne
Pour builder les images localement depuis les sources :
Une instance de démonstration est disponible sur **[loremind-demo.igmlcreation.fr](https://loremind-demo.igmlcreation.fr/)**.
```bash
git clone https://git.igmlcreation.fr/ietm64/LoreMindMJ.git
cd LoreMindMJ
# Créer un docker-compose.override.yml local (voir docs de contrib)
docker compose up -d --build
```
Quelques limites à connaître :
- 10 utilisateurs maximum simultanés (instances isolées)
- Session limitée à 20 minutes avant réinitialisation
- Partie IA non incluse dans la démo (nécessite Ollama ou 1min.ai côté serveur)
## License
## Soutenir le projet
LoreMind est **et restera gratuit en auto-hébergement**. Le développement avance plus vite avec votre soutien :
- **[Patreon](https://www.patreon.com/c/IGMLCreation)** — accès anticipé aux features, vote sur la roadmap, devlogs exclusifs
- **[Discord](https://discord.gg/cPpFzCjEzQ)** — annonces, support, retours utilisateurs
## Licence
LoreMind est distribué sous licence **[GNU AGPL v3](LICENSE)**.
En pratique :
- Tu peux l'utiliser gratuitement, l'héberger où tu veux, le modifier, le redistribuer.
- Si tu modifies le code et que tu exposes l'application modifiée sur un réseau (même en SaaS privé), tu dois rendre tes modifications publiques sous la même licence.
- Les univers (Lore) et campagnes que tu crées avec LoreMind **t'appartiennent entièrement** — la licence ne couvre que le code de l'application.
- Vous pouvez l'utiliser gratuitement, l'héberger, la modifier, la redistribuer.
- Si vous modifiez le code et que vous exposez l'application modifiée sur un réseau (même en SaaS privé), vous devez rendre vos modifications publiques sous la même licence.
- Les univers (Lore) et campagnes que vous créez avec LoreMind **vous appartiennent entièrement** — la licence ne couvre que le code de l'application.

View File

@@ -21,6 +21,7 @@ from app.domain.models import (
ChatMessage,
ChapterSummary,
CharacterSummary,
NpcSummary,
GameSystemContext,
LoreStructuralContext,
NarrativeEntityContext,
@@ -198,10 +199,12 @@ class ChatUseCase:
else "\n(Cette campagne n'est associée à aucun univers — tu peux proposer des éléments d'ambiance libres.)"
)
characters_block = ChatUseCase._format_characters(ctx.characters)
npcs_block = ChatUseCase._format_npcs(ctx.npcs)
return (
"--- CAMPAGNE COURANTE ---\n"
f"Nom : {ctx.campaign_name}{desc}{lore_note}\n"
f"{characters_block}\n"
f"{characters_block}"
f"{npcs_block}\n"
"Structure narrative (les flèches → indiquent des transitions de scène "
"déclenchées par un choix des joueurs) :\n"
f"{arcs_block}"
@@ -231,6 +234,33 @@ class ChatUseCase:
)
return "\n".join(lines) + "\n"
@staticmethod
def _format_npcs(npcs: list[NpcSummary]) -> str:
"""Bloc PNJ — symétrique aux PJ avec sa propre instruction anti-halluci.
Distinction importante : pour les PNJ, l'IA est ENCOURAGÉE à proposer de
nouveaux PNJ (création créative = OK). En revanche, elle ne doit pas
référencer comme existant un PNJ qui n'est pas dans la liste ci-dessous.
"""
if not npcs:
return (
"\nPersonnages non-joueurs (PNJ) : aucun défini pour l'instant. "
"Tu peux librement proposer de nouveaux PNJ au MJ, mais ne "
"fais pas comme s'ils existaient déjà dans la campagne.\n"
)
lines = ["\nPersonnages non-joueurs (PNJ) connus :"]
for n in npcs:
if n.snippet:
lines.append(f"- **{n.name}** — {n.snippet}")
else:
lines.append(f"- **{n.name}** (fiche vide)")
lines.append(
"Pour une fiche complète d'un PNJ existant (apparence, motivations), "
"n'invente rien : demande au MJ d'ouvrir l'éditeur du PNJ. Tu peux "
"en revanche proposer librement de NOUVEAUX PNJ."
)
return "\n".join(lines) + "\n"
@staticmethod
def _format_arcs(arcs: list[ArcSummary]) -> str:
if not arcs:
@@ -319,7 +349,8 @@ class ChatUseCase:
"arc": "ARC",
"chapter": "CHAPITRE",
"scene": "SCÈNE",
"character": "FICHE DE PERSONNAGE",
"character": "FICHE DE PERSONNAGE (PJ)",
"npc": "FICHE DE PNJ",
}.get(ne.entity_type.lower(), ne.entity_type.upper())
if ne.fields:
fields_block = "\n".join(

View File

@@ -170,6 +170,7 @@ class CampaignStructuralContext:
campaign_description: str | None
arcs: list[ArcSummary]
characters: list["CharacterSummary"] = field(default_factory=list)
npcs: list["NpcSummary"] = field(default_factory=list)
@dataclass(frozen=True)
@@ -185,6 +186,19 @@ class CharacterSummary:
snippet: str
@dataclass(frozen=True)
class NpcSummary:
"""Résumé d'un PNJ : symétrique à CharacterSummary.
Permet à l'IA de connaître les PNJ d'une campagne (nom + snippet) sans
injecter leurs fiches complètes. Évolution prévue : entity_type="npc"
pour focus sur la fiche complète.
"""
name: str
snippet: str
@dataclass(frozen=True)
class NarrativeEntityContext:
"""Contexte d'une entité narrative précise en cours d'édition.

View File

@@ -61,7 +61,16 @@ class OllamaLLMProvider:
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
response = await client.post(url, json=payload)
response.raise_for_status()
if response.status_code >= 400:
body = response.text
try:
err_obj = json.loads(body)
err_msg = err_obj.get("error") or body
except json.JSONDecodeError:
err_msg = body
raise LLMProviderError(
f"Ollama HTTP {response.status_code} : {err_msg.strip()[:500]}"
)
except httpx.HTTPError as exc:
raise LLMProviderError(
f"Erreur lors de l'appel à Ollama : {exc}"
@@ -105,7 +114,20 @@ class OllamaLLMProvider:
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
async with client.stream("POST", url, json=payload) as response:
response.raise_for_status()
if response.status_code >= 400:
# On lit le body d'erreur pour le remonter a l'utilisateur,
# sinon on ne voit que "500 Internal Server Error" sans
# savoir POURQUOI Ollama refuse (modele introuvable, OOM,
# num_ctx trop grand pour la VRAM, etc.).
body = (await response.aread()).decode("utf-8", errors="replace")
try:
err_obj = json.loads(body)
err_msg = err_obj.get("error") or body
except json.JSONDecodeError:
err_msg = body
raise LLMProviderError(
f"Ollama HTTP {response.status_code} : {err_msg.strip()[:500]}"
)
async for line in response.aiter_lines():
if not line.strip():
continue

View File

@@ -23,6 +23,7 @@ from app.domain.models import (
CampaignStructuralContext,
ChapterSummary,
CharacterSummary,
NpcSummary,
ChatMessage,
GameSystemContext,
LoreStructuralContext,
@@ -40,7 +41,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
app = FastAPI(
title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.",
version="0.5.0",
version="0.8.5",
)
@@ -205,6 +206,13 @@ class CharacterSummaryDTO(BaseModel):
snippet: str = ""
class NpcSummaryDTO(BaseModel):
"""Résumé d'un PNJ : symétrique à CharacterSummaryDTO."""
name: str
snippet: str = ""
class CampaignContextDTO(BaseModel):
"""Carte narrative enrichie : arcs → chapitres → scènes avec synopsis."""
@@ -212,12 +220,13 @@ class CampaignContextDTO(BaseModel):
campaign_description: str | None = None
arcs: list[ArcSummaryDTO] = Field(default_factory=list)
characters: list[CharacterSummaryDTO] = Field(default_factory=list)
npcs: list[NpcSummaryDTO] = Field(default_factory=list)
class NarrativeEntityDTO(BaseModel):
"""Entité narrative (arc/chapter/scene/character) en cours d'édition — focus optionnel."""
entity_type: str = Field(pattern="^(arc|chapter|scene|character)$")
entity_type: str = Field(pattern="^(arc|chapter|scene|character|npc)$")
title: str
fields: dict[str, str] = Field(default_factory=dict)
@@ -553,11 +562,16 @@ def _to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralCo
CharacterSummary(name=c.name, snippet=c.snippet)
for c in dto.characters
]
npcs = [
NpcSummary(name=n.name, snippet=n.snippet)
for n in dto.npcs
]
return CampaignStructuralContext(
campaign_name=dto.campaign_name,
campaign_description=dto.campaign_description,
arcs=arcs,
characters=characters,
npcs=npcs,
)
@@ -689,6 +703,76 @@ async def get_ollama_model_info(
return OllamaModelInfoDTO(context_length=0)
@app.post("/models/ollama/pull")
async def pull_ollama_model(
body: dict[str, str],
settings: Annotated[Settings, Depends(get_settings)],
) -> StreamingResponse:
"""Telecharge un modele depuis Ollama et streame la progression.
Proxifie l'endpoint `/api/pull` d'Ollama qui renvoie du JSON ligne par
ligne (NDJSON) avec le statut de chaque etape : manifest, layers,
digest, success. On reemet ce flux tel quel au client (le front
parsera les lignes et affichera une barre de progression).
Le timeout est intentionnellement tres long (60 min) car certains
modeles font 30+ Go.
"""
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name requis")
url = f"{settings.ollama_base_url}/api/pull"
async def stream() -> AsyncIterator[bytes]:
# On utilise un timeout long pour la lecture (60 min) mais court pour
# la connexion (10s) — si Ollama n'est pas joignable, on echoue vite.
timeout = httpx.Timeout(connect=10, read=3600, write=10, pool=10)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
async with client.stream("POST", url, json={"model": name, "stream": True}) as r:
if r.status_code != 200:
# Ollama renvoie un message JSON d'erreur. On le passe
# tel quel au client en preservant le code HTTP.
body_text = await r.aread()
yield body_text
return
async for chunk in r.aiter_bytes():
yield chunk
except httpx.HTTPError as e:
# Erreur reseau : on emet une ligne JSON d'erreur compatible
# avec le format NDJSON d'Ollama.
err = json.dumps({"error": f"Connexion a Ollama impossible : {e}"}) + "\n"
yield err.encode("utf-8")
# application/x-ndjson : un objet JSON par ligne, pas de wrapping SSE.
# C'est le format natif d'Ollama, le front le parsera ligne par ligne.
return StreamingResponse(stream(), media_type="application/x-ndjson")
@app.delete("/models/ollama/{name:path}")
async def delete_ollama_model(
name: str,
settings: Annotated[Settings, Depends(get_settings)],
) -> dict[str, str]:
"""Supprime un modele du serveur Ollama.
Le `:path` dans le pattern autorise les `:` du nom (ex: `gemma4:e4b`)
sans avoir besoin de URL-encoder cote client.
"""
if not name.strip():
raise HTTPException(status_code=400, detail="name requis")
url = f"{settings.ollama_base_url}/api/delete"
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.request("DELETE", url, json={"model": name})
if response.status_code == 404:
raise HTTPException(status_code=404, detail=f"Modele '{name}' introuvable")
response.raise_for_status()
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Ollama injoignable : {e}")
return {"status": "deleted", "name": name}
@app.get("/models/onemin")
def list_onemin_models() -> dict[str, list[dict[str, object]]]:
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur.

View File

@@ -8,13 +8,13 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
<version>3.2.12</version>
<relativePath/>
</parent>
<groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId>
<version>0.5.0</version>
<version>0.8.5</version>
<name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description>
@@ -83,6 +83,28 @@
<artifactId>minio</artifactId>
<version>8.5.11</version>
</dependency>
<!-- Nimbus JOSE+JWT — verification des JWT Ed25519 (EdDSA) emis par le relais
Patreon. Supporte nativement les cles Ed25519 via BouncyCastle. -->
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>9.40</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>1.78.1</version>
</dependency>
<!-- Google Tink : runtime requis par com.nimbusds.jose.crypto.Ed25519Verifier
(depuis Nimbus 9.x, la verification EdDSA delegue a Tink.subtle.Ed25519Verify).
Tink n'est PAS une dependance transitive de nimbus-jose-jwt → il faut
l'ajouter explicitement, sinon NoClassDefFoundError au premier verify(). -->
<dependency>
<groupId>com.google.crypto.tink</groupId>
<artifactId>tink</artifactId>
<version>1.14.1</version>
</dependency>
</dependencies>
<build>
@@ -98,6 +120,16 @@
</exclude>
</excludes>
</configuration>
<executions>
<!-- Genere META-INF/build-info.properties (project.version)
consomme par Spring BuildProperties pour exposer la
version courante a l'application (UpdateCheckService). -->
<execution>
<goals>
<goal>build-info</goal>
</goals>
</execution>
</executions>
</plugin>
<!-- JaCoCo : rapport de couverture des tests unitaires.

View File

@@ -2,12 +2,14 @@ package com.loremind;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* Classe principale de l'application LoreMind.
* Point d'entrée Spring Boot qui démarre l'application.
*/
@SpringBootApplication
@EnableScheduling
public class LoreMindApplication {
public static void main(String[] args) {

View File

@@ -1,9 +1,13 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@@ -17,17 +21,31 @@ import java.util.Optional;
public class ArcService {
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
public ArcService(ArcRepository arcRepository) {
public ArcService(ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository) {
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
}
/** Compte des entités qui seront supprimées en cascade avec l'arc. */
public record DeletionImpact(int chapters, int scenes) {}
public Arc createArc(String name, String description, String campaignId, int order) {
return createArc(name, description, campaignId, order, null);
}
public Arc createArc(String name, String description, String campaignId, int order, String icon) {
Arc arc = Arc.builder()
.name(name)
.description(description)
.campaignId(campaignId)
.order(order)
.icon(icon)
.build();
return arcRepository.save(arc);
}
@@ -59,7 +77,31 @@ public class ArcService {
return arcRepository.save(arc);
}
/**
* Calcule l'impact d'une suppression en cascade : chapitres + scènes
* qui disparaîtront avec l'arc.
*/
public DeletionImpact getDeletionImpact(String id) {
List<Chapter> chapters = chapterRepository.findByArcId(id);
int sceneTotal = 0;
for (Chapter chapter : chapters) {
sceneTotal += sceneRepository.findByChapterId(chapter.getId()).size();
}
return new DeletionImpact(chapters.size(), sceneTotal);
}
/**
* Supprime l'arc et toutes ses entités dépendantes (chapitres → scènes).
* Transactionnel : atomique.
*/
@Transactional
public void deleteArc(String id) {
for (Chapter chapter : chapterRepository.findByArcId(id)) {
for (var scene : sceneRepository.findByChapterId(chapter.getId())) {
sceneRepository.deleteById(scene.getId());
}
chapterRepository.deleteById(chapter.getId());
}
arcRepository.deleteById(id);
}

View File

@@ -1,8 +1,15 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@@ -16,9 +23,22 @@ import java.util.Optional;
public class CampaignService {
private final CampaignRepository campaignRepository;
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
private final CharacterRepository characterRepository;
public CampaignService(CampaignRepository campaignRepository) {
public CampaignService(
CampaignRepository campaignRepository,
ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository,
CharacterRepository characterRepository) {
this.campaignRepository = campaignRepository;
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
this.characterRepository = characterRepository;
}
/**
@@ -30,6 +50,12 @@ public class CampaignService {
*/
public record CampaignData(String name, String description, String loreId, String gameSystemId) {}
/**
* Compte des entités qui seront supprimées en cascade si la campagne est effacée.
* Utilisé par l'UI pour afficher un récapitulatif dans le dialogue de confirmation.
*/
public record DeletionImpact(int arcs, int chapters, int scenes, int characters) {}
public Campaign createCampaign(CampaignData data) {
Campaign campaign = Campaign.builder()
.name(data.name())
@@ -71,7 +97,48 @@ public class CampaignService {
return (id == null || id.isBlank()) ? null : id;
}
/**
* Calcule l'impact d'une suppression en cascade : nombre d'arcs, chapitres,
* scènes et personnages qui disparaîtront avec la campagne. Utilisé par l'UI
* pour afficher "X arcs, Y chapitres, Z scènes seront supprimés".
*/
public DeletionImpact getDeletionImpact(String id) {
List<Arc> arcs = arcRepository.findByCampaignId(id);
int chapterTotal = 0;
int sceneTotal = 0;
for (Arc arc : arcs) {
List<Chapter> chapters = chapterRepository.findByArcId(arc.getId());
chapterTotal += chapters.size();
for (Chapter chapter : chapters) {
sceneTotal += sceneRepository.findByChapterId(chapter.getId()).size();
}
}
int characterTotal = characterRepository.findByCampaignId(id).size();
return new DeletionImpact(arcs.size(), chapterTotal, sceneTotal, characterTotal);
}
/**
* Supprime la campagne et toutes ses entités dépendantes (arcs → chapitres →
* scènes, plus les personnages). L'opération est transactionnelle : soit
* tout disparaît, soit rien ne change. Les FKs applicatives n'ayant pas
* de contrainte CASCADE au niveau DB, on orchestre la cascade ici.
*/
@Transactional
public void deleteCampaign(String id) {
List<Arc> arcs = arcRepository.findByCampaignId(id);
for (Arc arc : arcs) {
List<Chapter> chapters = chapterRepository.findByArcId(arc.getId());
for (Chapter chapter : chapters) {
for (var scene : sceneRepository.findByChapterId(chapter.getId())) {
sceneRepository.deleteById(scene.getId());
}
chapterRepository.deleteById(chapter.getId());
}
arcRepository.deleteById(arc.getId());
}
for (var character : characterRepository.findByCampaignId(id)) {
characterRepository.deleteById(character.getId());
}
campaignRepository.deleteById(id);
}

View File

@@ -2,8 +2,10 @@ package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@@ -17,17 +19,27 @@ import java.util.Optional;
public class ChapterService {
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
public ChapterService(ChapterRepository chapterRepository) {
public ChapterService(ChapterRepository chapterRepository, SceneRepository sceneRepository) {
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
}
/** Compte des scènes qui seront supprimées en cascade avec le chapitre. */
public record DeletionImpact(int scenes) {}
public Chapter createChapter(String name, String description, String arcId, int order) {
return createChapter(name, description, arcId, order, null);
}
public Chapter createChapter(String name, String description, String arcId, int order, String icon) {
Chapter chapter = Chapter.builder()
.name(name)
.description(description)
.arcId(arcId)
.order(order)
.icon(icon)
.build();
return chapterRepository.save(chapter);
}
@@ -58,7 +70,17 @@ public class ChapterService {
return chapterRepository.save(chapter);
}
/** Compte des scènes qui tomberont avec le chapitre. */
public DeletionImpact getDeletionImpact(String id) {
return new DeletionImpact(sceneRepository.findByChapterId(id).size());
}
/** Supprime le chapitre et toutes ses scènes. Transactionnel : atomique. */
@Transactional
public void deleteChapter(String id) {
for (var scene : sceneRepository.findByChapterId(id)) {
sceneRepository.deleteById(scene.getId());
}
chapterRepository.deleteById(id);
}

View File

@@ -4,7 +4,9 @@ import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
@@ -22,8 +24,18 @@ public class CharacterService {
/**
* Parameter Object pour la création / mise à jour d'un Character.
* `order` est fourni par le controller ; si absent, le service le calcule.
* Les maps {@code values}/{@code imageValues} peuvent etre null (interpretees vides).
*/
public record CharacterData(String name, String markdownContent, String campaignId, Integer order) {}
public record CharacterData(
String name,
String portraitImageId,
String headerImageId,
Map<String, String> values,
Map<String, List<String>> imageValues,
Map<String, Map<String, String>> keyValueValues,
String campaignId,
Integer order
) {}
public Character createCharacter(CharacterData data) {
int order = data.order() != null
@@ -31,7 +43,11 @@ public class CharacterService {
: nextOrderFor(data.campaignId());
Character character = Character.builder()
.name(data.name())
.markdownContent(data.markdownContent())
.portraitImageId(data.portraitImageId())
.headerImageId(data.headerImageId())
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
.campaignId(data.campaignId())
.order(order)
.build();
@@ -50,7 +66,11 @@ public class CharacterService {
Character existing = characterRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Character non trouvé avec l'ID: " + id));
existing.setName(data.name());
existing.setMarkdownContent(data.markdownContent());
existing.setPortraitImageId(data.portraitImageId());
existing.setHeaderImageId(data.headerImageId());
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
if (data.order() != null) {
existing.setOrder(data.order());
}

View File

@@ -0,0 +1,85 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.ports.NpcRepository;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Service d'application pour les fiches de PNJ (campagne).
*/
@Service
public class NpcService {
private final NpcRepository npcRepository;
public NpcService(NpcRepository npcRepository) {
this.npcRepository = npcRepository;
}
public record NpcData(
String name,
String portraitImageId,
String headerImageId,
Map<String, String> values,
Map<String, List<String>> imageValues,
Map<String, Map<String, String>> keyValueValues,
String campaignId,
Integer order
) {}
public Npc createNpc(NpcData data) {
int order = data.order() != null
? data.order()
: nextOrderFor(data.campaignId());
Npc npc = Npc.builder()
.name(data.name())
.portraitImageId(data.portraitImageId())
.headerImageId(data.headerImageId())
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
.campaignId(data.campaignId())
.order(order)
.build();
return npcRepository.save(npc);
}
public Optional<Npc> getNpcById(String id) {
return npcRepository.findById(id);
}
public List<Npc> getNpcsByCampaignId(String campaignId) {
return npcRepository.findByCampaignId(campaignId);
}
public Npc updateNpc(String id, NpcData data) {
Npc existing = npcRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id));
existing.setName(data.name());
existing.setPortraitImageId(data.portraitImageId());
existing.setHeaderImageId(data.headerImageId());
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
if (data.order() != null) {
existing.setOrder(data.order());
}
return npcRepository.save(existing);
}
public void deleteNpc(String id) {
npcRepository.deleteById(id);
}
private int nextOrderFor(String campaignId) {
return npcRepository.findByCampaignId(campaignId).stream()
.mapToInt(Npc::getOrder)
.max()
.orElse(-1) + 1;
}
}

View File

@@ -26,11 +26,16 @@ public class SceneService {
}
public Scene createScene(String name, String description, String chapterId, int order) {
return createScene(name, description, chapterId, order, null);
}
public Scene createScene(String name, String description, String chapterId, int order, String icon) {
Scene scene = Scene.builder()
.name(name)
.description(description)
.chapterId(chapterId)
.order(order)
.icon(icon)
.build();
return sceneRepository.save(scene);
}
@@ -93,7 +98,7 @@ public class SceneService {
.collect(Collectors.toSet());
for (SceneBranch b : branches) {
String target = b.getTargetSceneId();
String target = b.targetSceneId();
if (target == null || target.isBlank()) {
throw new IllegalArgumentException("Une branche doit avoir une scène de destination");
}

View File

@@ -45,11 +45,7 @@ public class GameSystemContextBuilder {
private GameSystemContext build(GameSystem gs, GenerationIntent intent) {
Map<String, String> allSections = parseH2Sections(gs.getRulesMarkdown());
Map<String, String> filtered = filterByIntent(allSections, intent);
return GameSystemContext.builder()
.systemName(gs.getName())
.systemDescription(gs.getDescription())
.sections(filtered)
.build();
return new GameSystemContext(gs.getName(), gs.getDescription(), filtered);
}
/**

View File

@@ -2,6 +2,7 @@ package com.loremind.application.gamesystemcontext;
import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import com.loremind.domain.shared.template.TemplateField;
import org.springframework.stereotype.Service;
import java.util.List;
@@ -18,11 +19,14 @@ public class GameSystemService {
/**
* Parameter Object pour la création / mise à jour d'un GameSystem.
* Les templates peuvent etre null (interpretes comme listes vides).
*/
public record GameSystemData(
String name,
String description,
String rulesMarkdown,
List<TemplateField> characterTemplate,
List<TemplateField> npcTemplate,
String author,
boolean isPublic
) {}
@@ -35,6 +39,8 @@ public class GameSystemService {
.author(normalize(data.author()))
.isPublic(data.isPublic())
.build();
gameSystem.replaceCharacterTemplate(data.characterTemplate());
gameSystem.replaceNpcTemplate(data.npcTemplate());
return gameSystemRepository.save(gameSystem);
}
@@ -52,6 +58,8 @@ public class GameSystemService {
existing.setName(data.name());
existing.setDescription(data.description());
existing.setRulesMarkdown(data.rulesMarkdown());
existing.replaceCharacterTemplate(data.characterTemplate());
existing.replaceNpcTemplate(data.npcTemplate());
existing.setAuthor(normalize(data.author()));
existing.setPublic(data.isPublic());
return gameSystemRepository.save(existing);

View File

@@ -4,17 +4,20 @@ import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import org.springframework.stereotype.Component;
@@ -42,21 +45,24 @@ public class CampaignStructuralContextBuilder {
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
private final CharacterRepository characterRepository;
private final NpcRepository npcRepository;
public CampaignStructuralContextBuilder(
CampaignRepository campaignRepository,
ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository,
CharacterRepository characterRepository) {
CharacterRepository characterRepository,
NpcRepository npcRepository) {
this.campaignRepository = campaignRepository;
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
this.characterRepository = characterRepository;
this.npcRepository = npcRepository;
}
/** Longueur max du snippet de PJ injecté dans le contexte (coût tokens maîtrisé). */
/** Longueur max du snippet de PJ/PNJ injecté dans le contexte (coût tokens maîtrisé). */
private static final int CHARACTER_SNIPPET_MAX_LEN = 160;
/**
@@ -79,12 +85,17 @@ public class CampaignStructuralContextBuilder {
.map(this::toCharacterSummary)
.collect(Collectors.toList());
return CampaignStructuralContext.builder()
.campaignName(campaign.getName())
.campaignDescription(campaign.getDescription())
.arcs(arcs)
.characters(characters)
.build();
List<NpcSummary> npcs = npcRepository.findByCampaignId(campaignId).stream()
.sorted(Comparator.comparingInt(Npc::getOrder))
.map(this::toNpcSummary)
.collect(Collectors.toList());
return new CampaignStructuralContext(
campaign.getName(),
campaign.getDescription(),
arcs,
characters,
npcs);
}
/**
@@ -93,34 +104,44 @@ public class CampaignStructuralContextBuilder {
* sans injecter toute sa fiche.
*/
private CharacterSummary toCharacterSummary(Character c) {
return CharacterSummary.builder()
.name(c.getName())
.snippet(extractSnippet(c.getMarkdownContent()))
.build();
return new CharacterSummary(c.getName(), extractSnippet(c.getValues()));
}
private static String extractSnippet(String markdown) {
if (markdown == null || markdown.isBlank()) return "";
String firstLine = markdown.lines()
/** Symétrique à {@link #toCharacterSummary} pour les PNJ. */
private NpcSummary toNpcSummary(Npc n) {
return new NpcSummary(n.getName(), extractSnippet(n.getValues()));
}
/**
* Snippet pour le resume IA : 1re ligne signifiante de la 1re valeur non vide
* du template (refonte 2026-04-30 — remplace l'ancien parsing markdown).
*/
private static String extractSnippet(java.util.Map<String, String> values) {
if (values == null || values.isEmpty()) return "";
for (String value : values.values()) {
if (value == null || value.isBlank()) continue;
String firstLine = value.lines()
.map(String::strip)
.filter(l -> !l.isEmpty() && !l.startsWith("#"))
.findFirst()
.orElse("");
if (firstLine.isEmpty()) continue;
if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine;
return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + "";
}
return "";
}
private ArcSummary toArcSummary(Arc arc) {
List<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream()
.sorted(Comparator.comparingInt(Chapter::getOrder))
.map(this::toChapterSummary)
.collect(Collectors.toList());
return ArcSummary.builder()
.name(arc.getName())
.description(arc.getDescription())
.illustrationCount(countImages(arc.getIllustrationImageIds()))
.chapters(chapters)
.build();
return new ArcSummary(
arc.getName(),
arc.getDescription(),
countImages(arc.getIllustrationImageIds()),
chapters);
}
private ChapterSummary toChapterSummary(Chapter chapter) {
@@ -137,32 +158,28 @@ public class CampaignStructuralContextBuilder {
.map(s -> toSceneSummary(s, nameById))
.collect(Collectors.toList());
return ChapterSummary.builder()
.name(chapter.getName())
.description(chapter.getDescription())
.illustrationCount(countImages(chapter.getIllustrationImageIds()))
.scenes(summaries)
.build();
return new ChapterSummary(
chapter.getName(),
chapter.getDescription(),
countImages(chapter.getIllustrationImageIds()),
summaries);
}
private SceneSummary toSceneSummary(Scene scene, Map<String, String> nameById) {
List<BranchHint> hints = scene.getBranches() == null
? List.of()
: scene.getBranches().stream()
.map(b -> BranchHint.builder()
.label(b.getLabel())
.targetSceneName(nameById.getOrDefault(
b.getTargetSceneId(), "(scène inconnue)"))
.condition(b.getCondition())
.build())
.map(b -> new BranchHint(
b.label(),
nameById.getOrDefault(b.targetSceneId(), "(scène inconnue)"),
b.condition()))
.collect(Collectors.toList());
return SceneSummary.builder()
.name(scene.getName())
.description(scene.getDescription())
.illustrationCount(countImages(scene.getIllustrationImageIds()))
.branches(hints)
.build();
return new SceneSummary(
scene.getName(),
scene.getDescription(),
countImages(scene.getIllustrationImageIds()),
hints);
}
/** Helper defensif : compte les illustrations attachees (null-safe). */

View File

@@ -67,16 +67,15 @@ public class GeneratePageValuesUseCase {
requireNonEmptyFields(template);
GenerationContext context = GenerationContext.builder()
.loreName(lore.getName())
.loreDescription(lore.getDescription())
.folderName(folder.getName())
.templateName(template.getName())
// Seuls les champs TEXT sont envoyes a l'IA : les champs IMAGE
// necessitent un workflow different (pas de generation LLM texte).
.templateFields(template.textFieldNames())
.pageTitle(page.getTitle())
.build();
GenerationContext context = new GenerationContext(
lore.getName(),
lore.getDescription(),
folder.getName(),
template.getName(),
template.textFieldNames(),
page.getTitle());
GenerationResult result = aiProvider.generatePage(context);
return result.values();

View File

@@ -82,12 +82,11 @@ public class LoreStructuralContextBuilder {
Map<String, String> pageTitleById = pages.stream()
.collect(Collectors.toMap(Page::getId, Page::getTitle, (a, b) -> a));
return LoreStructuralContext.builder()
.loreName(lore.getName())
.loreDescription(lore.getDescription())
.folders(buildFoldersMap(nodes, pages, templateNameById, pageTitleById))
.tags(extractUniqueTags(pages))
.build();
return new LoreStructuralContext(
lore.getName(),
lore.getDescription(),
buildFoldersMap(nodes, pages, templateNameById, pageTitleById),
extractUniqueTags(pages));
}
private Map<String, List<PageSummary>> buildFoldersMap(
@@ -118,13 +117,12 @@ public class LoreStructuralContextBuilder {
Page page,
Map<String, String> templateNameById,
Map<String, String> pageTitleById) {
return PageSummary.builder()
.title(page.getTitle())
.templateName(templateNameById.getOrDefault(page.getTemplateId(), "?"))
.values(truncatedValues(page.getValues()))
.tags(page.getTags() != null ? List.copyOf(page.getTags()) : Collections.emptyList())
.relatedPageTitles(resolveRelatedTitles(page.getRelatedPageIds(), pageTitleById))
.build();
return new PageSummary(
page.getTitle(),
templateNameById.getOrDefault(page.getTemplateId(), "?"),
truncatedValues(page.getValues()),
page.getTags() != null ? List.copyOf(page.getTags()) : Collections.emptyList(),
resolveRelatedTitles(page.getRelatedPageIds(), pageTitleById));
}
/**

View File

@@ -3,10 +3,12 @@ package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
import org.springframework.stereotype.Component;
@@ -29,22 +31,25 @@ public class NarrativeEntityContextBuilder {
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
private final CharacterRepository characterRepository;
private final NpcRepository npcRepository;
public NarrativeEntityContextBuilder(
ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository,
CharacterRepository characterRepository) {
CharacterRepository characterRepository,
NpcRepository npcRepository) {
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
this.characterRepository = characterRepository;
this.npcRepository = npcRepository;
}
/**
* Charge l'entité narrative ciblée et la projette vers un VO du GenerationContext.
*
* @param entityType "arc", "chapter", "scene" ou "character" (insensible à la casse)
* @param entityType "arc", "chapter", "scene", "character" ou "npc" (insensible à la casse)
* @param entityId l'ID de l'entité
* @throws IllegalArgumentException si le type est inconnu ou l'entité introuvable
*/
@@ -55,6 +60,7 @@ public class NarrativeEntityContextBuilder {
case "chapter" -> fromChapter(loadChapter(entityId));
case "scene" -> fromScene(loadScene(entityId));
case "character" -> fromCharacter(loadCharacter(entityId));
case "npc" -> fromNpc(loadNpc(entityId));
default -> throw new IllegalArgumentException("Type d'entité narrative inconnu: " + entityType);
};
}
@@ -81,6 +87,11 @@ public class NarrativeEntityContextBuilder {
.orElseThrow(() -> new IllegalArgumentException("Personnage non trouvé: " + id));
}
private Npc loadNpc(String id) {
return npcRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("PNJ non trouvé: " + id));
}
// --- Mapping entité → VO ------------------------------------------------
private NarrativeEntityContext fromArc(Arc a) {
@@ -91,11 +102,7 @@ public class NarrativeEntityContextBuilder {
putField(fields, "rewards", a.getRewards());
putField(fields, "resolution", a.getResolution());
putField(fields, "gmNotes", a.getGmNotes());
return NarrativeEntityContext.builder()
.entityType("arc")
.title(a.getName())
.fields(fields)
.build();
return new NarrativeEntityContext("arc", a.getName(), fields);
}
private NarrativeEntityContext fromChapter(Chapter c) {
@@ -104,11 +111,7 @@ public class NarrativeEntityContextBuilder {
putField(fields, "playerObjectives", c.getPlayerObjectives());
putField(fields, "narrativeStakes", c.getNarrativeStakes());
putField(fields, "gmNotes", c.getGmNotes());
return NarrativeEntityContext.builder()
.entityType("chapter")
.title(c.getName())
.fields(fields)
.build();
return new NarrativeEntityContext("chapter", c.getName(), fields);
}
private NarrativeEntityContext fromScene(Scene s) {
@@ -122,21 +125,25 @@ public class NarrativeEntityContextBuilder {
putField(fields, "combatDifficulty", s.getCombatDifficulty());
putField(fields, "enemies", s.getEnemies());
putField(fields, "gmSecretNotes", s.getGmSecretNotes());
return NarrativeEntityContext.builder()
.entityType("scene")
.title(s.getName())
.fields(fields)
.build();
return new NarrativeEntityContext("scene", s.getName(), fields);
}
private NarrativeEntityContext fromCharacter(Character c) {
Map<String, String> fields = new LinkedHashMap<>();
putField(fields, "fiche complète (markdown)", c.getMarkdownContent());
return NarrativeEntityContext.builder()
.entityType("character")
.title(c.getName())
.fields(fields)
.build();
if (c.getValues() != null) {
// Champs templates exposes individuellement — meilleur pour le LLM que
// l'ancien blob markdown monolithique.
c.getValues().forEach((k, v) -> putField(fields, k, v));
}
return new NarrativeEntityContext("character", c.getName(), fields);
}
private NarrativeEntityContext fromNpc(Npc n) {
Map<String, String> fields = new LinkedHashMap<>();
if (n.getValues() != null) {
n.getValues().forEach((k, v) -> putField(fields, k, v));
}
return new NarrativeEntityContext("npc", n.getName(), fields);
}
/** Null/blank devient chaîne vide — uniforme côté prompt, pas de NPE côté LLM. */

View File

@@ -107,11 +107,6 @@ public class StreamChatForLoreUseCase {
? page.getValues()
: Collections.emptyMap();
return PageContext.builder()
.title(page.getTitle())
.templateName(templateName)
.templateFields(templateFields)
.values(values)
.build();
return new PageContext(page.getTitle(), templateName, templateFields, values);
}
}

View File

@@ -0,0 +1,135 @@
package com.loremind.application.licensing;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
/**
* Orchestre la bascule de canal stable <-> beta via le sidecar `switcher`.
*
* <p>Le sidecar tourne en permanence et watch un fichier {@code command.json}
* dans un volume partage. Quand on depose une commande, il :
* <ol>
* <li>Sed la ligne IMAGE_NAMESPACE du .env</li>
* <li>Lance docker compose pull + up -d</li>
* <li>Ecrit son resultat dans {@code result.json}</li>
* </ol>
*
* <p>Le Core n'a PAS acces au socket Docker — il delegue tout au sidecar
* via fichiers, ce qui evite que la compromission du Core ne donne RCE
* sur l'hote. Le sidecar valide strictement le contenu de la commande
* (channel ∈ {stable, beta} uniquement).
*
* <p>Le canal actuel se deduit du prefixe d'image courant (recupere via
* la variable d'env {@code IMAGE_NAMESPACE} ou {@code UPDATE_CHECK_IMAGES}) :
* presence de "loremind-beta-" => canal beta, sinon stable.
*/
@Service
public class ChannelSwitcherService {
private static final Logger log = LoggerFactory.getLogger(ChannelSwitcherService.class);
public enum Channel { STABLE, BETA }
public enum SwitchStatus { IN_PROGRESS, SUCCESS, ERROR }
/** Snapshot du dernier resultat de switch ecrit par le sidecar. */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record SwitchResult(
String id,
SwitchStatus status,
Channel channel,
String message,
Instant completedAt) {}
private final Path switcherDataPath;
private final String imageNamespace;
private final ObjectMapper json = new ObjectMapper();
public ChannelSwitcherService(
@Value("${SWITCHER_DATA_PATH:/shared/switcher}") String switcherDataPath,
// On lit IMAGE_NAMESPACE en priorite, puis UPDATE_CHECK_IMAGES en fallback
// (la deuxieme est toujours injectee par compose, contrairement a la premiere
// qui peut etre absente dans les .env legacy).
@Value("${IMAGE_NAMESPACE:${UPDATE_CHECK_IMAGES:}}") String imageNamespaceRaw) {
this.switcherDataPath = Path.of(switcherDataPath);
this.imageNamespace = imageNamespaceRaw != null ? imageNamespaceRaw : "";
log.info("ChannelSwitcherService initialized: dataPath={} imageNamespace={}",
switcherDataPath, this.imageNamespace);
}
/**
* Detection du canal courant a partir du prefixe d'image charge au demarrage.
* Pas de magie : si le namespace contient "beta-" on est en beta, sinon stable.
*/
public Channel getCurrentChannel() {
return imageNamespace.contains("loremind-beta-") ? Channel.BETA : Channel.STABLE;
}
/**
* Indique si le sidecar est disponible (volume partage accessible).
* Si non, on degrade en lecture seule (l'UI affichera l'ancien message
* avec instructions manuelles).
*/
public boolean isSwitcherAvailable() {
return Files.isDirectory(switcherDataPath) && Files.isWritable(switcherDataPath);
}
/**
* Depose une commande de switch dans le volume partage. Renvoie l'ID
* de la commande, que le client peut utiliser pour poller le status.
*
* @throws IllegalStateException si le sidecar n'est pas disponible
* @throws IOException si l'ecriture du fichier echoue
*/
public String requestSwitch(Channel target) throws IOException {
if (!isSwitcherAvailable()) {
throw new IllegalStateException("Switcher sidecar not available (volume mount missing)");
}
String id = UUID.randomUUID().toString();
Map<String, Object> command = new LinkedHashMap<>();
command.put("id", id);
command.put("channel", target.name().toLowerCase());
command.put("requestedAt", Instant.now().toString());
Path commandFile = switcherDataPath.resolve("command.json");
Path tmp = Files.createTempFile(switcherDataPath, "command-", ".tmp");
try {
json.writerWithDefaultPrettyPrinter().writeValue(tmp.toFile(), command);
// Atomic move : evite que le sidecar lise un fichier partiellement ecrit.
Files.move(tmp, commandFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} finally {
// Cleanup au cas ou move aurait echoue avant le rename.
Files.deleteIfExists(tmp);
}
log.info("Switch command written: id={} channel={}", id, target);
return id;
}
/**
* Lit le dernier resultat ecrit par le sidecar, s'il existe.
* Renvoie null si aucun switch n'a encore ete tente sur cette instance.
*/
public SwitchResult getLastResult() {
Path resultFile = switcherDataPath.resolve("result.json");
if (!Files.exists(resultFile)) return null;
try {
return json.readValue(resultFile.toFile(), SwitchResult.class);
} catch (IOException e) {
log.warn("Cannot parse switcher result.json: {}", e.getMessage());
return null;
}
}
}

View File

@@ -0,0 +1,261 @@
package com.loremind.application.licensing;
import com.loremind.domain.licensing.License;
import com.loremind.domain.licensing.LicenseClaims;
import com.loremind.domain.licensing.LicenseSnapshot;
import com.loremind.domain.licensing.LicenseStatus;
import com.loremind.domain.licensing.RegistryCredentials;
import com.loremind.domain.licensing.ports.JwtVerifier;
import com.loremind.domain.licensing.ports.LicenseRelay;
import com.loremind.domain.licensing.ports.LicenseRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
/**
* Service application pour la gestion de la licence Patreon.
* <p>
* Responsabilites :
* <ul>
* <li>Installer un nouveau JWT recu du relais (apres OAuth utilisateur)</li>
* <li>Calculer le {@link LicenseStatus} courant en respectant la grace period</li>
* <li>Renouveler le JWT avant expiration en appelant le relais</li>
* <li>Activer/desactiver le toggle "canal beta" cote utilisateur</li>
* <li>Distribuer les credentials registry pour le pull beta</li>
* </ul>
*/
@Service
public class LicenseService {
private static final Logger log = LoggerFactory.getLogger(LicenseService.class);
private final LicenseRepository repository;
private final JwtVerifier jwtVerifier;
private final LicenseRelay relay;
private final long gracePeriodSeconds;
private final long refreshBeforeExpirySeconds;
public LicenseService(
LicenseRepository repository,
JwtVerifier jwtVerifier,
LicenseRelay relay,
@Value("${licensing.grace-period-days:14}") int gracePeriodDays,
@Value("${licensing.refresh-before-expiry-days:2}") int refreshBeforeExpiryDays) {
this.repository = repository;
this.jwtVerifier = jwtVerifier;
this.relay = relay;
this.gracePeriodSeconds = (long) gracePeriodDays * 86_400L;
this.refreshBeforeExpirySeconds = (long) refreshBeforeExpiryDays * 86_400L;
}
/**
* @return true si le verifier est configure (cle publique presente).
* L'UI peut masquer toute la section Patreon si false.
*/
public boolean isLicensingEnabled() {
return jwtVerifier.isConfigured();
}
/**
* Genere ou retourne l'instance_id stable de cette installation.
* Stocke dans la licence elle-meme. Si pas de licence, en cree un volatil
* (sera persiste a la prochaine connexion).
*/
public String getOrCreateInstanceId() {
return repository.findCurrent()
.map(License::getInstanceId)
.orElseGet(() -> "li-" + UUID.randomUUID());
}
/**
* Construit l'URL OAuth pour ouvrir dans le navigateur de l'utilisateur.
*/
public String buildConnectUrl() {
return relay.buildConnectUrl(getOrCreateInstanceId());
}
/**
* Installe un JWT recu du relais (l'utilisateur l'a colle dans l'UI ou
* recu via deep-link). Verifie la signature, extrait les claims, persiste.
*/
public LicenseSnapshot installToken(String rawJwt) throws InstallException {
if (!jwtVerifier.isConfigured()) {
throw new InstallException("Licensing feature not enabled (no public key configured)");
}
LicenseClaims claims;
try {
claims = jwtVerifier.verify(rawJwt);
} catch (JwtVerifier.JwtVerificationException e) {
throw new InstallException("Invalid JWT: " + e.getMessage());
}
Instant now = Instant.now();
if (claims.expiresAt().isBefore(now)) {
throw new InstallException("JWT already expired");
}
Optional<License> existing = repository.findCurrent();
License toSave = License.builder()
.id("current")
.rawJwt(rawJwt)
.patreonUserId(claims.subject())
.tierId(claims.tierId())
.instanceId(claims.instanceId())
.issuedAt(claims.issuedAt())
.expiresAt(claims.expiresAt())
.lastRefreshAttemptAt(now)
.lastRefreshSucceeded(true)
// Au premier install, on active le canal beta par defaut.
// Sur reinstall apres deconnexion, on respecte la valeur precedente.
.betaChannelEnabled(existing.map(License::isBetaChannelEnabled).orElse(true))
.createdAt(existing.map(License::getCreatedAt).orElse(now))
.build();
License saved = repository.save(toSave);
log.info("Patreon license installed for user={} tier={} expires={}",
saved.getPatreonUserId(), saved.getTierId(), saved.getExpiresAt());
return snapshotOf(saved, now);
}
/**
* Etat courant de la licence pour exposition UI / decision technique.
*/
public LicenseSnapshot getCurrentSnapshot() {
Optional<License> opt = repository.findCurrent();
if (opt.isEmpty()) return LicenseSnapshot.none();
return snapshotOf(opt.get(), Instant.now());
}
/**
* Supprime la licence (deconnexion volontaire de Patreon par l'utilisateur).
*/
public void disconnect() {
repository.deleteCurrent();
log.info("Patreon license removed (user disconnect)");
}
/**
* Active ou desactive le canal beta. Necessite une licence valide ou en grace.
*/
public LicenseSnapshot setBetaChannelEnabled(boolean enabled) {
License current = repository.findCurrent()
.orElseThrow(() -> new IllegalStateException("No license installed"));
current.setBetaChannelEnabled(enabled);
License saved = repository.save(current);
return snapshotOf(saved, Instant.now());
}
/**
* Tente un refresh si la licence est proche de l'expiration. Idempotent.
* Appele par le daemon planifie + manuellement via l'UI ("Reessayer").
*
* @return true si un refresh a ete tente (avec ou sans succes)
*/
public boolean refreshIfNeeded() {
Optional<License> opt = repository.findCurrent();
if (opt.isEmpty()) return false;
License current = opt.get();
Instant now = Instant.now();
long secondsUntilExpiry = Duration.between(now, current.getExpiresAt()).getSeconds();
if (secondsUntilExpiry > refreshBeforeExpirySeconds) {
return false;
}
return doRefresh(current, now);
}
/**
* Force un refresh immediat (bouton UI "Reessayer maintenant").
*/
public boolean forceRefresh() {
return repository.findCurrent()
.map(license -> doRefresh(license, Instant.now()))
.orElse(false);
}
private boolean doRefresh(License current, Instant now) {
log.info("Refreshing Patreon license (current expires {})", current.getExpiresAt());
try {
String newJwt = relay.refreshToken(current.getRawJwt());
LicenseClaims claims = jwtVerifier.verify(newJwt);
current.setRawJwt(newJwt);
current.setIssuedAt(claims.issuedAt());
current.setExpiresAt(claims.expiresAt());
current.setTierId(claims.tierId());
current.setLastRefreshAttemptAt(now);
current.setLastRefreshSucceeded(true);
repository.save(current);
log.info("License refreshed successfully (new expiry {})", claims.expiresAt());
return true;
} catch (LicenseRelay.RelayException e) {
current.setLastRefreshAttemptAt(now);
current.setLastRefreshSucceeded(false);
repository.save(current);
if (e.getKind() == LicenseRelay.RelayErrorKind.REJECTED) {
log.warn("Relay rejected refresh ({}): tier may have been cancelled", e.getMessage());
} else {
log.warn("Relay refresh transient failure ({}): {}", e.getKind(), e.getMessage());
}
return true;
} catch (JwtVerifier.JwtVerificationException e) {
current.setLastRefreshAttemptAt(now);
current.setLastRefreshSucceeded(false);
repository.save(current);
log.error("Relay returned a JWT that fails verification: {}", e.getMessage());
return true;
}
}
/**
* Recupere les credentials registry pour pull du canal beta.
* @return empty si pas de licence valide ou relais en echec
*/
public Optional<RegistryCredentials> fetchRegistryCredentials() {
LicenseSnapshot snap = getCurrentSnapshot();
if (snap.status() != LicenseStatus.VALID && snap.status() != LicenseStatus.GRACE) {
return Optional.empty();
}
License current = repository.findCurrent().orElse(null);
if (current == null) return Optional.empty();
try {
return Optional.of(relay.fetchRegistryCredentials(current.getRawJwt()));
} catch (LicenseRelay.RelayException e) {
log.warn("Cannot fetch registry credentials ({}): {}", e.getKind(), e.getMessage());
return Optional.empty();
}
}
private LicenseSnapshot snapshotOf(License l, Instant now) {
LicenseStatus status = computeStatus(l, now);
return new LicenseSnapshot(
status,
l.getPatreonUserId(),
l.getTierId(),
l.getInstanceId(),
l.getExpiresAt(),
l.getLastRefreshAttemptAt(),
l.isLastRefreshSucceeded(),
l.isBetaChannelEnabled()
);
}
private LicenseStatus computeStatus(License l, Instant now) {
if (l.getExpiresAt() == null) return LicenseStatus.NONE;
if (now.isBefore(l.getExpiresAt())) return LicenseStatus.VALID;
long secondsPastExpiry = Duration.between(l.getExpiresAt(), now).getSeconds();
if (secondsPastExpiry <= gracePeriodSeconds) return LicenseStatus.GRACE;
return LicenseStatus.EXPIRED;
}
public static class InstallException extends Exception {
public InstallException(String message) {
super(message);
}
}
}

View File

@@ -1,9 +1,13 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@@ -16,11 +20,20 @@ import java.util.Optional;
public class LoreNodeService {
private final LoreNodeRepository loreNodeRepository;
private final PageRepository pageRepository;
public LoreNodeService(LoreNodeRepository loreNodeRepository) {
public LoreNodeService(LoreNodeRepository loreNodeRepository, PageRepository pageRepository) {
this.loreNodeRepository = loreNodeRepository;
this.pageRepository = pageRepository;
}
/**
* Compte des entités qui seront supprimées en cascade si le dossier est effacé :
* le dossier lui-même n'est pas compté, seuls les descendants (sous-dossiers
* récursifs + pages de l'ensemble du sous-arbre).
*/
public record DeletionImpact(int folders, int pages) {}
/**
* Crée un LoreNode (dossier) à partir d'un "objet changes" porteur des valeurs
* souhaitées (pattern Parameter Object) : évite les signatures qui gonflent
@@ -68,7 +81,64 @@ public class LoreNodeService {
return loreNodeRepository.save(existing);
}
/**
* Calcule l'impact d'une suppression en cascade : nombre de sous-dossiers
* (récursif, sans compter la racine) et de pages dans l'ensemble du sous-arbre.
*/
public DeletionImpact getDeletionImpact(String id) {
List<LoreNode> descendants = collectDescendants(id);
int pageTotal = pageRepository.findByNodeId(id).size();
for (LoreNode descendant : descendants) {
pageTotal += pageRepository.findByNodeId(descendant.getId()).size();
}
return new DeletionImpact(descendants.size(), pageTotal);
}
/**
* Supprime le dossier et tout son sous-arbre (sous-dossiers récursifs + pages).
* Suppression en profondeur d'abord (feuilles → racine) pour limiter les
* références orphelines en cours de transaction. Les FKs applicatives n'ayant
* pas de CASCADE en DB, on orchestre la descente ici.
*/
@Transactional
public void deleteLoreNode(String id) {
List<LoreNode> descendants = collectDescendants(id);
// Descendants retournés en ordre BFS (haut → bas) : on inverse pour
// supprimer les feuilles en premier, puis on finit par la racine.
for (int i = descendants.size() - 1; i >= 0; i--) {
String descendantId = descendants.get(i).getId();
deletePagesOfNode(descendantId);
loreNodeRepository.deleteById(descendantId);
}
deletePagesOfNode(id);
loreNodeRepository.deleteById(id);
}
private void deletePagesOfNode(String nodeId) {
for (Page page : pageRepository.findByNodeId(nodeId)) {
pageRepository.deleteById(page.getId());
}
}
/**
* Retourne tous les descendants (hors racine) d'un dossier, en ordre BFS.
* Parcours itératif pour éviter tout risque de débordement de pile sur
* une arborescence profonde malicieuse.
*/
private List<LoreNode> collectDescendants(String rootId) {
List<LoreNode> result = new ArrayList<>();
List<String> frontier = new ArrayList<>();
frontier.add(rootId);
while (!frontier.isEmpty()) {
List<String> nextFrontier = new ArrayList<>();
for (String parentId : frontier) {
for (LoreNode child : loreNodeRepository.findByParentId(parentId)) {
result.add(child);
nextFrontier.add(child.getId());
}
}
frontier = nextFrontier;
}
return result;
}
}

View File

@@ -1,10 +1,17 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.lorecontext.Lore;
import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@@ -26,15 +33,28 @@ public class LoreService {
private final LoreRepository loreRepository;
private final LoreNodeRepository loreNodeRepository;
private final PageRepository pageRepository;
private final TemplateRepository templateRepository;
private final CampaignRepository campaignRepository;
public LoreService(LoreRepository loreRepository,
LoreNodeRepository loreNodeRepository,
PageRepository pageRepository) {
PageRepository pageRepository,
TemplateRepository templateRepository,
CampaignRepository campaignRepository) {
this.loreRepository = loreRepository;
this.loreNodeRepository = loreNodeRepository;
this.pageRepository = pageRepository;
this.templateRepository = templateRepository;
this.campaignRepository = campaignRepository;
}
/**
* Compte des entités qui seront supprimées / détachées en cascade si le Lore
* est effacé. `detachedCampaigns` : campagnes qui perdront leur référence à
* ce Lore (leur loreId sera nullé) mais resteront présentes.
*/
public record DeletionImpact(int folders, int pages, int templates, int detachedCampaigns) {}
public Lore createLore(String name, String description) {
Lore lore = Lore.builder()
.name(name)
@@ -83,7 +103,54 @@ public class LoreService {
return loreRepository.save(lore);
}
/**
* Calcule l'impact d'une suppression de Lore en cascade : dossiers + pages
* + templates supprimés, et campagnes qui seront détachées (loreId → null
* sans être supprimées, car une campagne peut vivre sans univers).
*/
public DeletionImpact getDeletionImpact(String id) {
int folders = (int) loreNodeRepository.countByLoreId(id);
int pages = (int) pageRepository.countByLoreId(id);
int templates = templateRepository.findByLoreId(id).size();
int detached = countCampaignsReferencingLore(id);
return new DeletionImpact(folders, pages, templates, detached);
}
/**
* Supprime le Lore et toutes ses entités dépendantes (dossiers, pages, templates).
* Les campagnes qui référençaient ce Lore sont conservées — leur loreId est
* mis à null (une campagne peut légitimement exister sans univers associé).
* Opération transactionnelle : atomique.
*/
@Transactional
public void deleteLore(String id) {
// Pages d'abord : elles référencent nodeId ET loreId, on les supprime
// globalement via loreId pour éviter d'en rater une rattachée à un
// node orphelin (ne devrait pas arriver, mais ceinture+bretelles).
for (Page page : pageRepository.findByLoreId(id)) {
pageRepository.deleteById(page.getId());
}
for (LoreNode node : loreNodeRepository.findByLoreId(id)) {
loreNodeRepository.deleteById(node.getId());
}
for (Template template : templateRepository.findByLoreId(id)) {
templateRepository.deleteById(template.getId());
}
// Détache les campagnes : on garde la campagne, on nulle juste la référence.
for (Campaign campaign : campaignRepository.findAll()) {
if (id.equals(campaign.getLoreId())) {
campaign.setLoreId(null);
campaignRepository.save(campaign);
}
}
loreRepository.deleteById(id);
}
private int countCampaignsReferencingLore(String id) {
int count = 0;
for (Campaign campaign : campaignRepository.findAll()) {
if (id.equals(campaign.getLoreId())) count++;
}
return count;
}
}

View File

@@ -1,7 +1,7 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.springframework.stereotype.Service;

View File

@@ -21,6 +21,9 @@ public class Arc {
private String campaignId; // Référence vers la Campaign parente
private int order; // Ordre de l'arc dans la campagne
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// Champs narratifs enrichis (voir docs/maquettes/campagne/détail/)
private String themes; // Thèmes principaux explorés dans cet arc
private String stakes; // Enjeux globaux pour les personnages

View File

@@ -21,6 +21,9 @@ public class Chapter {
private String arcId; // Référence vers l'Arc parent
private int order; // Ordre du chapitre dans l'arc
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// Champs narratifs enrichis (voir docs/maquettes/campagne/détail/)
private String gmNotes; // Notes privées du MJ (non exportées vers FoundryVTT)
private String playerObjectives; // Objectifs des joueurs dans ce chapitre

View File

@@ -4,17 +4,26 @@ import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Fiche de personnage joueur (PJ) d'une campagne.
* <p>
* MVP : contenu markdown libre, l'utilisateur met ce qu'il veut (stats,
* backstory, équipement). Évolution prévue vers un système templaté par
* GameSystem (la fiche Nimble n'a pas les mêmes champs qu'une fiche D&D).
* Champs universels hard-codes : {@code name}, {@code portraitImageId},
* {@code headerImageId}. Tout le reste est piloté par le template PJ du
* GameSystem associé à la campagne (cf. {@link com.loremind.domain.gamesystemcontext.GameSystem#getCharacterTemplate}).
* <p>
* Scope strict PJ : les PNJ restent dans le Lore (pages templatées) ou
* dans les scènes elles-mêmes. Si le besoin de PNJ spécifiques à une
* campagne remonte, on étendra l'entité (ex: type enum PJ/PNJ).
* Les valeurs des champs templates sont stockées dans deux maps :
* - {@code values} : champs TEXT et NUMBER (numérique sérialisé en string,
* parsé à l'usage cote presentation)
* - {@code imageValues} : champs IMAGE (liste ordonnée d'IDs d'images par champ)
* <p>
* Le champ historique {@code markdownContent} a été supprimé (refonte 2026-04-30).
* Le contenu pre-existant est migré dans {@code values["Notes"]} par défaut.
* <p>
* Scope strict PJ : les PNJ sont gérés par l'entité {@link Npc} (invariants divergents).
*/
@Data
@Builder
@@ -23,11 +32,32 @@ public class Character {
private String id;
private String name;
/** ID de l'image portrait (champ universel hard-codé). Nullable. */
private String portraitImageId;
/** ID de l'image header/banniere (champ universel hard-codé). Nullable. */
private String headerImageId;
/**
* Contenu libre en markdown — stats + backstory + notes. Nullable à la création,
* renseigné progressivement par le MJ.
* Valeurs des champs TEXT et NUMBER du template PJ. Cle = nom du champ
* (sensible a la casse cote stockage mais comparaison case-insensitive
* dans le domaine GameSystem). Jamais null apres construction.
*/
private String markdownContent;
private Map<String, String> values;
/**
* Valeurs des champs IMAGE du template PJ. Cle = nom du champ, valeur =
* liste ordonnee d'IDs d'images. Jamais null apres construction.
*/
private Map<String, List<String>> imageValues;
/**
* Valeurs des champs KEY_VALUE_LIST du template PJ. Cle externe = nom du
* champ template (ex: "Caracteristiques"), cle interne = label predefini
* dans le template (ex: "FOR"), valeur = valeur saisie (ex: "16").
* Les labels suivent l'ordre defini dans TemplateField.labels.
*/
private Map<String, Map<String, String>> keyValueValues;
/** Référence vers la Campaign parente. */
private String campaignId;
@@ -37,4 +67,20 @@ public class Character {
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/** Garantit que les maps ne sont jamais null cote consommateur. */
public Map<String, String> getValues() {
if (values == null) values = new HashMap<>();
return values;
}
public Map<String, List<String>> getImageValues() {
if (imageValues == null) imageValues = new HashMap<>();
return imageValues;
}
public Map<String, Map<String, String>> getKeyValueValues() {
if (keyValueValues == null) keyValueValues = new HashMap<>();
return keyValueValues;
}
}

View File

@@ -0,0 +1,69 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Fiche de personnage non-joueur (PNJ) d'une campagne.
* <p>
* Entité dédiée distincte de {@link Character} (DDD assumé : invariants divergents
* à terme — faction, statut vivant/mort, visibilité côté joueurs, etc.).
* <p>
* Mêmes champs universels hard-codés et meme structure de templating que Character,
* pilotée par le template PNJ du GameSystem
* ({@link com.loremind.domain.gamesystemcontext.GameSystem#getNpcTemplate}).
* <p>
* Scope campagne : les PNJ "univers" (worldboss, figures du Lore) restent gérés
* via le système Page/Template du LoreContext.
*/
@Data
@Builder
public class Npc {
private String id;
private String name;
/** ID de l'image portrait (champ universel hard-code). Nullable. */
private String portraitImageId;
/** ID de l'image header/banniere (champ universel hard-code). Nullable. */
private String headerImageId;
/** Valeurs TEXT/NUMBER du template PNJ. Jamais null apres construction. */
private Map<String, String> values;
/** Valeurs IMAGE du template PNJ (listes d'IDs ordonnees par champ). Jamais null. */
private Map<String, List<String>> imageValues;
/** Valeurs KEY_VALUE_LIST : fieldName -> label -> value. Jamais null. */
private Map<String, Map<String, String>> keyValueValues;
/** Référence vers la Campaign parente (cross-aggregate via ID). */
private String campaignId;
/** Ordre d'affichage dans la liste des PNJ de la campagne. */
private int order;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public Map<String, String> getValues() {
if (values == null) values = new HashMap<>();
return values;
}
public Map<String, List<String>> getImageValues() {
if (imageValues == null) imageValues = new HashMap<>();
return imageValues;
}
public Map<String, Map<String, String>> getKeyValueValues() {
if (keyValueValues == null) keyValueValues = new HashMap<>();
return keyValueValues;
}
}

View File

@@ -21,6 +21,9 @@ public class Scene {
private String chapterId; // Référence vers le Chapter parent
private int order; // Ordre de la scène dans le chapitre
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// === Contexte et ambiance ===
private String location; // Lieu de la scène (ex: Taverne du Dragon d'Or)
private String timing; // Moment (ex: Soir, à la tombée de la nuit)

View File

@@ -1,31 +1,25 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Value;
import lombok.extern.jackson.Jacksonized;
/**
* Value Object représentant une "sortie" narrative depuis une Scene.
* Décrit un choix offert aux joueurs et la scène de destination associée.
* <p>
* Immuable (@Value) : pour "modifier" une branche on la remplace.
* @Jacksonized : permet à Jackson (sérialisation JSON via le converter JPA)
* de reconstruire l'objet en passant par le builder malgré l'absence de setters.
* Record Java : immuable par construction, sans aucune dépendance technique
* (pas de Lombok, pas de Jackson). Jackson 2.12+ sait sérialiser/désérialiser
* les records nativement via le constructeur canonique — c'est ce dont
* dépend le SceneBranchListJsonConverter pour le stockage JSONB.
* <p>
* Règle métier : targetSceneId DOIT pointer vers une Scene du MÊME Chapter
* (validation portée par SceneService).
*
* @param label Libellé du choix (ex: "Si les joueurs attaquent le garde").
* @param targetSceneId Id de la Scene de destination, intra-chapitre uniquement.
* @param condition Notes MJ privées sur la condition de déclenchement (optionnel).
*/
@Value
@Builder
@Jacksonized
public class SceneBranch {
public record SceneBranch(String label, String targetSceneId, String condition) {
/** Libellé du choix (ex: "Si les joueurs attaquent le garde"). */
String label;
/** Id de la Scene de destination, intra-chapitre uniquement. */
String targetSceneId;
/** Notes MJ privées sur la condition de déclenchement (optionnel). */
String condition;
/** Raccourci pour construire une branche sans condition (cas le plus courant). */
public static SceneBranch of(String label, String targetSceneId) {
return new SceneBranch(label, targetSceneId, null);
}
}

View File

@@ -0,0 +1,22 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Npc;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des fiches de PNJ (campagne).
*/
public interface NpcRepository {
Npc save(Npc npc);
Optional<Npc> findById(String id);
List<Npc> findByCampaignId(String campaignId);
void deleteById(String id);
boolean existsById(String id);
}

View File

@@ -1,9 +1,13 @@
package com.loremind.domain.gamesystemcontext;
import com.loremind.domain.shared.template.TemplateField;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* Entité de domaine représentant un GameSystem (système de JDR).
@@ -12,6 +16,10 @@ import java.time.LocalDateTime;
* d'un markdown monolithique structuré par titres H2. Les sections sont extraites
* à la volée lors de l'injection dans les prompts IA (cf. GameSystemContextSelector).
* <p>
* Porte aussi deux templates piloтant la structure des fiches PJ et PNJ d'une
* campagne adossée à ce système. Les fiches markdown libres ont laissé place à
* un système de champs typés (TEXT/IMAGE/NUMBER) défini ici.
* <p>
* {@code author} et {@code isPublic} sont des champs pensés pour un futur marketplace
* de rulesets partagés — non exploités au MVP mais persistés dès maintenant pour
* éviter une migration ultérieure.
@@ -27,6 +35,21 @@ public class GameSystem {
/** Markdown monolithique. Sections découpées par titres H2 (## Combat, ## Classes, etc.). */
private String rulesMarkdown;
/**
* Template de fiche PJ : champs typés affichés pour chaque personnage joueur.
* Hors champs universels hard-codés (nom, portrait, header). Jamais null après
* persistance — un template vide est représenté par une liste vide.
*/
private List<TemplateField> characterTemplate;
/**
* Template de fiche PNJ. Mêmes règles que {@link #characterTemplate}.
* Distinct du template PJ car les invariants métier divergent (un PNJ peut
* n'avoir qu'un nom + une motivation, un PJ porte généralement une feuille
* de stats complète).
*/
private List<TemplateField> npcTemplate;
/** Auteur déclaré — futur marketplace. Nullable. */
private String author;
@@ -35,4 +58,88 @@ public class GameSystem {
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
// --- Méthodes métier : templates PJ/PNJ --------------------------------
/**
* Ajoute un champ au template PJ. Refuse les doublons de nom (insensible à la casse)
* pour éviter les collisions de clés dans {@code Character.values}.
*/
public void addCharacterField(TemplateField field) {
characterTemplate = appendField(characterTemplate, field);
}
/** Pendant PNJ de {@link #addCharacterField}. */
public void addNpcField(TemplateField field) {
npcTemplate = appendField(npcTemplate, field);
}
/**
* Retire un champ du template PJ par nom (insensible à la casse).
* No-op silencieux si le champ n'existe pas — appelant n'a pas à pré-vérifier.
*/
public void removeCharacterField(String fieldName) {
characterTemplate = removeFieldByName(characterTemplate, fieldName);
}
public void removeNpcField(String fieldName) {
npcTemplate = removeFieldByName(npcTemplate, fieldName);
}
/**
* Remplace intégralement le template PJ. Utilisé pour le réordonnancement
* et l'édition en bloc côté UI. Valide l'unicité des noms.
*/
public void replaceCharacterTemplate(List<TemplateField> fields) {
characterTemplate = validateAndCopy(fields);
}
public void replaceNpcTemplate(List<TemplateField> fields) {
npcTemplate = validateAndCopy(fields);
}
// --- Helpers privés ----------------------------------------------------
private static List<TemplateField> appendField(List<TemplateField> current, TemplateField field) {
if (field == null || field.getName() == null || field.getName().isBlank()) {
throw new IllegalArgumentException("Field name is required");
}
List<TemplateField> next = current == null ? new ArrayList<>() : new ArrayList<>(current);
if (containsName(next, field.getName())) {
throw new IllegalArgumentException("Duplicate field name: " + field.getName());
}
next.add(field);
return next;
}
private static List<TemplateField> removeFieldByName(List<TemplateField> current, String fieldName) {
if (current == null || fieldName == null) return current;
List<TemplateField> next = new ArrayList<>(current);
next.removeIf(f -> equalsIgnoreCase(f.getName(), fieldName));
return next;
}
private static List<TemplateField> validateAndCopy(List<TemplateField> fields) {
if (fields == null) return new ArrayList<>();
List<TemplateField> copy = new ArrayList<>(fields.size());
for (TemplateField f : fields) {
if (f == null || f.getName() == null || f.getName().isBlank()) {
throw new IllegalArgumentException("Field name is required");
}
if (containsName(copy, f.getName())) {
throw new IllegalArgumentException("Duplicate field name: " + f.getName());
}
copy.add(f);
}
return copy;
}
private static boolean containsName(List<TemplateField> fields, String name) {
return fields.stream().anyMatch(f -> equalsIgnoreCase(f.getName(), name));
}
private static boolean equalsIgnoreCase(String a, String b) {
if (a == null || b == null) return a == b;
return a.toLowerCase(Locale.ROOT).equals(b.toLowerCase(Locale.ROOT));
}
}

View File

@@ -1,9 +1,5 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Singular;
import lombok.Value;
import java.util.List;
/**
@@ -22,16 +18,18 @@ import java.util.List;
* <p>
* La liste `arcs` préserve l'ordre narratif (tri sur `order` ascendant
* fait par le use case côté application layer).
* <p>
* Record Java : pur domaine, aucune dépendance technique.
*
* @param characters Personnages joueurs (PJ) de la campagne. Vide si aucun.
* @param npcs Personnages non-joueurs (PNJ) de la campagne. Vide si aucun.
*/
@Value
@Builder
public class CampaignStructuralContext {
String campaignName;
String campaignDescription;
@Singular List<ArcSummary> arcs;
/** Personnages joueurs (PJ) de la campagne. Vide si aucun. */
@Singular List<CharacterSummary> characters;
public record CampaignStructuralContext(
String campaignName,
String campaignDescription,
List<ArcSummary> arcs,
List<CharacterSummary> characters,
List<NpcSummary> npcs) {
/**
* Résumé d'un PJ : nom + snippet court du markdown.
@@ -40,53 +38,52 @@ public class CampaignStructuralContext {
* La fiche complète n'est injectée que si le PJ est l'entité focus
* (via NarrativeEntityContext, entity_type="character").
*/
@Value
@Builder
public static class CharacterSummary {
String name;
String snippet;
public record CharacterSummary(String name, String snippet) {
}
/** Résumé d'un arc : nom + description courte + ses chapitres. */
@Value
@Builder
public static class ArcSummary {
String name;
String description;
/** Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA). */
int illustrationCount;
@Singular List<ChapterSummary> chapters;
/**
* Résumé d'un PNJ : symétrique à {@link CharacterSummary}.
* Snippet court extrait du markdown — la fiche complète est réservée
* à un usage focus (à venir, entity_type="npc").
*/
public record NpcSummary(String name, String snippet) {
}
/**
* Résumé d'un arc : nom + description courte + ses chapitres.
*
* @param illustrationCount Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA).
*/
public record ArcSummary(
String name,
String description,
int illustrationCount,
List<ChapterSummary> chapters) {
}
/** Résumé d'un chapitre : nom + description courte + ses scènes. */
@Value
@Builder
public static class ChapterSummary {
String name;
String description;
int illustrationCount;
@Singular List<SceneSummary> scenes;
public record ChapterSummary(
String name,
String description,
int illustrationCount,
List<SceneSummary> scenes) {
}
/** Résumé d'une scène : nom + description courte + branches narratives. */
@Value
@Builder
public static class SceneSummary {
String name;
String description;
int illustrationCount;
@Singular List<BranchHint> branches;
public record SceneSummary(
String name,
String description,
int illustrationCount,
List<BranchHint> branches) {
}
/** Indice d'une branche narrative vers une autre scène du même chapitre. */
@Value
@Builder
public static class BranchHint {
/** Libellé du choix joueur (ex: "Si les joueurs attaquent le garde"). */
String label;
/** Nom de la scène cible (résolu depuis targetSceneId côté builder). */
String targetSceneName;
/** Condition MJ privée (optionnel). */
String condition;
/**
* Indice d'une branche narrative vers une autre scène du même chapitre.
*
* @param label Libellé du choix joueur (ex: "Si les joueurs attaquent le garde").
* @param targetSceneName Nom de la scène cible (résolu depuis targetSceneId côté builder).
* @param condition Condition MJ privée (optionnel).
*/
public record BranchHint(String label, String targetSceneName, String condition) {
}
}

View File

@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.List;
/**
@@ -21,28 +18,74 @@ import java.util.List;
* Un chat Lore ne reçoit JAMAIS de campaignContext : un Lore ne voit pas
* ses campagnes (asymétrie métier : la campagne est l'emprunteur du Lore,
* pas l'inverse).
*/
@Value
@Builder
public class ChatRequest {
List<ChatMessage> messages;
/** Optionnel : carte structurelle du Lore. Null si campagne non liée à un Lore. */
LoreStructuralContext loreContext;
/** Optionnel : contexte d'une page précise en cours d'édition (chat Lore uniquement). */
PageContext pageContext;
/** Optionnel : carte narrative d'une Campagne (chat Campagne uniquement). */
CampaignStructuralContext campaignContext;
/** Optionnel : entité narrative en cours d'édition (arc/chapter/scene). */
NarrativeEntityContext narrativeEntity;
/**
* Optionnel : règles du système de JDR de la campagne (filtrées par intent).
* <p>
* Record Java : pur domaine. Builder manuel fourni en raison des 6 champs
* dont 5 sont nullables — l'API fluide reste plus lisible aux call sites
* qu'un constructeur à 6 paramètres souvent à null.
*
* @param loreContext Optionnel : carte structurelle du Lore. Null si campagne non liée à un Lore.
* @param pageContext Optionnel : contexte d'une page précise en cours d'édition (chat Lore uniquement).
* @param campaignContext Optionnel : carte narrative d'une Campagne (chat Campagne uniquement).
* @param narrativeEntity Optionnel : entité narrative en cours d'édition (arc/chapter/scene).
* @param gameSystemContext Optionnel : règles du système de JDR de la campagne (filtrées par intent).
* Null si la campagne n'a pas de GameSystem associé. Campagne uniquement au MVP.
*/
GameSystemContext gameSystemContext;
public record ChatRequest(
List<ChatMessage> messages,
LoreStructuralContext loreContext,
PageContext pageContext,
CampaignStructuralContext campaignContext,
NarrativeEntityContext narrativeEntity,
GameSystemContext gameSystemContext) {
public static Builder builder() {
return new Builder();
}
/** Builder fluide : permet d'omettre les contextes non pertinents. */
public static final class Builder {
private List<ChatMessage> messages;
private LoreStructuralContext loreContext;
private PageContext pageContext;
private CampaignStructuralContext campaignContext;
private NarrativeEntityContext narrativeEntity;
private GameSystemContext gameSystemContext;
private Builder() {}
public Builder messages(List<ChatMessage> messages) {
this.messages = messages;
return this;
}
public Builder loreContext(LoreStructuralContext loreContext) {
this.loreContext = loreContext;
return this;
}
public Builder pageContext(PageContext pageContext) {
this.pageContext = pageContext;
return this;
}
public Builder campaignContext(CampaignStructuralContext campaignContext) {
this.campaignContext = campaignContext;
return this;
}
public Builder narrativeEntity(NarrativeEntityContext narrativeEntity) {
this.narrativeEntity = narrativeEntity;
return this;
}
public Builder gameSystemContext(GameSystemContext gameSystemContext) {
this.gameSystemContext = gameSystemContext;
return this;
}
public ChatRequest build() {
return new ChatRequest(messages, loreContext, pageContext,
campaignContext, narrativeEntity, gameSystemContext);
}
}
}

View File

@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.Map;
/**
@@ -11,20 +8,14 @@ import java.util.Map;
* Contient uniquement les sections pertinentes pour l'intent de génération
* en cours (sélection effectuée par GameSystemContextBuilder). Les sections
* sont indexées par leur titre H2 original (ex : "Combat", "Classes").
*/
@Value
@Builder
public class GameSystemContext {
/** Nom du système de JDR (ex : "Nimble", "D&D 5.1 SRD"). */
String systemName;
/** Description courte du système (nullable). */
String systemDescription;
/**
* Sections de règles pertinentes, indexées par titre H2.
*
* @param systemName Nom du système de JDR (ex : "Nimble", "D&D 5.1 SRD").
* @param systemDescription Description courte du système (nullable).
* @param sections Sections de règles pertinentes, indexées par titre H2.
* Vide si le GameSystem n'a aucune règle ou si aucune section ne matche l'intent.
*/
Map<String, String> sections;
public record GameSystemContext(
String systemName,
String systemDescription,
Map<String, String> sections) {
}

View File

@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.List;
/**
@@ -10,19 +7,16 @@ import java.util.List;
* pour remplir une Page à partir d'un Template.
* <p>
* Équivalent Java du PageGenerationContext Python (brain/app/domain/models.py).
* Entité pure du domaine : aucune dépendance technique.
* <p>
* Immuable via @Value (Lombok) : pas de setters, tous les champs final.
* C'est un DTO de domaine entrant dans le port AiProvider.
* Record Java : pur domaine, aucune dépendance technique.
*
* @param templateFields Champs à générer (clés attendues dans la réponse).
* @param folderName Nom du LoreNode parent (ex: "PNJ", "Lieux").
*/
@Value
@Builder
public class GenerationContext {
String loreName;
String loreDescription;
String folderName; // Nom du LoreNode parent (ex: "PNJ", "Lieux")
String templateName;
List<String> templateFields; // Champs à générer (clés attendues dans la réponse)
String pageTitle;
public record GenerationContext(
String loreName,
String loreDescription,
String folderName,
String templateName,
List<String> templateFields,
String pageTitle) {
}

View File

@@ -1,9 +1,5 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Singular;
import lombok.Value;
import java.util.List;
import java.util.Map;
@@ -16,15 +12,14 @@ import java.util.Map;
* <p>
* La map `folders` est indexée par nom de dossier et mappe vers la liste
* des pages qu'il contient (liste vide autorisée pour les dossiers vides).
* <p>
* Record Java : pur domaine, aucune dépendance technique.
*/
@Value
@Builder
public class LoreStructuralContext {
String loreName;
String loreDescription;
Map<String, List<PageSummary>> folders;
@Singular List<String> tags;
public record LoreStructuralContext(
String loreName,
String loreDescription,
Map<String, List<PageSummary>> folders,
List<String> tags) {
/**
* Résumé projeté d'une page pour l'IA.
@@ -40,13 +35,11 @@ public class LoreStructuralContext {
* uniquement ce qui est partageable en narration — les secrets MJ
* restent confinés à leur page d'édition).
*/
@Value
@Builder
public static class PageSummary {
String title;
String templateName;
Map<String, String> values;
List<String> tags;
List<String> relatedPageTitles;
public record PageSummary(
String title,
String templateName,
Map<String, String> values,
List<String> tags,
List<String> relatedPageTitles) {
}
}

View File

@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.Map;
/**
@@ -17,13 +14,11 @@ import java.util.Map;
* `fields` associe le nom d'un champ (ex: "themes", "playerNarration")
* à sa valeur actuelle (chaîne vide si non renseigné). Utiliser une
* LinkedHashMap à la construction pour un prompt lisible (ordre préservé).
*
* @param entityType "arc", "chapter" ou "scene" — utilisé pour libeller le bloc du prompt.
*/
@Value
@Builder
public class NarrativeEntityContext {
/** "arc", "chapter" ou "scene" — utilisé pour libeller le bloc du prompt. */
String entityType;
String title;
Map<String, String> fields;
public record NarrativeEntityContext(
String entityType,
String title,
Map<String, String> fields) {
}

View File

@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.List;
import java.util.Map;
@@ -14,14 +11,11 @@ import java.util.Map;
* à l'IA de focaliser ses suggestions sur les bons champs sans déborder
* sur d'autres pages/templates.
* <p>
* Object de valeur immuable, pur domaine aucune dépendance technique.
* Record Java : immuable, pur domaine, aucune dépendance technique.
*/
@Value
@Builder
public class PageContext {
String title;
String templateName;
List<String> templateFields;
Map<String, String> values;
public record PageContext(
String title,
String templateName,
List<String> templateFields,
Map<String, String> values) {
}

View File

@@ -0,0 +1,48 @@
package com.loremind.domain.licensing;
import lombok.Builder;
import lombok.Data;
import java.time.Instant;
/**
* Licence Patreon installee dans cette instance LoreMind.
* <p>
* Singleton (une seule licence par instance, identifiee logiquement par
* {@code id = "current"}). Contient le JWT brut emis par le relais OAuth
* + les claims extraits a la verification, plus l'etat operationnel
* (derniere tentative de refresh, succes/echec).
* <p>
* <b>Note securite :</b> {@link #rawJwt} est stocke tel quel ; sa signature
* Ed25519 est verifiee a chaque lecture. Pas besoin de chiffrement au repos
* supplementaire — un attaquant qui a acces a la base a deja l'instance,
* et le JWT ne donne aucun pouvoir au-dela du canal beta de cette instance.
*/
@Data
@Builder
public class License {
private String id;
private String rawJwt;
private String patreonUserId;
private String tierId;
private String instanceId;
private Instant issuedAt;
private Instant expiresAt;
private Instant lastRefreshAttemptAt;
private boolean lastRefreshSucceeded;
private boolean betaChannelEnabled;
private Instant createdAt;
private Instant updatedAt;
}

View File

@@ -0,0 +1,15 @@
package com.loremind.domain.licensing;
import java.time.Instant;
/**
* Claims extraits d'un JWT licence apres verification de signature.
* Immuable.
*/
public record LicenseClaims(
String subject,
String tierId,
String instanceId,
Instant issuedAt,
Instant expiresAt
) {}

View File

@@ -0,0 +1,23 @@
package com.loremind.domain.licensing;
import java.time.Instant;
/**
* Vue immuable de la licence pour exposition vers les couches superieures.
* Decouple le domaine du DTO web et permet de calculer le {@link LicenseStatus}
* a un instant donne sans muter l'entite.
*/
public record LicenseSnapshot(
LicenseStatus status,
String patreonUserId,
String tierId,
String instanceId,
Instant expiresAt,
Instant lastRefreshAttemptAt,
boolean lastRefreshSucceeded,
boolean betaChannelEnabled
) {
public static LicenseSnapshot none() {
return new LicenseSnapshot(LicenseStatus.NONE, null, null, null, null, null, false, false);
}
}

View File

@@ -0,0 +1,23 @@
package com.loremind.domain.licensing;
/**
* Etat operationnel de la licence vis-a-vis de l'acces beta.
* <p>
* Calcule a partir de la presence de licence + son JWT exp + grace period.
* <ul>
* <li>{@link #NONE} : aucune licence installee</li>
* <li>{@link #VALID} : JWT non expire, acces beta autorise</li>
* <li>{@link #GRACE} : JWT expire mais dans la periode de tolerance ;
* acces beta toujours autorise, l'UI doit avertir</li>
* <li>{@link #EXPIRED} : au-dela de la grace period, acces beta refuse</li>
* <li>{@link #UNVERIFIABLE} : JWT impossible a verifier (cle publique manquante,
* signature invalide, claims malformes) — traite comme NONE pour la securite</li>
* </ul>
*/
public enum LicenseStatus {
NONE,
VALID,
GRACE,
EXPIRED,
UNVERIFIABLE
}

View File

@@ -0,0 +1,18 @@
package com.loremind.domain.licensing;
import java.time.Instant;
/**
* Credentials de pull pour un registry Docker, distribues par le relais
* apres verification d'un JWT licence valide.
* <p>
* {@code expiresAt} peut etre {@code null} si le credential est statique
* (cas du PAT GHCR partage en MVP) ; sinon, l'instance doit re-demander
* de nouveaux credentials avant cette date.
*/
public record RegistryCredentials(
String registry,
String username,
String password,
Instant expiresAt
) {}

View File

@@ -0,0 +1,34 @@
package com.loremind.domain.licensing.ports;
import com.loremind.domain.licensing.RegistryCredentials;
import java.io.IOException;
/**
* Port de sortie : ecriture du docker config.json partage avec Watchtower.
* <p>
* Le fichier sert a Watchtower pour s'authentifier au registry prive (GHCR)
* lors du pull des images du canal beta. Volume Docker {@code docker-config}
* monte sur Core (en ecriture) et sur Watchtower (en lecture, via la variable
* {@code DOCKER_CONFIG}).
*/
public interface DockerConfigWriter {
/**
* Ecrit ou met a jour les credentials pour le registry indique.
* Cree le fichier s'il n'existe pas, conserve les autres registries deja
* presents (en theorie : aucun, mais defensif).
*/
void writeCredentials(RegistryCredentials credentials) throws IOException;
/**
* Supprime le fichier de credentials. Appele quand la licence est invalidee
* ou que le toggle beta passe a OFF.
*/
void clear() throws IOException;
/**
* @return true si le fichier de creds existe actuellement.
*/
boolean isPresent();
}

View File

@@ -0,0 +1,34 @@
package com.loremind.domain.licensing.ports;
import com.loremind.domain.licensing.LicenseClaims;
/**
* Port de sortie : verification de signature et extraction des claims
* d'un JWT emis par le relais.
* <p>
* Implemente cote infrastructure avec la cle publique Ed25519 embarquee
* (SPKI PEM via configuration {@code licensing.jwt.public-key}).
*/
public interface JwtVerifier {
/**
* Verifie la signature, l'issuer, l'audience et l'expiration du JWT.
* @throws JwtVerificationException si la signature est invalide ou les claims malformes
*/
LicenseClaims verify(String rawJwt) throws JwtVerificationException;
/**
* @return true si la cle publique est configuree et utilisable.
* Permet a l'application de masquer la feature licensing si pas configuree.
*/
boolean isConfigured();
class JwtVerificationException extends Exception {
public JwtVerificationException(String message) {
super(message);
}
public JwtVerificationException(String message, Throwable cause) {
super(message, cause);
}
}
}

View File

@@ -0,0 +1,59 @@
package com.loremind.domain.licensing.ports;
import com.loremind.domain.licensing.RegistryCredentials;
/**
* Port de sortie vers le service relais OAuth Patreon.
* Encapsule les appels HTTP : refresh JWT et fetch registry credentials.
*/
public interface LicenseRelay {
/**
* Demande au relais l'URL OAuth a ouvrir pour connecter le compte Patreon.
*/
String buildConnectUrl(String instanceId);
/**
* Demande au relais de renouveler un JWT existant. Le relais re-verifie
* le tier Patreon de l'utilisateur ; renvoie un nouveau JWT si toujours
* actif, ou leve {@link RelayException} sinon.
*/
String refreshToken(String currentJwt) throws RelayException;
/**
* Demande au relais les credentials de pull du registry beta.
*/
RegistryCredentials fetchRegistryCredentials(String currentJwt) throws RelayException;
/**
* Erreurs distinctes emises par le relais. Permet au service application
* de differencier "tier expire" (action utilisateur) de "relais down"
* (action transitoire, garde la grace period).
*/
class RelayException extends Exception {
private final RelayErrorKind kind;
public RelayException(RelayErrorKind kind, String message) {
super(message);
this.kind = kind;
}
public RelayException(RelayErrorKind kind, String message, Throwable cause) {
super(message, cause);
this.kind = kind;
}
public RelayErrorKind getKind() {
return kind;
}
}
enum RelayErrorKind {
/** Le relais est joignable mais refuse : tier non actif, JWT trop ancien, etc. */
REJECTED,
/** Le relais a renvoye un JWT mais il est invalide / non parsable. */
BAD_RESPONSE,
/** Le relais est injoignable / 5xx / timeout. */
TRANSIENT
}
}

View File

@@ -0,0 +1,19 @@
package com.loremind.domain.licensing.ports;
import com.loremind.domain.licensing.License;
import java.util.Optional;
/**
* Port de sortie pour la persistance de la licence installee.
* <p>
* Une seule licence par instance ({@code id = "current"} par convention).
*/
public interface LicenseRepository {
Optional<License> findCurrent();
License save(License license);
void deleteCurrent();
}

View File

@@ -1,15 +0,0 @@
package com.loremind.domain.lorecontext;
/**
* Type d'un champ dynamique d'un Template.
* <p>
* - TEXT : valeur textuelle libre (stockee dans Page.values : Map<String, String>)
* - IMAGE : galerie d'images, represente comme une liste d'IDs d'images
* (stockee dans Page.imageValues : Map<String, List<String>>)
* <p>
* Extension future possible : RICH_TEXT, NUMBER, DATE, BOOLEAN, LORE_LINK...
*/
public enum FieldType {
TEXT,
IMAGE
}

View File

@@ -1,5 +1,7 @@
package com.loremind.domain.lorecontext;
import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.shared.template.TemplateField;
import lombok.Builder;
import lombok.Data;

View File

@@ -1,50 +0,0 @@
package com.loremind.domain.lorecontext;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Value Object d'un champ de Template.
* <p>
* Un champ a un nom (affiche dans l'UI) et un type (TEXT ou IMAGE, extensible).
* Le type pilote le rendu cote front (textarea vs galerie d'images) ET
* la logique metier (seuls les champs TEXT sont envoyes a l'IA pour generation).
* <p>
* Pour les champs IMAGE, {@link #layout} precise la variante de rendu
* (gallery/hero/masonry/carousel). Nullable : l'absence equivaut a GALLERY.
* Ignore pour les champs TEXT.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TemplateField {
/** Nom du champ tel qu'affiche dans l'UI (ex: "Histoire", "Portrait"). */
private String name;
/** Type du champ, pilote le rendu et la generation IA. */
private FieldType type;
/** Variante de rendu pour les champs IMAGE. Null = GALLERY. */
private ImageLayout layout;
/** Constructeur de retrocompat : type seul, layout=null. */
public TemplateField(String name, FieldType type) {
this(name, type, null);
}
/** Raccourci : construit un champ de type TEXT (cas le plus courant). */
public static TemplateField text(String name) {
return new TemplateField(name, FieldType.TEXT, null);
}
/** Raccourci : construit un champ de type IMAGE avec layout GALLERY. */
public static TemplateField image(String name) {
return new TemplateField(name, FieldType.IMAGE, ImageLayout.GALLERY);
}
/** Raccourci : construit un champ IMAGE avec un layout specifique. */
public static TemplateField image(String name, ImageLayout layout) {
return new TemplateField(name, FieldType.IMAGE, layout);
}
}

View File

@@ -0,0 +1,20 @@
package com.loremind.domain.shared.template;
/**
* Type d'un champ dynamique de template (kernel partage).
* <p>
* - TEXT : valeur textuelle libre (Map<String, String>)
* - IMAGE : galerie d'images, liste d'IDs (Map<String, List<String>>)
* - NUMBER : valeur numerique stockee en texte (parsee a l'usage)
* - KEY_VALUE_LIST : liste de paires {label, value} avec labels figes au template
* (Map<String, Map<String, String>> : fieldName -> label -> value).
* Usage : stat blocks, listes de competences, traits.
* <p>
* Extension future possible : RICH_TEXT, DATE, BOOLEAN, REFERENCE...
*/
public enum FieldType {
TEXT,
IMAGE,
NUMBER,
KEY_VALUE_LIST
}

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.lorecontext;
package com.loremind.domain.shared.template;
/**
* Variante de rendu pour un champ de type IMAGE.
@@ -8,7 +8,7 @@ package com.loremind.domain.lorecontext;
* - MASONRY : mosaique hauteurs variables facon Pinterest
* - CAROUSEL : defilement horizontal
* <p>
* Uniquement significatif quand {@link FieldType} = IMAGE. Ignore pour TEXT.
* Uniquement significatif quand {@link FieldType} = IMAGE. Ignore sinon.
*/
public enum ImageLayout {
GALLERY,

View File

@@ -0,0 +1,73 @@
package com.loremind.domain.shared.template;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* Value Object d'un champ de Template (kernel partage).
* <p>
* Un champ a un nom (affiche dans l'UI) et un type. Le type pilote
* le rendu cote front et la logique metier (seuls les champs TEXT sont
* envoyes a l'IA pour generation).
* <p>
* Pour les champs IMAGE, {@link #layout} precise la variante de rendu
* (gallery/hero/masonry/carousel). Nullable : l'absence equivaut a GALLERY.
* Ignore pour les autres types.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TemplateField {
/** Nom du champ tel qu'affiche dans l'UI (ex: "Histoire", "Portrait"). */
private String name;
/** Type du champ, pilote le rendu et la generation IA. */
private FieldType type;
/** Variante de rendu pour les champs IMAGE. Null = GALLERY. */
private ImageLayout layout;
/**
* Labels predefinis pour les champs KEY_VALUE_LIST (ordre significatif).
* Ex: ["FOR","DEX","CON","INT","SAG","CHA"] pour un champ "Caracteristiques".
* Null/vide pour les autres types.
*/
private List<String> labels;
/** Constructeur de retrocompat : type seul, layout/labels=null. */
public TemplateField(String name, FieldType type) {
this(name, type, null, null);
}
/** Constructeur de retrocompat : type + layout, labels=null. */
public TemplateField(String name, FieldType type, ImageLayout layout) {
this(name, type, layout, null);
}
/** Raccourci : construit un champ de type TEXT (cas le plus courant). */
public static TemplateField text(String name) {
return new TemplateField(name, FieldType.TEXT, null, null);
}
/** Raccourci : construit un champ de type IMAGE avec layout GALLERY. */
public static TemplateField image(String name) {
return new TemplateField(name, FieldType.IMAGE, ImageLayout.GALLERY, null);
}
/** Raccourci : construit un champ IMAGE avec un layout specifique. */
public static TemplateField image(String name, ImageLayout layout) {
return new TemplateField(name, FieldType.IMAGE, layout, null);
}
/** Raccourci : construit un champ de type NUMBER. */
public static TemplateField number(String name) {
return new TemplateField(name, FieldType.NUMBER, null, null);
}
/** Raccourci : construit un champ KEY_VALUE_LIST avec labels predefinis. */
public static TemplateField keyValueList(String name, List<String> labels) {
return new TemplateField(name, FieldType.KEY_VALUE_LIST, null, labels);
}
}

View File

@@ -53,12 +53,12 @@ public class BrainAiClient implements AiProvider {
private BrainGeneratePageRequest toBrainRequest(GenerationContext context) {
return new BrainGeneratePageRequest(
context.getLoreName(),
context.getLoreDescription(),
context.getFolderName(),
context.getTemplateName(),
context.getTemplateFields(),
context.getPageTitle()
context.loreName(),
context.loreDescription(),
context.folderName(),
context.templateName(),
context.templateFields(),
context.pageTitle()
);
}

View File

@@ -5,6 +5,7 @@ import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummar
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
@@ -38,35 +39,35 @@ public class BrainChatPayloadBuilder {
public Map<String, Object> build(ChatRequest request) {
Map<String, Object> root = new LinkedHashMap<>();
root.put("messages", request.getMessages().stream()
root.put("messages", request.messages().stream()
.map(this::messageToMap)
.collect(Collectors.toList()));
if (request.getLoreContext() != null) {
root.put("lore_context", loreContextToMap(request.getLoreContext()));
if (request.loreContext() != null) {
root.put("lore_context", loreContextToMap(request.loreContext()));
}
if (request.getPageContext() != null) {
root.put("page_context", pageContextToMap(request.getPageContext()));
if (request.pageContext() != null) {
root.put("page_context", pageContextToMap(request.pageContext()));
}
if (request.getCampaignContext() != null) {
root.put("campaign_context", campaignContextToMap(request.getCampaignContext()));
if (request.campaignContext() != null) {
root.put("campaign_context", campaignContextToMap(request.campaignContext()));
}
if (request.getNarrativeEntity() != null) {
root.put("narrative_entity", narrativeEntityToMap(request.getNarrativeEntity()));
if (request.narrativeEntity() != null) {
root.put("narrative_entity", narrativeEntityToMap(request.narrativeEntity()));
}
if (request.getGameSystemContext() != null) {
root.put("game_system_context", gameSystemContextToMap(request.getGameSystemContext()));
if (request.gameSystemContext() != null) {
root.put("game_system_context", gameSystemContextToMap(request.gameSystemContext()));
}
return root;
}
private Map<String, Object> gameSystemContextToMap(GameSystemContext gs) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("system_name", gs.getSystemName());
if (gs.getSystemDescription() != null && !gs.getSystemDescription().isBlank()) {
map.put("system_description", gs.getSystemDescription());
map.put("system_name", gs.systemName());
if (gs.systemDescription() != null && !gs.systemDescription().isBlank()) {
map.put("system_description", gs.systemDescription());
}
map.put("sections", gs.getSections() != null ? gs.getSections() : Map.of());
map.put("sections", gs.sections() != null ? gs.sections() : Map.of());
return map;
}
@@ -79,67 +80,82 @@ public class BrainChatPayloadBuilder {
private Map<String, Object> loreContextToMap(LoreStructuralContext ctx) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("lore_name", ctx.getLoreName());
map.put("lore_description", ctx.getLoreDescription());
map.put("lore_name", ctx.loreName());
map.put("lore_description", ctx.loreDescription());
Map<String, Object> foldersMap = new LinkedHashMap<>();
for (Map.Entry<String, List<PageSummary>> e : ctx.getFolders().entrySet()) {
for (Map.Entry<String, List<PageSummary>> e : ctx.folders().entrySet()) {
foldersMap.put(e.getKey(), e.getValue().stream()
.map(this::pageSummaryToMap)
.collect(Collectors.toList()));
}
map.put("folders", foldersMap);
map.put("tags", ctx.getTags());
map.put("tags", ctx.tags());
return map;
}
private Map<String, Object> pageSummaryToMap(PageSummary ps) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("title", ps.getTitle());
map.put("template_name", ps.getTemplateName());
map.put("title", ps.title());
map.put("template_name", ps.templateName());
// values/tags/related_page_titles : omis si vides pour alléger le payload.
if (ps.getValues() != null && !ps.getValues().isEmpty()) {
map.put("values", ps.getValues());
if (ps.values() != null && !ps.values().isEmpty()) {
map.put("values", ps.values());
}
if (ps.getTags() != null && !ps.getTags().isEmpty()) {
map.put("tags", ps.getTags());
if (ps.tags() != null && !ps.tags().isEmpty()) {
map.put("tags", ps.tags());
}
if (ps.getRelatedPageTitles() != null && !ps.getRelatedPageTitles().isEmpty()) {
map.put("related_page_titles", ps.getRelatedPageTitles());
if (ps.relatedPageTitles() != null && !ps.relatedPageTitles().isEmpty()) {
map.put("related_page_titles", ps.relatedPageTitles());
}
return map;
}
private Map<String, Object> pageContextToMap(PageContext pc) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("title", pc.getTitle());
map.put("template_name", pc.getTemplateName());
map.put("template_fields", pc.getTemplateFields());
map.put("values", pc.getValues());
map.put("title", pc.title());
map.put("template_name", pc.templateName());
map.put("template_fields", pc.templateFields());
map.put("values", pc.values());
return map;
}
private Map<String, Object> campaignContextToMap(CampaignStructuralContext ctx) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("campaign_name", ctx.getCampaignName());
map.put("campaign_description", ctx.getCampaignDescription());
map.put("arcs", ctx.getArcs().stream()
map.put("campaign_name", ctx.campaignName());
map.put("campaign_description", ctx.campaignDescription());
map.put("arcs", ctx.arcs().stream()
.map(this::arcSummaryToMap)
.collect(Collectors.toList()));
// Liste des PJ : omise si aucun pour alléger le prompt des campagnes sans fiches.
if (ctx.getCharacters() != null && !ctx.getCharacters().isEmpty()) {
map.put("characters", ctx.getCharacters().stream()
if (ctx.characters() != null && !ctx.characters().isEmpty()) {
map.put("characters", ctx.characters().stream()
.map(this::characterSummaryToMap)
.collect(Collectors.toList()));
}
// Liste des PNJ : symétrique aux PJ, omise si vide pour alléger le payload.
if (ctx.npcs() != null && !ctx.npcs().isEmpty()) {
map.put("npcs", ctx.npcs().stream()
.map(this::npcSummaryToMap)
.collect(Collectors.toList()));
}
return map;
}
private Map<String, Object> characterSummaryToMap(CharacterSummary c) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", c.getName());
if (c.getSnippet() != null && !c.getSnippet().isBlank()) {
map.put("snippet", c.getSnippet());
map.put("name", c.name());
if (c.snippet() != null && !c.snippet().isBlank()) {
map.put("snippet", c.snippet());
}
return map;
}
private Map<String, Object> npcSummaryToMap(NpcSummary n) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", n.name());
if (n.snippet() != null && !n.snippet().isBlank()) {
map.put("snippet", n.snippet());
}
return map;
}
@@ -167,10 +183,10 @@ public class BrainChatPayloadBuilder {
private Map<String, Object> arcSummaryToMap(ArcSummary a) {
return structuralSummaryToMap(
a,
ArcSummary::getName,
ArcSummary::getDescription,
ArcSummary::getIllustrationCount,
(map, arc) -> map.put("chapters", arc.getChapters().stream()
ArcSummary::name,
ArcSummary::description,
ArcSummary::illustrationCount,
(map, arc) -> map.put("chapters", arc.chapters().stream()
.map(this::chapterSummaryToMap)
.collect(Collectors.toList())));
}
@@ -178,10 +194,10 @@ public class BrainChatPayloadBuilder {
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {
return structuralSummaryToMap(
c,
ChapterSummary::getName,
ChapterSummary::getDescription,
ChapterSummary::getIllustrationCount,
(map, chapter) -> map.put("scenes", chapter.getScenes().stream()
ChapterSummary::name,
ChapterSummary::description,
ChapterSummary::illustrationCount,
(map, chapter) -> map.put("scenes", chapter.scenes().stream()
.map(this::sceneSummaryToMap)
.collect(Collectors.toList())));
}
@@ -189,13 +205,13 @@ public class BrainChatPayloadBuilder {
private Map<String, Object> sceneSummaryToMap(SceneSummary s) {
return structuralSummaryToMap(
s,
SceneSummary::getName,
SceneSummary::getDescription,
SceneSummary::getIllustrationCount,
SceneSummary::name,
SceneSummary::description,
SceneSummary::illustrationCount,
(map, scene) -> {
// Branches narratives : omises si absentes (scènes linéaires classiques).
if (s.getBranches() != null && !s.getBranches().isEmpty()) {
map.put("branches", s.getBranches().stream()
if (s.branches() != null && !s.branches().isEmpty()) {
map.put("branches", s.branches().stream()
.map(this::branchHintToMap)
.collect(Collectors.toList()));
}
@@ -204,19 +220,19 @@ public class BrainChatPayloadBuilder {
private Map<String, Object> branchHintToMap(BranchHint b) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("label", b.getLabel());
map.put("target_scene_name", b.getTargetSceneName());
if (b.getCondition() != null && !b.getCondition().isBlank()) {
map.put("condition", b.getCondition());
map.put("label", b.label());
map.put("target_scene_name", b.targetSceneName());
if (b.condition() != null && !b.condition().isBlank()) {
map.put("condition", b.condition());
}
return map;
}
private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("entity_type", ne.getEntityType());
map.put("title", ne.getTitle());
map.put("fields", ne.getFields());
map.put("entity_type", ne.entityType());
map.put("title", ne.title());
map.put("fields", ne.fields());
return map;
}
}

View File

@@ -0,0 +1,111 @@
package com.loremind.infrastructure.licensing;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.loremind.domain.licensing.RegistryCredentials;
import com.loremind.domain.licensing.ports.DockerConfigWriter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.Base64;
/**
* Implementation : ecriture du fichier {@code config.json} au format Docker
* standard, dans un volume partage avec Watchtower.
* <p>
* Format produit :
* <pre>{@code
* {
* "auths": {
* "ghcr.io": {
* "auth": "<base64(username:password)>"
* }
* }
* }
* }</pre>
*/
@Component
public class FileDockerConfigWriter implements DockerConfigWriter {
private static final Logger log = LoggerFactory.getLogger(FileDockerConfigWriter.class);
private final Path configPath;
private final ObjectMapper mapper = new ObjectMapper();
public FileDockerConfigWriter(
@Value("${licensing.docker-config-path:/shared/docker/config.json}") String pathStr) {
this.configPath = Path.of(pathStr);
}
@Override
public void writeCredentials(RegistryCredentials credentials) throws IOException {
ensureParentDirectory();
ObjectNode root;
if (Files.exists(configPath)) {
try {
JsonNode existing = mapper.readTree(configPath.toFile());
root = existing.isObject() ? (ObjectNode) existing : mapper.createObjectNode();
} catch (IOException e) {
log.warn("Existing docker config unreadable, overwriting: {}", e.getMessage());
root = mapper.createObjectNode();
}
} else {
root = mapper.createObjectNode();
}
ObjectNode auths = root.has("auths") && root.get("auths").isObject()
? (ObjectNode) root.get("auths")
: root.putObject("auths");
String b64 = Base64.getEncoder().encodeToString(
(credentials.username() + ":" + credentials.password()).getBytes(StandardCharsets.UTF_8));
ObjectNode entry = mapper.createObjectNode();
entry.put("auth", b64);
auths.set(credentials.registry(), entry);
Files.writeString(configPath, mapper.writerWithDefaultPrettyPrinter().writeValueAsString(root),
StandardCharsets.UTF_8);
applyRestrictivePermissions();
log.info("Docker config written at {} for registry {}", configPath, credentials.registry());
}
@Override
public void clear() throws IOException {
if (Files.exists(configPath)) {
Files.delete(configPath);
log.info("Docker config cleared at {}", configPath);
}
}
@Override
public boolean isPresent() {
return Files.exists(configPath);
}
private void ensureParentDirectory() throws IOException {
Path parent = configPath.getParent();
if (parent != null && !Files.exists(parent)) {
Files.createDirectories(parent);
}
}
/** 0600 sur POSIX. Sur Windows (dev), no-op silencieux. */
private void applyRestrictivePermissions() {
try {
Files.setPosixFilePermissions(configPath, PosixFilePermissions.fromString("rw-------"));
} catch (UnsupportedOperationException | IOException e) {
// Windows / FS qui ne supporte pas POSIX => ignore (le conteneur tourne sous Linux en prod)
}
}
}

View File

@@ -0,0 +1,146 @@
package com.loremind.infrastructure.licensing;
import com.fasterxml.jackson.databind.JsonNode;
import com.loremind.domain.licensing.RegistryCredentials;
import com.loremind.domain.licensing.ports.LicenseRelay;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
/**
* Client HTTP du relais OAuth Patreon (deploye sur Cloudflare Workers).
* Voir {@code relay/} pour le code du relais.
*/
@Component
public class HttpLicenseRelay implements LicenseRelay {
private static final Logger log = LoggerFactory.getLogger(HttpLicenseRelay.class);
private final RestTemplate http;
private final String baseUrl;
public HttpLicenseRelay(
RestTemplateBuilder builder,
@Value("${licensing.relay.base-url:}") String baseUrl) {
this.http = builder
.setConnectTimeout(Duration.ofSeconds(5))
.setReadTimeout(Duration.ofSeconds(15))
.build();
this.baseUrl = stripTrailingSlash(baseUrl);
}
@Override
public String buildConnectUrl(String instanceId) {
if (baseUrl.isBlank()) {
throw new IllegalStateException("Licensing relay base URL not configured");
}
String encoded = URLEncoder.encode(instanceId, StandardCharsets.UTF_8);
return baseUrl + "/oauth/start?instance_id=" + encoded;
}
@Override
public String refreshToken(String currentJwt) throws RelayException {
if (baseUrl.isBlank()) {
throw new RelayException(RelayErrorKind.TRANSIENT, "relay not configured");
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, String> body = Map.of("jwt", currentJwt);
ResponseEntity<JsonNode> resp;
try {
resp = http.exchange(
baseUrl + "/token/refresh",
HttpMethod.POST,
new HttpEntity<>(body, headers),
JsonNode.class);
} catch (HttpClientErrorException e) {
throw new RelayException(RelayErrorKind.REJECTED,
"relay rejected refresh: " + e.getStatusCode() + " " + e.getStatusText());
} catch (HttpServerErrorException e) {
throw new RelayException(RelayErrorKind.TRANSIENT,
"relay 5xx: " + e.getStatusCode());
} catch (RestClientException e) {
throw new RelayException(RelayErrorKind.TRANSIENT, "relay unreachable: " + e.getMessage(), e);
}
JsonNode payload = resp.getBody();
if (payload == null || !payload.hasNonNull("jwt")) {
throw new RelayException(RelayErrorKind.BAD_RESPONSE, "missing jwt in refresh response");
}
return payload.get("jwt").asText();
}
@Override
public RegistryCredentials fetchRegistryCredentials(String currentJwt) throws RelayException {
if (baseUrl.isBlank()) {
throw new RelayException(RelayErrorKind.TRANSIENT, "relay not configured");
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, String> body = Map.of("jwt", currentJwt);
ResponseEntity<JsonNode> resp;
try {
resp = http.exchange(
baseUrl + "/registry/credentials",
HttpMethod.POST,
new HttpEntity<>(body, headers),
JsonNode.class);
} catch (HttpClientErrorException e) {
throw new RelayException(RelayErrorKind.REJECTED,
"relay rejected creds: " + e.getStatusCode() + " " + e.getStatusText());
} catch (HttpServerErrorException e) {
throw new RelayException(RelayErrorKind.TRANSIENT,
"relay 5xx: " + e.getStatusCode());
} catch (RestClientException e) {
throw new RelayException(RelayErrorKind.TRANSIENT, "relay unreachable: " + e.getMessage(), e);
}
JsonNode payload = resp.getBody();
if (payload == null
|| !payload.hasNonNull("registry")
|| !payload.hasNonNull("username")
|| !payload.hasNonNull("password")) {
throw new RelayException(RelayErrorKind.BAD_RESPONSE, "incomplete credentials response");
}
Instant expiresAt = null;
if (payload.hasNonNull("expires_at")) {
try {
expiresAt = Instant.parse(payload.get("expires_at").asText());
} catch (Exception e) {
log.warn("Cannot parse expires_at from relay creds response: {}", e.getMessage());
}
}
return new RegistryCredentials(
payload.get("registry").asText(),
payload.get("username").asText(),
payload.get("password").asText(),
expiresAt
);
}
private static String stripTrailingSlash(String s) {
if (s == null) return "";
String v = s.trim();
if (v.endsWith("/")) v = v.substring(0, v.length() - 1);
return v;
}
}

View File

@@ -0,0 +1,93 @@
package com.loremind.infrastructure.licensing;
import com.loremind.application.licensing.LicenseService;
import com.loremind.domain.licensing.LicenseSnapshot;
import com.loremind.domain.licensing.LicenseStatus;
import com.loremind.domain.licensing.RegistryCredentials;
import com.loremind.domain.licensing.ports.DockerConfigWriter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.Optional;
/**
* Daemon planifie qui :
* <ul>
* <li>renouvelle le JWT licence via le relais avant expiration (J-2)</li>
* <li>met a jour les credentials registry GHCR pour Watchtower
* (volume partage docker-config) tant que le canal beta est ON</li>
* <li>nettoie les credentials si la licence est invalidee ou le toggle OFF</li>
* </ul>
* Idempotent : peut tourner toutes les 6h sans risque, fait du no-op
* la plupart du temps.
*/
@Component
public class LicenseRefreshDaemon {
private static final Logger log = LoggerFactory.getLogger(LicenseRefreshDaemon.class);
/** 6 heures entre chaque cycle. Suffisant pour rattraper un J-2 sans surcharger. */
private static final long FIXED_DELAY_MS = 6L * 60L * 60L * 1000L;
/** Premier run apres 30s pour laisser le contexte Spring se stabiliser. */
private static final long INITIAL_DELAY_MS = 30_000L;
private final LicenseService licenseService;
private final DockerConfigWriter dockerConfigWriter;
public LicenseRefreshDaemon(LicenseService licenseService,
DockerConfigWriter dockerConfigWriter) {
this.licenseService = licenseService;
this.dockerConfigWriter = dockerConfigWriter;
}
@Scheduled(initialDelay = INITIAL_DELAY_MS, fixedDelay = FIXED_DELAY_MS)
public void tick() {
if (!licenseService.isLicensingEnabled()) {
return;
}
try {
licenseService.refreshIfNeeded();
syncDockerConfig();
} catch (Exception e) {
log.error("LicenseRefreshDaemon tick failed: {}", e.getMessage(), e);
}
}
/**
* Aligne le fichier docker config avec l'etat de la licence et le toggle :
* <ul>
* <li>VALID/GRACE + beta ON -> ecrit/refresh les creds</li>
* <li>tout autre cas -> efface le fichier</li>
* </ul>
*/
private void syncDockerConfig() {
LicenseSnapshot snap = licenseService.getCurrentSnapshot();
boolean shouldHaveCreds = snap.betaChannelEnabled()
&& (snap.status() == LicenseStatus.VALID || snap.status() == LicenseStatus.GRACE);
if (!shouldHaveCreds) {
try {
if (dockerConfigWriter.isPresent()) {
dockerConfigWriter.clear();
}
} catch (IOException e) {
log.warn("Cannot clear docker config: {}", e.getMessage());
}
return;
}
Optional<RegistryCredentials> creds = licenseService.fetchRegistryCredentials();
if (creds.isEmpty()) {
log.warn("Beta enabled but cannot fetch registry credentials (relay down or rejected)");
return;
}
try {
dockerConfigWriter.writeCredentials(creds.get());
} catch (IOException e) {
log.error("Cannot write docker config: {}", e.getMessage());
}
}
}

View File

@@ -0,0 +1,188 @@
package com.loremind.infrastructure.licensing;
import com.loremind.domain.licensing.LicenseClaims;
import com.loremind.domain.licensing.ports.JwtVerifier;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSVerifier;
import com.nimbusds.jose.crypto.Ed25519Verifier;
import com.nimbusds.jose.jwk.OctetKeyPair;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import org.bouncycastle.asn1.ASN1Sequence;
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.core.io.ClassPathResource;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.text.ParseException;
import java.util.Base64;
import java.util.Date;
/**
* Verifie les JWT EdDSA/Ed25519 emis par le relais Patreon.
* <p>
* La cle publique est fournie en PEM SPKI via la propriete
* {@code licensing.jwt.public-key} (env {@code LICENSING_JWT_PUBLIC_KEY}).
* Si la cle est absente ou invalide, {@link #isConfigured()} retourne false
* et {@link #verify} echoue systematiquement — la feature licensing est
* desactivee silencieusement.
*/
@Component
public class NimbusJwtVerifier implements JwtVerifier {
private static final Logger log = LoggerFactory.getLogger(NimbusJwtVerifier.class);
private final String expectedIssuer;
private final String expectedAudience;
private final OctetKeyPair publicKey;
public NimbusJwtVerifier(
@Value("${licensing.jwt.public-key:}") String publicKeyPemFromEnv,
@Value("${licensing.jwt.expected-issuer:loremind-auth}") String expectedIssuer,
@Value("${licensing.jwt.expected-audience:loremind-instance}") String expectedAudience) {
this.expectedIssuer = expectedIssuer;
this.expectedAudience = expectedAudience;
// Strategie : env var en priorite (rotation possible sans rebuild),
// sinon ressource classpath embarquee dans le binaire.
String pem = (publicKeyPemFromEnv != null && !publicKeyPemFromEnv.isBlank())
? publicKeyPemFromEnv
: loadEmbeddedKey();
this.publicKey = parsePemSpki(pem);
if (publicKey == null) {
log.info("Licensing JWT verifier disabled (no public key found)");
} else {
String source = (publicKeyPemFromEnv != null && !publicKeyPemFromEnv.isBlank()) ? "env" : "embedded";
log.info("Licensing JWT verifier enabled (issuer={}, audience={}, key source={})",
expectedIssuer, expectedAudience, source);
}
}
/**
* Charge la cle publique embarquee dans le binaire (resource classpath).
* Le fichier est un PEM SPKI standard, fourni a la build pour chaque
* release. Si absent, la feature licensing est desactivee.
*/
private static String loadEmbeddedKey() {
ClassPathResource resource = new ClassPathResource("licensing/jwt-public-key.pem");
if (!resource.exists()) {
return null;
}
try (InputStream in = resource.getInputStream()) {
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException e) {
log.warn("Cannot read embedded JWT public key: {}", e.getMessage());
return null;
}
}
@Override
public boolean isConfigured() {
return publicKey != null;
}
@Override
public LicenseClaims verify(String rawJwt) throws JwtVerificationException {
if (publicKey == null) {
throw new JwtVerificationException("JWT verifier not configured");
}
if (rawJwt == null || rawJwt.isBlank()) {
throw new JwtVerificationException("JWT is empty");
}
SignedJWT signed;
try {
signed = SignedJWT.parse(rawJwt);
} catch (ParseException e) {
throw new JwtVerificationException("JWT parse error: " + e.getMessage(), e);
}
JWSAlgorithm alg = signed.getHeader().getAlgorithm();
if (!JWSAlgorithm.EdDSA.equals(alg)) {
throw new JwtVerificationException("Unexpected JWT algorithm: " + alg);
}
try {
JWSVerifier verifier = new Ed25519Verifier(publicKey);
if (!signed.verify(verifier)) {
throw new JwtVerificationException("JWT signature invalid");
}
} catch (Exception e) {
throw new JwtVerificationException("JWT signature verification failed: " + e.getMessage(), e);
}
JWTClaimsSet claims;
try {
claims = signed.getJWTClaimsSet();
} catch (ParseException e) {
throw new JwtVerificationException("JWT claims parse error", e);
}
if (!expectedIssuer.equals(claims.getIssuer())) {
throw new JwtVerificationException("JWT issuer mismatch: " + claims.getIssuer());
}
if (claims.getAudience() == null || !claims.getAudience().contains(expectedAudience)) {
throw new JwtVerificationException("JWT audience mismatch");
}
Date exp = claims.getExpirationTime();
Date iat = claims.getIssueTime();
String sub = claims.getSubject();
if (exp == null || iat == null || sub == null) {
throw new JwtVerificationException("JWT missing required claims");
}
// Note : on ne refuse pas un JWT expire ici. C'est au LicenseService
// de decider ce qu'il fait d'un JWT expire (grace period, refresh, etc.).
// La verification de signature reste valide tant que la cle existe.
String tierId;
String instanceId;
try {
tierId = claims.getStringClaim("tier_id");
instanceId = claims.getStringClaim("instance_id");
} catch (ParseException e) {
throw new JwtVerificationException("JWT custom claim parse error", e);
}
if (tierId == null || tierId.isBlank() || instanceId == null || instanceId.isBlank()) {
throw new JwtVerificationException("JWT missing tier_id or instance_id");
}
return new LicenseClaims(
sub,
tierId,
instanceId,
iat.toInstant(),
exp.toInstant()
);
}
/**
* Parse une cle publique Ed25519 au format PEM SPKI vers un Nimbus
* {@link OctetKeyPair} (forme JWK utilisee pour la verification).
*/
private static OctetKeyPair parsePemSpki(String pem) {
if (pem == null || pem.isBlank()) return null;
try {
String base64 = pem
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replaceAll("\\s+", "");
byte[] der = Base64.getDecoder().decode(base64);
SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(ASN1Sequence.fromByteArray(der));
byte[] keyBytes = spki.getPublicKeyData().getOctets();
String x = Base64.getUrlEncoder().withoutPadding().encodeToString(keyBytes);
return new OctetKeyPair.Builder(com.nimbusds.jose.jwk.Curve.Ed25519, com.nimbusds.jose.util.Base64URL.from(x))
.build();
} catch (IOException | IllegalArgumentException e) {
log.warn("Cannot parse licensing JWT public key: {}", e.getMessage());
return null;
}
}
}

View File

@@ -0,0 +1,93 @@
package com.loremind.infrastructure.persistence;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* Backfill one-shot des fiches Character / Npc post-refonte 2026-04-30.
* <p>
* Avant la refonte, les fiches stockaient leur contenu dans la colonne
* {@code markdown_content}. Apres la refonte, le contenu est dans
* {@code field_values} (JSON Map<String,String>). La colonne
* {@code markdown_content} subsiste car Hibernate ddl-auto=update ne drop pas.
* <p>
* Ce backfill copie {@code markdown_content} dans {@code field_values["Notes"]}
* pour toutes les fiches qui ont un markdown non vide ET un field_values vide.
* Idempotent : si field_values contient deja des donnees, on ne touche pas.
* <p>
* La colonne {@code markdown_content} n'est PAS supprimee apres backfill —
* permet un rollback applicatif au cas ou. Suppression definitive a faire dans
* une release ulterieure quand la confiance est etablie.
*/
@Component
public class CharacterNpcMarkdownBackfill {
private static final Logger log = LoggerFactory.getLogger(CharacterNpcMarkdownBackfill.class);
private final JdbcTemplate jdbc;
private final ObjectMapper mapper = new ObjectMapper();
public CharacterNpcMarkdownBackfill(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@EventListener(ApplicationReadyEvent.class)
public void backfillIfNeeded() {
if (!hasMarkdownContentColumn("characters")) {
log.debug("Backfill skip : colonne markdown_content absente (deja migre ou install propre).");
return;
}
int chars = backfillTable("characters");
int npcs = backfillTable("npcs");
if (chars + npcs > 0) {
log.info("Backfill markdown -> field_values : {} character(s), {} npc(s) migre(s).", chars, npcs);
}
}
private boolean hasMarkdownContentColumn(String table) {
try {
Integer count = jdbc.queryForObject(
"SELECT COUNT(*) FROM information_schema.columns "
+ "WHERE table_name = ? AND column_name = 'markdown_content'",
Integer.class, table);
return count != null && count > 0;
} catch (Exception e) {
log.warn("Backfill : impossible de verifier la colonne markdown_content sur {}: {}",
table, e.getMessage());
return false;
}
}
private int backfillTable(String table) {
// Selection : fiches avec markdown non vide ET field_values vide ou absent.
// field_values peut etre NULL (legacy avant refonte) ou "{}" (refonte appliquee mais sans data).
String selectSql = "SELECT id, markdown_content FROM " + table
+ " WHERE markdown_content IS NOT NULL "
+ " AND markdown_content <> '' "
+ " AND (field_values IS NULL OR field_values = '' OR field_values = '{}')";
var rows = jdbc.queryForList(selectSql);
int migrated = 0;
for (var row : rows) {
Long id = ((Number) row.get("id")).longValue();
String markdown = (String) row.get("markdown_content");
String json;
try {
json = mapper.writeValueAsString(Map.of("Notes", markdown));
} catch (Exception e) {
log.error("Backfill {} id={} : echec serialisation JSON, ignore. {}", table, id, e.getMessage());
continue;
}
jdbc.update("UPDATE " + table + " SET field_values = ? WHERE id = ?", json, id);
migrated++;
}
return migrated;
}
}

View File

@@ -2,6 +2,8 @@ package com.loremind.infrastructure.persistence;
import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import com.loremind.domain.shared.template.ImageLayout;
import com.loremind.domain.shared.template.TemplateField;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
@@ -23,6 +25,10 @@ import java.util.List;
* <p>
* Idempotence : ne seed qu'une fois. Si l'utilisateur supprime un ruleset seedé,
* il ne revient pas au redémarrage — c'est voulu (respect du choix utilisateur).
* <p>
* Backfill 2026-04-30 : pour les GameSystems existants (avant la refonte
* template-based), on remplit aussi les templates PJ/PNJ par defaut s'ils
* sont vides — sinon les fiches restent inutilisables.
*/
@Component
public class GameSystemSeeder {
@@ -37,15 +43,37 @@ public class GameSystemSeeder {
@EventListener(ApplicationReadyEvent.class)
public void seedIfEmpty() {
if (!gameSystemRepository.findAll().isEmpty()) {
log.debug("GameSystem seed skipped — table non vide.");
return;
}
List<GameSystem> existing = gameSystemRepository.findAll();
if (existing.isEmpty()) {
log.info("Seed initial des GameSystems (table vide)...");
for (GameSystem gs : defaultSystems()) {
gameSystemRepository.save(gs);
}
log.info("GameSystems seedés : {}", defaultSystems().size());
return;
}
log.debug("GameSystem seed skipped — table non vide. Backfill templates si necessaire...");
backfillEmptyTemplates(existing);
}
/**
* Backfill idempotent : pour chaque GameSystem existant ou les deux templates
* sont vides (PJ ET PNJ), injecte le template generique. Si l'utilisateur a
* deja personnalise au moins un des deux, on ne touche a rien.
*/
private void backfillEmptyTemplates(List<GameSystem> systems) {
int patched = 0;
for (GameSystem gs : systems) {
boolean charEmpty = gs.getCharacterTemplate() == null || gs.getCharacterTemplate().isEmpty();
boolean npcEmpty = gs.getNpcTemplate() == null || gs.getNpcTemplate().isEmpty();
if (charEmpty && npcEmpty) {
gs.replaceCharacterTemplate(genericCharacterTemplate());
gs.replaceNpcTemplate(genericNpcTemplate());
gameSystemRepository.save(gs);
patched++;
}
}
if (patched > 0) log.info("Backfill templates GameSystem : {} systeme(s) patche(s).", patched);
}
private List<GameSystem> defaultSystems() {
@@ -56,6 +84,8 @@ public class GameSystemSeeder {
.author("LoreMind seed")
.isPublic(false)
.rulesMarkdown(NIMBLE_RULES)
.characterTemplate(nimbleCharacterTemplate())
.npcTemplate(genericNpcTemplate())
.build(),
GameSystem.builder()
.name("D&D 5e SRD (extrait)")
@@ -63,6 +93,8 @@ public class GameSystemSeeder {
.author("LoreMind seed")
.isPublic(false)
.rulesMarkdown(DND_SRD_RULES)
.characterTemplate(dndCharacterTemplate())
.npcTemplate(genericNpcTemplate())
.build(),
GameSystem.builder()
.name("Homebrew Exemple")
@@ -70,10 +102,66 @@ public class GameSystemSeeder {
.author("LoreMind seed")
.isPublic(false)
.rulesMarkdown(HOMEBREW_EXAMPLE)
.characterTemplate(genericCharacterTemplate())
.npcTemplate(genericNpcTemplate())
.build()
);
}
// --- Templates par defaut ---------------------------------------------
/** Template generique PJ — utilise pour Homebrew, backfill, et fallback. */
private static List<TemplateField> genericCharacterTemplate() {
return List.of(
TemplateField.text("Histoire"),
TemplateField.text("Personnalite"),
TemplateField.text("Apparence"),
TemplateField.image("Galerie", ImageLayout.GALLERY),
TemplateField.text("Notes")
);
}
/** Template generique PNJ — focus besoins MJ. */
private static List<TemplateField> genericNpcTemplate() {
return List.of(
TemplateField.text("Apparence"),
TemplateField.text("Motivation"),
TemplateField.text("Faction"),
TemplateField.text("Notes MJ")
);
}
private static List<TemplateField> nimbleCharacterTemplate() {
return List.of(
TemplateField.text("Classe"),
TemplateField.number("Blessures graves max"),
TemplateField.text("Capacites de classe"),
TemplateField.text("Equipement"),
TemplateField.text("Histoire"),
TemplateField.text("Objectifs personnels"),
TemplateField.image("Galerie", ImageLayout.GALLERY)
);
}
private static List<TemplateField> dndCharacterTemplate() {
return List.of(
TemplateField.text("Classe"),
TemplateField.text("Race"),
TemplateField.text("Historique"),
TemplateField.text("Alignement"),
TemplateField.number("Niveau"),
TemplateField.number("PV max"),
TemplateField.number("CA"),
TemplateField.keyValueList("Caracteristiques",
List.of("FOR", "DEX", "CON", "INT", "SAG", "CHA")),
TemplateField.text("Competences"),
TemplateField.text("Equipement"),
TemplateField.text("Sorts"),
TemplateField.text("Histoire"),
TemplateField.image("Galerie", ImageLayout.GALLERY)
);
}
private static final String NIMBLE_RULES = """
Système Nimble — résolution rapide, narration fluide, peu de tableaux. Agnostique (aucun univers imposé).

View File

@@ -0,0 +1,49 @@
package com.loremind.infrastructure.persistence.converter;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.Collections;
import java.util.Map;
/**
* Convertit une Map<String, Map<String, String>> en JSON et inversement.
* <p>
* Utilise pour Character/Npc.keyValueValues : pour chaque champ KEY_VALUE_LIST
* du template, stocke une map label -> value. Exemple :
* {"Caracteristiques": {"FOR":"16","DEX":"12","CON":"14"}}
* <p>
* Adaptateur technique pur : le domaine ignore ce converter.
*/
@Converter
public class StringMapMapJsonConverter
implements AttributeConverter<Map<String, Map<String, String>>, String> {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final TypeReference<Map<String, Map<String, String>>> TYPE_REF =
new TypeReference<>() {};
@Override
public String convertToDatabaseColumn(Map<String, Map<String, String>> attribute) {
if (attribute == null || attribute.isEmpty()) return "{}";
try {
return MAPPER.writeValueAsString(attribute);
} catch (Exception e) {
throw new IllegalStateException(
"Erreur serialisation Map<String, Map<String,String>> -> JSON", e);
}
}
@Override
public Map<String, Map<String, String>> convertToEntityAttribute(String dbData) {
if (dbData == null || dbData.isBlank()) return Collections.emptyMap();
try {
return MAPPER.readValue(dbData, TYPE_REF);
} catch (Exception e) {
throw new IllegalStateException(
"Erreur deserialisation JSON -> Map<String, Map<String,String>>", e);
}
}
}

View File

@@ -3,9 +3,9 @@ package com.loremind.infrastructure.persistence.converter;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.lorecontext.FieldType;
import com.loremind.domain.lorecontext.ImageLayout;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.shared.template.ImageLayout;
import com.loremind.domain.shared.template.TemplateField;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
@@ -85,8 +85,18 @@ public class TemplateFieldListJsonConverter
}
}
}
List<String> labels = null;
if (type == FieldType.KEY_VALUE_LIST) {
JsonNode labelsNode = item.path("labels");
if (labelsNode.isArray()) {
labels = new ArrayList<>();
for (JsonNode label : labelsNode) {
if (label.isTextual()) labels.add(label.asText());
}
}
}
if (name != null && !name.isBlank()) {
result.add(new TemplateField(name, type, layout));
result.add(new TemplateField(name, type, layout, labels));
}
}
// Autres types de noeuds (nombre, booleen...) : ignores silencieusement.

View File

@@ -37,6 +37,9 @@ public class ArcJpaEntity {
@Column(name = "\"order\"", nullable = false)
private int order;
@Column
private String icon;
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update)
@Column(columnDefinition = "TEXT")
private String themes;

View File

@@ -37,6 +37,9 @@ public class ChapterJpaEntity {
@Column(name = "\"order\"", nullable = false)
private int order;
@Column
private String icon;
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update)
@Column(name = "gm_notes", columnDefinition = "TEXT")
private String gmNotes;

View File

@@ -1,5 +1,8 @@
package com.loremind.infrastructure.persistence.entity;
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapMapJsonConverter;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
@@ -7,11 +10,18 @@ import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Entité JPA pour les fiches de personnages (PJ) d'une campagne.
* Pas de FK physique vers campaigns (weak reference cross-agrégat intra-contexte :
* on reste dans le Campaign Context, mais l'agrégat Character est autonome).
* Entité JPA pour les fiches de personnages (PJ).
* <p>
* Refonte 2026-04-30 : ancien champ markdownContent migré vers {@code values["Notes"]}
* via Hibernate au demarrage. Hibernate ddl-auto=update ajoute les nouvelles colonnes
* sans dropper {@code markdown_content} — les donnees existantes sont conservees mais
* plus mappees au domaine. Migration manuelle prevue (script SQL one-shot) si le
* deploiement passe en bluegreen.
*/
@Entity
@Table(name = "characters")
@@ -28,8 +38,26 @@ public class CharacterJpaEntity {
@Column(nullable = false)
private String name;
@Column(name = "markdown_content", columnDefinition = "TEXT")
private String markdownContent;
@Column(name = "portrait_image_id")
private String portraitImageId;
@Column(name = "header_image_id")
private String headerImageId;
/** Valeurs TEXT/NUMBER serialisees JSON. */
@Convert(converter = StringMapJsonConverter.class)
@Column(name = "field_values", columnDefinition = "TEXT")
private Map<String, String> values;
/** Valeurs IMAGE serialisees JSON. */
@Convert(converter = StringListMapJsonConverter.class)
@Column(name = "image_values", columnDefinition = "TEXT")
private Map<String, List<String>> imageValues;
/** Valeurs KEY_VALUE_LIST serialisees JSON (fieldName -> label -> value). */
@Convert(converter = StringMapMapJsonConverter.class)
@Column(name = "key_value_values", columnDefinition = "TEXT")
private Map<String, Map<String, String>> keyValueValues;
@Column(name = "campaign_id", nullable = false)
private Long campaignId;
@@ -47,6 +75,9 @@ public class CharacterJpaEntity {
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
if (values == null) values = new HashMap<>();
if (imageValues == null) imageValues = new HashMap<>();
if (keyValueValues == null) keyValueValues = new HashMap<>();
}
@PreUpdate

View File

@@ -1,5 +1,7 @@
package com.loremind.infrastructure.persistence.entity;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.persistence.converter.TemplateFieldListJsonConverter;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
@@ -7,6 +9,8 @@ import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* Entité JPA pour la persistance des GameSystems (systèmes de JDR).
@@ -32,6 +36,16 @@ public class GameSystemJpaEntity {
@Column(name = "rules_markdown", columnDefinition = "TEXT")
private String rulesMarkdown;
/** Template PJ serialise en JSON via {@link TemplateFieldListJsonConverter}. */
@Convert(converter = TemplateFieldListJsonConverter.class)
@Column(name = "character_template", columnDefinition = "TEXT")
private List<TemplateField> characterTemplate;
/** Template PNJ serialise en JSON. */
@Convert(converter = TemplateFieldListJsonConverter.class)
@Column(name = "npc_template", columnDefinition = "TEXT")
private List<TemplateField> npcTemplate;
@Column
private String author;
@@ -48,6 +62,8 @@ public class GameSystemJpaEntity {
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
if (characterTemplate == null) characterTemplate = new ArrayList<>();
if (npcTemplate == null) npcTemplate = new ArrayList<>();
}
@PreUpdate

View File

@@ -0,0 +1,72 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
/**
* Entite JPA pour la licence Patreon installee.
* <p>
* Singleton : une seule ligne par instance (id = "current"). Ce design permet
* de ne jamais avoir de licence "fantome" en base et de simplifier les queries.
*/
@Entity
@Table(name = "licenses")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LicenseJpaEntity {
@Id
private String id;
@Column(name = "raw_jwt", columnDefinition = "TEXT", nullable = false)
private String rawJwt;
@Column(name = "patreon_user_id", nullable = false)
private String patreonUserId;
@Column(name = "tier_id", nullable = false)
private String tierId;
@Column(name = "instance_id", nullable = false)
private String instanceId;
@Column(name = "issued_at", nullable = false)
private Instant issuedAt;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
@Column(name = "last_refresh_attempt_at")
private Instant lastRefreshAttemptAt;
@Column(name = "last_refresh_succeeded", nullable = false)
private boolean lastRefreshSucceeded;
@Column(name = "beta_channel_enabled", nullable = false)
private boolean betaChannelEnabled;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@PrePersist
protected void onCreate() {
Instant now = Instant.now();
if (createdAt == null) createdAt = now;
updatedAt = now;
}
@PreUpdate
protected void onUpdate() {
updatedAt = Instant.now();
}
}

View File

@@ -0,0 +1,79 @@
package com.loremind.infrastructure.persistence.entity;
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapMapJsonConverter;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Entité JPA pour les fiches de PNJ. Memes regles que CharacterJpaEntity
* (cf. note de refonte 2026-04-30 sur la migration markdownContent).
*/
@Entity
@Table(name = "npcs")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class NpcJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(name = "portrait_image_id")
private String portraitImageId;
@Column(name = "header_image_id")
private String headerImageId;
@Convert(converter = StringMapJsonConverter.class)
@Column(name = "field_values", columnDefinition = "TEXT")
private Map<String, String> values;
@Convert(converter = StringListMapJsonConverter.class)
@Column(name = "image_values", columnDefinition = "TEXT")
private Map<String, List<String>> imageValues;
@Convert(converter = StringMapMapJsonConverter.class)
@Column(name = "key_value_values", columnDefinition = "TEXT")
private Map<String, Map<String, String>> keyValueValues;
@Column(name = "campaign_id", nullable = false)
private Long campaignId;
@Column(name = "\"order\"", nullable = false)
private int order;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
if (values == null) values = new HashMap<>();
if (imageValues == null) imageValues = new HashMap<>();
if (keyValueValues == null) keyValueValues = new HashMap<>();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -39,6 +39,9 @@ public class SceneJpaEntity {
@Column(name = "\"order\"", nullable = false)
private int order;
@Column
private String icon;
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate (ddl-auto=update)
// Contexte et ambiance

View File

@@ -1,6 +1,6 @@
package com.loremind.infrastructure.persistence.entity;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.persistence.converter.TemplateFieldListJsonConverter;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;

View File

@@ -0,0 +1,9 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.LicenseJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface LicenseJpaRepository extends JpaRepository<LicenseJpaEntity, String> {
}

View File

@@ -0,0 +1,13 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.NpcJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface NpcJpaRepository extends JpaRepository<NpcJpaEntity, Long> {
List<NpcJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
}

View File

@@ -71,6 +71,7 @@ public class PostgresArcRepository implements ArcRepository {
.description(jpaEntity.getDescription())
.campaignId(jpaEntity.getCampaignId().toString())
.order(jpaEntity.getOrder())
.icon(jpaEntity.getIcon())
.themes(jpaEntity.getThemes())
.stakes(jpaEntity.getStakes())
.gmNotes(jpaEntity.getGmNotes())
@@ -99,6 +100,7 @@ public class PostgresArcRepository implements ArcRepository {
.description(arc.getDescription())
.campaignId(Long.parseLong(arc.getCampaignId()))
.order(arc.getOrder())
.icon(arc.getIcon())
.themes(arc.getThemes())
.stakes(arc.getStakes())
.gmNotes(arc.getGmNotes())

View File

@@ -71,6 +71,7 @@ public class PostgresChapterRepository implements ChapterRepository {
.description(jpaEntity.getDescription())
.arcId(jpaEntity.getArcId().toString())
.order(jpaEntity.getOrder())
.icon(jpaEntity.getIcon())
.gmNotes(jpaEntity.getGmNotes())
.playerObjectives(jpaEntity.getPlayerObjectives())
.narrativeStakes(jpaEntity.getNarrativeStakes())
@@ -96,6 +97,7 @@ public class PostgresChapterRepository implements ChapterRepository {
.description(chapter.getDescription())
.arcId(Long.parseLong(chapter.getArcId()))
.order(chapter.getOrder())
.icon(chapter.getIcon())
.gmNotes(chapter.getGmNotes())
.playerObjectives(chapter.getPlayerObjectives())
.narrativeStakes(chapter.getNarrativeStakes())

View File

@@ -6,6 +6,7 @@ import com.loremind.infrastructure.persistence.entity.CharacterJpaEntity;
import com.loremind.infrastructure.persistence.jpa.CharacterJpaRepository;
import org.springframework.stereotype.Repository;
import java.util.HashMap;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@@ -52,7 +53,11 @@ public class PostgresCharacterRepository implements CharacterRepository {
return Character.builder()
.id(e.getId().toString())
.name(e.getName())
.markdownContent(e.getMarkdownContent())
.portraitImageId(e.getPortraitImageId())
.headerImageId(e.getHeaderImageId())
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
.campaignId(e.getCampaignId().toString())
.order(e.getOrder())
.createdAt(e.getCreatedAt())
@@ -65,7 +70,11 @@ public class PostgresCharacterRepository implements CharacterRepository {
return CharacterJpaEntity.builder()
.id(id)
.name(c.getName())
.markdownContent(c.getMarkdownContent())
.portraitImageId(c.getPortraitImageId())
.headerImageId(c.getHeaderImageId())
.values(c.getValues() != null ? new HashMap<>(c.getValues()) : new HashMap<>())
.imageValues(c.getImageValues() != null ? new HashMap<>(c.getImageValues()) : new HashMap<>())
.keyValueValues(c.getKeyValueValues() != null ? new HashMap<>(c.getKeyValueValues()) : new HashMap<>())
.campaignId(Long.parseLong(c.getCampaignId()))
.order(c.getOrder())
.createdAt(c.getCreatedAt())

View File

@@ -61,6 +61,12 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
.name(e.getName())
.description(e.getDescription())
.rulesMarkdown(e.getRulesMarkdown())
.characterTemplate(e.getCharacterTemplate() != null
? new java.util.ArrayList<>(e.getCharacterTemplate())
: new java.util.ArrayList<>())
.npcTemplate(e.getNpcTemplate() != null
? new java.util.ArrayList<>(e.getNpcTemplate())
: new java.util.ArrayList<>())
.author(e.getAuthor())
.isPublic(e.isPublic())
.createdAt(e.getCreatedAt())
@@ -75,6 +81,12 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
.name(g.getName())
.description(g.getDescription())
.rulesMarkdown(g.getRulesMarkdown())
.characterTemplate(g.getCharacterTemplate() != null
? new java.util.ArrayList<>(g.getCharacterTemplate())
: new java.util.ArrayList<>())
.npcTemplate(g.getNpcTemplate() != null
? new java.util.ArrayList<>(g.getNpcTemplate())
: new java.util.ArrayList<>())
.author(g.getAuthor())
.isPublic(g.isPublic())
.createdAt(g.getCreatedAt())

View File

@@ -0,0 +1,76 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.licensing.License;
import com.loremind.domain.licensing.ports.LicenseRepository;
import com.loremind.infrastructure.persistence.entity.LicenseJpaEntity;
import com.loremind.infrastructure.persistence.jpa.LicenseJpaRepository;
import org.springframework.stereotype.Repository;
import java.time.Instant;
import java.util.Optional;
@Repository
public class PostgresLicenseRepository implements LicenseRepository {
static final String CURRENT_ID = "current";
private final LicenseJpaRepository jpa;
public PostgresLicenseRepository(LicenseJpaRepository jpa) {
this.jpa = jpa;
}
@Override
public Optional<License> findCurrent() {
return jpa.findById(CURRENT_ID).map(this::toDomain);
}
@Override
public License save(License license) {
LicenseJpaEntity entity = toEntity(license);
if (entity.getCreatedAt() == null) {
entity.setCreatedAt(Instant.now());
}
LicenseJpaEntity saved = jpa.save(entity);
return toDomain(saved);
}
@Override
public void deleteCurrent() {
jpa.deleteById(CURRENT_ID);
}
private License toDomain(LicenseJpaEntity e) {
return License.builder()
.id(e.getId())
.rawJwt(e.getRawJwt())
.patreonUserId(e.getPatreonUserId())
.tierId(e.getTierId())
.instanceId(e.getInstanceId())
.issuedAt(e.getIssuedAt())
.expiresAt(e.getExpiresAt())
.lastRefreshAttemptAt(e.getLastRefreshAttemptAt())
.lastRefreshSucceeded(e.isLastRefreshSucceeded())
.betaChannelEnabled(e.isBetaChannelEnabled())
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
.build();
}
private LicenseJpaEntity toEntity(License l) {
return LicenseJpaEntity.builder()
.id(CURRENT_ID)
.rawJwt(l.getRawJwt())
.patreonUserId(l.getPatreonUserId())
.tierId(l.getTierId())
.instanceId(l.getInstanceId())
.issuedAt(l.getIssuedAt())
.expiresAt(l.getExpiresAt())
.lastRefreshAttemptAt(l.getLastRefreshAttemptAt())
.lastRefreshSucceeded(l.isLastRefreshSucceeded())
.betaChannelEnabled(l.isBetaChannelEnabled())
.createdAt(l.getCreatedAt())
.updatedAt(l.getUpdatedAt())
.build();
}
}

View File

@@ -0,0 +1,84 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.ports.NpcRepository;
import com.loremind.infrastructure.persistence.entity.NpcJpaEntity;
import com.loremind.infrastructure.persistence.jpa.NpcJpaRepository;
import org.springframework.stereotype.Repository;
import java.util.HashMap;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Repository
public class PostgresNpcRepository implements NpcRepository {
private final NpcJpaRepository jpaRepository;
public PostgresNpcRepository(NpcJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Npc save(Npc npc) {
NpcJpaEntity entity = toJpaEntity(npc);
NpcJpaEntity saved = jpaRepository.save(entity);
return toDomainEntity(saved);
}
@Override
public Optional<Npc> findById(String id) {
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity);
}
@Override
public List<Npc> findByCampaignId(String campaignId) {
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
@Override
public void deleteById(String id) {
jpaRepository.deleteById(Long.parseLong(id));
}
@Override
public boolean existsById(String id) {
return jpaRepository.existsById(Long.parseLong(id));
}
private Npc toDomainEntity(NpcJpaEntity e) {
return Npc.builder()
.id(e.getId().toString())
.name(e.getName())
.portraitImageId(e.getPortraitImageId())
.headerImageId(e.getHeaderImageId())
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
.campaignId(e.getCampaignId().toString())
.order(e.getOrder())
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
.build();
}
private NpcJpaEntity toJpaEntity(Npc n) {
Long id = n.getId() != null ? Long.parseLong(n.getId()) : null;
return NpcJpaEntity.builder()
.id(id)
.name(n.getName())
.portraitImageId(n.getPortraitImageId())
.headerImageId(n.getHeaderImageId())
.values(n.getValues() != null ? new HashMap<>(n.getValues()) : new HashMap<>())
.imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>())
.keyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>())
.campaignId(Long.parseLong(n.getCampaignId()))
.order(n.getOrder())
.createdAt(n.getCreatedAt())
.updatedAt(n.getUpdatedAt())
.build();
}
}

View File

@@ -71,6 +71,7 @@ public class PostgresSceneRepository implements SceneRepository {
.description(jpaEntity.getDescription())
.chapterId(jpaEntity.getChapterId().toString())
.order(jpaEntity.getOrder())
.icon(jpaEntity.getIcon())
.location(jpaEntity.getLocation())
.timing(jpaEntity.getTiming())
.atmosphere(jpaEntity.getAtmosphere())
@@ -104,6 +105,7 @@ public class PostgresSceneRepository implements SceneRepository {
.description(scene.getDescription())
.chapterId(Long.parseLong(scene.getChapterId()))
.order(scene.getOrder())
.icon(scene.getIcon())
.location(scene.getLocation())
.timing(scene.getTiming())
.atmosphere(scene.getAtmosphere())

View File

@@ -0,0 +1,459 @@
package com.loremind.infrastructure.updates;
import com.loremind.application.licensing.LicenseService;
import com.loremind.domain.licensing.LicenseSnapshot;
import com.loremind.domain.licensing.LicenseStatus;
import com.loremind.domain.licensing.RegistryCredentials;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.info.BuildProperties;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Detection des mises a jour disponibles + declenchement via Watchtower.
* <p>
* <b>Strategie</b> : comparaison de versions semver, pas de digests.
* <ul>
* <li>La version courante de l'app est lue depuis {@link BuildProperties}
* (genere par spring-boot-maven-plugin dans META-INF/build-info.properties).</li>
* <li>Pour chaque image suivie, on interroge le registry sur
* {@code /v2/<image>/tags/list}, on extrait les tags semver, on prend le max.</li>
* <li>Si max > version courante => UPDATE_AVAILABLE.</li>
* <li>Si max == version courante => UP_TO_DATE.</li>
* <li>Si registry injoignable ou aucun tag valide => UNKNOWN.</li>
* </ul>
*
* <b>Pourquoi pas les digests ?</b> Le bug historique etait : le baseline-digest
* pose au @PostConstruct supposait que le pull venait d'avoir lieu (vrai apres
* `docker compose pull && up -d`, faux apres un simple restart de daemon ou un
* OOM). La version semver lue depuis le binaire est <b>fiable par construction</b> :
* c'est ce que le code source declare faire tourner.
*/
@Service
public class UpdateCheckService {
private static final Logger log = LoggerFactory.getLogger(UpdateCheckService.class);
private final RestTemplate http;
private final String registry;
private final List<String> images;
private final String watchtowerUrl;
private final String watchtowerToken;
private final List<String> betaImages;
private final LicenseService licenseService;
/** Version semver courante du binaire (ex: "0.8.0"). Source de verite. */
private final String currentVersion;
public UpdateCheckService(
RestTemplateBuilder builder,
@Value("${update-check.registry:}") String registry,
@Value("${update-check.images:}") String imagesCsv,
@Value("${update-check.watchtower-url:http://watchtower:8080}") String watchtowerUrl,
@Value("${update-check.watchtower-token:}") String watchtowerToken,
@Value("${licensing.beta.images:}") String betaImagesCsv,
LicenseService licenseService,
@Nullable BuildProperties buildProperties) {
this.http = builder
.setConnectTimeout(Duration.ofSeconds(5))
.setReadTimeout(Duration.ofSeconds(15))
.build();
this.registry = normalizeRegistry(registry);
this.images = parseImages(imagesCsv);
this.watchtowerUrl = watchtowerUrl;
this.watchtowerToken = watchtowerToken;
this.betaImages = parseImages(betaImagesCsv);
this.licenseService = licenseService;
this.currentVersion = buildProperties != null ? buildProperties.getVersion() : null;
log.info("Update check init - registry={} images={} currentVersion={}",
this.registry, this.images, this.currentVersion);
}
public boolean isEnabled() {
return watchtowerToken != null && !watchtowerToken.isBlank() && !images.isEmpty();
}
/**
* @return version courante exposee aux endpoints (ex: pour affichage UI).
* {@code null} si build-info.properties absent (dev en IDE sans build Maven).
*/
public String getCurrentVersion() {
return currentVersion;
}
public UpdateStatus check() {
if (!isEnabled()) {
return new UpdateStatus(false, false, false, null, List.of(), Instant.now());
}
if (currentVersion == null) {
log.warn("Update check : currentVersion absente (build-info manquant). Tous UNKNOWN.");
List<ImageStatus> statuses = new ArrayList<>();
for (String image : images) {
statuses.add(new ImageStatus(image, null, null, ImageStatusKind.UNKNOWN));
}
return new UpdateStatus(true, false, true, null, statuses, Instant.now());
}
List<ImageStatus> statuses = new ArrayList<>();
boolean anyUpdate = false;
boolean anyUnknown = false;
for (String image : images) {
String latest = null;
try {
latest = fetchLatestSemverTag(registry, image, null);
} catch (Exception e) {
log.warn("Tags fetch failed for {}: {}", image, e.getMessage());
}
ImageStatusKind kind;
if (latest == null) {
kind = ImageStatusKind.UNKNOWN;
anyUnknown = true;
} else {
int cmp = compareSemver(currentVersion, latest);
if (cmp >= 0) {
kind = ImageStatusKind.UP_TO_DATE;
} else {
kind = ImageStatusKind.UPDATE_AVAILABLE;
anyUpdate = true;
}
}
statuses.add(new ImageStatus(image, currentVersion, latest, kind));
}
return new UpdateStatus(true, anyUpdate, anyUnknown, currentVersion, statuses, Instant.now());
}
/**
* Verifie l'etat du canal beta (images privees GHCR) avec auth basique.
*/
public BetaStatus checkBeta() {
if (!licenseService.isLicensingEnabled()) {
return BetaStatus.disabled("licensing-not-configured");
}
LicenseSnapshot snap = licenseService.getCurrentSnapshot();
if (snap.status() != LicenseStatus.VALID && snap.status() != LicenseStatus.GRACE) {
return BetaStatus.disabled("license-" + snap.status().name().toLowerCase());
}
if (!snap.betaChannelEnabled()) {
return BetaStatus.disabled("beta-toggle-off");
}
if (betaImages.isEmpty()) {
return BetaStatus.disabled("no-beta-images-configured");
}
Optional<RegistryCredentials> creds = licenseService.fetchRegistryCredentials();
if (creds.isEmpty()) {
return new BetaStatus(true, false, true, List.of(), Instant.now(), "relay-unavailable");
}
String basicAuth = "Basic " + Base64.getEncoder().encodeToString(
(creds.get().username() + ":" + creds.get().password()).getBytes(StandardCharsets.UTF_8));
String betaRegistry = normalizeRegistry(creds.get().registry());
List<ImageStatus> statuses = new ArrayList<>();
boolean anyUpdate = false;
boolean anyUnknown = false;
for (String image : betaImages) {
String latest = null;
try {
latest = fetchLatestSemverTag(betaRegistry, image, basicAuth);
} catch (Exception e) {
log.warn("Beta tags fetch failed for {}: {}", image, e.getMessage());
}
ImageStatusKind kind;
if (latest == null) {
kind = ImageStatusKind.UNKNOWN;
anyUnknown = true;
} else if (currentVersion != null && compareSemver(currentVersion, latest) >= 0) {
kind = ImageStatusKind.UP_TO_DATE;
} else {
kind = ImageStatusKind.UPDATE_AVAILABLE;
anyUpdate = true;
}
statuses.add(new ImageStatus(image, currentVersion, latest, kind));
}
return new BetaStatus(true, anyUpdate, anyUnknown, statuses, Instant.now(), null);
}
public void apply() {
if (!isEnabled()) {
throw new IllegalStateException("Update apply not configured (WATCHTOWER_TOKEN missing)");
}
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(watchtowerToken);
http.exchange(
watchtowerUrl + "/v1/update",
HttpMethod.POST,
new HttpEntity<>(headers),
Void.class);
}
// -----------------------------------------------------------------------
// Registry HTTP API v2 - tags listing + auth bearer
// -----------------------------------------------------------------------
/**
* Interroge le registry pour la liste des tags d'une image, parse les
* versions semver et retourne la plus elevee. {@code null} si echec
* ou aucun tag valide.
*
* @param registryUrl URL normalisee (ex: "https://ghcr.io")
* @param image nom de l'image (ex: "igmlcreation/loremind-core")
* @param authHeader optionnel - "Basic ..." pour les registries prives
*/
private String fetchLatestSemverTag(String registryUrl, String image, @Nullable String authHeader) {
String url = registryUrl + "/v2/" + image + "/tags/list";
HttpHeaders headers = new HttpHeaders();
headers.setAccept(List.of(MediaType.APPLICATION_JSON));
if (authHeader != null) {
headers.set(HttpHeaders.AUTHORIZATION, authHeader);
}
TagsListResponse body;
try {
body = tagsCall(url, headers);
} catch (HttpClientErrorException.Unauthorized e) {
String www = e.getResponseHeaders() == null ? null
: e.getResponseHeaders().getFirst(HttpHeaders.WWW_AUTHENTICATE);
String token = obtainBearerToken(www, authHeader);
if (token == null) {
log.warn("Cannot obtain bearer token for {} (registry response: {})", image, www);
return null;
}
HttpHeaders bearerHeaders = new HttpHeaders();
bearerHeaders.setAccept(List.of(MediaType.APPLICATION_JSON));
bearerHeaders.setBearerAuth(token);
body = tagsCall(url, bearerHeaders);
}
if (body == null || body.tags == null || body.tags.isEmpty()) return null;
return findMaxSemver(body.tags);
}
private TagsListResponse tagsCall(String url, HttpHeaders headers) {
ResponseEntity<TagsListResponse> resp = http.exchange(
url, HttpMethod.GET, new HttpEntity<>(headers), TagsListResponse.class);
return resp.getBody();
}
/**
* Parcourt la liste des tags, garde uniquement ceux qui parsent en semver
* (1 a 3 chiffres separes par des points, optionnel prefix "v"), retourne le max.
* Pre-release / build metadata sont strippes pour la comparaison.
*/
@Nullable
static String findMaxSemver(List<String> tags) {
String maxTag = null;
int[] maxParts = null;
for (String t : tags) {
if (t == null || t.isBlank()) continue;
int[] parts = parseSemver(t);
if (parts == null) continue;
if (maxParts == null || compareParts(parts, maxParts) > 0) {
maxParts = parts;
maxTag = t;
}
}
return maxTag;
}
/** @return [major, minor, patch] ou null si non parsable. */
@Nullable
static int[] parseSemver(String tag) {
if (tag == null) return null;
String s = tag.trim();
if (s.isEmpty()) return null;
if (s.startsWith("v") || s.startsWith("V")) s = s.substring(1);
int dashIdx = s.indexOf('-');
if (dashIdx > 0) s = s.substring(0, dashIdx);
int plusIdx = s.indexOf('+');
if (plusIdx > 0) s = s.substring(0, plusIdx);
String[] parts = s.split("\\.");
if (parts.length < 1 || parts.length > 3) return null;
int[] result = new int[]{0, 0, 0};
for (int i = 0; i < parts.length; i++) {
try {
int v = Integer.parseInt(parts[i]);
if (v < 0) return null;
result[i] = v;
} catch (NumberFormatException e) {
return null;
}
}
return result;
}
/** Compare deux versions semver brutes (sans prefix). Negatif si a < b. */
static int compareSemver(String a, String b) {
int[] aParts = parseSemver(a);
int[] bParts = parseSemver(b);
if (aParts == null || bParts == null) return 0;
return compareParts(aParts, bParts);
}
private static int compareParts(int[] a, int[] b) {
for (int i = 0; i < 3; i++) {
int diff = Integer.compare(a[i], b[i]);
if (diff != 0) return diff;
}
return 0;
}
/**
* Suit le challenge {@code WWW-Authenticate: Bearer realm="..."} pour obtenir
* un token. Si {@code basicAuth} est fourni, l'utilise pour l'echange (cas
* registry prive). Sinon anonyme (cas registry public).
*/
@SuppressWarnings("rawtypes")
private String obtainBearerToken(@Nullable String wwwAuth, @Nullable String basicAuth) {
if (wwwAuth == null) return null;
String prefix = "Bearer ";
if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null;
Map<String, String> params = parseAuthParams(wwwAuth.substring(prefix.length()));
String realm = params.get("realm");
if (realm == null) return null;
StringBuilder url = new StringBuilder(realm);
boolean hasQuery = realm.contains("?");
for (String key : new String[]{"service", "scope"}) {
String v = params.get(key);
if (v != null) {
String encoded = URLEncoder.encode(v, StandardCharsets.UTF_8)
.replace("%3A", ":")
.replace("%2F", "/");
url.append(hasQuery ? '&' : '?').append(key).append('=').append(encoded);
hasQuery = true;
}
}
try {
HttpHeaders headers = new HttpHeaders();
if (basicAuth != null) {
headers.set(HttpHeaders.AUTHORIZATION, basicAuth);
}
ResponseEntity<Map> resp = http.exchange(url.toString(), HttpMethod.GET,
new HttpEntity<>(headers), Map.class);
Map<?, ?> body = resp.getBody();
if (body == null) return null;
Object t = body.get("token");
if (t == null) t = body.get("access_token");
return t == null ? null : t.toString();
} catch (Exception e) {
log.warn("Bearer token request failed: {}", e.getMessage());
return null;
}
}
/** Parser minimaliste pour {@code key="value", key2="value2"}. */
private static Map<String, String> parseAuthParams(String s) {
Map<String, String> out = new HashMap<>();
int i = 0;
int n = s.length();
while (i < n) {
while (i < n && (s.charAt(i) == ',' || s.charAt(i) == ' ')) i++;
int eq = s.indexOf('=', i);
if (eq < 0) break;
String key = s.substring(i, eq).trim();
int valStart = eq + 1;
String val;
if (valStart < n && s.charAt(valStart) == '"') {
int valEnd = s.indexOf('"', valStart + 1);
if (valEnd < 0) break;
val = s.substring(valStart + 1, valEnd);
i = valEnd + 1;
} else {
int valEnd = s.indexOf(',', valStart);
if (valEnd < 0) valEnd = n;
val = s.substring(valStart, valEnd).trim();
i = valEnd;
}
out.put(key, val);
}
return out;
}
private static String normalizeRegistry(String value) {
if (value == null || value.isBlank()) return "";
String v = value.trim();
if (!v.startsWith("http://") && !v.startsWith("https://")) {
v = "https://" + v;
}
if (v.endsWith("/")) v = v.substring(0, v.length() - 1);
return v;
}
private static List<String> parseImages(String csv) {
if (csv == null || csv.isBlank()) return List.of();
List<String> out = new ArrayList<>();
for (String part : csv.split(",")) {
String p = part.trim();
if (!p.isEmpty()) out.add(p);
}
return out;
}
// -----------------------------------------------------------------------
// Records / DTO
// -----------------------------------------------------------------------
public enum ImageStatusKind { UP_TO_DATE, UPDATE_AVAILABLE, UNKNOWN }
public record UpdateStatus(
boolean enabled,
boolean updateAvailable,
boolean anyUnknown,
String currentVersion,
List<ImageStatus> images,
Instant checkedAt) {}
/**
* Statut par image. {@code localVersion} = version embarquee dans le binaire ;
* {@code remoteVersion} = plus haute version semver trouvee dans le registry.
* {@code updateAvailable} est derive de {@code status} (back-compat front).
*/
public record ImageStatus(
String image,
String localVersion,
String remoteVersion,
ImageStatusKind status,
boolean updateAvailable) {
public ImageStatus(String image, String localVersion, String remoteVersion, ImageStatusKind status) {
this(image, localVersion, remoteVersion, status, status == ImageStatusKind.UPDATE_AVAILABLE);
}
}
public record BetaStatus(
boolean enabled,
boolean updateAvailable,
boolean anyUnknown,
List<ImageStatus> images,
Instant checkedAt,
String disabledReason) {
public static BetaStatus disabled(String reason) {
return new BetaStatus(false, false, false, List.of(), Instant.now(), reason);
}
}
/** DTO pour deserialisation Jackson de /v2/.../tags/list. */
static class TagsListResponse {
public String name;
public List<String> tags;
}
}

View File

@@ -0,0 +1,97 @@
package com.loremind.infrastructure.web;
import jakarta.persistence.EntityNotFoundException;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Intercepteur global d'exceptions pour TOUS les @RestController.
*
* <p>Role :
* <ul>
* <li>Logger systematiquement les exceptions non gerees (avec stack trace + path)
* — evite d'avoir a creuser dans les logs Docker apres coup.</li>
* <li>Renvoyer un JSON propre au client (`{error, type, ...}`) au lieu du 500 nu
* par defaut de Spring — utile pour debug cote frontend (visible directement
* dans la DevTools reseau).</li>
* <li>Mapper les exceptions courantes vers des status HTTP appropries
* (IllegalArgumentException -> 400, EntityNotFoundException -> 404).</li>
* </ul>
*
* <p>Important : ne court-circuite PAS les try/catch locaux des controllers
* (ex: LicenseController.install catche InstallException -> 400 lui-meme).
* Ce handler n'attrape QUE ce qui a echappe au catch local.
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
/**
* Violation d'invariant domaine (doublons, valeurs invalides, etc.) -> 400.
* Concentre ici la logique qui etait dupliquee dans GameSystemController.
*/
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, String>> handleIllegalArgument(IllegalArgumentException ex) {
return ResponseEntity.badRequest().body(Map.of("error", safeMessage(ex)));
}
/** Entite JPA introuvable -> 404. */
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<Map<String, String>> handleNotFound(EntityNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", safeMessage(ex)));
}
/** JSON malforme dans le body de la requete -> 400. */
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<Map<String, String>> handleUnreadable(HttpMessageNotReadableException ex) {
return ResponseEntity.badRequest().body(Map.of("error", "Malformed request body"));
}
/** Validation @Valid echouee -> 400 avec liste des erreurs par champ. */
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> fields = new LinkedHashMap<>();
ex.getBindingResult().getFieldErrors().forEach(e ->
fields.put(e.getField(), e.getDefaultMessage() != null ? e.getDefaultMessage() : "invalid"));
return ResponseEntity.badRequest().body(Map.of(
"error", "Validation failed",
"fields", fields
));
}
/**
* Fallback : tout ce qui n'a pas ete catche au-dessus -> 500, mais avec
* un log ERROR explicite (path + stack trace) et un body JSON debuggable
* cote client. C'est LE filet de securite.
*
* Note : on attrape Throwable (pas Exception) pour aussi capturer les
* Error (NoClassDefFoundError, OutOfMemoryError... — cf. incident Tink).
* On NE swallow PAS — on log AVANT de renvoyer une reponse.
*/
@ExceptionHandler(Throwable.class)
public ResponseEntity<Map<String, String>> handleUnexpected(HttpServletRequest request, Throwable ex) {
log.error("Unhandled exception on {} {}", request.getMethod(), request.getRequestURI(), ex);
Map<String, String> body = new LinkedHashMap<>();
body.put("error", "Internal server error");
body.put("type", ex.getClass().getSimpleName());
String msg = safeMessage(ex);
if (!msg.isEmpty()) body.put("message", msg);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
}
/** Evite les NPE quand getMessage() est null sur certaines exceptions. */
private static String safeMessage(Throwable ex) {
return ex.getMessage() != null ? ex.getMessage() : "";
}
}

View File

@@ -66,6 +66,8 @@ public class SecurityConfig {
// Preflight CORS toujours libre (le browser n'envoie pas Authorization sur OPTIONS)
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/settings/**").hasRole("ADMIN")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/license/**").hasRole("ADMIN")
.anyRequest().permitAll()
)
.httpBasic(basic -> {});

View File

@@ -28,7 +28,7 @@ public class ArcController {
@PostMapping
public ResponseEntity<ArcDTO> createArc(@RequestBody ArcDTO arcDTO) {
Arc arc = arcMapper.toDomain(arcDTO);
Arc createdArc = arcService.createArc(arc.getName(), arc.getDescription(), arc.getCampaignId(), arc.getOrder());
Arc createdArc = arcService.createArc(arc.getName(), arc.getDescription(), arc.getCampaignId(), arc.getOrder(), arc.getIcon());
return ResponseEntity.ok(arcMapper.toDTO(createdArc));
}
@@ -40,17 +40,11 @@ public class ArcController {
}
@GetMapping
public ResponseEntity<List<ArcDTO>> getAllArcs() {
List<Arc> arcs = arcService.getAllArcs();
List<ArcDTO> arcDTOs = arcs.stream()
.map(arcMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(arcDTOs);
}
@GetMapping("/campaign/{campaignId}")
public ResponseEntity<List<ArcDTO>> getArcsByCampaignId(@PathVariable String campaignId) {
List<Arc> arcs = arcService.getArcsByCampaignId(campaignId);
public ResponseEntity<List<ArcDTO>> getAllArcs(
@RequestParam(value = "campaignId", required = false) String campaignId) {
List<Arc> arcs = (campaignId != null && !campaignId.isBlank())
? arcService.getArcsByCampaignId(campaignId)
: arcService.getAllArcs();
List<ArcDTO> arcDTOs = arcs.stream()
.map(arcMapper::toDTO)
.collect(Collectors.toList());
@@ -68,4 +62,12 @@ public class ArcController {
arcService.deleteArc(id);
return ResponseEntity.noContent().build();
}
@GetMapping("/{id}/deletion-impact")
public ResponseEntity<ArcService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
if (!arcService.arcExists(id)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(arcService.getDeletionImpact(id));
}
}

View File

@@ -74,4 +74,16 @@ public class CampaignController {
campaignService.deleteCampaign(id);
return ResponseEntity.noContent().build();
}
/**
* Récapitulatif des entités qui seront supprimées en cascade : utilisé par
* l'UI pour afficher "X arcs, Y chapitres, Z scènes..." dans la confirmation.
*/
@GetMapping("/{id}/deletion-impact")
public ResponseEntity<CampaignService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
if (!campaignService.campaignExists(id)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(campaignService.getDeletionImpact(id));
}
}

View File

@@ -28,7 +28,7 @@ public class ChapterController {
@PostMapping
public ResponseEntity<ChapterDTO> createChapter(@RequestBody ChapterDTO chapterDTO) {
Chapter chapter = chapterMapper.toDomain(chapterDTO);
Chapter createdChapter = chapterService.createChapter(chapter.getName(), chapter.getDescription(), chapter.getArcId(), chapter.getOrder());
Chapter createdChapter = chapterService.createChapter(chapter.getName(), chapter.getDescription(), chapter.getArcId(), chapter.getOrder(), chapter.getIcon());
return ResponseEntity.ok(chapterMapper.toDTO(createdChapter));
}
@@ -40,17 +40,11 @@ public class ChapterController {
}
@GetMapping
public ResponseEntity<List<ChapterDTO>> getAllChapters() {
List<Chapter> chapters = chapterService.getAllChapters();
List<ChapterDTO> chapterDTOs = chapters.stream()
.map(chapterMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(chapterDTOs);
}
@GetMapping("/arc/{arcId}")
public ResponseEntity<List<ChapterDTO>> getChaptersByArcId(@PathVariable String arcId) {
List<Chapter> chapters = chapterService.getChaptersByArcId(arcId);
public ResponseEntity<List<ChapterDTO>> getAllChapters(
@RequestParam(value = "arcId", required = false) String arcId) {
List<Chapter> chapters = (arcId != null && !arcId.isBlank())
? chapterService.getChaptersByArcId(arcId)
: chapterService.getAllChapters();
List<ChapterDTO> chapterDTOs = chapters.stream()
.map(chapterMapper::toDTO)
.collect(Collectors.toList());
@@ -68,4 +62,12 @@ public class ChapterController {
chapterService.deleteChapter(id);
return ResponseEntity.noContent().build();
}
@GetMapping("/{id}/deletion-impact")
public ResponseEntity<ChapterService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
if (!chapterService.chapterExists(id)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(chapterService.getDeletionImpact(id));
}
}

View File

@@ -24,9 +24,7 @@ public class CharacterController {
@PostMapping
public ResponseEntity<CharacterDTO> createCharacter(@RequestBody CharacterDTO dto) {
Character created = characterService.createCharacter(
new CharacterService.CharacterData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), null)
);
Character created = characterService.createCharacter(toData(dto, null));
return ResponseEntity.ok(characterMapper.toDTO(created));
}
@@ -47,10 +45,7 @@ public class CharacterController {
@PutMapping("/{id}")
public ResponseEntity<CharacterDTO> updateCharacter(@PathVariable String id, @RequestBody CharacterDTO dto) {
Character updated = characterService.updateCharacter(
id,
new CharacterService.CharacterData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), dto.getOrder())
);
Character updated = characterService.updateCharacter(id, toData(dto, dto.getOrder()));
return ResponseEntity.ok(characterMapper.toDTO(updated));
}
@@ -59,4 +54,17 @@ public class CharacterController {
characterService.deleteCharacter(id);
return ResponseEntity.noContent().build();
}
private CharacterService.CharacterData toData(CharacterDTO dto, Integer order) {
return new CharacterService.CharacterData(
dto.getName(),
dto.getPortraitImageId(),
dto.getHeaderImageId(),
dto.getValues(),
dto.getImageValues(),
dto.getKeyValueValues(),
dto.getCampaignId(),
order
);
}
}

View File

@@ -0,0 +1,36 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.infrastructure.updates.UpdateCheckService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* Expose la configuration publique consommee par le frontend au demarrage.
* Activer le mode demo via la variable d'env DEMO_MODE=true : le front
* masque alors Settings / Export VTT, et les endpoints sensibles sont
* verrouilles cote serveur (cf. SettingsController).
*/
@RestController
@RequestMapping("/api/config")
public class ConfigController {
private final boolean demoMode;
private final UpdateCheckService updates;
public ConfigController(@Value("${app.demo-mode:false}") boolean demoMode,
UpdateCheckService updates) {
this.demoMode = demoMode;
this.updates = updates;
}
@GetMapping
public Map<String, Object> getPublicConfig() {
return Map.of(
"demoMode", demoMode,
"updateCheckEnabled", updates.isEnabled());
}
}

View File

@@ -2,11 +2,15 @@ package com.loremind.infrastructure.web.controller;
import com.loremind.application.gamesystemcontext.GameSystemService;
import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import com.loremind.infrastructure.web.mapper.GameSystemMapper;
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@@ -16,10 +20,14 @@ public class GameSystemController {
private final GameSystemService gameSystemService;
private final GameSystemMapper gameSystemMapper;
private final TemplateFieldMapper templateFieldMapper;
public GameSystemController(GameSystemService gameSystemService, GameSystemMapper gameSystemMapper) {
public GameSystemController(GameSystemService gameSystemService,
GameSystemMapper gameSystemMapper,
TemplateFieldMapper templateFieldMapper) {
this.gameSystemService = gameSystemService;
this.gameSystemMapper = gameSystemMapper;
this.templateFieldMapper = templateFieldMapper;
}
@PostMapping
@@ -68,8 +76,17 @@ public class GameSystemController {
dto.getName(),
dto.getDescription(),
dto.getRulesMarkdown(),
toDomainFields(dto.getCharacterTemplate()),
toDomainFields(dto.getNpcTemplate()),
dto.getAuthor(),
dto.isPublic()
);
}
private List<TemplateField> toDomainFields(List<TemplateFieldDTO> dtos) {
if (dtos == null) return new ArrayList<>();
List<TemplateField> out = new ArrayList<>(dtos.size());
for (TemplateFieldDTO d : dtos) out.add(templateFieldMapper.toDomain(d));
return out;
}
}

View File

@@ -0,0 +1,155 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.licensing.ChannelSwitcherService;
import com.loremind.application.licensing.LicenseService;
import com.loremind.application.licensing.LicenseService.InstallException;
import com.loremind.domain.licensing.LicenseSnapshot;
import com.loremind.infrastructure.web.dto.licensing.ChannelStatusDTO;
import com.loremind.infrastructure.web.dto.licensing.LicenseStatusDTO;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.Locale;
import java.util.Map;
/**
* Endpoints de gestion de la licence Patreon.
*
* <ul>
* <li>{@code GET /api/license} : etat courant (status, tier, expiration...)</li>
* <li>{@code GET /api/license/connect-url} : URL OAuth a ouvrir dans le navigateur</li>
* <li>{@code POST /api/license/install} : colle un JWT recu du relais</li>
* <li>{@code DELETE /api/license} : deconnecte Patreon (efface la licence)</li>
* <li>{@code POST /api/license/refresh} : force un refresh manuel</li>
* <li>{@code PUT /api/license/beta-channel} : active/desactive le canal beta</li>
* </ul>
*/
@RestController
@RequestMapping("/api/license")
public class LicenseController {
private final LicenseService licenseService;
private final ChannelSwitcherService channelSwitcher;
public LicenseController(LicenseService licenseService, ChannelSwitcherService channelSwitcher) {
this.licenseService = licenseService;
this.channelSwitcher = channelSwitcher;
}
@GetMapping
public LicenseStatusDTO getStatus() {
boolean enabled = licenseService.isLicensingEnabled();
LicenseSnapshot snap = licenseService.getCurrentSnapshot();
return LicenseStatusDTO.from(enabled, snap);
}
@GetMapping("/connect-url")
public Map<String, String> getConnectUrl() {
return Map.of("url", licenseService.buildConnectUrl());
}
@PostMapping("/install")
public ResponseEntity<?> install(@RequestBody InstallRequest request) {
if (request == null || request.jwt() == null || request.jwt().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "missing jwt"));
}
try {
LicenseSnapshot snap = licenseService.installToken(request.jwt());
return ResponseEntity.ok(LicenseStatusDTO.from(true, snap));
} catch (InstallException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@DeleteMapping
public ResponseEntity<Void> disconnect() {
licenseService.disconnect();
return ResponseEntity.noContent().build();
}
@PostMapping("/refresh")
public ResponseEntity<LicenseStatusDTO> refresh() {
licenseService.forceRefresh();
boolean enabled = licenseService.isLicensingEnabled();
return ResponseEntity.ok(LicenseStatusDTO.from(enabled, licenseService.getCurrentSnapshot()));
}
@PutMapping("/beta-channel")
public ResponseEntity<?> setBetaChannel(@RequestBody BetaChannelRequest request) {
if (request == null) {
return ResponseEntity.badRequest().body(Map.of("error", "missing body"));
}
try {
LicenseSnapshot snap = licenseService.setBetaChannelEnabled(request.enabled());
return ResponseEntity.ok(LicenseStatusDTO.from(true, snap));
} catch (IllegalStateException e) {
return ResponseEntity.status(409).body(Map.of("error", e.getMessage()));
}
}
// ─── Bascule de canal (stable <-> beta) via sidecar switcher ────────────
//
// Le flux :
// 1. UI POST /api/license/channel/switch { channel: "beta" }
// 2. Core valide la licence (refus si target=beta sans Patreon actif)
// 3. Core depose une commande dans le volume partage
// 4. Sidecar `switcher` la traite (sed .env, docker compose up -d)
// 5. UI poll GET /api/license/channel pour suivre le status
/** Etat courant : canal actuel + dispo du sidecar + dernier resultat. */
@GetMapping("/channel")
public ChannelStatusDTO getChannel() {
return ChannelStatusDTO.from(channelSwitcher);
}
/** Declenche un switch de canal. Renvoie l'ID de la commande pour le polling. */
@PostMapping("/channel/switch")
public ResponseEntity<?> switchChannel(@RequestBody ChannelSwitchRequest request) {
if (request == null || request.channel() == null) {
return ResponseEntity.badRequest().body(Map.of("error", "missing channel"));
}
ChannelSwitcherService.Channel target;
try {
target = ChannelSwitcherService.Channel.valueOf(request.channel().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of(
"error", "invalid channel (allowed: stable, beta)"));
}
// Garde : pas de switch vers beta sans licence Patreon valide.
// Le switcher ferait le boulot quoi qu'il arrive (il valide juste le
// format), donc c'est ici qu'on doit refuser cote metier.
// VALID + GRACE autorisent l'acces beta (cf. javadoc de LicenseStatus).
if (target == ChannelSwitcherService.Channel.BETA) {
LicenseSnapshot snap = licenseService.getCurrentSnapshot();
com.loremind.domain.licensing.LicenseStatus s = (snap != null) ? snap.status() : null;
boolean allowed = s == com.loremind.domain.licensing.LicenseStatus.VALID
|| s == com.loremind.domain.licensing.LicenseStatus.GRACE;
if (!allowed) {
return ResponseEntity.status(403).body(Map.of(
"error", "Aucune licence Patreon active — impossible de basculer sur le canal beta."));
}
}
if (!channelSwitcher.isSwitcherAvailable()) {
return ResponseEntity.status(503).body(Map.of(
"error", "Sidecar switcher non disponible (mise a jour requise du docker-compose.yml)."));
}
try {
String id = channelSwitcher.requestSwitch(target);
return ResponseEntity.accepted().body(Map.of(
"id", id,
"channel", target.name().toLowerCase(Locale.ROOT)));
} catch (IOException e) {
return ResponseEntity.status(500).body(Map.of(
"error", "Impossible d'ecrire la commande de switch: " + e.getMessage()));
}
}
public record InstallRequest(String jwt) {}
public record BetaChannelRequest(boolean enabled) {}
public record ChannelSwitchRequest(String channel) {}
}

View File

@@ -69,4 +69,17 @@ public class LoreController {
loreService.deleteLore(id);
return ResponseEntity.noContent().build();
}
/**
* Récapitulatif des entités qui seront supprimées / détachées en cascade.
* Utilisé par l'UI pour afficher "X dossiers, Y pages, Z templates,
* N campagne(s) détachée(s)" dans la confirmation.
*/
@GetMapping("/{id}/deletion-impact")
public ResponseEntity<LoreService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
if (loreService.getLoreById(id).isEmpty()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(loreService.getDeletionImpact(id));
}
}

View File

@@ -97,4 +97,16 @@ public class LoreNodeController {
loreNodeService.deleteLoreNode(id);
return ResponseEntity.noContent().build();
}
/**
* Récapitulatif des entités qui seront supprimées en cascade : utilisé par
* l'UI pour afficher "X sous-dossiers, Y pages..." dans la confirmation.
*/
@GetMapping("/{id}/deletion-impact")
public ResponseEntity<LoreNodeService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
if (loreNodeService.getLoreNodeById(id).isEmpty()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(loreNodeService.getDeletionImpact(id));
}
}

View File

@@ -0,0 +1,70 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.campaigncontext.NpcService;
import com.loremind.domain.campaigncontext.Npc;
import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO;
import com.loremind.infrastructure.web.mapper.NpcMapper;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/npcs")
public class NpcController {
private final NpcService npcService;
private final NpcMapper npcMapper;
public NpcController(NpcService npcService, NpcMapper npcMapper) {
this.npcService = npcService;
this.npcMapper = npcMapper;
}
@PostMapping
public ResponseEntity<NpcDTO> createNpc(@RequestBody NpcDTO dto) {
Npc created = npcService.createNpc(toData(dto, null));
return ResponseEntity.ok(npcMapper.toDTO(created));
}
@GetMapping("/{id}")
public ResponseEntity<NpcDTO> getNpcById(@PathVariable String id) {
return npcService.getNpcById(id)
.map(n -> ResponseEntity.ok(npcMapper.toDTO(n)))
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/campaign/{campaignId}")
public ResponseEntity<List<NpcDTO>> getNpcsByCampaign(@PathVariable String campaignId) {
List<NpcDTO> dtos = npcService.getNpcsByCampaignId(campaignId).stream()
.map(npcMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
@PutMapping("/{id}")
public ResponseEntity<NpcDTO> updateNpc(@PathVariable String id, @RequestBody NpcDTO dto) {
Npc updated = npcService.updateNpc(id, toData(dto, dto.getOrder()));
return ResponseEntity.ok(npcMapper.toDTO(updated));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteNpc(@PathVariable String id) {
npcService.deleteNpc(id);
return ResponseEntity.noContent().build();
}
private NpcService.NpcData toData(NpcDTO dto, Integer order) {
return new NpcService.NpcData(
dto.getName(),
dto.getPortraitImageId(),
dto.getHeaderImageId(),
dto.getValues(),
dto.getImageValues(),
dto.getKeyValueValues(),
dto.getCampaignId(),
order
);
}
}

Some files were not shown because too many files have changed in this diff Show More