Compare commits
9 Commits
v0.7.2
...
v0.8.4-bet
| Author | SHA1 | Date | |
|---|---|---|---|
| f24ef0891e | |||
| 7c74c12f3e | |||
| 86836ad81c | |||
| 7c4a42327d | |||
| 52e389db24 | |||
| efaf5a3794 | |||
| 4fe93b5ff3 | |||
| 0f2d1b1efe | |||
| 5ff05242a8 |
@@ -42,19 +42,24 @@ jobs:
|
|||||||
username: ${{ env.GHCR_NAMESPACE }}
|
username: ${{ env.GHCR_NAMESPACE }}
|
||||||
password: ${{ secrets.GHCR_TOKEN }}
|
password: ${{ secrets.GHCR_TOKEN }}
|
||||||
|
|
||||||
- name: Extract version
|
# 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
|
id: meta
|
||||||
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
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
|
||||||
|
|
||||||
# Push vers les deux registries en un seul build (build-push-action
|
# Build & push canal STABLE
|
||||||
# accepte une liste de tags ; aucun build supplementaire necessaire).
|
- name: Build & push ${{ matrix.component }} (stable)
|
||||||
# Naming :
|
if: steps.meta.outputs.channel == 'stable'
|
||||||
# - Gitea : conserve l'ancien pattern ietm64/<component> pour ne pas
|
|
||||||
# casser les installs existantes qui ont REGISTRY=git.igmlcreation.fr
|
|
||||||
# dans leur .env.
|
|
||||||
# - GHCR : nouveau pattern igmlcreation/loremind-<component> qui evite
|
|
||||||
# la collision avec d'autres projets de l'org.
|
|
||||||
- name: Build & push ${{ matrix.component }}
|
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: ./${{ matrix.component }}
|
context: ./${{ matrix.component }}
|
||||||
@@ -64,3 +69,19 @@ jobs:
|
|||||||
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
${{ 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 }}:latest
|
||||||
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
${{ 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 }}
|
||||||
|
|||||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -7,6 +7,11 @@
|
|||||||
brain/data/settings.json
|
brain/data/settings.json
|
||||||
*.key
|
*.key
|
||||||
*.pem
|
*.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
|
# Java / Spring Boot / Maven
|
||||||
@@ -97,3 +102,9 @@ loremind-docs/
|
|||||||
# Docker Compose override (dev uniquement, non-distribue aux end users)
|
# Docker Compose override (dev uniquement, non-distribue aux end users)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Relais OAuth Patreon (repo Gitea separe, clone localement pour facilite)
|
||||||
|
# ============================================================================
|
||||||
|
relay/
|
||||||
|
scripts/bump-version.mjs
|
||||||
|
|||||||
67
README.md
67
README.md
@@ -1,37 +1,66 @@
|
|||||||
# LoreMind
|
# LoreMind
|
||||||
|
|
||||||
|
> Application web auto-hébergeable pour MJ qui veulent centraliser leur univers, leurs campagnes et leurs personnages — avec un assistant IA contextuel.
|
||||||
|
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://loremind-docs.igmlcreation.fr/)
|
||||||
|
[](https://loremind-demo.igmlcreation.fr/)
|
||||||
|
[](https://www.patreon.com/c/IGMLCreation)
|
||||||
|
[](https://discord.gg/cPpFzCjEzQ)
|
||||||
|
|
||||||
|
## Découvrir LoreMind en vidéo
|
||||||
|
|
||||||
|
[](https://www.youtube.com/watch?v=llJkmlotbB8)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Loremind est une application web angular auto-hébergable afin de venir en aide aux Maîtres de jeu qui souhaitent centraliser leur univers et leurs campagnes.
|
## Ce que ça fait
|
||||||
Cette dernière intègre un moteur IA qui va ingérer le contenu du lore et de la campagne afin de pouvoir répondre à des questions précises sur l'univers ou la campagne, mais également proposer des idées de création dans le contexte de la campagne et du lore.
|
|
||||||
Pour le moment seul Ollama est supporté pour la partie locale, il y-a également une intégration pour 1min.ai. Plus tard, d'autres moteurs seront supportés.
|
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.
|
||||||
|
|
||||||
|
### Lore
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Game System
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Campaign
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Assistant IA
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
La documentation complète est accessible sur le site [loremind-docs](https://loremind-docs.igmlcreation.fr/)
|
Toute la documentation (installation, configuration, prise en main) est sur **[loremind-docs.igmlcreation.fr](https://loremind-docs.igmlcreation.fr/)**.
|
||||||
|
|
||||||
Pour l'installation, consultez le guide dans cette dernière .
|
## Démo en ligne
|
||||||
|
|
||||||
## Fonctionnalités
|
Une instance de démonstration est disponible sur **[loremind-demo.igmlcreation.fr](https://loremind-demo.igmlcreation.fr/)**.
|
||||||
|
|
||||||
- Gestion centralisée du Lore : Lieux, Factions, PNJ, et tous les éléments de votre univers
|
Quelques limites à connaître :
|
||||||
- Suivi de campagnes : Sessions, actions des joueurs, chronologie
|
- 10 utilisateurs maximum simultanés (instances isolées)
|
||||||
- Moteur IA intégré : Génération automatique de contenu (PNJ, Villes, Quêtes) à partir de templates
|
- 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)
|
||||||
|
|
||||||
## Démo
|
## Soutenir le projet
|
||||||
|
|
||||||
Une démo est disponible sur le site [loremind-demo](https://loremind-demo.igmlcreation.fr/)
|
LoreMind est **et restera gratuit en auto-hébergement**. Le développement avance plus vite avec votre soutien :
|
||||||
|
|
||||||
!! Attention, la démo est uniquement accessible à 10 personnes à la fois (instances personnalisées). Cette limite est mise en place pour éviter l'overhead sur les ressources serveur.
|
- **[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
|
||||||
|
|
||||||
Cette dernière est utilisable 20 minutes maximum par session avant d'être réinitialiser.
|
## Licence
|
||||||
Vous comprendrez également qu'elle ne contient pas de démo pour la partie IA, pour laquelle il faut configurer un serveur Ollama (et qui ferait donc exploser le serveur) ou utiliser 1min.ai.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
LoreMind est distribué sous licence **[GNU AGPL v3](LICENSE)**.
|
LoreMind est distribué sous licence **[GNU AGPL v3](LICENSE)**.
|
||||||
|
|
||||||
En pratique :
|
En pratique :
|
||||||
- Tu peux l'utiliser gratuitement, l'héberger où tu veux, le modifier, le redistribuer.
|
- Vous pouvez l'utiliser gratuitement, l'héberger, la modifier, la 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.
|
- 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 tu crées avec LoreMind **t'appartiennent entièrement** — la licence ne couvre que le code de l'application.
|
- 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.
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="LoreMind Brain",
|
title="LoreMind Brain",
|
||||||
description="Backend IA pour la génération de contenu narratif.",
|
description="Backend IA pour la génération de contenu narratif.",
|
||||||
version="0.6.6",
|
version="0.8.4-beta",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
27
core/pom.xml
27
core/pom.xml
@@ -8,13 +8,13 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-parent</artifactId>
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
<version>3.2.0</version>
|
<version>3.2.12</version>
|
||||||
<relativePath/>
|
<relativePath/>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>0.7.2</version>
|
<version>0.8.4-beta</version>
|
||||||
<name>LoreMind Core</name>
|
<name>LoreMind Core</name>
|
||||||
<description>Backend Core - Architecture Hexagonale</description>
|
<description>Backend Core - Architecture Hexagonale</description>
|
||||||
|
|
||||||
@@ -83,6 +83,19 @@
|
|||||||
<artifactId>minio</artifactId>
|
<artifactId>minio</artifactId>
|
||||||
<version>8.5.11</version>
|
<version>8.5.11</version>
|
||||||
</dependency>
|
</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>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
@@ -98,6 +111,16 @@
|
|||||||
</exclude>
|
</exclude>
|
||||||
</excludes>
|
</excludes>
|
||||||
</configuration>
|
</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>
|
</plugin>
|
||||||
|
|
||||||
<!-- JaCoCo : rapport de couverture des tests unitaires.
|
<!-- JaCoCo : rapport de couverture des tests unitaires.
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ package com.loremind;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Classe principale de l'application LoreMind.
|
* Classe principale de l'application LoreMind.
|
||||||
* Point d'entrée Spring Boot qui démarre l'application.
|
* Point d'entrée Spring Boot qui démarre l'application.
|
||||||
*/
|
*/
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@EnableScheduling
|
||||||
public class LoreMindApplication {
|
public class LoreMindApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import com.loremind.domain.campaigncontext.Character;
|
|||||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,8 +24,18 @@ public class CharacterService {
|
|||||||
/**
|
/**
|
||||||
* Parameter Object pour la création / mise à jour d'un Character.
|
* Parameter Object pour la création / mise à jour d'un Character.
|
||||||
* `order` est fourni par le controller ; si absent, le service le calcule.
|
* `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) {
|
public Character createCharacter(CharacterData data) {
|
||||||
int order = data.order() != null
|
int order = data.order() != null
|
||||||
@@ -31,7 +43,11 @@ public class CharacterService {
|
|||||||
: nextOrderFor(data.campaignId());
|
: nextOrderFor(data.campaignId());
|
||||||
Character character = Character.builder()
|
Character character = Character.builder()
|
||||||
.name(data.name())
|
.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())
|
.campaignId(data.campaignId())
|
||||||
.order(order)
|
.order(order)
|
||||||
.build();
|
.build();
|
||||||
@@ -50,7 +66,11 @@ public class CharacterService {
|
|||||||
Character existing = characterRepository.findById(id)
|
Character existing = characterRepository.findById(id)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Character non trouvé avec l'ID: " + id));
|
.orElseThrow(() -> new IllegalArgumentException("Character non trouvé avec l'ID: " + id));
|
||||||
existing.setName(data.name());
|
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) {
|
if (data.order() != null) {
|
||||||
existing.setOrder(data.order());
|
existing.setOrder(data.order());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import com.loremind.domain.campaigncontext.Npc;
|
|||||||
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,11 +21,16 @@ public class NpcService {
|
|||||||
this.npcRepository = npcRepository;
|
this.npcRepository = npcRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public record NpcData(
|
||||||
* Parameter Object pour la création / mise à jour d'un Npc.
|
String name,
|
||||||
* `order` est fourni par le controller ; si absent, le service le calcule.
|
String portraitImageId,
|
||||||
*/
|
String headerImageId,
|
||||||
public record NpcData(String name, String markdownContent, String campaignId, Integer order) {}
|
Map<String, String> values,
|
||||||
|
Map<String, List<String>> imageValues,
|
||||||
|
Map<String, Map<String, String>> keyValueValues,
|
||||||
|
String campaignId,
|
||||||
|
Integer order
|
||||||
|
) {}
|
||||||
|
|
||||||
public Npc createNpc(NpcData data) {
|
public Npc createNpc(NpcData data) {
|
||||||
int order = data.order() != null
|
int order = data.order() != null
|
||||||
@@ -31,7 +38,11 @@ public class NpcService {
|
|||||||
: nextOrderFor(data.campaignId());
|
: nextOrderFor(data.campaignId());
|
||||||
Npc npc = Npc.builder()
|
Npc npc = Npc.builder()
|
||||||
.name(data.name())
|
.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())
|
.campaignId(data.campaignId())
|
||||||
.order(order)
|
.order(order)
|
||||||
.build();
|
.build();
|
||||||
@@ -50,7 +61,11 @@ public class NpcService {
|
|||||||
Npc existing = npcRepository.findById(id)
|
Npc existing = npcRepository.findById(id)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id));
|
.orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id));
|
||||||
existing.setName(data.name());
|
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) {
|
if (data.order() != null) {
|
||||||
existing.setOrder(data.order());
|
existing.setOrder(data.order());
|
||||||
}
|
}
|
||||||
@@ -61,7 +76,6 @@ public class NpcService {
|
|||||||
npcRepository.deleteById(id);
|
npcRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Renvoie la prochaine position libre — append en fin de liste. */
|
|
||||||
private int nextOrderFor(String campaignId) {
|
private int nextOrderFor(String campaignId) {
|
||||||
return npcRepository.findByCampaignId(campaignId).stream()
|
return npcRepository.findByCampaignId(campaignId).stream()
|
||||||
.mapToInt(Npc::getOrder)
|
.mapToInt(Npc::getOrder)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.loremind.application.gamesystemcontext;
|
|||||||
|
|
||||||
import com.loremind.domain.gamesystemcontext.GameSystem;
|
import com.loremind.domain.gamesystemcontext.GameSystem;
|
||||||
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
|
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
|
||||||
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -18,11 +19,14 @@ public class GameSystemService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Parameter Object pour la création / mise à jour d'un GameSystem.
|
* Parameter Object pour la création / mise à jour d'un GameSystem.
|
||||||
|
* Les templates peuvent etre null (interpretes comme listes vides).
|
||||||
*/
|
*/
|
||||||
public record GameSystemData(
|
public record GameSystemData(
|
||||||
String name,
|
String name,
|
||||||
String description,
|
String description,
|
||||||
String rulesMarkdown,
|
String rulesMarkdown,
|
||||||
|
List<TemplateField> characterTemplate,
|
||||||
|
List<TemplateField> npcTemplate,
|
||||||
String author,
|
String author,
|
||||||
boolean isPublic
|
boolean isPublic
|
||||||
) {}
|
) {}
|
||||||
@@ -35,6 +39,8 @@ public class GameSystemService {
|
|||||||
.author(normalize(data.author()))
|
.author(normalize(data.author()))
|
||||||
.isPublic(data.isPublic())
|
.isPublic(data.isPublic())
|
||||||
.build();
|
.build();
|
||||||
|
gameSystem.replaceCharacterTemplate(data.characterTemplate());
|
||||||
|
gameSystem.replaceNpcTemplate(data.npcTemplate());
|
||||||
return gameSystemRepository.save(gameSystem);
|
return gameSystemRepository.save(gameSystem);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +58,8 @@ public class GameSystemService {
|
|||||||
existing.setName(data.name());
|
existing.setName(data.name());
|
||||||
existing.setDescription(data.description());
|
existing.setDescription(data.description());
|
||||||
existing.setRulesMarkdown(data.rulesMarkdown());
|
existing.setRulesMarkdown(data.rulesMarkdown());
|
||||||
|
existing.replaceCharacterTemplate(data.characterTemplate());
|
||||||
|
existing.replaceNpcTemplate(data.npcTemplate());
|
||||||
existing.setAuthor(normalize(data.author()));
|
existing.setAuthor(normalize(data.author()));
|
||||||
existing.setPublic(data.isPublic());
|
existing.setPublic(data.isPublic());
|
||||||
return gameSystemRepository.save(existing);
|
return gameSystemRepository.save(existing);
|
||||||
|
|||||||
@@ -104,23 +104,32 @@ public class CampaignStructuralContextBuilder {
|
|||||||
* sans injecter toute sa fiche.
|
* sans injecter toute sa fiche.
|
||||||
*/
|
*/
|
||||||
private CharacterSummary toCharacterSummary(Character c) {
|
private CharacterSummary toCharacterSummary(Character c) {
|
||||||
return new CharacterSummary(c.getName(), extractSnippet(c.getMarkdownContent()));
|
return new CharacterSummary(c.getName(), extractSnippet(c.getValues()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Symétrique à {@link #toCharacterSummary} pour les PNJ. */
|
/** Symétrique à {@link #toCharacterSummary} pour les PNJ. */
|
||||||
private NpcSummary toNpcSummary(Npc n) {
|
private NpcSummary toNpcSummary(Npc n) {
|
||||||
return new NpcSummary(n.getName(), extractSnippet(n.getMarkdownContent()));
|
return new NpcSummary(n.getName(), extractSnippet(n.getValues()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String extractSnippet(String markdown) {
|
/**
|
||||||
if (markdown == null || markdown.isBlank()) return "";
|
* Snippet pour le resume IA : 1re ligne signifiante de la 1re valeur non vide
|
||||||
String firstLine = markdown.lines()
|
* du template (refonte 2026-04-30 — remplace l'ancien parsing markdown).
|
||||||
.map(String::strip)
|
*/
|
||||||
.filter(l -> !l.isEmpty() && !l.startsWith("#"))
|
private static String extractSnippet(java.util.Map<String, String> values) {
|
||||||
.findFirst()
|
if (values == null || values.isEmpty()) return "";
|
||||||
.orElse("");
|
for (String value : values.values()) {
|
||||||
if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine;
|
if (value == null || value.isBlank()) continue;
|
||||||
return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + "…";
|
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) {
|
private ArcSummary toArcSummary(Arc arc) {
|
||||||
|
|||||||
@@ -130,13 +130,19 @@ public class NarrativeEntityContextBuilder {
|
|||||||
|
|
||||||
private NarrativeEntityContext fromCharacter(Character c) {
|
private NarrativeEntityContext fromCharacter(Character c) {
|
||||||
Map<String, String> fields = new LinkedHashMap<>();
|
Map<String, String> fields = new LinkedHashMap<>();
|
||||||
putField(fields, "fiche complète (markdown)", c.getMarkdownContent());
|
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);
|
return new NarrativeEntityContext("character", c.getName(), fields);
|
||||||
}
|
}
|
||||||
|
|
||||||
private NarrativeEntityContext fromNpc(Npc n) {
|
private NarrativeEntityContext fromNpc(Npc n) {
|
||||||
Map<String, String> fields = new LinkedHashMap<>();
|
Map<String, String> fields = new LinkedHashMap<>();
|
||||||
putField(fields, "fiche complète (markdown)", n.getMarkdownContent());
|
if (n.getValues() != null) {
|
||||||
|
n.getValues().forEach((k, v) -> putField(fields, k, v));
|
||||||
|
}
|
||||||
return new NarrativeEntityContext("npc", n.getName(), fields);
|
return new NarrativeEntityContext("npc", n.getName(), fields);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.loremind.application.lorecontext;
|
package com.loremind.application.lorecontext;
|
||||||
|
|
||||||
import com.loremind.domain.lorecontext.Template;
|
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 com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
|||||||
@@ -4,18 +4,26 @@ import lombok.Builder;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fiche de personnage joueur (PJ) d'une campagne.
|
* Fiche de personnage joueur (PJ) d'une campagne.
|
||||||
* <p>
|
* <p>
|
||||||
* MVP : contenu markdown libre, l'utilisateur met ce qu'il veut (stats,
|
* Champs universels hard-codes : {@code name}, {@code portraitImageId},
|
||||||
* backstory, équipement). Évolution prévue vers un système templaté par
|
* {@code headerImageId}. Tout le reste est piloté par le template PJ du
|
||||||
* GameSystem (la fiche Nimble n'a pas les mêmes champs qu'une fiche D&D).
|
* GameSystem associé à la campagne (cf. {@link com.loremind.domain.gamesystemcontext.GameSystem#getCharacterTemplate}).
|
||||||
* <p>
|
* <p>
|
||||||
* Scope strict PJ : les PNJ sont gérés par l'entité {@link Npc} dédiée
|
* Les valeurs des champs templates sont stockées dans deux maps :
|
||||||
* (entité distincte plutôt qu'enum PJ/PNJ — invariants métier divergents).
|
* - {@code values} : champs TEXT et NUMBER (numérique sérialisé en string,
|
||||||
* Évolution prévue : système de templating partagé PJ/PNJ piloté par
|
* parsé à l'usage cote presentation)
|
||||||
* GameSystem pour adapter les blocs aux différents systèmes de JDR.
|
* - {@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
|
@Data
|
||||||
@Builder
|
@Builder
|
||||||
@@ -24,11 +32,32 @@ public class Character {
|
|||||||
private String id;
|
private String id;
|
||||||
private String name;
|
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,
|
* Valeurs des champs TEXT et NUMBER du template PJ. Cle = nom du champ
|
||||||
* renseigné progressivement par le MJ.
|
* (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. */
|
/** Référence vers la Campaign parente. */
|
||||||
private String campaignId;
|
private String campaignId;
|
||||||
@@ -38,4 +67,20 @@ public class Character {
|
|||||||
|
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,21 +4,22 @@ import lombok.Builder;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
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.
|
* Fiche de personnage non-joueur (PNJ) d'une campagne.
|
||||||
* <p>
|
* <p>
|
||||||
* MVP : entité dédiée, distincte de {@link Character} (PJ). Choix DDD assumé —
|
* Entité dédiée distincte de {@link Character} (DDD assumé : invariants divergents
|
||||||
* un PNJ a vocation à porter à terme des invariants métier propres (faction,
|
* à terme — faction, statut vivant/mort, visibilité côté joueurs, etc.).
|
||||||
* statut vivant/mort/disparu, visibilité côté joueurs, relations inter-PNJ)
|
|
||||||
* qui n'ont aucun sens sur un PJ. Mutualiser via un enum aurait pollué l'entité
|
|
||||||
* PJ avec des champs inutiles ({@code if (type == NPC)} partout = anti-pattern).
|
|
||||||
* <p>
|
* <p>
|
||||||
* Contenu markdown libre comme les PJ. Évolution prévue : templating partagé
|
* Mêmes champs universels hard-codés et meme structure de templating que Character,
|
||||||
* PJ/PNJ piloté par GameSystem.
|
* pilotée par le template PNJ du GameSystem
|
||||||
|
* ({@link com.loremind.domain.gamesystemcontext.GameSystem#getNpcTemplate}).
|
||||||
* <p>
|
* <p>
|
||||||
* Scope campagne : les PNJ "univers" (worldboss, figures du Lore) restent
|
* Scope campagne : les PNJ "univers" (worldboss, figures du Lore) restent gérés
|
||||||
* gérés via le système Page/Template du LoreContext.
|
* via le système Page/Template du LoreContext.
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder
|
||||||
@@ -27,10 +28,22 @@ public class Npc {
|
|||||||
private String id;
|
private String id;
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
/** Contenu libre markdown — description, motivation, stats, notes MJ. Nullable à la création. */
|
/** ID de l'image portrait (champ universel hard-code). Nullable. */
|
||||||
private String markdownContent;
|
private String portraitImageId;
|
||||||
|
|
||||||
/** Référence vers la Campaign parente (cross-aggregate via ID, jamais d'objet). */
|
/** 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;
|
private String campaignId;
|
||||||
|
|
||||||
/** Ordre d'affichage dans la liste des PNJ de la campagne. */
|
/** Ordre d'affichage dans la liste des PNJ de la campagne. */
|
||||||
@@ -38,4 +51,19 @@ public class Npc {
|
|||||||
|
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package com.loremind.domain.gamesystemcontext;
|
package com.loremind.domain.gamesystemcontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
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).
|
* 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
|
* 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).
|
* à la volée lors de l'injection dans les prompts IA (cf. GameSystemContextSelector).
|
||||||
* <p>
|
* <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
|
* {@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
|
* de rulesets partagés — non exploités au MVP mais persistés dès maintenant pour
|
||||||
* éviter une migration ultérieure.
|
* éviter une migration ultérieure.
|
||||||
@@ -27,6 +35,21 @@ public class GameSystem {
|
|||||||
/** Markdown monolithique. Sections découpées par titres H2 (## Combat, ## Classes, etc.). */
|
/** Markdown monolithique. Sections découpées par titres H2 (## Combat, ## Classes, etc.). */
|
||||||
private String rulesMarkdown;
|
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. */
|
/** Auteur déclaré — futur marketplace. Nullable. */
|
||||||
private String author;
|
private String author;
|
||||||
|
|
||||||
@@ -35,4 +58,88 @@ public class GameSystem {
|
|||||||
|
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.loremind.domain.lorecontext;
|
package com.loremind.domain.lorecontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.shared.template.FieldType;
|
||||||
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.lorecontext;
|
package com.loremind.domain.shared.template;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Variante de rendu pour un champ de type IMAGE.
|
* Variante de rendu pour un champ de type IMAGE.
|
||||||
@@ -8,7 +8,7 @@ package com.loremind.domain.lorecontext;
|
|||||||
* - MASONRY : mosaique hauteurs variables facon Pinterest
|
* - MASONRY : mosaique hauteurs variables facon Pinterest
|
||||||
* - CAROUSEL : defilement horizontal
|
* - CAROUSEL : defilement horizontal
|
||||||
* <p>
|
* <p>
|
||||||
* Uniquement significatif quand {@link FieldType} = IMAGE. Ignore pour TEXT.
|
* Uniquement significatif quand {@link FieldType} = IMAGE. Ignore sinon.
|
||||||
*/
|
*/
|
||||||
public enum ImageLayout {
|
public enum ImageLayout {
|
||||||
GALLERY,
|
GALLERY,
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package com.loremind.infrastructure.persistence;
|
|||||||
|
|
||||||
import com.loremind.domain.gamesystemcontext.GameSystem;
|
import com.loremind.domain.gamesystemcontext.GameSystem;
|
||||||
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
|
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.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
@@ -23,6 +25,10 @@ import java.util.List;
|
|||||||
* <p>
|
* <p>
|
||||||
* Idempotence : ne seed qu'une fois. Si l'utilisateur supprime un ruleset seedé,
|
* 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).
|
* 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
|
@Component
|
||||||
public class GameSystemSeeder {
|
public class GameSystemSeeder {
|
||||||
@@ -37,15 +43,37 @@ public class GameSystemSeeder {
|
|||||||
|
|
||||||
@EventListener(ApplicationReadyEvent.class)
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
public void seedIfEmpty() {
|
public void seedIfEmpty() {
|
||||||
if (!gameSystemRepository.findAll().isEmpty()) {
|
List<GameSystem> existing = gameSystemRepository.findAll();
|
||||||
log.debug("GameSystem seed skipped — table non vide.");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
log.info("Seed initial des GameSystems (table vide)...");
|
log.debug("GameSystem seed skipped — table non vide. Backfill templates si necessaire...");
|
||||||
for (GameSystem gs : defaultSystems()) {
|
backfillEmptyTemplates(existing);
|
||||||
gameSystemRepository.save(gs);
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
log.info("GameSystems seedés : {}", defaultSystems().size());
|
if (patched > 0) log.info("Backfill templates GameSystem : {} systeme(s) patche(s).", patched);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<GameSystem> defaultSystems() {
|
private List<GameSystem> defaultSystems() {
|
||||||
@@ -56,6 +84,8 @@ public class GameSystemSeeder {
|
|||||||
.author("LoreMind seed")
|
.author("LoreMind seed")
|
||||||
.isPublic(false)
|
.isPublic(false)
|
||||||
.rulesMarkdown(NIMBLE_RULES)
|
.rulesMarkdown(NIMBLE_RULES)
|
||||||
|
.characterTemplate(nimbleCharacterTemplate())
|
||||||
|
.npcTemplate(genericNpcTemplate())
|
||||||
.build(),
|
.build(),
|
||||||
GameSystem.builder()
|
GameSystem.builder()
|
||||||
.name("D&D 5e SRD (extrait)")
|
.name("D&D 5e SRD (extrait)")
|
||||||
@@ -63,6 +93,8 @@ public class GameSystemSeeder {
|
|||||||
.author("LoreMind seed")
|
.author("LoreMind seed")
|
||||||
.isPublic(false)
|
.isPublic(false)
|
||||||
.rulesMarkdown(DND_SRD_RULES)
|
.rulesMarkdown(DND_SRD_RULES)
|
||||||
|
.characterTemplate(dndCharacterTemplate())
|
||||||
|
.npcTemplate(genericNpcTemplate())
|
||||||
.build(),
|
.build(),
|
||||||
GameSystem.builder()
|
GameSystem.builder()
|
||||||
.name("Homebrew Exemple")
|
.name("Homebrew Exemple")
|
||||||
@@ -70,10 +102,66 @@ public class GameSystemSeeder {
|
|||||||
.author("LoreMind seed")
|
.author("LoreMind seed")
|
||||||
.isPublic(false)
|
.isPublic(false)
|
||||||
.rulesMarkdown(HOMEBREW_EXAMPLE)
|
.rulesMarkdown(HOMEBREW_EXAMPLE)
|
||||||
|
.characterTemplate(genericCharacterTemplate())
|
||||||
|
.npcTemplate(genericNpcTemplate())
|
||||||
.build()
|
.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 = """
|
private static final String NIMBLE_RULES = """
|
||||||
Système Nimble — résolution rapide, narration fluide, peu de tableaux. Agnostique (aucun univers imposé).
|
Système Nimble — résolution rapide, narration fluide, peu de tableaux. Agnostique (aucun univers imposé).
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,9 @@ package com.loremind.infrastructure.persistence.converter;
|
|||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.loremind.domain.lorecontext.FieldType;
|
import com.loremind.domain.shared.template.FieldType;
|
||||||
import com.loremind.domain.lorecontext.ImageLayout;
|
import com.loremind.domain.shared.template.ImageLayout;
|
||||||
import com.loremind.domain.lorecontext.TemplateField;
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import jakarta.persistence.AttributeConverter;
|
import jakarta.persistence.AttributeConverter;
|
||||||
import jakarta.persistence.Converter;
|
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()) {
|
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.
|
// Autres types de noeuds (nombre, booleen...) : ignores silencieusement.
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.loremind.infrastructure.persistence.entity;
|
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 jakarta.persistence.*;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
@@ -7,11 +10,18 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
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.
|
* Entité JPA pour les fiches de personnages (PJ).
|
||||||
* Pas de FK physique vers campaigns (weak reference cross-agrégat intra-contexte :
|
* <p>
|
||||||
* on reste dans le Campaign Context, mais l'agrégat Character est autonome).
|
* 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
|
@Entity
|
||||||
@Table(name = "characters")
|
@Table(name = "characters")
|
||||||
@@ -28,8 +38,26 @@ public class CharacterJpaEntity {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
@Column(name = "markdown_content", columnDefinition = "TEXT")
|
@Column(name = "portrait_image_id")
|
||||||
private String markdownContent;
|
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)
|
@Column(name = "campaign_id", nullable = false)
|
||||||
private Long campaignId;
|
private Long campaignId;
|
||||||
@@ -47,6 +75,9 @@ public class CharacterJpaEntity {
|
|||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now();
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now();
|
||||||
|
if (values == null) values = new HashMap<>();
|
||||||
|
if (imageValues == null) imageValues = new HashMap<>();
|
||||||
|
if (keyValueValues == null) keyValueValues = new HashMap<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.loremind.infrastructure.persistence.entity;
|
package com.loremind.infrastructure.persistence.entity;
|
||||||
|
|
||||||
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
|
import com.loremind.infrastructure.persistence.converter.TemplateFieldListJsonConverter;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
@@ -7,6 +9,8 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entité JPA pour la persistance des GameSystems (systèmes de JDR).
|
* Entité JPA pour la persistance des GameSystems (systèmes de JDR).
|
||||||
@@ -32,6 +36,16 @@ public class GameSystemJpaEntity {
|
|||||||
@Column(name = "rules_markdown", columnDefinition = "TEXT")
|
@Column(name = "rules_markdown", columnDefinition = "TEXT")
|
||||||
private String rulesMarkdown;
|
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
|
@Column
|
||||||
private String author;
|
private String author;
|
||||||
|
|
||||||
@@ -48,6 +62,8 @@ public class GameSystemJpaEntity {
|
|||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now();
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now();
|
||||||
|
if (characterTemplate == null) characterTemplate = new ArrayList<>();
|
||||||
|
if (npcTemplate == null) npcTemplate = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.loremind.infrastructure.persistence.entity;
|
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 jakarta.persistence.*;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
@@ -7,10 +10,13 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entité JPA pour les fiches de PNJ d'une campagne.
|
* Entité JPA pour les fiches de PNJ. Memes regles que CharacterJpaEntity
|
||||||
* Pas de FK physique vers campaigns (weak reference cross-agrégat intra-contexte).
|
* (cf. note de refonte 2026-04-30 sur la migration markdownContent).
|
||||||
*/
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "npcs")
|
@Table(name = "npcs")
|
||||||
@@ -27,8 +33,23 @@ public class NpcJpaEntity {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
@Column(name = "markdown_content", columnDefinition = "TEXT")
|
@Column(name = "portrait_image_id")
|
||||||
private String markdownContent;
|
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)
|
@Column(name = "campaign_id", nullable = false)
|
||||||
private Long campaignId;
|
private Long campaignId;
|
||||||
@@ -46,6 +67,9 @@ public class NpcJpaEntity {
|
|||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now();
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now();
|
||||||
|
if (values == null) values = new HashMap<>();
|
||||||
|
if (imageValues == null) imageValues = new HashMap<>();
|
||||||
|
if (keyValueValues == null) keyValueValues = new HashMap<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.loremind.infrastructure.persistence.entity;
|
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 com.loremind.infrastructure.persistence.converter.TemplateFieldListJsonConverter;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import com.loremind.infrastructure.persistence.entity.CharacterJpaEntity;
|
|||||||
import com.loremind.infrastructure.persistence.jpa.CharacterJpaRepository;
|
import com.loremind.infrastructure.persistence.jpa.CharacterJpaRepository;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
@@ -52,7 +53,11 @@ public class PostgresCharacterRepository implements CharacterRepository {
|
|||||||
return Character.builder()
|
return Character.builder()
|
||||||
.id(e.getId().toString())
|
.id(e.getId().toString())
|
||||||
.name(e.getName())
|
.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())
|
.campaignId(e.getCampaignId().toString())
|
||||||
.order(e.getOrder())
|
.order(e.getOrder())
|
||||||
.createdAt(e.getCreatedAt())
|
.createdAt(e.getCreatedAt())
|
||||||
@@ -65,7 +70,11 @@ public class PostgresCharacterRepository implements CharacterRepository {
|
|||||||
return CharacterJpaEntity.builder()
|
return CharacterJpaEntity.builder()
|
||||||
.id(id)
|
.id(id)
|
||||||
.name(c.getName())
|
.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()))
|
.campaignId(Long.parseLong(c.getCampaignId()))
|
||||||
.order(c.getOrder())
|
.order(c.getOrder())
|
||||||
.createdAt(c.getCreatedAt())
|
.createdAt(c.getCreatedAt())
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
|
|||||||
.name(e.getName())
|
.name(e.getName())
|
||||||
.description(e.getDescription())
|
.description(e.getDescription())
|
||||||
.rulesMarkdown(e.getRulesMarkdown())
|
.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())
|
.author(e.getAuthor())
|
||||||
.isPublic(e.isPublic())
|
.isPublic(e.isPublic())
|
||||||
.createdAt(e.getCreatedAt())
|
.createdAt(e.getCreatedAt())
|
||||||
@@ -75,6 +81,12 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
|
|||||||
.name(g.getName())
|
.name(g.getName())
|
||||||
.description(g.getDescription())
|
.description(g.getDescription())
|
||||||
.rulesMarkdown(g.getRulesMarkdown())
|
.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())
|
.author(g.getAuthor())
|
||||||
.isPublic(g.isPublic())
|
.isPublic(g.isPublic())
|
||||||
.createdAt(g.getCreatedAt())
|
.createdAt(g.getCreatedAt())
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import com.loremind.infrastructure.persistence.entity.NpcJpaEntity;
|
|||||||
import com.loremind.infrastructure.persistence.jpa.NpcJpaRepository;
|
import com.loremind.infrastructure.persistence.jpa.NpcJpaRepository;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
@@ -52,7 +53,11 @@ public class PostgresNpcRepository implements NpcRepository {
|
|||||||
return Npc.builder()
|
return Npc.builder()
|
||||||
.id(e.getId().toString())
|
.id(e.getId().toString())
|
||||||
.name(e.getName())
|
.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())
|
.campaignId(e.getCampaignId().toString())
|
||||||
.order(e.getOrder())
|
.order(e.getOrder())
|
||||||
.createdAt(e.getCreatedAt())
|
.createdAt(e.getCreatedAt())
|
||||||
@@ -65,7 +70,11 @@ public class PostgresNpcRepository implements NpcRepository {
|
|||||||
return NpcJpaEntity.builder()
|
return NpcJpaEntity.builder()
|
||||||
.id(id)
|
.id(id)
|
||||||
.name(n.getName())
|
.name(n.getName())
|
||||||
.markdownContent(n.getMarkdownContent())
|
.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()))
|
.campaignId(Long.parseLong(n.getCampaignId()))
|
||||||
.order(n.getOrder())
|
.order(n.getOrder())
|
||||||
.createdAt(n.getCreatedAt())
|
.createdAt(n.getCreatedAt())
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
package com.loremind.infrastructure.updates;
|
package com.loremind.infrastructure.updates;
|
||||||
|
|
||||||
import jakarta.annotation.PostConstruct;
|
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.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.info.BuildProperties;
|
||||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||||
import org.springframework.http.HttpEntity;
|
import org.springframework.http.HttpEntity;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.client.HttpClientErrorException;
|
import org.springframework.web.client.HttpClientErrorException;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
@@ -19,172 +24,174 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detection des mises a jour disponibles + declenchement via Watchtower.
|
* 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>
|
||||||
*
|
*
|
||||||
* Strategie :
|
* <b>Pourquoi pas les digests ?</b> Le bug historique etait : le baseline-digest
|
||||||
* - Au demarrage, on interroge le registry pour le digest courant de chaque
|
* pose au @PostConstruct supposait que le pull venait d'avoir lieu (vrai apres
|
||||||
* image suivie ({@code update-check.images}). On stocke ces digests comme
|
* `docker compose pull && up -d`, faux apres un simple restart de daemon ou un
|
||||||
* "baseline" (= ce que le conteneur en cours d'execution est cense faire
|
* OOM). La version semver lue depuis le binaire est <b>fiable par construction</b> :
|
||||||
* tourner, puisque le `docker compose pull` precede toujours `up -d`).
|
* c'est ce que le code source declare faire tourner.
|
||||||
* - Si l'init echoue (reseau Docker pas encore pret, registry transitoirement
|
|
||||||
* indisponible), un thread daemon de retry avec backoff complete les
|
|
||||||
* baselines manquantes en arriere-plan.
|
|
||||||
* - {@link #check()} re-interroge le registry et compare. Si un digest a
|
|
||||||
* change, une mise a jour est disponible. Si la baseline manque (echec
|
|
||||||
* de tous les retries), retourne {@link ImageStatusKind#UNKNOWN} pour
|
|
||||||
* cette image — JAMAIS d'alignement silencieux (eviterait des MAJ ratees).
|
|
||||||
* - {@link #apply()} POST sur /v1/update de Watchtower (qui doit etre lance
|
|
||||||
* avec WATCHTOWER_HTTP_API_UPDATE=true et le meme token).
|
|
||||||
*
|
|
||||||
* Apres un apply reussi, Watchtower redemarre core => ce service est
|
|
||||||
* re-instancie => baseline re-aligne sur le registry => check renvoie
|
|
||||||
* "pas de MAJ" (etat coherent).
|
|
||||||
*
|
|
||||||
* La feature est <b>desactivee silencieusement</b> si {@code WATCHTOWER_TOKEN}
|
|
||||||
* n'est pas defini : check/apply renvoient des reponses neutres et l'UI
|
|
||||||
* masque le badge / bouton.
|
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class UpdateCheckService {
|
public class UpdateCheckService {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(UpdateCheckService.class);
|
private static final Logger log = LoggerFactory.getLogger(UpdateCheckService.class);
|
||||||
|
|
||||||
private static final List<MediaType> MANIFEST_ACCEPT = List.of(
|
|
||||||
MediaType.parseMediaType("application/vnd.docker.distribution.manifest.v2+json"),
|
|
||||||
MediaType.parseMediaType("application/vnd.docker.distribution.manifest.list.v2+json"),
|
|
||||||
MediaType.parseMediaType("application/vnd.oci.image.manifest.v1+json"),
|
|
||||||
MediaType.parseMediaType("application/vnd.oci.image.index.v1+json")
|
|
||||||
);
|
|
||||||
|
|
||||||
private final RestTemplate http;
|
private final RestTemplate http;
|
||||||
private final String registry;
|
private final String registry;
|
||||||
private final List<String> images;
|
private final List<String> images;
|
||||||
private final String tag;
|
|
||||||
private final String watchtowerUrl;
|
private final String watchtowerUrl;
|
||||||
private final String watchtowerToken;
|
private final String watchtowerToken;
|
||||||
|
private final List<String> betaImages;
|
||||||
private final Map<String, String> baselineDigests = new ConcurrentHashMap<>();
|
private final LicenseService licenseService;
|
||||||
|
/** Version semver courante du binaire (ex: "0.8.0"). Source de verite. */
|
||||||
|
private final String currentVersion;
|
||||||
|
|
||||||
public UpdateCheckService(
|
public UpdateCheckService(
|
||||||
RestTemplateBuilder builder,
|
RestTemplateBuilder builder,
|
||||||
@Value("${update-check.registry:}") String registry,
|
@Value("${update-check.registry:}") String registry,
|
||||||
@Value("${update-check.images:}") String imagesCsv,
|
@Value("${update-check.images:}") String imagesCsv,
|
||||||
@Value("${update-check.tag:latest}") String tag,
|
|
||||||
@Value("${update-check.watchtower-url:http://watchtower:8080}") String watchtowerUrl,
|
@Value("${update-check.watchtower-url:http://watchtower:8080}") String watchtowerUrl,
|
||||||
@Value("${update-check.watchtower-token:}") String watchtowerToken) {
|
@Value("${update-check.watchtower-token:}") String watchtowerToken,
|
||||||
|
@Value("${licensing.beta.images:}") String betaImagesCsv,
|
||||||
|
LicenseService licenseService,
|
||||||
|
@Nullable BuildProperties buildProperties) {
|
||||||
this.http = builder
|
this.http = builder
|
||||||
.setConnectTimeout(Duration.ofSeconds(5))
|
.setConnectTimeout(Duration.ofSeconds(5))
|
||||||
.setReadTimeout(Duration.ofSeconds(15))
|
.setReadTimeout(Duration.ofSeconds(15))
|
||||||
.build();
|
.build();
|
||||||
this.registry = normalizeRegistry(registry);
|
this.registry = normalizeRegistry(registry);
|
||||||
this.images = parseImages(imagesCsv);
|
this.images = parseImages(imagesCsv);
|
||||||
this.tag = tag;
|
|
||||||
this.watchtowerUrl = watchtowerUrl;
|
this.watchtowerUrl = watchtowerUrl;
|
||||||
this.watchtowerToken = watchtowerToken;
|
this.watchtowerToken = watchtowerToken;
|
||||||
}
|
this.betaImages = parseImages(betaImagesCsv);
|
||||||
|
this.licenseService = licenseService;
|
||||||
/** Backoff progressif (ms) pour retry de baseline en cas d'echec initial. */
|
this.currentVersion = buildProperties != null ? buildProperties.getVersion() : null;
|
||||||
private static final long[] BASELINE_RETRY_BACKOFFS_MS = {2_000, 5_000, 15_000, 30_000, 60_000};
|
log.info("Update check init - registry={} images={} currentVersion={}",
|
||||||
|
this.registry, this.images, this.currentVersion);
|
||||||
@PostConstruct
|
|
||||||
void initBaseline() {
|
|
||||||
if (!isEnabled()) {
|
|
||||||
log.info("Update check disabled (WATCHTOWER_TOKEN not set)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
log.info("Update check enabled - registry={} images={} tag={}", registry, images, tag);
|
|
||||||
boolean complete = tryBaselineMissing();
|
|
||||||
if (!complete) {
|
|
||||||
startBaselineRetryThread();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tente de poser la baseline pour les images qui ne l'ont pas encore.
|
|
||||||
* @return true si TOUTES les images ont leur baseline apres cet essai.
|
|
||||||
*/
|
|
||||||
private boolean tryBaselineMissing() {
|
|
||||||
for (String image : images) {
|
|
||||||
if (baselineDigests.containsKey(image)) continue;
|
|
||||||
try {
|
|
||||||
String digest = fetchRemoteDigest(image);
|
|
||||||
if (digest != null) {
|
|
||||||
baselineDigests.put(image, digest);
|
|
||||||
log.debug("Baseline digest for {} = {}", image, digest);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Cannot baseline digest for {}: {}", image, e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return baselineDigests.size() == images.size();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lance un thread daemon qui retente de poser les baselines manquantes
|
|
||||||
* avec backoff. Le thread s'arrete des que toutes les baselines sont
|
|
||||||
* posees, ou apres epuisement des backoffs (et alors {@link #check()}
|
|
||||||
* retournera UNKNOWN pour ces images jusqu'au prochain redemarrage).
|
|
||||||
*/
|
|
||||||
private void startBaselineRetryThread() {
|
|
||||||
Thread t = new Thread(() -> {
|
|
||||||
for (long backoff : BASELINE_RETRY_BACKOFFS_MS) {
|
|
||||||
try {
|
|
||||||
Thread.sleep(backoff);
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (tryBaselineMissing()) {
|
|
||||||
log.info("Baseline complete after retry");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.warn("Baseline incomplete after all retries; check() will return UNKNOWN for missing images");
|
|
||||||
}, "update-baseline-retry");
|
|
||||||
t.setDaemon(true);
|
|
||||||
t.start();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isEnabled() {
|
public boolean isEnabled() {
|
||||||
return watchtowerToken != null && !watchtowerToken.isBlank() && !images.isEmpty();
|
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() {
|
public UpdateStatus check() {
|
||||||
if (!isEnabled()) {
|
if (!isEnabled()) {
|
||||||
return new UpdateStatus(false, false, false, List.of(), Instant.now());
|
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<>();
|
List<ImageStatus> statuses = new ArrayList<>();
|
||||||
boolean anyUpdate = false;
|
boolean anyUpdate = false;
|
||||||
boolean anyUnknown = false;
|
boolean anyUnknown = false;
|
||||||
for (String image : images) {
|
for (String image : images) {
|
||||||
String baseline = baselineDigests.get(image);
|
String latest = null;
|
||||||
String remote = null;
|
|
||||||
try {
|
try {
|
||||||
remote = fetchRemoteDigest(image);
|
latest = fetchLatestSemverTag(registry, image, null);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Check failed for {}: {}", image, e.getMessage());
|
log.warn("Tags fetch failed for {}: {}", image, e.getMessage());
|
||||||
}
|
}
|
||||||
// PAS d'alignement lazy si baseline absente : ce serait un faux negatif
|
|
||||||
// silencieux. On reporte UNKNOWN pour que l'UI le signale.
|
|
||||||
ImageStatusKind kind;
|
ImageStatusKind kind;
|
||||||
if (baseline == null || remote == null) {
|
if (latest == null) {
|
||||||
kind = ImageStatusKind.UNKNOWN;
|
kind = ImageStatusKind.UNKNOWN;
|
||||||
anyUnknown = true;
|
anyUnknown = true;
|
||||||
} else if (baseline.equals(remote)) {
|
} 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;
|
kind = ImageStatusKind.UP_TO_DATE;
|
||||||
} else {
|
} else {
|
||||||
kind = ImageStatusKind.UPDATE_AVAILABLE;
|
kind = ImageStatusKind.UPDATE_AVAILABLE;
|
||||||
anyUpdate = true;
|
anyUpdate = true;
|
||||||
}
|
}
|
||||||
statuses.add(new ImageStatus(image, baseline, remote, kind));
|
statuses.add(new ImageStatus(image, currentVersion, latest, kind));
|
||||||
}
|
}
|
||||||
return new UpdateStatus(true, anyUpdate, anyUnknown, statuses, Instant.now());
|
return new BetaStatus(true, anyUpdate, anyUnknown, statuses, Instant.now(), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void apply() {
|
public void apply() {
|
||||||
@@ -193,10 +200,6 @@ public class UpdateCheckService {
|
|||||||
}
|
}
|
||||||
HttpHeaders headers = new HttpHeaders();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
headers.setBearerAuth(watchtowerToken);
|
headers.setBearerAuth(watchtowerToken);
|
||||||
// Watchtower /v1/update declenche un scan+update immediat de tous les
|
|
||||||
// conteneurs labellises. La reponse est synchrone et peut prendre
|
|
||||||
// plusieurs secondes; en cas de redemarrage de core, le client
|
|
||||||
// recevra une connexion coupee — c'est attendu, l'UI le gere.
|
|
||||||
http.exchange(
|
http.exchange(
|
||||||
watchtowerUrl + "/v1/update",
|
watchtowerUrl + "/v1/update",
|
||||||
HttpMethod.POST,
|
HttpMethod.POST,
|
||||||
@@ -205,40 +208,121 @@ public class UpdateCheckService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Registry HTTP API v2
|
// Registry HTTP API v2 - tags listing + auth bearer
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
private String fetchRemoteDigest(String image) {
|
/**
|
||||||
String url = registry + "/v2/" + image + "/manifests/" + tag;
|
* 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();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
headers.setAccept(MANIFEST_ACCEPT);
|
headers.setAccept(List.of(MediaType.APPLICATION_JSON));
|
||||||
|
if (authHeader != null) {
|
||||||
|
headers.set(HttpHeaders.AUTHORIZATION, authHeader);
|
||||||
|
}
|
||||||
|
TagsListResponse body;
|
||||||
try {
|
try {
|
||||||
return digestCall(url, headers);
|
body = tagsCall(url, headers);
|
||||||
} catch (HttpClientErrorException.Unauthorized e) {
|
} catch (HttpClientErrorException.Unauthorized e) {
|
||||||
String www = e.getResponseHeaders() == null ? null
|
String www = e.getResponseHeaders() == null ? null
|
||||||
: e.getResponseHeaders().getFirst(HttpHeaders.WWW_AUTHENTICATE);
|
: e.getResponseHeaders().getFirst(HttpHeaders.WWW_AUTHENTICATE);
|
||||||
String token = obtainBearerToken(www);
|
String token = obtainBearerToken(www, authHeader);
|
||||||
if (token == null) {
|
if (token == null) {
|
||||||
log.warn("Cannot obtain bearer token for {} (registry response: {})", image, www);
|
log.warn("Cannot obtain bearer token for {} (registry response: {})", image, www);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
headers.setBearerAuth(token);
|
HttpHeaders bearerHeaders = new HttpHeaders();
|
||||||
return digestCall(url, headers);
|
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 String digestCall(String url, HttpHeaders headers) {
|
private TagsListResponse tagsCall(String url, HttpHeaders headers) {
|
||||||
ResponseEntity<Void> resp = http.exchange(
|
ResponseEntity<TagsListResponse> resp = http.exchange(
|
||||||
url, HttpMethod.HEAD, new HttpEntity<>(headers), Void.class);
|
url, HttpMethod.GET, new HttpEntity<>(headers), TagsListResponse.class);
|
||||||
return resp.getHeaders().getFirst("Docker-Content-Digest");
|
return resp.getBody();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Suit le challenge {@code WWW-Authenticate: Bearer realm="...",service="...",scope="..."}
|
* Parcourt la liste des tags, garde uniquement ceux qui parsent en semver
|
||||||
* pour obtenir un jeton (anonyme — suffisant pour les images publiques).
|
* (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")
|
@SuppressWarnings("rawtypes")
|
||||||
private String obtainBearerToken(String wwwAuth) {
|
private String obtainBearerToken(@Nullable String wwwAuth, @Nullable String basicAuth) {
|
||||||
if (wwwAuth == null) return null;
|
if (wwwAuth == null) return null;
|
||||||
String prefix = "Bearer ";
|
String prefix = "Bearer ";
|
||||||
if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null;
|
if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null;
|
||||||
@@ -250,23 +334,20 @@ public class UpdateCheckService {
|
|||||||
for (String key : new String[]{"service", "scope"}) {
|
for (String key : new String[]{"service", "scope"}) {
|
||||||
String v = params.get(key);
|
String v = params.get(key);
|
||||||
if (v != null) {
|
if (v != null) {
|
||||||
// URLEncoder fait du "form encoding" qui transforme `:` et `/`
|
|
||||||
// en %3A et %2F. La plupart des registries (Docker Hub, Gitea)
|
|
||||||
// acceptent les deux, mais GHCR est strict et rejette le scope
|
|
||||||
// encode (403 DENIED). On preserve donc `:` et `/` dans la
|
|
||||||
// valeur, conformement a ce que GHCR attend
|
|
||||||
// (et que docker pull lui-meme envoie).
|
|
||||||
String encoded = URLEncoder.encode(v, StandardCharsets.UTF_8)
|
String encoded = URLEncoder.encode(v, StandardCharsets.UTF_8)
|
||||||
.replace("%3A", ":")
|
.replace("%3A", ":")
|
||||||
.replace("%2F", "/");
|
.replace("%2F", "/");
|
||||||
url.append(hasQuery ? '&' : '?')
|
url.append(hasQuery ? '&' : '?').append(key).append('=').append(encoded);
|
||||||
.append(key).append('=')
|
|
||||||
.append(encoded);
|
|
||||||
hasQuery = true;
|
hasQuery = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
ResponseEntity<Map> resp = http.getForEntity(url.toString(), Map.class);
|
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();
|
Map<?, ?> body = resp.getBody();
|
||||||
if (body == null) return null;
|
if (body == null) return null;
|
||||||
Object t = body.get("token");
|
Object t = body.get("token");
|
||||||
@@ -327,41 +408,52 @@ public class UpdateCheckService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Records de retour (sortis sous forme JSON par Jackson)
|
// Records / DTO
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
|
||||||
* Etat tri-state d'une image vis-a-vis du registry.
|
|
||||||
* <ul>
|
|
||||||
* <li>{@link #UP_TO_DATE} : digest local == digest remote.</li>
|
|
||||||
* <li>{@link #UPDATE_AVAILABLE} : digests differents, MAJ disponible.</li>
|
|
||||||
* <li>{@link #UNKNOWN} : impossible de comparer (baseline ou remote manquant).
|
|
||||||
* L'UI doit afficher un avertissement plutot que de declarer "a jour".</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
|
||||||
public enum ImageStatusKind { UP_TO_DATE, UPDATE_AVAILABLE, UNKNOWN }
|
public enum ImageStatusKind { UP_TO_DATE, UPDATE_AVAILABLE, UNKNOWN }
|
||||||
|
|
||||||
public record UpdateStatus(
|
public record UpdateStatus(
|
||||||
boolean enabled,
|
boolean enabled,
|
||||||
boolean updateAvailable,
|
boolean updateAvailable,
|
||||||
boolean anyUnknown,
|
boolean anyUnknown,
|
||||||
|
String currentVersion,
|
||||||
List<ImageStatus> images,
|
List<ImageStatus> images,
|
||||||
Instant checkedAt) {}
|
Instant checkedAt) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Le champ {@code updateAvailable} est conserve pour la compatibilite
|
* Statut par image. {@code localVersion} = version embarquee dans le binaire ;
|
||||||
* avec les anciens clients ; il est strictement derive de {@code status}
|
* {@code remoteVersion} = plus haute version semver trouvee dans le registry.
|
||||||
* dans le constructeur compact.
|
* {@code updateAvailable} est derive de {@code status} (back-compat front).
|
||||||
*/
|
*/
|
||||||
public record ImageStatus(
|
public record ImageStatus(
|
||||||
String image,
|
String image,
|
||||||
String localDigest,
|
String localVersion,
|
||||||
String remoteDigest,
|
String remoteVersion,
|
||||||
ImageStatusKind status,
|
ImageStatusKind status,
|
||||||
boolean updateAvailable) {
|
boolean updateAvailable) {
|
||||||
|
|
||||||
public ImageStatus(String image, String localDigest, String remoteDigest, ImageStatusKind status) {
|
public ImageStatus(String image, String localVersion, String remoteVersion, ImageStatusKind status) {
|
||||||
this(image, localDigest, remoteDigest, status, status == ImageStatusKind.UPDATE_AVAILABLE);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ public class SecurityConfig {
|
|||||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||||
.requestMatchers("/api/settings/**").hasRole("ADMIN")
|
.requestMatchers("/api/settings/**").hasRole("ADMIN")
|
||||||
.requestMatchers("/api/admin/**").hasRole("ADMIN")
|
.requestMatchers("/api/admin/**").hasRole("ADMIN")
|
||||||
|
.requestMatchers("/api/license/**").hasRole("ADMIN")
|
||||||
.anyRequest().permitAll()
|
.anyRequest().permitAll()
|
||||||
)
|
)
|
||||||
.httpBasic(basic -> {});
|
.httpBasic(basic -> {});
|
||||||
|
|||||||
@@ -24,9 +24,7 @@ public class CharacterController {
|
|||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<CharacterDTO> createCharacter(@RequestBody CharacterDTO dto) {
|
public ResponseEntity<CharacterDTO> createCharacter(@RequestBody CharacterDTO dto) {
|
||||||
Character created = characterService.createCharacter(
|
Character created = characterService.createCharacter(toData(dto, null));
|
||||||
new CharacterService.CharacterData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), null)
|
|
||||||
);
|
|
||||||
return ResponseEntity.ok(characterMapper.toDTO(created));
|
return ResponseEntity.ok(characterMapper.toDTO(created));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,10 +45,7 @@ public class CharacterController {
|
|||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
public ResponseEntity<CharacterDTO> updateCharacter(@PathVariable String id, @RequestBody CharacterDTO dto) {
|
public ResponseEntity<CharacterDTO> updateCharacter(@PathVariable String id, @RequestBody CharacterDTO dto) {
|
||||||
Character updated = characterService.updateCharacter(
|
Character updated = characterService.updateCharacter(id, toData(dto, dto.getOrder()));
|
||||||
id,
|
|
||||||
new CharacterService.CharacterData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), dto.getOrder())
|
|
||||||
);
|
|
||||||
return ResponseEntity.ok(characterMapper.toDTO(updated));
|
return ResponseEntity.ok(characterMapper.toDTO(updated));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,4 +54,17 @@ public class CharacterController {
|
|||||||
characterService.deleteCharacter(id);
|
characterService.deleteCharacter(id);
|
||||||
return ResponseEntity.noContent().build();
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,16 @@ package com.loremind.infrastructure.web.controller;
|
|||||||
|
|
||||||
import com.loremind.application.gamesystemcontext.GameSystemService;
|
import com.loremind.application.gamesystemcontext.GameSystemService;
|
||||||
import com.loremind.domain.gamesystemcontext.GameSystem;
|
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.gamesystemcontext.GameSystemDTO;
|
||||||
|
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||||
import com.loremind.infrastructure.web.mapper.GameSystemMapper;
|
import com.loremind.infrastructure.web.mapper.GameSystemMapper;
|
||||||
|
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@@ -16,10 +21,14 @@ public class GameSystemController {
|
|||||||
|
|
||||||
private final GameSystemService gameSystemService;
|
private final GameSystemService gameSystemService;
|
||||||
private final GameSystemMapper gameSystemMapper;
|
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.gameSystemService = gameSystemService;
|
||||||
this.gameSystemMapper = gameSystemMapper;
|
this.gameSystemMapper = gameSystemMapper;
|
||||||
|
this.templateFieldMapper = templateFieldMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@@ -63,13 +72,28 @@ public class GameSystemController {
|
|||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mappe les violations d'invariants domaine (doublons de champs, etc.) en 400. */
|
||||||
|
@ExceptionHandler(IllegalArgumentException.class)
|
||||||
|
public ResponseEntity<String> onIllegalArgument(IllegalArgumentException ex) {
|
||||||
|
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
private GameSystemService.GameSystemData toData(GameSystemDTO dto) {
|
private GameSystemService.GameSystemData toData(GameSystemDTO dto) {
|
||||||
return new GameSystemService.GameSystemData(
|
return new GameSystemService.GameSystemData(
|
||||||
dto.getName(),
|
dto.getName(),
|
||||||
dto.getDescription(),
|
dto.getDescription(),
|
||||||
dto.getRulesMarkdown(),
|
dto.getRulesMarkdown(),
|
||||||
|
toDomainFields(dto.getCharacterTemplate()),
|
||||||
|
toDomainFields(dto.getNpcTemplate()),
|
||||||
dto.getAuthor(),
|
dto.getAuthor(),
|
||||||
dto.isPublic()
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package com.loremind.infrastructure.web.controller;
|
||||||
|
|
||||||
|
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.LicenseStatusDTO;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
public LicenseController(LicenseService licenseService) {
|
||||||
|
this.licenseService = licenseService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record InstallRequest(String jwt) {}
|
||||||
|
public record BetaChannelRequest(boolean enabled) {}
|
||||||
|
}
|
||||||
@@ -24,9 +24,7 @@ public class NpcController {
|
|||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<NpcDTO> createNpc(@RequestBody NpcDTO dto) {
|
public ResponseEntity<NpcDTO> createNpc(@RequestBody NpcDTO dto) {
|
||||||
Npc created = npcService.createNpc(
|
Npc created = npcService.createNpc(toData(dto, null));
|
||||||
new NpcService.NpcData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), null)
|
|
||||||
);
|
|
||||||
return ResponseEntity.ok(npcMapper.toDTO(created));
|
return ResponseEntity.ok(npcMapper.toDTO(created));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,10 +45,7 @@ public class NpcController {
|
|||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
public ResponseEntity<NpcDTO> updateNpc(@PathVariable String id, @RequestBody NpcDTO dto) {
|
public ResponseEntity<NpcDTO> updateNpc(@PathVariable String id, @RequestBody NpcDTO dto) {
|
||||||
Npc updated = npcService.updateNpc(
|
Npc updated = npcService.updateNpc(id, toData(dto, dto.getOrder()));
|
||||||
id,
|
|
||||||
new NpcService.NpcData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), dto.getOrder())
|
|
||||||
);
|
|
||||||
return ResponseEntity.ok(npcMapper.toDTO(updated));
|
return ResponseEntity.ok(npcMapper.toDTO(updated));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,4 +54,17 @@ public class NpcController {
|
|||||||
npcService.deleteNpc(id);
|
npcService.deleteNpc(id);
|
||||||
return ResponseEntity.noContent().build();
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package com.loremind.infrastructure.web.controller;
|
|||||||
|
|
||||||
import com.loremind.application.lorecontext.TemplateService;
|
import com.loremind.application.lorecontext.TemplateService;
|
||||||
import com.loremind.domain.lorecontext.Template;
|
import com.loremind.domain.lorecontext.Template;
|
||||||
import com.loremind.domain.lorecontext.TemplateField;
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
|
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
|
||||||
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
|
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
|
||||||
import com.loremind.infrastructure.web.mapper.TemplateMapper;
|
import com.loremind.infrastructure.web.mapper.TemplateMapper;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.loremind.infrastructure.web.controller;
|
package com.loremind.infrastructure.web.controller;
|
||||||
|
|
||||||
import com.loremind.infrastructure.updates.UpdateCheckService;
|
import com.loremind.infrastructure.updates.UpdateCheckService;
|
||||||
|
import com.loremind.infrastructure.updates.UpdateCheckService.BetaStatus;
|
||||||
import com.loremind.infrastructure.updates.UpdateCheckService.UpdateStatus;
|
import com.loremind.infrastructure.updates.UpdateCheckService.UpdateStatus;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -44,6 +45,12 @@ public class UpdatesController {
|
|||||||
return updates.check();
|
return updates.check();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/check-beta")
|
||||||
|
public BetaStatus checkBeta() {
|
||||||
|
guardDemoMode();
|
||||||
|
return updates.checkBeta();
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/apply")
|
@PostMapping("/apply")
|
||||||
public ResponseEntity<Map<String, Object>> apply() {
|
public ResponseEntity<Map<String, Object>> apply() {
|
||||||
guardDemoMode();
|
guardDemoMode();
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.loremind.infrastructure.web.controller;
|
||||||
|
|
||||||
|
import org.springframework.boot.info.BuildProperties;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoint public exposant la version courante du binaire.
|
||||||
|
* <p>
|
||||||
|
* Consomme par le frontend pour detecter qu'une mise a jour a ete deployee
|
||||||
|
* pendant qu'un onglet utilisateur etait deja ouvert : si la version polled
|
||||||
|
* differe de celle observee au boot, l'UI affiche un bandeau "rechargez".
|
||||||
|
* <p>
|
||||||
|
* Volontairement public (pas d'auth) : la version est deja exposee dans le
|
||||||
|
* JAR / l'image Docker, aucun risque de leak.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/version")
|
||||||
|
public class VersionController {
|
||||||
|
|
||||||
|
private final String version;
|
||||||
|
|
||||||
|
public VersionController(@Nullable BuildProperties buildProperties) {
|
||||||
|
this.version = buildProperties != null ? buildProperties.getVersion() : "dev";
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public Map<String, String> getVersion() {
|
||||||
|
return Map.of("version", version);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,26 @@ package com.loremind.infrastructure.web.dto.campaigncontext;
|
|||||||
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DTO pour les fiches de personnages (PJ) d'une campagne.
|
* DTO pour les fiches de personnages (PJ) d'une campagne.
|
||||||
|
* Reflete la refonte template-based : champs universels hard-codes (name,
|
||||||
|
* portrait, header) + maps {@code values}/{@code imageValues} pour les
|
||||||
|
* champs templates pilotes par le GameSystem.
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
public class CharacterDTO {
|
public class CharacterDTO {
|
||||||
|
|
||||||
private String id;
|
private String id;
|
||||||
private String name;
|
private String name;
|
||||||
private String markdownContent;
|
private String portraitImageId;
|
||||||
|
private String headerImageId;
|
||||||
|
private Map<String, String> values = new HashMap<>();
|
||||||
|
private Map<String, List<String>> imageValues = new HashMap<>();
|
||||||
|
private Map<String, Map<String, String>> keyValueValues = new HashMap<>();
|
||||||
private String campaignId;
|
private String campaignId;
|
||||||
private int order;
|
private int order;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,23 @@ package com.loremind.infrastructure.web.dto.campaigncontext;
|
|||||||
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DTO pour les fiches de PNJ d'une campagne.
|
* DTO pour les fiches de PNJ d'une campagne. Meme structure que CharacterDTO.
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
public class NpcDTO {
|
public class NpcDTO {
|
||||||
|
|
||||||
private String id;
|
private String id;
|
||||||
private String name;
|
private String name;
|
||||||
private String markdownContent;
|
private String portraitImageId;
|
||||||
|
private String headerImageId;
|
||||||
|
private Map<String, String> values = new HashMap<>();
|
||||||
|
private Map<String, List<String>> imageValues = new HashMap<>();
|
||||||
|
private Map<String, Map<String, String>> keyValueValues = new HashMap<>();
|
||||||
private String campaignId;
|
private String campaignId;
|
||||||
private int order;
|
private int order;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
package com.loremind.infrastructure.web.dto.gamesystemcontext;
|
package com.loremind.infrastructure.web.dto.gamesystemcontext;
|
||||||
|
|
||||||
|
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DTO pour l'entité GameSystem (système de JDR).
|
* DTO pour l'entité GameSystem (système de JDR).
|
||||||
|
* Expose les templates PJ/PNJ comme listes de TemplateFieldDTO pour le wire.
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
public class GameSystemDTO {
|
public class GameSystemDTO {
|
||||||
@@ -12,6 +17,8 @@ public class GameSystemDTO {
|
|||||||
private String name;
|
private String name;
|
||||||
private String description;
|
private String description;
|
||||||
private String rulesMarkdown;
|
private String rulesMarkdown;
|
||||||
|
private List<TemplateFieldDTO> characterTemplate = new ArrayList<>();
|
||||||
|
private List<TemplateFieldDTO> npcTemplate = new ArrayList<>();
|
||||||
private String author;
|
private String author;
|
||||||
private boolean isPublic;
|
private boolean isPublic;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.loremind.infrastructure.web.dto.licensing;
|
||||||
|
|
||||||
|
import com.loremind.domain.licensing.LicenseSnapshot;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vue serialisee de l'etat de la licence pour le frontend.
|
||||||
|
* Le {@code rawJwt} n'est volontairement JAMAIS expose.
|
||||||
|
*/
|
||||||
|
public record LicenseStatusDTO(
|
||||||
|
boolean enabled,
|
||||||
|
String status,
|
||||||
|
String patreonUserId,
|
||||||
|
String tierId,
|
||||||
|
String instanceId,
|
||||||
|
Instant expiresAt,
|
||||||
|
Instant lastRefreshAttemptAt,
|
||||||
|
Boolean lastRefreshSucceeded,
|
||||||
|
boolean betaChannelEnabled
|
||||||
|
) {
|
||||||
|
public static LicenseStatusDTO from(boolean enabled, LicenseSnapshot snap) {
|
||||||
|
return new LicenseStatusDTO(
|
||||||
|
enabled,
|
||||||
|
snap.status().name(),
|
||||||
|
snap.patreonUserId(),
|
||||||
|
snap.tierId(),
|
||||||
|
snap.instanceId(),
|
||||||
|
snap.expiresAt(),
|
||||||
|
snap.lastRefreshAttemptAt(),
|
||||||
|
snap.lastRefreshAttemptAt() != null ? snap.lastRefreshSucceeded() : null,
|
||||||
|
snap.betaChannelEnabled()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.loremind.infrastructure.web.dto.lorecontext;
|
package com.loremind.infrastructure.web.dto.lorecontext;
|
||||||
|
|
||||||
|
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
package com.loremind.infrastructure.web.dto.lorecontext;
|
|
||||||
|
|
||||||
import lombok.AllArgsConstructor;
|
|
||||||
import lombok.Data;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DTO pour un champ de Template.
|
|
||||||
* <p>
|
|
||||||
* Miroir wire-friendly de {@link com.loremind.domain.lorecontext.TemplateField}.
|
|
||||||
* Le type est serialise en string (TEXT/IMAGE) pour interop facile avec Angular.
|
|
||||||
* Le layout (null pour TEXT, ou GALLERY/HERO/MASONRY/CAROUSEL pour IMAGE) pilote
|
|
||||||
* le rendu visuel des champs image cote front.
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
@NoArgsConstructor
|
|
||||||
@AllArgsConstructor
|
|
||||||
public class TemplateFieldDTO {
|
|
||||||
private String name;
|
|
||||||
/** "TEXT" ou "IMAGE" (string pour serialisation JSON transparente). */
|
|
||||||
private String type;
|
|
||||||
/** "GALLERY" | "HERO" | "MASONRY" | "CAROUSEL", null si type=TEXT. */
|
|
||||||
private String layout;
|
|
||||||
|
|
||||||
/** Retrocompat : constructeur sans layout. */
|
|
||||||
public TemplateFieldDTO(String name, String type) {
|
|
||||||
this(name, type, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.loremind.infrastructure.web.dto.shared;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO pour un champ de Template.
|
||||||
|
* <p>
|
||||||
|
* Miroir wire-friendly de {@link com.loremind.domain.shared.template.TemplateField}.
|
||||||
|
* Le type est serialise en string (TEXT/IMAGE) pour interop facile avec Angular.
|
||||||
|
* Le layout (null pour TEXT, ou GALLERY/HERO/MASONRY/CAROUSEL pour IMAGE) pilote
|
||||||
|
* le rendu visuel des champs image cote front.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class TemplateFieldDTO {
|
||||||
|
private String name;
|
||||||
|
/** "TEXT" | "IMAGE" | "NUMBER" | "KEY_VALUE_LIST". */
|
||||||
|
private String type;
|
||||||
|
/** "GALLERY" | "HERO" | "MASONRY" | "CAROUSEL", uniquement pour IMAGE. */
|
||||||
|
private String layout;
|
||||||
|
/** Labels predefinis pour KEY_VALUE_LIST (ordre significatif). */
|
||||||
|
private List<String> labels;
|
||||||
|
|
||||||
|
/** Retrocompat : constructeur sans labels. */
|
||||||
|
public TemplateFieldDTO(String name, String type, String layout) {
|
||||||
|
this(name, type, layout, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retrocompat : constructeur sans layout ni labels. */
|
||||||
|
public TemplateFieldDTO(String name, String type) {
|
||||||
|
this(name, type, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import com.loremind.domain.campaigncontext.Character;
|
|||||||
import com.loremind.infrastructure.web.dto.campaigncontext.CharacterDTO;
|
import com.loremind.infrastructure.web.dto.campaigncontext.CharacterDTO;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class CharacterMapper {
|
public class CharacterMapper {
|
||||||
|
|
||||||
@@ -12,7 +14,11 @@ public class CharacterMapper {
|
|||||||
CharacterDTO dto = new CharacterDTO();
|
CharacterDTO dto = new CharacterDTO();
|
||||||
dto.setId(c.getId());
|
dto.setId(c.getId());
|
||||||
dto.setName(c.getName());
|
dto.setName(c.getName());
|
||||||
dto.setMarkdownContent(c.getMarkdownContent());
|
dto.setPortraitImageId(c.getPortraitImageId());
|
||||||
|
dto.setHeaderImageId(c.getHeaderImageId());
|
||||||
|
dto.setValues(c.getValues() != null ? new HashMap<>(c.getValues()) : new HashMap<>());
|
||||||
|
dto.setImageValues(c.getImageValues() != null ? new HashMap<>(c.getImageValues()) : new HashMap<>());
|
||||||
|
dto.setKeyValueValues(c.getKeyValueValues() != null ? new HashMap<>(c.getKeyValueValues()) : new HashMap<>());
|
||||||
dto.setCampaignId(c.getCampaignId());
|
dto.setCampaignId(c.getCampaignId());
|
||||||
dto.setOrder(c.getOrder());
|
dto.setOrder(c.getOrder());
|
||||||
return dto;
|
return dto;
|
||||||
@@ -23,7 +29,11 @@ public class CharacterMapper {
|
|||||||
return Character.builder()
|
return Character.builder()
|
||||||
.id(dto.getId())
|
.id(dto.getId())
|
||||||
.name(dto.getName())
|
.name(dto.getName())
|
||||||
.markdownContent(dto.getMarkdownContent())
|
.portraitImageId(dto.getPortraitImageId())
|
||||||
|
.headerImageId(dto.getHeaderImageId())
|
||||||
|
.values(dto.getValues() != null ? new HashMap<>(dto.getValues()) : new HashMap<>())
|
||||||
|
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
|
||||||
|
.keyValueValues(dto.getKeyValueValues() != null ? new HashMap<>(dto.getKeyValueValues()) : new HashMap<>())
|
||||||
.campaignId(dto.getCampaignId())
|
.campaignId(dto.getCampaignId())
|
||||||
.order(dto.getOrder())
|
.order(dto.getOrder())
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
package com.loremind.infrastructure.web.mapper;
|
package com.loremind.infrastructure.web.mapper;
|
||||||
|
|
||||||
import com.loremind.domain.gamesystemcontext.GameSystem;
|
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.gamesystemcontext.GameSystemDTO;
|
||||||
|
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class GameSystemMapper {
|
public class GameSystemMapper {
|
||||||
|
|
||||||
|
private final TemplateFieldMapper fieldMapper;
|
||||||
|
|
||||||
|
public GameSystemMapper(TemplateFieldMapper fieldMapper) {
|
||||||
|
this.fieldMapper = fieldMapper;
|
||||||
|
}
|
||||||
|
|
||||||
public GameSystemDTO toDTO(GameSystem g) {
|
public GameSystemDTO toDTO(GameSystem g) {
|
||||||
if (g == null) return null;
|
if (g == null) return null;
|
||||||
GameSystemDTO dto = new GameSystemDTO();
|
GameSystemDTO dto = new GameSystemDTO();
|
||||||
@@ -14,6 +25,8 @@ public class GameSystemMapper {
|
|||||||
dto.setName(g.getName());
|
dto.setName(g.getName());
|
||||||
dto.setDescription(g.getDescription());
|
dto.setDescription(g.getDescription());
|
||||||
dto.setRulesMarkdown(g.getRulesMarkdown());
|
dto.setRulesMarkdown(g.getRulesMarkdown());
|
||||||
|
dto.setCharacterTemplate(toDTOList(g.getCharacterTemplate()));
|
||||||
|
dto.setNpcTemplate(toDTOList(g.getNpcTemplate()));
|
||||||
dto.setAuthor(g.getAuthor());
|
dto.setAuthor(g.getAuthor());
|
||||||
dto.setPublic(g.isPublic());
|
dto.setPublic(g.isPublic());
|
||||||
return dto;
|
return dto;
|
||||||
@@ -26,8 +39,24 @@ public class GameSystemMapper {
|
|||||||
.name(dto.getName())
|
.name(dto.getName())
|
||||||
.description(dto.getDescription())
|
.description(dto.getDescription())
|
||||||
.rulesMarkdown(dto.getRulesMarkdown())
|
.rulesMarkdown(dto.getRulesMarkdown())
|
||||||
|
.characterTemplate(toDomainList(dto.getCharacterTemplate()))
|
||||||
|
.npcTemplate(toDomainList(dto.getNpcTemplate()))
|
||||||
.author(dto.getAuthor())
|
.author(dto.getAuthor())
|
||||||
.isPublic(dto.isPublic())
|
.isPublic(dto.isPublic())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<TemplateFieldDTO> toDTOList(List<TemplateField> fields) {
|
||||||
|
if (fields == null) return new ArrayList<>();
|
||||||
|
List<TemplateFieldDTO> out = new ArrayList<>(fields.size());
|
||||||
|
for (TemplateField f : fields) out.add(fieldMapper.toDTO(f));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TemplateField> toDomainList(List<TemplateFieldDTO> dtos) {
|
||||||
|
if (dtos == null) return new ArrayList<>();
|
||||||
|
List<TemplateField> out = new ArrayList<>(dtos.size());
|
||||||
|
for (TemplateFieldDTO d : dtos) out.add(fieldMapper.toDomain(d));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import com.loremind.domain.campaigncontext.Npc;
|
|||||||
import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO;
|
import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class NpcMapper {
|
public class NpcMapper {
|
||||||
|
|
||||||
@@ -12,7 +14,11 @@ public class NpcMapper {
|
|||||||
NpcDTO dto = new NpcDTO();
|
NpcDTO dto = new NpcDTO();
|
||||||
dto.setId(n.getId());
|
dto.setId(n.getId());
|
||||||
dto.setName(n.getName());
|
dto.setName(n.getName());
|
||||||
dto.setMarkdownContent(n.getMarkdownContent());
|
dto.setPortraitImageId(n.getPortraitImageId());
|
||||||
|
dto.setHeaderImageId(n.getHeaderImageId());
|
||||||
|
dto.setValues(n.getValues() != null ? new HashMap<>(n.getValues()) : new HashMap<>());
|
||||||
|
dto.setImageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>());
|
||||||
|
dto.setKeyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>());
|
||||||
dto.setCampaignId(n.getCampaignId());
|
dto.setCampaignId(n.getCampaignId());
|
||||||
dto.setOrder(n.getOrder());
|
dto.setOrder(n.getOrder());
|
||||||
return dto;
|
return dto;
|
||||||
@@ -23,7 +29,11 @@ public class NpcMapper {
|
|||||||
return Npc.builder()
|
return Npc.builder()
|
||||||
.id(dto.getId())
|
.id(dto.getId())
|
||||||
.name(dto.getName())
|
.name(dto.getName())
|
||||||
.markdownContent(dto.getMarkdownContent())
|
.portraitImageId(dto.getPortraitImageId())
|
||||||
|
.headerImageId(dto.getHeaderImageId())
|
||||||
|
.values(dto.getValues() != null ? new HashMap<>(dto.getValues()) : new HashMap<>())
|
||||||
|
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
|
||||||
|
.keyValueValues(dto.getKeyValueValues() != null ? new HashMap<>(dto.getKeyValueValues()) : new HashMap<>())
|
||||||
.campaignId(dto.getCampaignId())
|
.campaignId(dto.getCampaignId())
|
||||||
.order(dto.getOrder())
|
.order(dto.getOrder())
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
package com.loremind.infrastructure.web.mapper;
|
package com.loremind.infrastructure.web.mapper;
|
||||||
|
|
||||||
import com.loremind.domain.lorecontext.FieldType;
|
import com.loremind.domain.shared.template.FieldType;
|
||||||
import com.loremind.domain.lorecontext.ImageLayout;
|
import com.loremind.domain.shared.template.ImageLayout;
|
||||||
import com.loremind.domain.lorecontext.TemplateField;
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import com.loremind.infrastructure.web.dto.lorecontext.TemplateFieldDTO;
|
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mapper pour convertir entre {@link TemplateField} (domaine) et
|
* Mapper pour convertir entre {@link TemplateField} (domaine) et
|
||||||
* {@link TemplateFieldDTO} (wire).
|
* {@link TemplateFieldDTO} (wire).
|
||||||
* <p>
|
* <p>
|
||||||
* Tolerance : un type inconnu recu du client est interprete comme TEXT
|
* Tolerance : un type inconnu recu du client est interprete comme TEXT.
|
||||||
* (plus safe que de rejeter la requete et d'interrompre la sauvegarde).
|
|
||||||
* Un layout inconnu ou absent sur un champ IMAGE est interprete comme GALLERY.
|
* Un layout inconnu ou absent sur un champ IMAGE est interprete comme GALLERY.
|
||||||
* Le layout est force a null pour les champs TEXT.
|
* Layout/labels forces a null pour les types qui ne les utilisent pas.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class TemplateFieldMapper {
|
public class TemplateFieldMapper {
|
||||||
@@ -26,7 +28,11 @@ public class TemplateFieldMapper {
|
|||||||
ImageLayout layout = field.getLayout() != null ? field.getLayout() : ImageLayout.GALLERY;
|
ImageLayout layout = field.getLayout() != null ? field.getLayout() : ImageLayout.GALLERY;
|
||||||
layoutStr = layout.name();
|
layoutStr = layout.name();
|
||||||
}
|
}
|
||||||
return new TemplateFieldDTO(field.getName(), typeStr, layoutStr);
|
List<String> labels = null;
|
||||||
|
if (field.getType() == FieldType.KEY_VALUE_LIST && field.getLabels() != null) {
|
||||||
|
labels = new ArrayList<>(field.getLabels());
|
||||||
|
}
|
||||||
|
return new TemplateFieldDTO(field.getName(), typeStr, layoutStr, labels);
|
||||||
}
|
}
|
||||||
|
|
||||||
public TemplateField toDomain(TemplateFieldDTO dto) {
|
public TemplateField toDomain(TemplateFieldDTO dto) {
|
||||||
@@ -47,6 +53,10 @@ public class TemplateFieldMapper {
|
|||||||
layout = ImageLayout.GALLERY;
|
layout = ImageLayout.GALLERY;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return new TemplateField(dto.getName(), type, layout);
|
List<String> labels = null;
|
||||||
|
if (type == FieldType.KEY_VALUE_LIST && dto.getLabels() != null) {
|
||||||
|
labels = new ArrayList<>(dto.getLabels());
|
||||||
|
}
|
||||||
|
return new TemplateField(dto.getName(), type, layout, labels);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package com.loremind.infrastructure.web.mapper;
|
package com.loremind.infrastructure.web.mapper;
|
||||||
|
|
||||||
import com.loremind.domain.lorecontext.Template;
|
import com.loremind.domain.lorecontext.Template;
|
||||||
import com.loremind.domain.lorecontext.TemplateField;
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
|
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
|
||||||
import com.loremind.infrastructure.web.dto.lorecontext.TemplateFieldDTO;
|
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
|||||||
@@ -65,6 +65,39 @@ app.demo-mode=${DEMO_MODE:false}
|
|||||||
# Feature desactivee si UPDATE_CHECK_WATCHTOWER_TOKEN est vide.
|
# Feature desactivee si UPDATE_CHECK_WATCHTOWER_TOKEN est vide.
|
||||||
update-check.registry=${UPDATE_CHECK_REGISTRY:}
|
update-check.registry=${UPDATE_CHECK_REGISTRY:}
|
||||||
update-check.images=${UPDATE_CHECK_IMAGES:}
|
update-check.images=${UPDATE_CHECK_IMAGES:}
|
||||||
update-check.tag=${UPDATE_CHECK_TAG:latest}
|
|
||||||
update-check.watchtower-url=${WATCHTOWER_URL:http://watchtower:8080}
|
update-check.watchtower-url=${WATCHTOWER_URL:http://watchtower:8080}
|
||||||
update-check.watchtower-token=${WATCHTOWER_TOKEN:}
|
update-check.watchtower-token=${WATCHTOWER_TOKEN:}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Licensing (canal beta gate par Patreon)
|
||||||
|
# ============================================================================
|
||||||
|
# URL du relais OAuth Patreon (Cloudflare Workers). En prod : valeur par defaut.
|
||||||
|
licensing.relay.base-url=${LICENSING_RELAY_BASE_URL:https://loremind-auth.igmlcreation.fr}
|
||||||
|
|
||||||
|
# Cle publique Ed25519 (PEM SPKI) qui verifie les JWT emis par le relais.
|
||||||
|
# En prod : chargee automatiquement depuis classpath:licensing/jwt-public-key.pem
|
||||||
|
# (embarquee dans le binaire). Cette propriete sert UNIQUEMENT a la rotation
|
||||||
|
# de cle ou aux tests : si LICENSING_JWT_PUBLIC_KEY est defini, il prevaut
|
||||||
|
# sur le fichier embarque.
|
||||||
|
licensing.jwt.public-key=${LICENSING_JWT_PUBLIC_KEY:}
|
||||||
|
licensing.jwt.expected-issuer=loremind-auth
|
||||||
|
licensing.jwt.expected-audience=loremind-instance
|
||||||
|
|
||||||
|
# Periode de tolerance apres expiration du JWT pendant laquelle l'instance
|
||||||
|
# garde l'acces beta meme si le relais est indisponible pour le refresh.
|
||||||
|
licensing.grace-period-days=14
|
||||||
|
# Avant J-N de l'expiration, le daemon tente un refresh.
|
||||||
|
licensing.refresh-before-expiry-days=2
|
||||||
|
|
||||||
|
# Identifiant stable de l'instance (UUID genere a la premiere connexion Patreon
|
||||||
|
# et conserve en base). Utilise dans le state OAuth + dans le JWT.
|
||||||
|
licensing.instance-id-file=${LICENSING_INSTANCE_ID_FILE:}
|
||||||
|
|
||||||
|
# Image beta : si la licence est valide ET le toggle canal beta active,
|
||||||
|
# UpdateCheckService check ces images en plus du canal stable.
|
||||||
|
licensing.beta.images=${LICENSING_BETA_IMAGES:igmlcreation/loremind-beta-core,igmlcreation/loremind-beta-brain,igmlcreation/loremind-beta-web}
|
||||||
|
|
||||||
|
# Chemin de sortie pour le docker config.json partage avec Watchtower.
|
||||||
|
# Volume Docker `docker-config` monte sur ce chemin dans Core, et sur
|
||||||
|
# `/shared/docker` dans Watchtower (DOCKER_CONFIG=/shared/docker).
|
||||||
|
licensing.docker-config-path=${LICENSING_DOCKER_CONFIG_PATH:/shared/docker/config.json}
|
||||||
|
|||||||
29
core/src/main/resources/licensing/README.md
Normal file
29
core/src/main/resources/licensing/README.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Cle publique JWT du relais OAuth Patreon
|
||||||
|
|
||||||
|
Le fichier `jwt-public-key.pem` contient la **cle publique Ed25519** qui sert
|
||||||
|
a verifier la signature des JWT licence emis par le relais
|
||||||
|
(`loremind-auth.igmlcreation.fr`).
|
||||||
|
|
||||||
|
## Pourquoi ici ?
|
||||||
|
|
||||||
|
- C'est une **cle publique** : par nature non-secrete, elle peut etre committee
|
||||||
|
dans le repo public et embarquee dans le binaire distribue.
|
||||||
|
- Cela evite a chaque utilisateur final de devoir renseigner manuellement la
|
||||||
|
cle dans son `.env` au moment de l'installation.
|
||||||
|
- L'env `LICENSING_JWT_PUBLIC_KEY` peut surcharger cette valeur (utile pour
|
||||||
|
la rotation de cle sans rebuild ou pour les tests).
|
||||||
|
|
||||||
|
## Si le fichier est absent
|
||||||
|
|
||||||
|
La feature licensing est **desactivee silencieusement** : `LicenseService.isLicensingEnabled()`
|
||||||
|
renvoie `false`, et l'UI masque toute la section Patreon.
|
||||||
|
|
||||||
|
## Rotation de cle
|
||||||
|
|
||||||
|
1. Generer une nouvelle paire dans le relais : `npm run keys:generate`
|
||||||
|
2. Pousser la nouvelle cle privee : `wrangler secret put JWT_PRIVATE_KEY`
|
||||||
|
3. Remplacer `jwt-public-key.pem` ici avec la nouvelle cle publique
|
||||||
|
4. Rebuild + redeployer LoreMind (les anciens JWT seront refuses au prochain
|
||||||
|
refresh, l'utilisateur sera invite a reconnecter Patreon)
|
||||||
|
5. Optionnel : pendant la transition, supporter les deux cles en parallele
|
||||||
|
(pas implemente en MVP, peut etre ajoute si besoin operationnel)
|
||||||
3
core/src/main/resources/licensing/jwt-public-key.pem
Normal file
3
core/src/main/resources/licensing/jwt-public-key.pem
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MCowBQYDK2VwAyEArbfaqBq54HJR1pKqliTShKrNIab32gpBwSTDw90I4wg=
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
@@ -11,6 +11,7 @@ import org.mockito.Mock;
|
|||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
@@ -38,7 +39,7 @@ public class NpcServiceTest {
|
|||||||
testNpc = Npc.builder()
|
testNpc = Npc.builder()
|
||||||
.id("npc-1")
|
.id("npc-1")
|
||||||
.name("Borin le forgeron")
|
.name("Borin le forgeron")
|
||||||
.markdownContent("# Borin\nForgeron nain")
|
.values(new java.util.HashMap<>(Map.of("Notes", "Forgeron nain")))
|
||||||
.campaignId("camp-1")
|
.campaignId("camp-1")
|
||||||
.order(1)
|
.order(1)
|
||||||
.build();
|
.build();
|
||||||
@@ -49,7 +50,8 @@ public class NpcServiceTest {
|
|||||||
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
||||||
|
|
||||||
Npc result = npcService.createNpc(
|
Npc result = npcService.createNpc(
|
||||||
new NpcService.NpcData("Borin le forgeron", "# Borin", "camp-1", 5));
|
new NpcService.NpcData("Borin le forgeron", null, null,
|
||||||
|
Map.of("Notes", "Borin"), null, null, "camp-1", 5));
|
||||||
|
|
||||||
assertNotNull(result);
|
assertNotNull(result);
|
||||||
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
||||||
@@ -65,7 +67,7 @@ public class NpcServiceTest {
|
|||||||
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b));
|
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b));
|
||||||
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
||||||
|
|
||||||
npcService.createNpc(new NpcService.NpcData("Nouveau", null, "camp-1", null));
|
npcService.createNpc(new NpcService.NpcData("Nouveau", null, null, null, null, null, "camp-1", null));
|
||||||
|
|
||||||
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
||||||
verify(npcRepository).save(captor.capture());
|
verify(npcRepository).save(captor.capture());
|
||||||
@@ -77,7 +79,7 @@ public class NpcServiceTest {
|
|||||||
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
|
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
|
||||||
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
||||||
|
|
||||||
npcService.createNpc(new NpcService.NpcData("Premier", null, "camp-1", null));
|
npcService.createNpc(new NpcService.NpcData("Premier", null, null, null, null, null, "camp-1", null));
|
||||||
|
|
||||||
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
||||||
verify(npcRepository).save(captor.capture());
|
verify(npcRepository).save(captor.capture());
|
||||||
@@ -121,10 +123,11 @@ public class NpcServiceTest {
|
|||||||
when(npcRepository.save(any(Npc.class))).thenAnswer(inv -> inv.getArgument(0));
|
when(npcRepository.save(any(Npc.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
Npc result = npcService.updateNpc("npc-1",
|
Npc result = npcService.updateNpc("npc-1",
|
||||||
new NpcService.NpcData("Borin renommé", "# v2", "camp-1", 7));
|
new NpcService.NpcData("Borin renommé", null, null,
|
||||||
|
Map.of("Notes", "v2"), null, null, "camp-1", 7));
|
||||||
|
|
||||||
assertEquals("Borin renommé", result.getName());
|
assertEquals("Borin renommé", result.getName());
|
||||||
assertEquals("# v2", result.getMarkdownContent());
|
assertEquals("v2", result.getValues().get("Notes"));
|
||||||
assertEquals(7, result.getOrder());
|
assertEquals(7, result.getOrder());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +137,8 @@ public class NpcServiceTest {
|
|||||||
when(npcRepository.save(any(Npc.class))).thenAnswer(inv -> inv.getArgument(0));
|
when(npcRepository.save(any(Npc.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
Npc result = npcService.updateNpc("npc-1",
|
Npc result = npcService.updateNpc("npc-1",
|
||||||
new NpcService.NpcData("Borin", "# txt", "camp-1", null));
|
new NpcService.NpcData("Borin", null, null,
|
||||||
|
Map.of("Notes", "txt"), null, null, "camp-1", null));
|
||||||
|
|
||||||
// testNpc avait order=1 → préservé
|
// testNpc avait order=1 → préservé
|
||||||
assertEquals(1, result.getOrder());
|
assertEquals(1, result.getOrder());
|
||||||
@@ -146,7 +150,7 @@ public class NpcServiceTest {
|
|||||||
|
|
||||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||||
() -> npcService.updateNpc("missing",
|
() -> npcService.updateNpc("missing",
|
||||||
new NpcService.NpcData("x", null, "camp-1", null)));
|
new NpcService.NpcData("x", null, null, null, null, null, "camp-1", null)));
|
||||||
assertTrue(ex.getMessage().contains("missing"));
|
assertTrue(ex.getMessage().contains("missing"));
|
||||||
verify(npcRepository, never()).save(any());
|
verify(npcRepository, never()).save(any());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,19 +153,19 @@ public class CampaignStructuralContextBuilderTest {
|
|||||||
void testBuild_ProjectsCharactersAndNpcsWithSnippets() {
|
void testBuild_ProjectsCharactersAndNpcsWithSnippets() {
|
||||||
Character pj1 = Character.builder().id("c-1").campaignId("camp-1").order(1)
|
Character pj1 = Character.builder().id("c-1").campaignId("camp-1").order(1)
|
||||||
.name("Aragorn")
|
.name("Aragorn")
|
||||||
.markdownContent("# Aragorn\n\nRôdeur du Nord, héritier d'Isildur.")
|
.values(new java.util.HashMap<>(java.util.Map.of("Histoire", "# Aragorn\n\nRôdeur du Nord, héritier d'Isildur.")))
|
||||||
.build();
|
.build();
|
||||||
Character pj2 = Character.builder().id("c-2").campaignId("camp-1").order(2)
|
Character pj2 = Character.builder().id("c-2").campaignId("camp-1").order(2)
|
||||||
.name("Legolas")
|
.name("Legolas")
|
||||||
.markdownContent(null) // pas de snippet → string vide
|
.values(null) // pas de snippet → string vide
|
||||||
.build();
|
.build();
|
||||||
Npc npc1 = Npc.builder().id("n-1").campaignId("camp-1").order(2)
|
Npc npc1 = Npc.builder().id("n-1").campaignId("camp-1").order(2)
|
||||||
.name("Borin le forgeron")
|
.name("Borin le forgeron")
|
||||||
.markdownContent("# Borin\n\nNain barbu au regard perçant, ancien clan Feuillefer.")
|
.values(new java.util.HashMap<>(java.util.Map.of("Histoire", "# Borin\n\nNain barbu au regard perçant, ancien clan Feuillefer.")))
|
||||||
.build();
|
.build();
|
||||||
Npc npc2 = Npc.builder().id("n-2").campaignId("camp-1").order(1)
|
Npc npc2 = Npc.builder().id("n-2").campaignId("camp-1").order(1)
|
||||||
.name("Dame Elara")
|
.name("Dame Elara")
|
||||||
.markdownContent("")
|
.values(new java.util.HashMap<>(java.util.Map.of("Histoire", "")))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
|
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
|
||||||
@@ -196,7 +196,7 @@ public class CampaignStructuralContextBuilderTest {
|
|||||||
// Snippet > 160 chars : doit être tronqué à 159 + "…"
|
// Snippet > 160 chars : doit être tronqué à 159 + "…"
|
||||||
String longLine = "x".repeat(200);
|
String longLine = "x".repeat(200);
|
||||||
Npc longNpc = Npc.builder().id("n-1").campaignId("camp-1").order(1)
|
Npc longNpc = Npc.builder().id("n-1").campaignId("camp-1").order(1)
|
||||||
.name("Verbeux").markdownContent(longLine).build();
|
.name("Verbeux").values(new java.util.HashMap<>(java.util.Map.of("Histoire", longLine))).build();
|
||||||
|
|
||||||
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
|
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
|
||||||
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
|
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ package com.loremind.application.generationcontext;
|
|||||||
import com.loremind.domain.generationcontext.GenerationContext;
|
import com.loremind.domain.generationcontext.GenerationContext;
|
||||||
import com.loremind.domain.generationcontext.GenerationResult;
|
import com.loremind.domain.generationcontext.GenerationResult;
|
||||||
import com.loremind.domain.generationcontext.ports.AiProvider;
|
import com.loremind.domain.generationcontext.ports.AiProvider;
|
||||||
import com.loremind.domain.lorecontext.FieldType;
|
import com.loremind.domain.shared.template.FieldType;
|
||||||
import com.loremind.domain.lorecontext.Lore;
|
import com.loremind.domain.lorecontext.Lore;
|
||||||
import com.loremind.domain.lorecontext.LoreNode;
|
import com.loremind.domain.lorecontext.LoreNode;
|
||||||
import com.loremind.domain.lorecontext.Page;
|
import com.loremind.domain.lorecontext.Page;
|
||||||
import com.loremind.domain.lorecontext.Template;
|
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.LoreNodeRepository;
|
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
|
||||||
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
||||||
import com.loremind.domain.lorecontext.ports.PageRepository;
|
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||||
|
|||||||
@@ -115,8 +115,13 @@ public class NarrativeEntityContextBuilderTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testBuild_Character_MarkdownProjected() {
|
void testBuild_Character_MarkdownProjected() {
|
||||||
|
// Refonte 2026-04-30 : les valeurs templates sont projetees individuellement
|
||||||
|
// dans la map fields (cle = nom du champ template).
|
||||||
Character c = Character.builder()
|
Character c = Character.builder()
|
||||||
.id("c-1").name("Aragorn").markdownContent("# Aragorn\nRôdeur")
|
.id("c-1").name("Aragorn")
|
||||||
|
.values(new java.util.HashMap<>(java.util.Map.of(
|
||||||
|
"Histoire", "# Aragorn\nRôdeur",
|
||||||
|
"Race", "Humain")))
|
||||||
.build();
|
.build();
|
||||||
when(characterRepository.findById("c-1")).thenReturn(Optional.of(c));
|
when(characterRepository.findById("c-1")).thenReturn(Optional.of(c));
|
||||||
|
|
||||||
@@ -124,14 +129,17 @@ public class NarrativeEntityContextBuilderTest {
|
|||||||
|
|
||||||
assertEquals("character", ctx.entityType());
|
assertEquals("character", ctx.entityType());
|
||||||
assertEquals("Aragorn", ctx.title());
|
assertEquals("Aragorn", ctx.title());
|
||||||
assertEquals("# Aragorn\nRôdeur", ctx.fields().get("fiche complète (markdown)"));
|
assertEquals("# Aragorn\nRôdeur", ctx.fields().get("Histoire"));
|
||||||
|
assertEquals("Humain", ctx.fields().get("Race"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testBuild_Npc_MarkdownProjected() {
|
void testBuild_Npc_MarkdownProjected() {
|
||||||
Npc n = Npc.builder()
|
Npc n = Npc.builder()
|
||||||
.id("n-1").name("Borin le forgeron")
|
.id("n-1").name("Borin le forgeron")
|
||||||
.markdownContent("# Borin\n**Faction :** Clan Feuillefer")
|
.values(new java.util.HashMap<>(java.util.Map.of(
|
||||||
|
"Faction", "Clan Feuillefer",
|
||||||
|
"Histoire", "# Borin")))
|
||||||
.build();
|
.build();
|
||||||
when(npcRepository.findById("n-1")).thenReturn(Optional.of(n));
|
when(npcRepository.findById("n-1")).thenReturn(Optional.of(n));
|
||||||
|
|
||||||
@@ -139,13 +147,14 @@ public class NarrativeEntityContextBuilderTest {
|
|||||||
|
|
||||||
assertEquals("npc", ctx.entityType());
|
assertEquals("npc", ctx.entityType());
|
||||||
assertEquals("Borin le forgeron", ctx.title());
|
assertEquals("Borin le forgeron", ctx.title());
|
||||||
assertEquals("# Borin\n**Faction :** Clan Feuillefer",
|
assertEquals("Clan Feuillefer", ctx.fields().get("Faction"));
|
||||||
ctx.fields().get("fiche complète (markdown)"));
|
assertEquals("# Borin", ctx.fields().get("Histoire"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testBuild_Npc_NormalizesCase() {
|
void testBuild_Npc_NormalizesCase() {
|
||||||
Npc n = Npc.builder().id("n-1").name("Elara").markdownContent("desc").build();
|
Npc n = Npc.builder().id("n-1").name("Elara")
|
||||||
|
.values(new java.util.HashMap<>(java.util.Map.of("Notes", "desc"))).build();
|
||||||
when(npcRepository.findById("n-1")).thenReturn(Optional.of(n));
|
when(npcRepository.findById("n-1")).thenReturn(Optional.of(n));
|
||||||
|
|
||||||
NarrativeEntityContext ctx = builder.build(" NPC ", "n-1");
|
NarrativeEntityContext ctx = builder.build(" NPC ", "n-1");
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import com.loremind.domain.generationcontext.ChatRequest;
|
|||||||
import com.loremind.domain.generationcontext.ChatUsage;
|
import com.loremind.domain.generationcontext.ChatUsage;
|
||||||
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
||||||
import com.loremind.domain.generationcontext.ports.AiChatProvider;
|
import com.loremind.domain.generationcontext.ports.AiChatProvider;
|
||||||
import com.loremind.domain.lorecontext.FieldType;
|
import com.loremind.domain.shared.template.FieldType;
|
||||||
import com.loremind.domain.lorecontext.Page;
|
import com.loremind.domain.lorecontext.Page;
|
||||||
import com.loremind.domain.lorecontext.Template;
|
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.PageRepository;
|
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||||
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.loremind.application.lorecontext;
|
package com.loremind.application.lorecontext;
|
||||||
|
|
||||||
import com.loremind.domain.lorecontext.Template;
|
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 com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package com.loremind.domain.gamesystemcontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.shared.template.FieldType;
|
||||||
|
import com.loremind.domain.shared.template.ImageLayout;
|
||||||
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests unitaires du domaine GameSystem ciblant la gestion des templates PJ/PNJ.
|
||||||
|
* Le ruleset markdown est testé ailleurs via GameSystemContextSelector.
|
||||||
|
*/
|
||||||
|
class GameSystemTest {
|
||||||
|
|
||||||
|
// --- addCharacterField --------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addCharacterField_appendsField() {
|
||||||
|
GameSystem gs = GameSystem.builder().build();
|
||||||
|
|
||||||
|
gs.addCharacterField(TemplateField.text("Histoire"));
|
||||||
|
gs.addCharacterField(TemplateField.image("Portrait", ImageLayout.HERO));
|
||||||
|
|
||||||
|
assertEquals(2, gs.getCharacterTemplate().size());
|
||||||
|
assertEquals("Histoire", gs.getCharacterTemplate().get(0).getName());
|
||||||
|
assertEquals(FieldType.IMAGE, gs.getCharacterTemplate().get(1).getType());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addCharacterField_rejectsDuplicateNameCaseInsensitive() {
|
||||||
|
GameSystem gs = GameSystem.builder().build();
|
||||||
|
gs.addCharacterField(TemplateField.text("Histoire"));
|
||||||
|
|
||||||
|
// Doublon de cle dans Character.values garanti casse-insensible :
|
||||||
|
// "Histoire" et "histoire" produiraient la meme cle JSON.
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> gs.addCharacterField(TemplateField.number("HISTOIRE")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addCharacterField_rejectsBlankName() {
|
||||||
|
GameSystem gs = GameSystem.builder().build();
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> gs.addCharacterField(new TemplateField(" ", FieldType.TEXT)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- removeCharacterField ----------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removeCharacterField_removesByNameCaseInsensitive() {
|
||||||
|
GameSystem gs = GameSystem.builder()
|
||||||
|
.characterTemplate(new ArrayList<>(List.of(
|
||||||
|
TemplateField.text("Histoire"),
|
||||||
|
TemplateField.text("Notes")
|
||||||
|
)))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
gs.removeCharacterField("HISTOIRE");
|
||||||
|
|
||||||
|
assertEquals(1, gs.getCharacterTemplate().size());
|
||||||
|
assertEquals("Notes", gs.getCharacterTemplate().get(0).getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removeCharacterField_silentNoOpWhenMissing() {
|
||||||
|
GameSystem gs = GameSystem.builder()
|
||||||
|
.characterTemplate(new ArrayList<>(List.of(TemplateField.text("Histoire"))))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
gs.removeCharacterField("absent");
|
||||||
|
|
||||||
|
assertEquals(1, gs.getCharacterTemplate().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- replaceCharacterTemplate ------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replaceCharacterTemplate_overwritesEntireList() {
|
||||||
|
GameSystem gs = GameSystem.builder()
|
||||||
|
.characterTemplate(new ArrayList<>(List.of(TemplateField.text("Old"))))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
gs.replaceCharacterTemplate(List.of(
|
||||||
|
TemplateField.text("A"),
|
||||||
|
TemplateField.number("B")));
|
||||||
|
|
||||||
|
assertEquals(2, gs.getCharacterTemplate().size());
|
||||||
|
assertEquals("A", gs.getCharacterTemplate().get(0).getName());
|
||||||
|
assertEquals("B", gs.getCharacterTemplate().get(1).getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replaceCharacterTemplate_rejectsDuplicates() {
|
||||||
|
GameSystem gs = GameSystem.builder().build();
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> gs.replaceCharacterTemplate(List.of(
|
||||||
|
TemplateField.text("a"),
|
||||||
|
TemplateField.text("A"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replaceCharacterTemplate_nullBecomesEmptyList() {
|
||||||
|
GameSystem gs = GameSystem.builder().build();
|
||||||
|
gs.replaceCharacterTemplate(null);
|
||||||
|
assertTrue(gs.getCharacterTemplate().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replaceCharacterTemplate_isolatesInternalListFromCallerMutations() {
|
||||||
|
// Garantie d'encapsulation : muter la liste passee ne doit pas affecter le GameSystem.
|
||||||
|
List<TemplateField> external = new ArrayList<>(List.of(TemplateField.text("A")));
|
||||||
|
GameSystem gs = GameSystem.builder().build();
|
||||||
|
|
||||||
|
gs.replaceCharacterTemplate(external);
|
||||||
|
external.add(TemplateField.text("B"));
|
||||||
|
|
||||||
|
assertEquals(1, gs.getCharacterTemplate().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Templates NPC : meme logique, sanity check minimal ----------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void npcTemplate_followsSameRulesAsCharacterTemplate() {
|
||||||
|
GameSystem gs = GameSystem.builder().build();
|
||||||
|
|
||||||
|
gs.addNpcField(TemplateField.text("Motivation"));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> gs.addNpcField(TemplateField.text("motivation")));
|
||||||
|
|
||||||
|
gs.removeNpcField("Motivation");
|
||||||
|
assertTrue(gs.getNpcTemplate().isEmpty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.loremind.domain.lorecontext;
|
package com.loremind.domain.lorecontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.shared.template.ImageLayout;
|
||||||
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.lorecontext;
|
package com.loremind.domain.shared.template;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.loremind.infrastructure.persistence.converter;
|
package com.loremind.infrastructure.persistence.converter;
|
||||||
|
|
||||||
import com.loremind.domain.lorecontext.FieldType;
|
import com.loremind.domain.shared.template.FieldType;
|
||||||
import com.loremind.domain.lorecontext.ImageLayout;
|
import com.loremind.domain.shared.template.ImageLayout;
|
||||||
import com.loremind.domain.lorecontext.TemplateField;
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package com.loremind.infrastructure.persistence.postgres;
|
package com.loremind.infrastructure.persistence.postgres;
|
||||||
|
|
||||||
import com.loremind.domain.lorecontext.FieldType;
|
import com.loremind.domain.shared.template.FieldType;
|
||||||
import com.loremind.domain.lorecontext.ImageLayout;
|
import com.loremind.domain.shared.template.ImageLayout;
|
||||||
import com.loremind.domain.lorecontext.Lore;
|
import com.loremind.domain.lorecontext.Lore;
|
||||||
import com.loremind.domain.lorecontext.Template;
|
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.LoreRepository;
|
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
||||||
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
package com.loremind.infrastructure.updates;
|
package com.loremind.infrastructure.updates;
|
||||||
|
|
||||||
|
import com.loremind.application.licensing.LicenseService;
|
||||||
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatus;
|
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatus;
|
||||||
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatusKind;
|
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatusKind;
|
||||||
|
import com.loremind.infrastructure.updates.UpdateCheckService.TagsListResponse;
|
||||||
import com.loremind.infrastructure.updates.UpdateCheckService.UpdateStatus;
|
import com.loremind.infrastructure.updates.UpdateCheckService.UpdateStatus;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.info.BuildProperties;
|
||||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.List;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.Properties;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
@@ -19,64 +23,68 @@ import static org.mockito.ArgumentMatchers.eq;
|
|||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test unitaire pour UpdateCheckService.
|
* Tests UpdateCheckService - approche semver (post-refactor v0.8.x).
|
||||||
*
|
*
|
||||||
* Couvre les invariants critiques de la detection de MAJ :
|
* Couvre :
|
||||||
* - feature desactivee si token absent
|
* - feature desactivee si WATCHTOWER_TOKEN absent
|
||||||
* - status UP_TO_DATE quand baseline == remote
|
* - UP_TO_DATE quand version locale == max(tags remote)
|
||||||
* - status UPDATE_AVAILABLE quand baseline != remote
|
* - UPDATE_AVAILABLE quand un tag plus eleve existe
|
||||||
* - status UNKNOWN quand baseline manque (PAS d'alignement lazy — invariant
|
* - UNKNOWN quand le registry echoue
|
||||||
* central, regression historique)
|
* - UNKNOWN quand BuildProperties est absent (currentVersion = null)
|
||||||
* - status UNKNOWN quand remote impossible a fetcher
|
* - parseSemver / findMaxSemver / compareSemver utilitaires
|
||||||
* - drapeaux top-level updateAvailable / anyUnknown coherents
|
|
||||||
* - back-compat : champ updateAvailable sur ImageStatus = (status == UPDATE_AVAILABLE)
|
|
||||||
*/
|
*/
|
||||||
public class UpdateCheckServiceTest {
|
public class UpdateCheckServiceTest {
|
||||||
|
|
||||||
private static UpdateCheckService newService(String token) {
|
private static UpdateCheckService newService(String token, String currentVersion) {
|
||||||
|
BuildProperties bp = null;
|
||||||
|
if (currentVersion != null) {
|
||||||
|
Properties p = new Properties();
|
||||||
|
p.setProperty("version", currentVersion);
|
||||||
|
bp = new BuildProperties(p);
|
||||||
|
}
|
||||||
|
// licenseService null : la beta est testee separement, ces tests
|
||||||
|
// couvrent uniquement le canal stable.
|
||||||
return new UpdateCheckService(
|
return new UpdateCheckService(
|
||||||
new RestTemplateBuilder(),
|
new RestTemplateBuilder(),
|
||||||
"ghcr.io",
|
"ghcr.io",
|
||||||
"igmlcreation/loremind-core,igmlcreation/loremind-brain",
|
"igmlcreation/loremind-core,igmlcreation/loremind-brain",
|
||||||
"latest",
|
|
||||||
"http://watchtower:8080",
|
"http://watchtower:8080",
|
||||||
token
|
token,
|
||||||
|
"",
|
||||||
|
null,
|
||||||
|
bp
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Injecte un RestTemplate moque dans le service deja construit, et pose
|
|
||||||
* directement les baselines pour eviter les vrais appels HTTP.
|
|
||||||
*/
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
private static void setBaselines(UpdateCheckService svc, Map<String, String> baselines) {
|
|
||||||
((Map<String, String>) ReflectionTestUtils.getField(svc, "baselineDigests")).putAll(baselines);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static RestTemplate stubHttp(UpdateCheckService svc) {
|
private static RestTemplate stubHttp(UpdateCheckService svc) {
|
||||||
RestTemplate http = mock(RestTemplate.class);
|
RestTemplate http = mock(RestTemplate.class);
|
||||||
ReflectionTestUtils.setField(svc, "http", http);
|
ReflectionTestUtils.setField(svc, "http", http);
|
||||||
return http;
|
return http;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void stubRemoteDigest(RestTemplate http, String image, String digest) {
|
private static void stubTags(RestTemplate http, String image, List<String> tags) {
|
||||||
HttpHeaders headers = new HttpHeaders();
|
TagsListResponse body = new TagsListResponse();
|
||||||
if (digest != null) headers.add("Docker-Content-Digest", digest);
|
body.name = image;
|
||||||
ResponseEntity<Void> resp = new ResponseEntity<>(headers, org.springframework.http.HttpStatus.OK);
|
body.tags = tags;
|
||||||
when(http.exchange(eq("https://ghcr.io/v2/" + image + "/manifests/latest"),
|
ResponseEntity<TagsListResponse> resp = new ResponseEntity<>(body, HttpStatus.OK);
|
||||||
eq(org.springframework.http.HttpMethod.HEAD), any(), eq(Void.class)))
|
when(http.exchange(eq("https://ghcr.io/v2/" + image + "/tags/list"),
|
||||||
|
eq(HttpMethod.GET), any(), eq(TagsListResponse.class)))
|
||||||
.thenReturn(resp);
|
.thenReturn(resp);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void stubRemoteFailure(RestTemplate http, String image) {
|
private static void stubTagsFailure(RestTemplate http, String image) {
|
||||||
when(http.exchange(eq("https://ghcr.io/v2/" + image + "/manifests/latest"),
|
when(http.exchange(eq("https://ghcr.io/v2/" + image + "/tags/list"),
|
||||||
eq(org.springframework.http.HttpMethod.HEAD), any(), eq(Void.class)))
|
eq(HttpMethod.GET), any(), eq(TagsListResponse.class)))
|
||||||
.thenThrow(new RuntimeException("network down"));
|
.thenThrow(new RuntimeException("network down"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Comportement du service
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void disabledWhenTokenMissing() {
|
void disabledWhenTokenMissing() {
|
||||||
UpdateCheckService svc = newService("");
|
UpdateCheckService svc = newService("", "0.8.0");
|
||||||
UpdateStatus status = svc.check();
|
UpdateStatus status = svc.check();
|
||||||
assertFalse(status.enabled());
|
assertFalse(status.enabled());
|
||||||
assertFalse(status.updateAvailable());
|
assertFalse(status.updateAvailable());
|
||||||
@@ -85,118 +93,153 @@ public class UpdateCheckServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void upToDate_whenBaselineEqualsRemote() {
|
void upToDate_whenCurrentEqualsMaxRemote() {
|
||||||
UpdateCheckService svc = newService("token");
|
UpdateCheckService svc = newService("token", "0.8.0");
|
||||||
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
|
|
||||||
setBaselines(svc, Map.of(
|
|
||||||
"igmlcreation/loremind-core", "sha256:aaa",
|
|
||||||
"igmlcreation/loremind-brain", "sha256:bbb"
|
|
||||||
));
|
|
||||||
RestTemplate http = stubHttp(svc);
|
RestTemplate http = stubHttp(svc);
|
||||||
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:aaa");
|
stubTags(http, "igmlcreation/loremind-core",
|
||||||
stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:bbb");
|
List.of("0.7.0", "0.7.1", "0.7.2", "0.8.0", "latest"));
|
||||||
|
stubTags(http, "igmlcreation/loremind-brain",
|
||||||
|
List.of("0.7.0", "0.8.0", "latest"));
|
||||||
|
|
||||||
UpdateStatus status = svc.check();
|
UpdateStatus status = svc.check();
|
||||||
|
|
||||||
assertTrue(status.enabled());
|
assertTrue(status.enabled());
|
||||||
assertFalse(status.updateAvailable());
|
assertFalse(status.updateAvailable());
|
||||||
assertFalse(status.anyUnknown());
|
assertFalse(status.anyUnknown());
|
||||||
|
assertEquals("0.8.0", status.currentVersion());
|
||||||
for (ImageStatus img : status.images()) {
|
for (ImageStatus img : status.images()) {
|
||||||
assertEquals(ImageStatusKind.UP_TO_DATE, img.status());
|
assertEquals(ImageStatusKind.UP_TO_DATE, img.status());
|
||||||
|
assertEquals("0.8.0", img.localVersion());
|
||||||
|
assertEquals("0.8.0", img.remoteVersion());
|
||||||
assertFalse(img.updateAvailable(), "back-compat bool");
|
assertFalse(img.updateAvailable(), "back-compat bool");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void updateAvailable_whenRemoteDiffers() {
|
void updateAvailable_whenRemoteHigher() {
|
||||||
UpdateCheckService svc = newService("token");
|
UpdateCheckService svc = newService("token", "0.7.2");
|
||||||
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
|
|
||||||
setBaselines(svc, Map.of(
|
|
||||||
"igmlcreation/loremind-core", "sha256:OLD",
|
|
||||||
"igmlcreation/loremind-brain", "sha256:bbb"
|
|
||||||
));
|
|
||||||
RestTemplate http = stubHttp(svc);
|
RestTemplate http = stubHttp(svc);
|
||||||
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:NEW");
|
stubTags(http, "igmlcreation/loremind-core",
|
||||||
stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:bbb");
|
List.of("0.7.0", "0.7.1", "0.7.2", "0.8.0", "latest"));
|
||||||
|
stubTags(http, "igmlcreation/loremind-brain",
|
||||||
|
List.of("0.7.2", "latest"));
|
||||||
|
|
||||||
UpdateStatus status = svc.check();
|
UpdateStatus status = svc.check();
|
||||||
|
|
||||||
assertTrue(status.updateAvailable());
|
assertTrue(status.updateAvailable());
|
||||||
assertFalse(status.anyUnknown());
|
assertFalse(status.anyUnknown());
|
||||||
|
|
||||||
ImageStatus core = status.images().stream()
|
ImageStatus core = status.images().stream()
|
||||||
.filter(i -> i.image().endsWith("core")).findFirst().orElseThrow();
|
.filter(i -> i.image().endsWith("core")).findFirst().orElseThrow();
|
||||||
assertEquals(ImageStatusKind.UPDATE_AVAILABLE, core.status());
|
assertEquals(ImageStatusKind.UPDATE_AVAILABLE, core.status());
|
||||||
|
assertEquals("0.7.2", core.localVersion());
|
||||||
|
assertEquals("0.8.0", core.remoteVersion());
|
||||||
assertTrue(core.updateAvailable(), "back-compat bool");
|
assertTrue(core.updateAvailable(), "back-compat bool");
|
||||||
|
|
||||||
ImageStatus brain = status.images().stream()
|
ImageStatus brain = status.images().stream()
|
||||||
.filter(i -> i.image().endsWith("brain")).findFirst().orElseThrow();
|
.filter(i -> i.image().endsWith("brain")).findFirst().orElseThrow();
|
||||||
assertEquals(ImageStatusKind.UP_TO_DATE, brain.status());
|
assertEquals(ImageStatusKind.UP_TO_DATE, brain.status());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void unknown_whenBaselineMissing_DOES_NOT_lazyAlign() {
|
void unknown_whenRegistryFails() {
|
||||||
// INVARIANT CENTRAL : si la baseline est absente (echec init au boot),
|
UpdateCheckService svc = newService("token", "0.8.0");
|
||||||
// on NE DOIT PAS aligner lazy sur le remote courant — sinon une MAJ
|
|
||||||
// pousse APRES le boot serait declaree "a jour" silencieusement.
|
|
||||||
UpdateCheckService svc = newService("token");
|
|
||||||
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
|
|
||||||
// baseline DELIBEREMENT vide
|
|
||||||
RestTemplate http = stubHttp(svc);
|
RestTemplate http = stubHttp(svc);
|
||||||
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:remote-now");
|
stubTagsFailure(http, "igmlcreation/loremind-core");
|
||||||
stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:remote-now-2");
|
stubTags(http, "igmlcreation/loremind-brain", List.of("0.8.0"));
|
||||||
|
|
||||||
|
UpdateStatus status = svc.check();
|
||||||
|
|
||||||
|
assertTrue(status.anyUnknown());
|
||||||
|
ImageStatus core = status.images().stream()
|
||||||
|
.filter(i -> i.image().endsWith("core")).findFirst().orElseThrow();
|
||||||
|
assertEquals(ImageStatusKind.UNKNOWN, core.status());
|
||||||
|
assertNull(core.remoteVersion());
|
||||||
|
assertEquals("0.8.0", core.localVersion());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknown_whenNoValidSemverTags() {
|
||||||
|
UpdateCheckService svc = newService("token", "0.8.0");
|
||||||
|
RestTemplate http = stubHttp(svc);
|
||||||
|
stubTags(http, "igmlcreation/loremind-core", List.of("latest", "stable", "main"));
|
||||||
|
stubTags(http, "igmlcreation/loremind-brain", List.of("0.8.0"));
|
||||||
|
|
||||||
|
UpdateStatus status = svc.check();
|
||||||
|
|
||||||
|
assertTrue(status.anyUnknown());
|
||||||
|
ImageStatus core = status.images().stream()
|
||||||
|
.filter(i -> i.image().endsWith("core")).findFirst().orElseThrow();
|
||||||
|
assertEquals(ImageStatusKind.UNKNOWN, core.status());
|
||||||
|
assertNull(core.remoteVersion());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknown_whenBuildPropertiesAbsent() {
|
||||||
|
// INVARIANT : pas de version courante => tout UNKNOWN, jamais "a jour"
|
||||||
|
// par defaut. Evite de declarer "a jour" un build dev sans build-info.
|
||||||
|
UpdateCheckService svc = newService("token", null);
|
||||||
|
RestTemplate http = stubHttp(svc);
|
||||||
|
// Meme si on stub des tags, le service doit bypass et renvoyer UNKNOWN
|
||||||
|
stubTags(http, "igmlcreation/loremind-core", List.of("0.8.0"));
|
||||||
|
|
||||||
UpdateStatus status = svc.check();
|
UpdateStatus status = svc.check();
|
||||||
|
|
||||||
assertTrue(status.enabled());
|
assertTrue(status.enabled());
|
||||||
assertFalse(status.updateAvailable());
|
assertFalse(status.updateAvailable());
|
||||||
assertTrue(status.anyUnknown());
|
assertTrue(status.anyUnknown());
|
||||||
|
assertNull(status.currentVersion());
|
||||||
for (ImageStatus img : status.images()) {
|
for (ImageStatus img : status.images()) {
|
||||||
assertEquals(ImageStatusKind.UNKNOWN, img.status());
|
assertEquals(ImageStatusKind.UNKNOWN, img.status());
|
||||||
assertNull(img.localDigest());
|
|
||||||
assertNotNull(img.remoteDigest()); // remote OK, baseline manquante
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// VERIFICATION CRITIQUE : la baseline ne doit PAS avoir ete posee.
|
// -----------------------------------------------------------------
|
||||||
@SuppressWarnings("unchecked")
|
// Utilitaires semver
|
||||||
Map<String, String> baselines = (Map<String, String>) ReflectionTestUtils.getField(svc, "baselineDigests");
|
// -----------------------------------------------------------------
|
||||||
assertTrue(baselines.isEmpty(),
|
|
||||||
"check() ne doit JAMAIS aligner lazy la baseline sur le remote — "
|
@Test
|
||||||
+ "regression de bug historique (faux negatif silencieux).");
|
void parseSemver_acceptsCommonFormats() {
|
||||||
|
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("0.8.0"));
|
||||||
|
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("v0.8.0"));
|
||||||
|
assertArrayEquals(new int[]{1, 0, 0}, UpdateCheckService.parseSemver("1.0.0"));
|
||||||
|
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("0.8.0-beta.1"));
|
||||||
|
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("0.8.0+build.42"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void unknown_whenRemoteFetchFails() {
|
void parseSemver_rejectsInvalid() {
|
||||||
UpdateCheckService svc = newService("token");
|
assertNull(UpdateCheckService.parseSemver(null));
|
||||||
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
|
assertNull(UpdateCheckService.parseSemver(""));
|
||||||
setBaselines(svc, Map.of("igmlcreation/loremind-core", "sha256:aaa",
|
assertNull(UpdateCheckService.parseSemver("latest"));
|
||||||
"igmlcreation/loremind-brain", "sha256:bbb"));
|
assertNull(UpdateCheckService.parseSemver("stable"));
|
||||||
RestTemplate http = stubHttp(svc);
|
assertNull(UpdateCheckService.parseSemver("0.8.0.1.2"));
|
||||||
stubRemoteFailure(http, "igmlcreation/loremind-core");
|
assertNull(UpdateCheckService.parseSemver("0.x.0"));
|
||||||
stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:bbb");
|
|
||||||
|
|
||||||
UpdateStatus status = svc.check();
|
|
||||||
|
|
||||||
assertFalse(status.updateAvailable());
|
|
||||||
assertTrue(status.anyUnknown());
|
|
||||||
ImageStatus core = status.images().stream()
|
|
||||||
.filter(i -> i.image().endsWith("core")).findFirst().orElseThrow();
|
|
||||||
assertEquals(ImageStatusKind.UNKNOWN, core.status());
|
|
||||||
assertNull(core.remoteDigest());
|
|
||||||
assertEquals("sha256:aaa", core.localDigest()); // baseline preservee
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void mixedStatuses_anyUnknownAndAnyUpdateBothTrue() {
|
void compareSemver_basic() {
|
||||||
UpdateCheckService svc = newService("token");
|
assertTrue(UpdateCheckService.compareSemver("0.7.2", "0.8.0") < 0);
|
||||||
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
|
assertTrue(UpdateCheckService.compareSemver("0.8.0", "0.7.2") > 0);
|
||||||
setBaselines(svc, Map.of("igmlcreation/loremind-core", "sha256:OLD"));
|
assertEquals(0, UpdateCheckService.compareSemver("0.8.0", "0.8.0"));
|
||||||
// brain n'a pas de baseline -> UNKNOWN
|
assertEquals(0, UpdateCheckService.compareSemver("v0.8.0", "0.8.0"));
|
||||||
RestTemplate http = stubHttp(svc);
|
assertTrue(UpdateCheckService.compareSemver("0.8.0", "0.10.0") < 0);
|
||||||
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:NEW");
|
assertTrue(UpdateCheckService.compareSemver("1.0.0", "0.99.99") > 0);
|
||||||
stubRemoteFailure(http, "igmlcreation/loremind-brain");
|
}
|
||||||
|
|
||||||
UpdateStatus status = svc.check();
|
@Test
|
||||||
|
void findMaxSemver_picksHighest() {
|
||||||
|
assertEquals("0.8.0", UpdateCheckService.findMaxSemver(
|
||||||
|
List.of("0.7.0", "0.7.1", "0.7.2", "0.8.0", "latest")));
|
||||||
|
assertEquals("0.10.0", UpdateCheckService.findMaxSemver(
|
||||||
|
List.of("0.8.0", "0.10.0", "0.9.5")));
|
||||||
|
assertEquals("v1.0.0", UpdateCheckService.findMaxSemver(
|
||||||
|
List.of("v0.8.0", "v1.0.0", "latest")));
|
||||||
|
}
|
||||||
|
|
||||||
assertTrue(status.updateAvailable(), "core a une MAJ disponible");
|
@Test
|
||||||
assertTrue(status.anyUnknown(), "brain est UNKNOWN");
|
void findMaxSemver_returnsNullWhenNoValidTag() {
|
||||||
|
assertNull(UpdateCheckService.findMaxSemver(List.of("latest", "stable", "main")));
|
||||||
|
assertNull(UpdateCheckService.findMaxSemver(List.of()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package com.loremind.infrastructure.web.controller;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
|
||||||
|
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.MvcResult;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests d'integration du GameSystemController centres sur la persistance
|
||||||
|
* des templates PJ/PNJ via l'API REST. Le CRUD de base est suppose stable.
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@Transactional
|
||||||
|
class GameSystemControllerTest {
|
||||||
|
|
||||||
|
@Autowired private MockMvc mockMvc;
|
||||||
|
@Autowired private ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_persistsCharacterAndNpcTemplates() throws Exception {
|
||||||
|
GameSystemDTO dto = new GameSystemDTO();
|
||||||
|
dto.setName("Nimble Test");
|
||||||
|
dto.setRulesMarkdown("## Combat\n- d20");
|
||||||
|
dto.setCharacterTemplate(List.of(
|
||||||
|
new TemplateFieldDTO("Histoire", "TEXT", null),
|
||||||
|
new TemplateFieldDTO("PV", "NUMBER", null),
|
||||||
|
new TemplateFieldDTO("Portrait", "IMAGE", "HERO")));
|
||||||
|
dto.setNpcTemplate(List.of(
|
||||||
|
new TemplateFieldDTO("Motivation", "TEXT", null)));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/game-systems")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(dto)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.id").exists())
|
||||||
|
.andExpect(jsonPath("$.characterTemplate.length()").value(3))
|
||||||
|
.andExpect(jsonPath("$.characterTemplate[1].type").value("NUMBER"))
|
||||||
|
.andExpect(jsonPath("$.characterTemplate[2].layout").value("HERO"))
|
||||||
|
.andExpect(jsonPath("$.npcTemplate.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.npcTemplate[0].name").value("Motivation"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void update_replacesTemplates() throws Exception {
|
||||||
|
// Creation initiale avec un seul champ.
|
||||||
|
GameSystemDTO dto = new GameSystemDTO();
|
||||||
|
dto.setName("RuleSet");
|
||||||
|
dto.setCharacterTemplate(List.of(new TemplateFieldDTO("Old", "TEXT", null)));
|
||||||
|
|
||||||
|
MvcResult posted = mockMvc.perform(post("/api/game-systems")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(dto)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andReturn();
|
||||||
|
|
||||||
|
GameSystemDTO created = objectMapper.readValue(
|
||||||
|
posted.getResponse().getContentAsString(), GameSystemDTO.class);
|
||||||
|
|
||||||
|
// Replace template integralement.
|
||||||
|
created.setCharacterTemplate(List.of(
|
||||||
|
new TemplateFieldDTO("Histoire", "TEXT", null),
|
||||||
|
new TemplateFieldDTO("Niveau", "NUMBER", null)));
|
||||||
|
|
||||||
|
mockMvc.perform(put("/api/game-systems/{id}", created.getId())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(created)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.characterTemplate.length()").value(2))
|
||||||
|
.andExpect(jsonPath("$.characterTemplate[0].name").value("Histoire"))
|
||||||
|
.andExpect(jsonPath("$.characterTemplate[1].type").value("NUMBER"));
|
||||||
|
|
||||||
|
// Verification que le GET independant retourne bien les nouveaux champs (pas de cache stale).
|
||||||
|
mockMvc.perform(get("/api/game-systems/{id}", created.getId()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.characterTemplate[?(@.name == 'Old')]").doesNotExist())
|
||||||
|
.andExpect(jsonPath("$.characterTemplate[?(@.name == 'Histoire')]").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_rejectsDuplicateFieldNames() throws Exception {
|
||||||
|
GameSystemDTO dto = new GameSystemDTO();
|
||||||
|
dto.setName("BadRules");
|
||||||
|
dto.setCharacterTemplate(List.of(
|
||||||
|
new TemplateFieldDTO("Nom", "TEXT", null),
|
||||||
|
new TemplateFieldDTO("nom", "NUMBER", null)));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/game-systems")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(dto)))
|
||||||
|
.andExpect(status().is4xxClientError());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import com.loremind.domain.lorecontext.Template;
|
|||||||
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
||||||
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||||
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
|
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
|
||||||
import com.loremind.infrastructure.web.dto.lorecontext.TemplateFieldDTO;
|
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
|||||||
@@ -94,6 +94,19 @@ services:
|
|||||||
UPDATE_CHECK_TAG: ${TAG:-latest}
|
UPDATE_CHECK_TAG: ${TAG:-latest}
|
||||||
WATCHTOWER_URL: http://watchtower:8080
|
WATCHTOWER_URL: http://watchtower:8080
|
||||||
WATCHTOWER_TOKEN: ${WATCHTOWER_TOKEN:-}
|
WATCHTOWER_TOKEN: ${WATCHTOWER_TOKEN:-}
|
||||||
|
# Licensing : la cle publique JWT est embarquee dans le binaire
|
||||||
|
# (core/src/main/resources/licensing/jwt-public-key.pem).
|
||||||
|
# LICENSING_JWT_PUBLIC_KEY est un override optionnel (rotation de cle
|
||||||
|
# sans rebuild) - non defini par defaut.
|
||||||
|
LICENSING_JWT_PUBLIC_KEY: ${LICENSING_JWT_PUBLIC_KEY:-}
|
||||||
|
LICENSING_RELAY_BASE_URL: ${LICENSING_RELAY_BASE_URL:-https://loremind-auth.igmlcreation.fr}
|
||||||
|
# Chemin du docker config.json partage avec Watchtower
|
||||||
|
LICENSING_DOCKER_CONFIG_PATH: /shared/docker/config.json
|
||||||
|
volumes:
|
||||||
|
# Volume partage avec Watchtower : Core ecrit les credentials registry
|
||||||
|
# GHCR (recus du relais) ici, Watchtower les utilise pour pull les images
|
||||||
|
# privees du canal beta. Pas de creds = no-op.
|
||||||
|
- docker-config:/shared/docker
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# Ollama embarque (option par defaut pour les utilisateurs sans Ollama installe).
|
# Ollama embarque (option par defaut pour les utilisateurs sans Ollama installe).
|
||||||
@@ -169,7 +182,14 @@ services:
|
|||||||
profiles: ["autoupdate"]
|
profiles: ["autoupdate"]
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
# Volume partage avec Core : credentials registry GHCR (canal beta).
|
||||||
|
# Watchtower lit le config.json depuis DOCKER_CONFIG.
|
||||||
|
- docker-config:/shared/docker
|
||||||
environment:
|
environment:
|
||||||
|
# Indique a Watchtower (et au CLI Docker embarque) ou trouver le
|
||||||
|
# config.json. Active automatiquement l'auth GHCR pour les images
|
||||||
|
# du canal beta des que Core a ecrit le fichier.
|
||||||
|
DOCKER_CONFIG: /shared/docker
|
||||||
WATCHTOWER_LABEL_ENABLE: "true"
|
WATCHTOWER_LABEL_ENABLE: "true"
|
||||||
WATCHTOWER_CLEANUP: "true"
|
WATCHTOWER_CLEANUP: "true"
|
||||||
WATCHTOWER_INCLUDE_RESTARTING: "true"
|
WATCHTOWER_INCLUDE_RESTARTING: "true"
|
||||||
@@ -191,3 +211,6 @@ volumes:
|
|||||||
minio-data:
|
minio-data:
|
||||||
brain-data:
|
brain-data:
|
||||||
ollama-data:
|
ollama-data:
|
||||||
|
# Volume partage Core <-> Watchtower : config.json Docker pour
|
||||||
|
# l'authentification au registry prive GHCR (canal beta Patreon).
|
||||||
|
docker-config:
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
Auteur : ietm64
|
Auteur : ietm64
|
||||||
Licence : AGPL-3.0
|
Licence : AGPL-3.0
|
||||||
Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR
|
Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR
|
||||||
Version : 0.7.2
|
Version : 0.8.3
|
||||||
|
|
||||||
.LINK
|
.LINK
|
||||||
https://github.com/IGMLcreation/LoreMind
|
https://github.com/IGMLcreation/LoreMind
|
||||||
|
|||||||
@@ -355,3 +355,55 @@ export async function getTemplateById(
|
|||||||
expect(res.ok(), `GET /api/templates/${templateId} -> ${res.status()}`).toBeTruthy();
|
expect(res.ok(), `GET /api/templates/${templateId} -> ${res.status()}`).toBeTruthy();
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────── GameSystem ───────────────
|
||||||
|
|
||||||
|
export interface SeededGameSystem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedGameSystem(
|
||||||
|
request: APIRequestContext,
|
||||||
|
opts: { name?: string; description?: string; author?: string; rulesMarkdown?: string } = {},
|
||||||
|
): Promise<SeededGameSystem> {
|
||||||
|
const name = opts.name ?? `E2E GameSystem ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||||
|
const res = await request.post('/api/game-systems', {
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
description: opts.description ?? null,
|
||||||
|
author: opts.author ?? null,
|
||||||
|
rulesMarkdown: opts.rulesMarkdown ?? null,
|
||||||
|
characterTemplate: [],
|
||||||
|
npcTemplate: [],
|
||||||
|
isPublic: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok(), `POST /api/game-systems -> ${res.status()}`).toBeTruthy();
|
||||||
|
const gs = await res.json();
|
||||||
|
return { id: gs.id, name };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteGameSystem(
|
||||||
|
request: APIRequestContext,
|
||||||
|
id: string,
|
||||||
|
): Promise<void> {
|
||||||
|
// Best-effort : ignore 404 si déjà supprimé par le test (ex: delete spec).
|
||||||
|
await request.delete(`/api/game-systems/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGameSystemById(
|
||||||
|
request: APIRequestContext,
|
||||||
|
id: string,
|
||||||
|
): Promise<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
author: string | null;
|
||||||
|
rulesMarkdown: string | null;
|
||||||
|
isPublic: boolean;
|
||||||
|
}> {
|
||||||
|
const res = await request.get(`/api/game-systems/${id}`);
|
||||||
|
expect(res.ok(), `GET /api/game-systems/${id} -> ${res.status()}`).toBeTruthy();
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,10 +24,12 @@ test.describe('Arc delete', () => {
|
|||||||
page,
|
page,
|
||||||
request,
|
request,
|
||||||
}) => {
|
}) => {
|
||||||
page.on('dialog', (dialog) => dialog.accept());
|
|
||||||
|
|
||||||
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
||||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
||||||
|
|
||||||
@@ -36,10 +38,12 @@ test.describe('Arc delete', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('keeps the arc when confirm is dismissed', async ({ page, request }) => {
|
test('keeps the arc when confirm is dismissed', async ({ page, request }) => {
|
||||||
page.on('dialog', (dialog) => dialog.dismiss());
|
|
||||||
|
|
||||||
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
||||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}/edit$`));
|
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}/edit$`));
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ test.describe('Arc edit', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
||||||
|
// Attend que le formulaire soit prerempli par le ngOnInit (HTTP async) avant
|
||||||
|
// de fill — sinon le patchValue du load arrive APRES nos fills et ecrase
|
||||||
|
// les valeurs, le test echoue alors a la verif persisted.name.
|
||||||
|
await expect(page.getByLabel(/Titre de l'arc/i)).toHaveValue(arc.name);
|
||||||
|
|
||||||
await page.getByLabel(/Titre de l'arc/i).fill(newName);
|
await page.getByLabel(/Titre de l'arc/i).fill(newName);
|
||||||
await page.getByLabel(/Synopsis de l'arc/i).fill(values.description);
|
await page.getByLabel(/Synopsis de l'arc/i).fill(values.description);
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ test.describe('Campaign delete', () => {
|
|||||||
page,
|
page,
|
||||||
request,
|
request,
|
||||||
}) => {
|
}) => {
|
||||||
page.on('dialog', (dialog) => dialog.accept());
|
|
||||||
|
|
||||||
await page.goto(`/campaigns/${campaign.id}`);
|
await page.goto(`/campaigns/${campaign.id}`);
|
||||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/campaigns$/);
|
await expect(page).toHaveURL(/\/campaigns$/);
|
||||||
|
|
||||||
@@ -28,10 +30,12 @@ test.describe('Campaign delete', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('keeps the campaign when confirm is dismissed', async ({ page, request }) => {
|
test('keeps the campaign when confirm is dismissed', async ({ page, request }) => {
|
||||||
page.on('dialog', (dialog) => dialog.dismiss());
|
|
||||||
|
|
||||||
await page.goto(`/campaigns/${campaign.id}`);
|
await page.goto(`/campaigns/${campaign.id}`);
|
||||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
||||||
|
|
||||||
|
|||||||
@@ -17,20 +17,22 @@ test.describe('NPC creation', () => {
|
|||||||
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('creates an NPC and redirects back to the campaign', async ({ page, request }) => {
|
test('creates an NPC and redirects to the NPC detail page', async ({ page, request }) => {
|
||||||
|
// Note : depuis la refonte 2026-04-30 les fiches PNJ utilisent des champs
|
||||||
|
// templates dynamiques pilotes par le GameSystem (plus de markdownContent
|
||||||
|
// libre). La campagne seedee n'a pas de GameSystem donc on ne fill que le
|
||||||
|
// nom — c'est suffisant pour valider la creation + la redirection.
|
||||||
const npcName = `Borin le forgeron ${Date.now()}`;
|
const npcName = `Borin le forgeron ${Date.now()}`;
|
||||||
const markdown = '# Borin\n\n**Faction :** Clan Feuillefer\n\nNain barbu au regard perçant.';
|
|
||||||
|
|
||||||
await page.goto(`/campaigns/${campaign.id}/npcs/create`);
|
await page.goto(`/campaigns/${campaign.id}/npcs/create`);
|
||||||
await expect(page.getByRole('heading', { name: /Nouveau PNJ/i })).toBeVisible();
|
await expect(page.getByRole('heading', { name: /Nouveau PNJ/i })).toBeVisible();
|
||||||
|
|
||||||
await page.getByLabel(/Nom du PNJ/i).fill(npcName);
|
await page.getByLabel(/Nom du PNJ/i).fill(npcName);
|
||||||
await page.getByLabel(/Fiche \(markdown\)/i).fill(markdown);
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /^Créer$/i }).click();
|
await page.getByRole('button', { name: /^Créer$/i }).click();
|
||||||
|
|
||||||
// Retour à la page campagne après création
|
// Redirection vers la fiche du PNJ après création
|
||||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/npcs/\\d+$`));
|
||||||
|
|
||||||
// Persistance vérifiée via API
|
// Persistance vérifiée via API
|
||||||
const npcs = await getNpcsByCampaign(request, campaign.id);
|
const npcs = await getNpcsByCampaign(request, campaign.id);
|
||||||
@@ -58,7 +60,7 @@ test.describe('NPC creation', () => {
|
|||||||
await page.getByLabel(/Nom du PNJ/i).fill(npcName);
|
await page.getByLabel(/Nom du PNJ/i).fill(npcName);
|
||||||
await page.getByRole('button', { name: /^Créer$/i }).click();
|
await page.getByRole('button', { name: /^Créer$/i }).click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/npcs/\\d+$`));
|
||||||
|
|
||||||
// Le nœud "PNJ" doit apparaître dans la sidebar avec le nouveau PNJ.
|
// Le nœud "PNJ" doit apparaître dans la sidebar avec le nouveau PNJ.
|
||||||
// On clique sur le nœud PNJ pour le déplier au cas où il serait fermé,
|
// On clique sur le nœud PNJ pour le déplier au cas où il serait fermé,
|
||||||
|
|||||||
@@ -14,19 +14,19 @@ test.describe('NPC edit', () => {
|
|||||||
|
|
||||||
test.beforeEach(async ({ request }) => {
|
test.beforeEach(async ({ request }) => {
|
||||||
campaign = await seedCampaign(request);
|
campaign = await seedCampaign(request);
|
||||||
npc = await seedNpc(request, {
|
npc = await seedNpc(request, { campaignId: campaign.id });
|
||||||
campaignId: campaign.id,
|
|
||||||
markdownContent: '# Initial\n\nFiche de départ.',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterEach(async ({ request }) => {
|
test.afterEach(async ({ request }) => {
|
||||||
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('edits name + markdown content and persists via API', async ({ page, request }) => {
|
test('edits name and persists via API', async ({ page, request }) => {
|
||||||
|
// Note : depuis la refonte 2026-04-30 les fiches PNJ utilisent des champs
|
||||||
|
// templates dynamiques pilotes par le GameSystem, plus le markdownContent
|
||||||
|
// libre. La campagne seedee n'a pas de GameSystem donc pas de champs
|
||||||
|
// dynamiques a tester ici — on se contente du nom (champ universel).
|
||||||
const newName = `${npc.name} (renommé)`;
|
const newName = `${npc.name} (renommé)`;
|
||||||
const newMarkdown = '# Borin réécrit\n\n**Statut :** Disparu\n\nDes traces dans la neige...';
|
|
||||||
|
|
||||||
await page.goto(`/campaigns/${campaign.id}/npcs/${npc.id}/edit`);
|
await page.goto(`/campaigns/${campaign.id}/npcs/${npc.id}/edit`);
|
||||||
|
|
||||||
@@ -34,7 +34,6 @@ test.describe('NPC edit', () => {
|
|||||||
await expect(page.getByLabel(/Nom du PNJ/i)).toHaveValue(npc.name);
|
await expect(page.getByLabel(/Nom du PNJ/i)).toHaveValue(npc.name);
|
||||||
|
|
||||||
await page.getByLabel(/Nom du PNJ/i).fill(newName);
|
await page.getByLabel(/Nom du PNJ/i).fill(newName);
|
||||||
await page.getByLabel(/Fiche \(markdown\)/i).fill(newMarkdown);
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /^Enregistrer$/i }).click();
|
await page.getByRole('button', { name: /^Enregistrer$/i }).click();
|
||||||
|
|
||||||
@@ -43,7 +42,6 @@ test.describe('NPC edit', () => {
|
|||||||
|
|
||||||
const persisted = await getNpcById(request, npc.id);
|
const persisted = await getNpcById(request, npc.id);
|
||||||
expect(persisted.name).toBe(newName);
|
expect(persisted.name).toBe(newName);
|
||||||
expect(persisted.markdownContent).toBe(newMarkdown);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('save button is disabled when name is cleared', async ({ page }) => {
|
test('save button is disabled when name is cleared', async ({ page }) => {
|
||||||
|
|||||||
74
web/e2e/tests/game-system/game-system-create.spec.ts
Normal file
74
web/e2e/tests/game-system/game-system-create.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { deleteGameSystem } from '../../fixtures/api';
|
||||||
|
|
||||||
|
test.describe('GameSystem creation', () => {
|
||||||
|
// Les game systems crees par les tests sont nettoyes via cet array — chaque
|
||||||
|
// test pousse les IDs qu'il a crees pour qu'on les supprime en afterEach.
|
||||||
|
const createdIds: string[] = [];
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
while (createdIds.length) {
|
||||||
|
const id = createdIds.pop()!;
|
||||||
|
await deleteGameSystem(request, id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates a game system and redirects to the list', async ({ page, request }) => {
|
||||||
|
const gsName = `Système E2E ${Date.now()}`;
|
||||||
|
const description = 'Système créé par les tests automatisés.';
|
||||||
|
const author = 'Playwright';
|
||||||
|
|
||||||
|
await page.goto('/game-systems');
|
||||||
|
await expect(page.getByRole('heading', { name: /Systèmes de JDR/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Carte "Nouveau système" → ouvre l'editeur en mode creation.
|
||||||
|
await page.locator('.gs-card.card-new').click();
|
||||||
|
await expect(page).toHaveURL(/\/game-systems\/create$/);
|
||||||
|
await expect(page.getByRole('heading', { name: /Nouveau système de JDR/i })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel(/^Nom/i).fill(gsName);
|
||||||
|
await page.getByLabel(/Description courte/i).fill(description);
|
||||||
|
await page.getByLabel(/Auteur/i).fill(author);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Créer$/i }).click();
|
||||||
|
|
||||||
|
// Redirection vers la liste apres creation.
|
||||||
|
await expect(page).toHaveURL(/\/game-systems$/);
|
||||||
|
// Et la carte du nouveau systeme est visible dans la grille.
|
||||||
|
await expect(page.locator('.gs-card', { hasText: gsName })).toBeVisible();
|
||||||
|
|
||||||
|
// Verification API : le systeme est bien persistant.
|
||||||
|
const all = await request.get('/api/game-systems').then((r) => r.json());
|
||||||
|
const created = all.find((gs: { id: string; name: string }) => gs.name === gsName);
|
||||||
|
expect(created).toBeDefined();
|
||||||
|
expect(created.author).toBe(author);
|
||||||
|
createdIds.push(created.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('submit button is disabled when name is empty', async ({ page }) => {
|
||||||
|
await page.goto('/game-systems/create');
|
||||||
|
|
||||||
|
const submit = page.getByRole('button', { name: /^Créer$/i });
|
||||||
|
await expect(submit).toBeDisabled();
|
||||||
|
|
||||||
|
await page.getByLabel(/^Nom/i).fill('Quelque chose');
|
||||||
|
await expect(submit).toBeEnabled();
|
||||||
|
|
||||||
|
await page.getByLabel(/^Nom/i).fill(' ');
|
||||||
|
await expect(submit).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancel returns to the list without creating', async ({ page, request }) => {
|
||||||
|
const abandoned = `Système abandonné ${Date.now()}`;
|
||||||
|
|
||||||
|
await page.goto('/game-systems/create');
|
||||||
|
await page.getByLabel(/^Nom/i).fill(abandoned);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Annuler$/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/game-systems$/);
|
||||||
|
|
||||||
|
// Rien n'a ete cree cote API.
|
||||||
|
const all = await request.get('/api/game-systems').then((r) => r.json());
|
||||||
|
expect(all.find((gs: { name: string }) => gs.name === abandoned)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
61
web/e2e/tests/game-system/game-system-delete.spec.ts
Normal file
61
web/e2e/tests/game-system/game-system-delete.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
seedGameSystem,
|
||||||
|
deleteGameSystem,
|
||||||
|
type SeededGameSystem,
|
||||||
|
} from '../../fixtures/api';
|
||||||
|
|
||||||
|
test.describe('GameSystem delete', () => {
|
||||||
|
let gs: SeededGameSystem;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
gs = await seedGameSystem(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
// Best-effort cleanup — ne fait rien si deja supprime par le test.
|
||||||
|
if (gs?.id) await deleteGameSystem(request, gs.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deletes a game system after confirming and removes it from the list', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/game-systems');
|
||||||
|
|
||||||
|
const card = page.locator('.gs-card', { hasText: gs.name });
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
|
||||||
|
// Bouton corbeille dans le coin de la carte du systeme seede.
|
||||||
|
await card.locator('.icon-btn').click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(dialog).toContainText(gs.name);
|
||||||
|
|
||||||
|
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||||
|
|
||||||
|
// La carte disparait apres reload de la liste.
|
||||||
|
await expect(page.locator('.gs-card', { hasText: gs.name })).toHaveCount(0);
|
||||||
|
|
||||||
|
const res = await request.get(`/api/game-systems/${gs.id}`);
|
||||||
|
expect(res.status()).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the game system when cancel is clicked', async ({ page, request }) => {
|
||||||
|
await page.goto('/game-systems');
|
||||||
|
|
||||||
|
const card = page.locator('.gs-card', { hasText: gs.name });
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
await card.locator('.icon-btn').click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
|
||||||
|
|
||||||
|
// La carte est toujours la, le systeme est toujours en base.
|
||||||
|
await expect(page.locator('.gs-card', { hasText: gs.name })).toBeVisible();
|
||||||
|
const res = await request.get(`/api/game-systems/${gs.id}`);
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
68
web/e2e/tests/game-system/game-system-edit.spec.ts
Normal file
68
web/e2e/tests/game-system/game-system-edit.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
seedGameSystem,
|
||||||
|
deleteGameSystem,
|
||||||
|
getGameSystemById,
|
||||||
|
type SeededGameSystem,
|
||||||
|
} from '../../fixtures/api';
|
||||||
|
|
||||||
|
test.describe('GameSystem edit', () => {
|
||||||
|
let gs: SeededGameSystem;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
gs = await seedGameSystem(request, {
|
||||||
|
description: 'Description initiale.',
|
||||||
|
author: 'Auteur initial',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
if (gs?.id) await deleteGameSystem(request, gs.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('form is prefilled with the game system data', async ({ page }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: /Éditer le système/i })).toBeVisible();
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
await expect(page.getByLabel(/Description courte/i)).toHaveValue('Description initiale.');
|
||||||
|
await expect(page.getByLabel(/Auteur/i)).toHaveValue('Auteur initial');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('edits name and description and persists them to API', async ({ page, request }) => {
|
||||||
|
const newName = `${gs.name} renamed`;
|
||||||
|
const newDescription = 'Description mise à jour par le test.';
|
||||||
|
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
|
||||||
|
// Attente que le formulaire soit prerempli avant de fill — sinon le load
|
||||||
|
// async ecrase les valeurs filled (cf. bug arc-edit corrige).
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
await page.getByLabel(/^Nom/i).fill(newName);
|
||||||
|
await page.getByLabel(/Description courte/i).fill(newDescription);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Enregistrer$/i }).click();
|
||||||
|
|
||||||
|
// Retour a la liste apres save.
|
||||||
|
await expect(page).toHaveURL(/\/game-systems$/);
|
||||||
|
|
||||||
|
const persisted = await getGameSystemById(request, gs.id);
|
||||||
|
expect(persisted.name).toBe(newName);
|
||||||
|
expect(persisted.description).toBe(newDescription);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('save button is disabled when name is cleared', async ({ page }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
const nameField = page.getByLabel(/^Nom/i);
|
||||||
|
const saveBtn = page.getByRole('button', { name: /^Enregistrer$/i });
|
||||||
|
|
||||||
|
await expect(saveBtn).toBeEnabled();
|
||||||
|
await nameField.fill('');
|
||||||
|
await expect(saveBtn).toBeDisabled();
|
||||||
|
await nameField.fill('OK');
|
||||||
|
await expect(saveBtn).toBeEnabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
111
web/e2e/tests/game-system/game-system-sections.spec.ts
Normal file
111
web/e2e/tests/game-system/game-system-sections.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
seedGameSystem,
|
||||||
|
deleteGameSystem,
|
||||||
|
type SeededGameSystem,
|
||||||
|
} from '../../fixtures/api';
|
||||||
|
|
||||||
|
test.describe('GameSystem rule sections editor', () => {
|
||||||
|
let gs: SeededGameSystem;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
// On part d'un GameSystem vide (pas de regles seedees) — chaque test gere
|
||||||
|
// ses propres ajouts pour eviter les couplages.
|
||||||
|
gs = await seedGameSystem(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
if (gs?.id) await deleteGameSystem(request, gs.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds a suggested section, fills it, and persists it', async ({ page, request }) => {
|
||||||
|
const sectionContent = 'Initiative à d20, action+bonus+mouvement, dégâts par dés.';
|
||||||
|
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
// Attendre le chargement du form (nom prerempli).
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
// Empty state visible tant qu'aucune section n'est ajoutee.
|
||||||
|
await expect(page.locator('.section-list .empty-hint')).toBeVisible();
|
||||||
|
|
||||||
|
// Ajout via la chip suggeree "Combat".
|
||||||
|
await page.locator('.add-row .chip', { hasText: 'Combat' }).click();
|
||||||
|
|
||||||
|
// Une section-card est apparue avec titre "Combat" prerempli + textarea visible.
|
||||||
|
const card = page.locator('.section-card').first();
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
await expect(card.locator('.section-title-input')).toHaveValue('Combat');
|
||||||
|
await card.locator('.section-content').fill(sectionContent);
|
||||||
|
|
||||||
|
// Save + retour a la liste.
|
||||||
|
await page.getByRole('button', { name: /^Enregistrer$/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/game-systems$/);
|
||||||
|
|
||||||
|
// Verification cote API : le markdown contient bien la section + son contenu.
|
||||||
|
const persisted = await request.get(`/api/game-systems/${gs.id}`).then((r) => r.json());
|
||||||
|
expect(persisted.rulesMarkdown).toContain('## Combat');
|
||||||
|
expect(persisted.rulesMarkdown).toContain(sectionContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disables a suggested chip after it has been used', async ({ page }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
const combatChip = page.locator('.add-row .chip', { hasText: 'Combat' });
|
||||||
|
await expect(combatChip).toBeEnabled();
|
||||||
|
|
||||||
|
await combatChip.click();
|
||||||
|
|
||||||
|
// Apres ajout, la chip "Combat" est desactivee (suggestion deja utilisee).
|
||||||
|
await expect(combatChip).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds a custom blank section via "Autre…" and lets the user name it', async ({ page }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
await page.locator('.add-row .chip-custom', { hasText: /Autre/i }).click();
|
||||||
|
|
||||||
|
// Section vierge ajoutee : titre vide, prete a remplir.
|
||||||
|
const card = page.locator('.section-card').first();
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
const titleInput = card.locator('.section-title-input');
|
||||||
|
await expect(titleInput).toHaveValue('');
|
||||||
|
await titleInput.fill('Sorts');
|
||||||
|
await expect(titleInput).toHaveValue('Sorts');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removes a section', async ({ page }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
await page.locator('.add-row .chip', { hasText: 'Combat' }).click();
|
||||||
|
await page.locator('.add-row .chip', { hasText: 'Classes' }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('.section-card')).toHaveCount(2);
|
||||||
|
|
||||||
|
// Supprime la premiere section (Combat).
|
||||||
|
await page.locator('.section-card').first().locator('.btn-remove').click();
|
||||||
|
await expect(page.locator('.section-card')).toHaveCount(1);
|
||||||
|
await expect(page.locator('.section-card').first().locator('.section-title-input')).toHaveValue('Classes');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collapses and expands a section', async ({ page }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
await page.locator('.add-row .chip', { hasText: 'Combat' }).click();
|
||||||
|
const card = page.locator('.section-card').first();
|
||||||
|
|
||||||
|
// Par defaut deployee : textarea visible.
|
||||||
|
await expect(card.locator('.section-content')).toBeVisible();
|
||||||
|
|
||||||
|
// Clic sur le bouton collapse → textarea masquee.
|
||||||
|
await card.locator('.btn-collapse').click();
|
||||||
|
await expect(card.locator('.section-content')).toHaveCount(0);
|
||||||
|
|
||||||
|
// Re-clic → re-deployee.
|
||||||
|
await card.locator('.btn-collapse').click();
|
||||||
|
await expect(card.locator('.section-content')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
151
web/e2e/tests/game-system/game-system-templates.spec.ts
Normal file
151
web/e2e/tests/game-system/game-system-templates.spec.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { test, expect, Page } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
seedGameSystem,
|
||||||
|
deleteGameSystem,
|
||||||
|
type SeededGameSystem,
|
||||||
|
} from '../../fixtures/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests du composant <app-template-fields-editor> dans le contexte GameSystem.
|
||||||
|
*
|
||||||
|
* Le composant est instancie DEUX fois sur la page d'edition d'un GameSystem
|
||||||
|
* (une fois pour PJ "characterTemplate", une fois pour PNJ "npcTemplate"), donc
|
||||||
|
* les selecteurs doivent etre scopes a l'instance ciblee. On utilise un helper
|
||||||
|
* `tfe(label)` qui renvoie le locator de l'editeur correspondant au titre.
|
||||||
|
*/
|
||||||
|
test.describe('GameSystem template fields editor (PJ / PNJ)', () => {
|
||||||
|
let gs: SeededGameSystem;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
gs = await seedGameSystem(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
if (gs?.id) await deleteGameSystem(request, gs.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Helper : retourne le locator de l'editeur de templates par son label. */
|
||||||
|
const tfe = (page: Page, label: 'PJ' | 'PNJ') =>
|
||||||
|
page.locator('.tfe').filter({ hasText: `Champs de la fiche ${label}` });
|
||||||
|
|
||||||
|
test('adds a suggested field to the PJ template and persists it', async ({ page, request }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
const pjEditor = tfe(page, 'PJ');
|
||||||
|
await expect(pjEditor).toBeVisible();
|
||||||
|
|
||||||
|
// Ajout de "Histoire" via la chip suggeree.
|
||||||
|
await pjEditor.locator('.tfe-add .chip', { hasText: 'Histoire' }).click();
|
||||||
|
|
||||||
|
// Une row apparait avec le nom prerempli.
|
||||||
|
const row = pjEditor.locator('.tfe-item').first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await expect(row.locator('.tfe-name')).toHaveValue('Histoire');
|
||||||
|
|
||||||
|
// Save → retour a la liste.
|
||||||
|
await page.getByRole('button', { name: /^Enregistrer$/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/game-systems$/);
|
||||||
|
|
||||||
|
// Verification API : le champ est bien dans characterTemplate.
|
||||||
|
const persisted = await request.get(`/api/game-systems/${gs.id}`).then((r) => r.json());
|
||||||
|
expect(persisted.characterTemplate).toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ name: 'Histoire' })]),
|
||||||
|
);
|
||||||
|
// npcTemplate non touche (toujours vide).
|
||||||
|
expect(persisted.npcTemplate ?? []).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds a custom NUMBER field via "Nombre" chip', async ({ page }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
const pjEditor = tfe(page, 'PJ');
|
||||||
|
await pjEditor.locator('.tfe-add .chip-custom', { hasText: 'Nombre' }).click();
|
||||||
|
|
||||||
|
const row = pjEditor.locator('.tfe-item').first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
// Champ vide, nom a remplir, type "NUMBER" pre-selectionne dans le select.
|
||||||
|
await expect(row.locator('.tfe-name')).toHaveValue('');
|
||||||
|
await expect(row.locator('.tfe-type')).toHaveValue('NUMBER');
|
||||||
|
|
||||||
|
await row.locator('.tfe-name').fill('Points de vie');
|
||||||
|
await expect(row.locator('.tfe-name')).toHaveValue('Points de vie');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PJ and PNJ editors are independent (adding to one does not affect the other)', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
await tfe(page, 'PJ').locator('.tfe-add .chip', { hasText: 'Histoire' }).click();
|
||||||
|
await tfe(page, 'PNJ').locator('.tfe-add .chip', { hasText: 'Motivation' }).click();
|
||||||
|
|
||||||
|
await expect(tfe(page, 'PJ').locator('.tfe-item')).toHaveCount(1);
|
||||||
|
await expect(tfe(page, 'PNJ').locator('.tfe-item')).toHaveCount(1);
|
||||||
|
await expect(tfe(page, 'PJ').locator('.tfe-item').first().locator('.tfe-name')).toHaveValue('Histoire');
|
||||||
|
await expect(tfe(page, 'PNJ').locator('.tfe-item').first().locator('.tfe-name')).toHaveValue('Motivation');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Enregistrer$/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/game-systems$/);
|
||||||
|
|
||||||
|
const persisted = await request.get(`/api/game-systems/${gs.id}`).then((r) => r.json());
|
||||||
|
expect(persisted.characterTemplate).toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ name: 'Histoire' })]),
|
||||||
|
);
|
||||||
|
expect(persisted.npcTemplate).toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ name: 'Motivation' })]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removes a field from the template', async ({ page }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
const pjEditor = tfe(page, 'PJ');
|
||||||
|
await pjEditor.locator('.tfe-add .chip', { hasText: 'Histoire' }).click();
|
||||||
|
await pjEditor.locator('.tfe-add .chip', { hasText: 'Apparence' }).click();
|
||||||
|
|
||||||
|
await expect(pjEditor.locator('.tfe-item')).toHaveCount(2);
|
||||||
|
|
||||||
|
// Supprime le premier champ (Histoire) via son btn-remove.
|
||||||
|
await pjEditor.locator('.tfe-item').first().locator('.btn-remove').click();
|
||||||
|
await expect(pjEditor.locator('.tfe-item')).toHaveCount(1);
|
||||||
|
await expect(pjEditor.locator('.tfe-item').first().locator('.tfe-name')).toHaveValue('Apparence');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reorders fields with the up arrow button', async ({ page }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
const pjEditor = tfe(page, 'PJ');
|
||||||
|
await pjEditor.locator('.tfe-add .chip', { hasText: 'Histoire' }).click();
|
||||||
|
await pjEditor.locator('.tfe-add .chip', { hasText: 'Apparence' }).click();
|
||||||
|
|
||||||
|
// Ordre initial : Histoire, Apparence.
|
||||||
|
let rows = pjEditor.locator('.tfe-item');
|
||||||
|
await expect(rows.nth(0).locator('.tfe-name')).toHaveValue('Histoire');
|
||||||
|
await expect(rows.nth(1).locator('.tfe-name')).toHaveValue('Apparence');
|
||||||
|
|
||||||
|
// Monte Apparence d'un cran.
|
||||||
|
await rows.nth(1).locator('.btn-arrow').first().click();
|
||||||
|
|
||||||
|
rows = pjEditor.locator('.tfe-item');
|
||||||
|
await expect(rows.nth(0).locator('.tfe-name')).toHaveValue('Apparence');
|
||||||
|
await expect(rows.nth(1).locator('.tfe-name')).toHaveValue('Histoire');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disables a suggested chip after the field has been added', async ({ page }) => {
|
||||||
|
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||||
|
|
||||||
|
const pjEditor = tfe(page, 'PJ');
|
||||||
|
const histoireChip = pjEditor.locator('.tfe-add .chip', { hasText: 'Histoire' });
|
||||||
|
|
||||||
|
await expect(histoireChip).toBeEnabled();
|
||||||
|
await histoireChip.click();
|
||||||
|
await expect(histoireChip).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user