6 Commits

Author SHA1 Message Date
7c4a42327d Mise en place du picker d'image pour la partie header / illustration des fiches personnage
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m1s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Successful in 1m42s
Migration pour l'ancienne partie des fiches perso vers les nouvelles pages
Vue retravaillée pour les fiches perso
2026-04-30 10:54:27 +02:00
52e389db24 Refonte du système JDR + système de personnage joueurs / non joueurs :
Some checks failed
E2E Tests / e2e (push) Failing after 21s
- Système de templating dans le game system : en effet, les templates sont liés au game system car les fiches personnages ne sont pas forcément les même selon les jeux (perso Dnd possède + de compétences que Nimble par exemple)
- changement des fiches personnages pour adapter le templating au niveau des campagnes et remplir des pages de perso
2026-04-30 10:42:09 +02:00
efaf5a3794 Mise en place d'un composant permettant d'améliorer l'experience de mise à jour (via un rafraichissement de l'appli).
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m0s
Build & Push Images / build (core) (push) Successful in 1m47s
Build & Push Images / build (web) (push) Successful in 1m38s
Modification de la partie web pour prendre la modification en compte
2026-04-29 14:39:30 +02:00
4fe93b5ff3 Correction problème mise à jour : l'application ne voyait pas les mises à jour quand on lançait docker après avoir push la dernière version.
Some checks failed
E2E Tests / e2e (push) Failing after 21s
Build & Push Images / build (brain) (push) Successful in 1m4s
Build & Push Images / build (core) (push) Successful in 1m31s
Build & Push Images / build (web) (push) Successful in 1m38s
Effectivement : au demarrage, docker ce mettait automatiquement sur la dernière version alors qu'il n'avait pas necessairement récupérer, ducoup comparaison faisait true et on arrivait pas à avoir la derniere version du code.
Push de la clé jwt publique : sinon pas incluse dans le jar finale et la section patreon n'apparaissait pas.
2026-04-29 10:56:37 +02:00
0f2d1b1efe Correction updateCheckServiceTest qui faisait planter le build gitea
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (core) (push) Successful in 1m27s
Build & Push Images / build (web) (push) Successful in 1m34s
Build & Push Images / build (brain) (push) Successful in 53s
2026-04-28 19:12:09 +02:00
5ff05242a8 Mise en place de la connexion au canal privé pour la bêta avec Patreon et passage en v0.8.0
Some checks failed
E2E Tests / e2e (push) Failing after 16s
Build & Push Images / build (brain) (push) Failing after 48s
Build & Push Images / build (core) (push) Failing after 1m18s
Build & Push Images / build (web) (push) Successful in 1m35s
2026-04-28 19:04:11 +02:00
128 changed files with 5290 additions and 581 deletions

10
.gitignore vendored
View File

@@ -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,8 @@ 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/

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>0.7.2</version> <version>0.8.1</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.

View File

@@ -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) {

View File

@@ -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,17 @@ 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,
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 +42,10 @@ 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<>())
.campaignId(data.campaignId()) .campaignId(data.campaignId())
.order(order) .order(order)
.build(); .build();
@@ -50,7 +64,10 @@ 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<>());
if (data.order() != null) { if (data.order() != null) {
existing.setOrder(data.order()); existing.setOrder(data.order());
} }

View File

@@ -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,15 @@ 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,
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 +37,10 @@ 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<>())
.campaignId(data.campaignId()) .campaignId(data.campaignId())
.order(order) .order(order)
.build(); .build();
@@ -50,7 +59,10 @@ 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<>());
if (data.order() != null) { if (data.order() != null) {
existing.setOrder(data.order()); existing.setOrder(data.order());
} }
@@ -61,7 +73,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)

View File

@@ -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);

View File

@@ -104,24 +104,33 @@ 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).
*/
private static String extractSnippet(java.util.Map<String, String> values) {
if (values == null || values.isEmpty()) return "";
for (String value : values.values()) {
if (value == null || value.isBlank()) continue;
String firstLine = value.lines()
.map(String::strip) .map(String::strip)
.filter(l -> !l.isEmpty() && !l.startsWith("#")) .filter(l -> !l.isEmpty() && !l.startsWith("#"))
.findFirst() .findFirst()
.orElse(""); .orElse("");
if (firstLine.isEmpty()) continue;
if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine; if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine;
return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + ""; return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + "";
} }
return "";
}
private ArcSummary toArcSummary(Arc arc) { private ArcSummary toArcSummary(Arc arc) {
List<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream() List<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream()

View File

@@ -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);
} }

View File

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

View File

@@ -1,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;

View File

@@ -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,24 @@ 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;
/** Référence vers la Campaign parente. */ /** Référence vers la Campaign parente. */
private String campaignId; private String campaignId;
@@ -38,4 +59,15 @@ 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;
}
} }

View File

@@ -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,19 @@ 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;
/** 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 +48,14 @@ 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;
}
} }

View File

@@ -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));
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
package com.loremind.domain.lorecontext; 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;

View File

@@ -0,0 +1,16 @@
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)
* <p>
* Extension future possible : RICH_TEXT, DATE, BOOLEAN, KEY_VALUE_LIST, REFERENCE...
*/
public enum FieldType {
TEXT,
IMAGE,
NUMBER
}

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.lorecontext; package com.loremind.domain.shared.template;
/** /**
* Variante de rendu pour un champ de type IMAGE. * 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,

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.lorecontext; package com.loremind.domain.shared.template;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
@@ -6,15 +6,15 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* Value Object d'un champ de Template. * Value Object d'un champ de Template (kernel partage).
* <p> * <p>
* Un champ a un nom (affiche dans l'UI) et un type (TEXT ou IMAGE, extensible). * Un champ a un nom (affiche dans l'UI) et un type. Le type pilote
* Le type pilote le rendu cote front (textarea vs galerie d'images) ET * le rendu cote front et la logique metier (seuls les champs TEXT sont
* la logique metier (seuls les champs TEXT sont envoyes a l'IA pour generation). * envoyes a l'IA pour generation).
* <p> * <p>
* Pour les champs IMAGE, {@link #layout} precise la variante de rendu * Pour les champs IMAGE, {@link #layout} precise la variante de rendu
* (gallery/hero/masonry/carousel). Nullable : l'absence equivaut a GALLERY. * (gallery/hero/masonry/carousel). Nullable : l'absence equivaut a GALLERY.
* Ignore pour les champs TEXT. * Ignore pour les autres types.
*/ */
@Data @Data
@Builder @Builder
@@ -47,4 +47,9 @@ public class TemplateField {
public static TemplateField image(String name, ImageLayout layout) { public static TemplateField image(String name, ImageLayout layout) {
return new TemplateField(name, FieldType.IMAGE, layout); return new TemplateField(name, FieldType.IMAGE, layout);
} }
/** Raccourci : construit un champ de type NUMBER. */
public static TemplateField number(String name) {
return new TemplateField(name, FieldType.NUMBER, null);
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,8 @@ package com.loremind.infrastructure.persistence;
import com.loremind.domain.gamesystemcontext.GameSystem; import com.loremind.domain.gamesystemcontext.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()) {
return;
}
log.info("Seed initial des GameSystems (table vide)..."); log.info("Seed initial des GameSystems (table vide)...");
for (GameSystem gs : defaultSystems()) { for (GameSystem gs : defaultSystems()) {
gameSystemRepository.save(gs); gameSystemRepository.save(gs);
} }
log.info("GameSystems seedés : {}", defaultSystems().size()); log.info("GameSystems seedés : {}", defaultSystems().size());
return;
}
log.debug("GameSystem seed skipped — table non vide. Backfill templates si necessaire...");
backfillEmptyTemplates(existing);
}
/**
* Backfill idempotent : pour chaque GameSystem existant ou les deux templates
* sont vides (PJ ET PNJ), injecte le template generique. Si l'utilisateur a
* deja personnalise au moins un des deux, on ne touche a rien.
*/
private void backfillEmptyTemplates(List<GameSystem> systems) {
int patched = 0;
for (GameSystem gs : systems) {
boolean charEmpty = gs.getCharacterTemplate() == null || gs.getCharacterTemplate().isEmpty();
boolean npcEmpty = gs.getNpcTemplate() == null || gs.getNpcTemplate().isEmpty();
if (charEmpty && npcEmpty) {
gs.replaceCharacterTemplate(genericCharacterTemplate());
gs.replaceNpcTemplate(genericNpcTemplate());
gameSystemRepository.save(gs);
patched++;
}
}
if (patched > 0) log.info("Backfill templates GameSystem : {} systeme(s) patche(s).", patched);
} }
private List<GameSystem> defaultSystems() { 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,70 @@ 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.number("FOR"),
TemplateField.number("DEX"),
TemplateField.number("CON"),
TemplateField.number("INT"),
TemplateField.number("SAG"),
TemplateField.number("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é).

View File

@@ -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;

View File

@@ -1,5 +1,7 @@
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 jakarta.persistence.*; import jakarta.persistence.*;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
@@ -7,11 +9,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 +37,21 @@ 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;
@Column(name = "campaign_id", nullable = false) @Column(name = "campaign_id", nullable = false)
private Long campaignId; private Long campaignId;
@@ -47,6 +69,8 @@ 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<>();
} }
@PreUpdate @PreUpdate

View File

@@ -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

View File

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

View File

@@ -1,5 +1,7 @@
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 jakarta.persistence.*; import jakarta.persistence.*;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
@@ -7,10 +9,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 +32,19 @@ 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;
@Column(name = "campaign_id", nullable = false) @Column(name = "campaign_id", nullable = false)
private Long campaignId; private Long campaignId;
@@ -46,6 +62,8 @@ 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<>();
} }
@PreUpdate @PreUpdate

View File

@@ -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;

View File

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

View File

@@ -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,10 @@ 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<>())
.campaignId(e.getCampaignId().toString()) .campaignId(e.getCampaignId().toString())
.order(e.getOrder()) .order(e.getOrder())
.createdAt(e.getCreatedAt()) .createdAt(e.getCreatedAt())
@@ -65,7 +69,10 @@ 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<>())
.campaignId(Long.parseLong(c.getCampaignId())) .campaignId(Long.parseLong(c.getCampaignId()))
.order(c.getOrder()) .order(c.getOrder())
.createdAt(c.getCreatedAt()) .createdAt(c.getCreatedAt())

View File

@@ -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())

View File

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

View File

@@ -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,10 @@ 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<>())
.campaignId(e.getCampaignId().toString()) .campaignId(e.getCampaignId().toString())
.order(e.getOrder()) .order(e.getOrder())
.createdAt(e.getCreatedAt()) .createdAt(e.getCreatedAt())
@@ -65,7 +69,10 @@ 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<>())
.campaignId(Long.parseLong(n.getCampaignId())) .campaignId(Long.parseLong(n.getCampaignId()))
.order(n.getOrder()) .order(n.getOrder())
.createdAt(n.getCreatedAt()) .createdAt(n.getCreatedAt())

View File

@@ -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; 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));
} }
return new UpdateStatus(true, anyUpdate, anyUnknown, statuses, Instant.now()); statuses.add(new ImageStatus(image, currentVersion, latest, kind));
}
return new UpdateStatus(true, anyUpdate, anyUnknown, currentVersion, statuses, Instant.now());
}
/**
* Verifie l'etat du canal beta (images privees GHCR) avec auth basique.
*/
public BetaStatus checkBeta() {
if (!licenseService.isLicensingEnabled()) {
return BetaStatus.disabled("licensing-not-configured");
}
LicenseSnapshot snap = licenseService.getCurrentSnapshot();
if (snap.status() != LicenseStatus.VALID && snap.status() != LicenseStatus.GRACE) {
return BetaStatus.disabled("license-" + snap.status().name().toLowerCase());
}
if (!snap.betaChannelEnabled()) {
return BetaStatus.disabled("beta-toggle-off");
}
if (betaImages.isEmpty()) {
return BetaStatus.disabled("no-beta-images-configured");
}
Optional<RegistryCredentials> creds = licenseService.fetchRegistryCredentials();
if (creds.isEmpty()) {
return new BetaStatus(true, false, true, List.of(), Instant.now(), "relay-unavailable");
}
String basicAuth = "Basic " + Base64.getEncoder().encodeToString(
(creds.get().username() + ":" + creds.get().password()).getBytes(StandardCharsets.UTF_8));
String betaRegistry = normalizeRegistry(creds.get().registry());
List<ImageStatus> statuses = new ArrayList<>();
boolean anyUpdate = false;
boolean anyUnknown = false;
for (String image : betaImages) {
String latest = null;
try {
latest = fetchLatestSemverTag(betaRegistry, image, basicAuth);
} catch (Exception e) {
log.warn("Beta tags fetch failed for {}: {}", image, e.getMessage());
}
ImageStatusKind kind;
if (latest == null) {
kind = ImageStatusKind.UNKNOWN;
anyUnknown = true;
} else if (currentVersion != null && compareSemver(currentVersion, latest) >= 0) {
kind = ImageStatusKind.UP_TO_DATE;
} else {
kind = ImageStatusKind.UPDATE_AVAILABLE;
anyUpdate = true;
}
statuses.add(new ImageStatus(image, currentVersion, latest, kind));
}
return new BetaStatus(true, anyUpdate, anyUnknown, statuses, Instant.now(), null);
} }
public void apply() { 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;
}
} }

View File

@@ -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 -> {});

View File

@@ -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,16 @@ 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.getCampaignId(),
order
);
}
} }

View File

@@ -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;
}
} }

View File

@@ -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) {}
}

View File

@@ -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,16 @@ 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.getCampaignId(),
order
);
}
} }

View File

@@ -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;

View File

@@ -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();

View File

@@ -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);
}
}

View File

@@ -2,15 +2,25 @@ 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 String campaignId; private String campaignId;
private int order; private int order;
} }

View File

@@ -2,15 +2,22 @@ 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 String campaignId; private String campaignId;
private int order; private int order;
} }

View File

@@ -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;
} }

View File

@@ -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()
);
}
}

View File

@@ -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;

View File

@@ -1,4 +1,4 @@
package com.loremind.infrastructure.web.dto.lorecontext; package com.loremind.infrastructure.web.dto.shared;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
@@ -7,7 +7,7 @@ import lombok.NoArgsConstructor;
/** /**
* DTO pour un champ de Template. * DTO pour un champ de Template.
* <p> * <p>
* Miroir wire-friendly de {@link com.loremind.domain.lorecontext.TemplateField}. * 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 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 layout (null pour TEXT, ou GALLERY/HERO/MASONRY/CAROUSEL pour IMAGE) pilote
* le rendu visuel des champs image cote front. * le rendu visuel des champs image cote front.

View File

@@ -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,10 @@ 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.setCampaignId(c.getCampaignId()); dto.setCampaignId(c.getCampaignId());
dto.setOrder(c.getOrder()); dto.setOrder(c.getOrder());
return dto; return dto;
@@ -23,7 +28,10 @@ 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<>())
.campaignId(dto.getCampaignId()) .campaignId(dto.getCampaignId())
.order(dto.getOrder()) .order(dto.getOrder())
.build(); .build();

View File

@@ -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;
}
} }

View File

@@ -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,10 @@ 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.setCampaignId(n.getCampaignId()); dto.setCampaignId(n.getCampaignId());
dto.setOrder(n.getOrder()); dto.setOrder(n.getOrder());
return dto; return dto;
@@ -23,7 +28,10 @@ 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<>())
.campaignId(dto.getCampaignId()) .campaignId(dto.getCampaignId())
.order(dto.getOrder()) .order(dto.getOrder())
.build(); .build();

View File

@@ -1,9 +1,9 @@
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;
/** /**

View File

@@ -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;

View File

@@ -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}

View 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)

View File

@@ -0,0 +1,3 @@
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEArbfaqBq54HJR1pKqliTShKrNIab32gpBwSTDw90I4wg=
-----END PUBLIC KEY-----

View File

@@ -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, "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, "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, "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, "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, "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, "camp-1", null)));
assertTrue(ex.getMessage().contains("missing")); assertTrue(ex.getMessage().contains("missing"));
verify(npcRepository, never()).save(any()); verify(npcRepository, never()).save(any());
} }

View File

@@ -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());

View File

@@ -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;

View File

@@ -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");

View File

@@ -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;

View File

@@ -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;

View File

@@ -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());
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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()));
} }
} }

View File

@@ -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());
}
}

View File

@@ -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;

View File

@@ -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:

View File

@@ -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.1
.LINK .LINK
https://github.com/IGMLcreation/LoreMind https://github.com/IGMLcreation/LoreMind

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.7.2", "version": "0.8.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "loremind-web", "name": "loremind-web",
"version": "0.7.2", "version": "0.8.1",
"dependencies": { "dependencies": {
"@angular/animations": "^17.0.0", "@angular/animations": "^17.0.0",
"@angular/common": "^17.0.0", "@angular/common": "^17.0.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.7.2", "version": "0.8.1",
"description": "LoreMind Frontend - Angular", "description": "LoreMind Frontend - Angular",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",

View File

@@ -1,3 +1,5 @@
<app-update-banner></app-update-banner>
<div class="app-container"> <div class="app-container">
<app-sidebar></app-sidebar> <app-sidebar></app-sidebar>

View File

@@ -4,13 +4,23 @@ import { RouterOutlet } from '@angular/router';
import { SidebarComponent } from './sidebar/sidebar.component'; import { SidebarComponent } from './sidebar/sidebar.component';
import { SecondarySidebarComponent } from './shared/secondary-sidebar/secondary-sidebar.component'; import { SecondarySidebarComponent } from './shared/secondary-sidebar/secondary-sidebar.component';
import { GlobalSearchComponent } from './shared/global-search/global-search.component'; import { GlobalSearchComponent } from './shared/global-search/global-search.component';
import { UpdateBannerComponent } from './shared/update-banner/update-banner.component';
import { LayoutService } from './services/layout.service'; import { LayoutService } from './services/layout.service';
import { GlobalSearchService } from './services/global-search.service'; import { GlobalSearchService } from './services/global-search.service';
import { VersionCheckerService } from './services/version-checker.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
standalone: true, standalone: true,
imports: [RouterOutlet, SidebarComponent, SecondarySidebarComponent, GlobalSearchComponent, AsyncPipe, NgIf], imports: [
RouterOutlet,
SidebarComponent,
SecondarySidebarComponent,
GlobalSearchComponent,
UpdateBannerComponent,
AsyncPipe,
NgIf,
],
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'] styleUrls: ['./app.component.scss']
}) })
@@ -19,8 +29,14 @@ export class AppComponent {
constructor( constructor(
private layoutService: LayoutService, private layoutService: LayoutService,
private globalSearch: GlobalSearchService private globalSearch: GlobalSearchService,
) {} versionChecker: VersionCheckerService,
) {
// Demarre la detection de mise a jour en arriere-plan.
// Si une nouvelle version est deployee pendant la session, l'UpdateBanner
// s'affichera automatiquement.
versionChecker.start();
}
@HostListener('document:keydown', ['$event']) @HostListener('document:keydown', ['$event'])
onKeydown(event: KeyboardEvent): void { onKeydown(event: KeyboardEvent): void {

View File

@@ -18,8 +18,10 @@ export const routes: Routes = [
{ path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) }, { path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) },
{ path: 'campaigns/:campaignId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) }, { path: 'campaigns/:campaignId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
{ path: 'campaigns/:campaignId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) }, { path: 'campaigns/:campaignId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
{ path: 'campaigns/:campaignId/characters/:characterId', loadComponent: () => import('./campaigns/character/character-view/character-view.component').then(m => m.CharacterViewComponent) },
{ path: 'campaigns/:campaignId/npcs/create', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) }, { path: 'campaigns/:campaignId/npcs/create', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
{ path: 'campaigns/:campaignId/npcs/:npcId/edit', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) }, { path: 'campaigns/:campaignId/npcs/:npcId/edit', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
{ path: 'campaigns/:campaignId/npcs/:npcId', loadComponent: () => import('./campaigns/npc/npc-view/npc-view.component').then(m => m.NpcViewComponent) },
{ path: 'campaigns/:campaignId/arcs/create', loadComponent: () => import('./campaigns/arc/arc-create/arc-create.component').then(m => m.ArcCreateComponent) }, { path: 'campaigns/:campaignId/arcs/create', loadComponent: () => import('./campaigns/arc/arc-create/arc-create.component').then(m => m.ArcCreateComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc/arc-view/arc-view.component').then(m => m.ArcViewComponent) }, { path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc/arc-view/arc-view.component').then(m => m.ArcViewComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/edit', loadComponent: () => import('./campaigns/arc/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) }, { path: 'campaigns/:campaignId/arcs/:arcId/edit', loadComponent: () => import('./campaigns/arc/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) },

View File

@@ -90,7 +90,7 @@
</div> </div>
<div class="characters-grid" *ngIf="characters.length > 0"> <div class="characters-grid" *ngIf="characters.length > 0">
<div class="character-card" *ngFor="let character of characters" (click)="editCharacter(character)"> <div class="character-card" *ngFor="let character of characters" (click)="viewCharacter(character)">
<lucide-icon [img]="User" [size]="20" class="character-icon"></lucide-icon> <lucide-icon [img]="User" [size]="20" class="character-icon"></lucide-icon>
<div class="character-info"> <div class="character-info">
<span class="character-name">{{ character.name }}</span> <span class="character-name">{{ character.name }}</span>
@@ -123,7 +123,7 @@
</div> </div>
<div class="characters-grid" *ngIf="npcs.length > 0"> <div class="characters-grid" *ngIf="npcs.length > 0">
<div class="character-card" *ngFor="let npc of npcs" (click)="editNpc(npc)"> <div class="character-card" *ngFor="let npc of npcs" (click)="viewNpc(npc)">
<lucide-icon [img]="Drama" [size]="20" class="character-icon character-icon--npc"></lucide-icon> <lucide-icon [img]="Drama" [size]="20" class="character-icon character-icon--npc"></lucide-icon>
<div class="character-info"> <div class="character-info">
<span class="character-name">{{ npc.name }}</span> <span class="character-name">{{ npc.name }}</span>

View File

@@ -194,6 +194,17 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id, 'edit']); this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id, 'edit']);
} }
/** Ouvre la vue lecture seule (style WorldAnvil) — clic sur la carte. */
viewCharacter(character: Character): void {
if (!this.campaign || !character.id) return;
this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id]);
}
viewNpc(npc: Npc): void {
if (!this.campaign || !npc.id) return;
this.router.navigate(['/campaigns', this.campaign.id, 'npcs', npc.id]);
}
createArc(): void { createArc(): void {
if (!this.campaign) return; if (!this.campaign) return;
this.router.navigate(['/campaigns', this.campaign.id, 'arcs', 'create']); this.router.navigate(['/campaigns', this.campaign.id, 'arcs', 'create']);
@@ -205,20 +216,24 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
} }
/** /**
* Extrait une ligne de résumé depuis le markdown (1re ligne non-vide, non-titre). * Extrait une ligne de resume pour la fiche PJ/PNJ — 1re valeur de template
* Générique : utilisé pour les fiches PJ comme PNJ (mêmes besoins d'aperçu carte). * non-vide (apres refonte 2026-04-30, remplace l'ancien parsing markdown).
*/ */
personaSnippet(p: { markdownContent?: string | null }): string { personaSnippet(p: { values?: Record<string, string> }): string {
if (!p.markdownContent) return '(Fiche vide)'; const values = p.values ?? {};
const firstMeaningful = p.markdownContent for (const v of Object.values(values)) {
if (!v) continue;
const firstMeaningful = v
.split('\n') .split('\n')
.map(l => l.trim()) .map(l => l.trim())
.find(l => l && !l.startsWith('#')); .find(l => l && !l.startsWith('#'));
if (!firstMeaningful) return '(Fiche vide)'; if (!firstMeaningful) continue;
return firstMeaningful.length > 80 return firstMeaningful.length > 80
? firstMeaningful.substring(0, 77) + '…' ? firstMeaningful.substring(0, 77) + '…'
: firstMeaningful; : firstMeaningful;
} }
return '(Fiche vide)';
}
/** Alias gardé pour compatibilité avec les anciens templates. */ /** Alias gardé pour compatibilité avec les anciens templates. */
characterSnippet(c: Character): string { characterSnippet(c: Character): string {

View File

@@ -35,18 +35,35 @@
/> />
</div> </div>
<div class="field content-field"> <div class="field-row image-row">
<label>Fiche (markdown)</label> <div class="field portrait-field">
<p class="hint"> <label>Portrait</label>
Tout en markdown libre : stats, classe, backstory, équipement, objectifs personnels… <app-single-image-picker
L'IA lira ces infos pour rester cohérente quand vous générez des scènes impliquant ce PJ. [imageId]="portraitImageId"
</p> aspectRatio="1 / 1"
<textarea hint="Carre conseille (400×400)."
[(ngModel)]="markdownContent" (imageIdChange)="portraitImageId = $event">
name="markdownContent" </app-single-image-picker>
rows="22" </div>
placeholder="# Thorin Grand-Hache&#10;&#10;**Race :** Nain&#10;**Classe :** Guerrier niveau 4&#10;**PV :** 35 / 35&#10;&#10;## Stats&#10;- Force : 16&#10;- Dextérité : 12&#10;...&#10;&#10;## Backstory&#10;Originaire des montagnes du Nord, Thorin a fui son clan après..." <div class="field header-field">
></textarea> <label>Bandeau / Header</label>
<app-single-image-picker
[imageId]="headerImageId"
aspectRatio="3 / 1"
hint="Format paysage conseille (1200×400)."
(imageIdChange)="headerImageId = $event">
</app-single-image-picker>
</div>
</div>
<div class="template-fields">
<app-dynamic-fields-form
[fields]="templateFields"
[values]="values"
[imageValues]="imageValues"
(valuesChange)="values = $event"
(imageValuesChange)="imageValues = $event">
</app-dynamic-fields-form>
</div> </div>
<div class="actions"> <div class="actions">

View File

@@ -4,22 +4,28 @@ import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lucide-angular'; import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lucide-angular';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { Character } from '../../../services/character.model'; import { CampaignService } from '../../../services/campaign.service';
import { GameSystemService } from '../../../services/game-system.service';
import { TemplateField } from '../../../services/template.model';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component'; import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component';
/** /**
* Éditeur plein écran d'une fiche de personnage (PJ). * Editeur plein ecran d'une fiche de personnage (PJ).
* Double rôle création/édition : * Refonte 2026-04-30 : remplace le markdown libre par un formulaire dynamique
* - `/campaigns/:campaignId/characters/create` → POST * pilote par le characterTemplate du GameSystem associe a la campagne.
* - `/campaigns/:campaignId/characters/:characterId/edit` → PUT
* *
* MVP : name + markdown libre. Évolution prévue vers un template dérivé * Comportements :
* du GameSystem de la campagne (stats structurées). * - Si la campagne n'a pas de GameSystem ou si son template est vide, affiche
* uniquement les champs universels (nom, portrait, header).
* - Le picker d'images dedie portrait/header est hors scope MVP — pour l'instant
* saisie manuelle d'IDs d'images.
*/ */
@Component({ @Component({
selector: 'app-character-edit', selector: 'app-character-edit',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent], imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent],
templateUrl: './character-edit.component.html', templateUrl: './character-edit.component.html',
styleUrls: ['./character-edit.component.scss'] styleUrls: ['./character-edit.component.scss']
}) })
@@ -30,12 +36,11 @@ export class CharacterEditComponent implements OnInit {
readonly Trash2 = Trash2; readonly Trash2 = Trash2;
readonly Sparkles = Sparkles; readonly Sparkles = Sparkles;
/** État drawer chat IA focalisé sur ce PJ. */
chatOpen = false; chatOpen = false;
readonly chatQuickSuggestions = [ readonly chatQuickSuggestions = [
'Propose une backstory cohérente avec l\'univers', 'Propose une backstory coherente avec l\'univers',
'Suggère 3 objectifs personnels pour ce personnage', 'Suggere 3 objectifs personnels pour ce personnage',
'Aide-moi à équilibrer les stats de combat' 'Aide-moi a equilibrer les stats de combat'
]; ];
toggleChat(): void { this.chatOpen = !this.chatOpen; } toggleChat(): void { this.chatOpen = !this.chatOpen; }
@@ -44,13 +49,19 @@ export class CharacterEditComponent implements OnInit {
characterId: string | null = null; characterId: string | null = null;
name = ''; name = '';
markdownContent = ''; portraitImageId: string | null = null;
headerImageId: string | null = null;
values: Record<string, string> = {};
imageValues: Record<string, string[]> = {};
templateFields: TemplateField[] = [];
private order = 0; private order = 0;
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private service: CharacterService private service: CharacterService,
private campaignService: CampaignService,
private gameSystemService: GameSystemService
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
@@ -58,11 +69,18 @@ export class CharacterEditComponent implements OnInit {
this.campaignId = params.get('campaignId'); this.campaignId = params.get('campaignId');
this.characterId = params.get('characterId'); this.characterId = params.get('characterId');
if (this.campaignId) {
this.loadTemplateForCampaign(this.campaignId);
}
if (this.characterId) { if (this.characterId) {
this.service.getById(this.characterId).subscribe({ this.service.getById(this.characterId).subscribe({
next: (c) => { next: (c) => {
this.name = c.name; this.name = c.name;
this.markdownContent = c.markdownContent ?? ''; this.portraitImageId = c.portraitImageId ?? null;
this.headerImageId = c.headerImageId ?? null;
this.values = c.values ?? {};
this.imageValues = c.imageValues ?? {};
this.order = c.order ?? 0; this.order = c.order ?? 0;
}, },
error: () => this.back() error: () => this.back()
@@ -70,21 +88,35 @@ export class CharacterEditComponent implements OnInit {
} }
} }
private loadTemplateForCampaign(campaignId: string): void {
this.campaignService.getCampaignById(campaignId).subscribe({
next: (campaign) => {
if (!campaign.gameSystemId) {
this.templateFields = [];
return;
}
this.gameSystemService.getById(campaign.gameSystemId).subscribe({
next: (gs) => { this.templateFields = gs.characterTemplate ?? []; },
error: () => { this.templateFields = []; }
});
},
error: () => { this.templateFields = []; }
});
}
submit(): void { submit(): void {
if (!this.name.trim() || !this.campaignId) return; if (!this.name.trim() || !this.campaignId) return;
const req = this.characterId const payload = {
? this.service.update(this.characterId, {
id: this.characterId,
name: this.name.trim(), name: this.name.trim(),
markdownContent: this.markdownContent || null, portraitImageId: this.portraitImageId,
campaignId: this.campaignId, headerImageId: this.headerImageId,
order: this.order values: this.values,
}) imageValues: this.imageValues,
: this.service.create({
name: this.name.trim(),
markdownContent: this.markdownContent || null,
campaignId: this.campaignId campaignId: this.campaignId
}); };
const req = this.characterId
? this.service.update(this.characterId, { ...payload, id: this.characterId, order: this.order })
: this.service.create(payload);
req.subscribe({ req.subscribe({
next: () => this.back(), next: () => this.back(),
error: () => console.error('Erreur sauvegarde Character') error: () => console.error('Erreur sauvegarde Character')
@@ -93,7 +125,7 @@ export class CharacterEditComponent implements OnInit {
deleteCharacter(): void { deleteCharacter(): void {
if (!this.characterId) return; if (!this.characterId) return;
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irréversible.`)) return; if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irreversible.`)) return;
this.service.delete(this.characterId).subscribe({ this.service.delete(this.characterId).subscribe({
next: () => this.back(), next: () => this.back(),
error: () => console.error('Erreur suppression Character') error: () => console.error('Erreur suppression Character')

View File

@@ -0,0 +1,28 @@
<div class="cv-page">
<div class="cv-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour
</button>
<span class="spacer"></span>
<button class="btn-ai" (click)="toggleChat()" [class.active]="chatOpen" *ngIf="characterId">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
<button class="btn-edit" (click)="edit()">
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon>
Editer
</button>
</div>
<app-persona-view [persona]="character" [templateFields]="templateFields"></app-persona-view>
</div>
<app-ai-chat-drawer
*ngIf="characterId && campaignId"
[campaignId]="campaignId"
entityType="character"
[entityId]="characterId"
[isOpen]="chatOpen"
(close)="chatOpen = false">
</app-ai-chat-drawer>

View File

@@ -0,0 +1,53 @@
.cv-page {
padding: 16px 0 48px;
min-height: 100vh;
}
.cv-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 32px 16px;
max-width: 1100px;
margin: 0 auto;
.spacer { flex: 1; }
}
.btn-back,
.btn-edit,
.btn-ai {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
color: #d1d5db;
font-size: 0.85rem;
cursor: pointer;
transition: all 120ms;
&:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
}
.btn-edit {
border-color: rgba(209, 168, 120, 0.4);
color: #d1a878;
&:hover {
background: rgba(209, 168, 120, 0.15);
}
}
.btn-ai {
&.active {
background: rgba(168, 85, 247, 0.2);
border-color: rgba(168, 85, 247, 0.5);
color: #d8b4fe;
}
}

View File

@@ -0,0 +1,80 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular';
import { CharacterService } from '../../../services/character.service';
import { CampaignService } from '../../../services/campaign.service';
import { GameSystemService } from '../../../services/game-system.service';
import { TemplateField } from '../../../services/template.model';
import { Character } from '../../../services/character.model';
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
/**
* Vue lecture seule "WorldAnvil" d'une fiche PJ.
* Route : /campaigns/:campaignId/characters/:characterId
*/
@Component({
selector: 'app-character-view',
standalone: true,
imports: [CommonModule, LucideAngularModule, PersonaViewComponent, AiChatDrawerComponent],
templateUrl: './character-view.component.html',
styleUrls: ['./character-view.component.scss']
})
export class CharacterViewComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Edit3 = Edit3;
readonly Sparkles = Sparkles;
campaignId: string | null = null;
characterId: string | null = null;
character: Character | null = null;
templateFields: TemplateField[] = [];
chatOpen = false;
toggleChat(): void { this.chatOpen = !this.chatOpen; }
constructor(
private route: ActivatedRoute,
private router: Router,
private service: CharacterService,
private campaignService: CampaignService,
private gameSystemService: GameSystemService
) {}
ngOnInit(): void {
const params = this.route.snapshot.paramMap;
this.campaignId = params.get('campaignId');
this.characterId = params.get('characterId');
if (this.characterId) {
this.service.getById(this.characterId).subscribe({
next: c => { this.character = c; },
error: () => this.back()
});
}
if (this.campaignId) {
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
if (camp.gameSystemId) {
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => {
this.templateFields = gs.characterTemplate ?? [];
});
}
});
}
}
edit(): void {
if (this.campaignId && this.characterId) {
this.router.navigate(['/campaigns', this.campaignId, 'characters', this.characterId, 'edit']);
}
}
back(): void {
if (this.campaignId) {
this.router.navigate(['/campaigns', this.campaignId]);
} else {
this.router.navigate(['/campaigns']);
}
}
}

View File

@@ -35,18 +35,35 @@
/> />
</div> </div>
<div class="field content-field"> <div class="field-row image-row">
<label>Fiche (markdown)</label> <div class="field portrait-field">
<p class="hint"> <label>Portrait</label>
Tout en markdown libre : apparence, motivations, faction, secrets, stats, notes MJ… <app-single-image-picker
À terme, l'IA pourra exploiter ces infos pour incarner le PNJ avec cohérence dans les scènes. [imageId]="portraitImageId"
</p> aspectRatio="1 / 1"
<textarea hint="Carre conseille (400×400)."
[(ngModel)]="markdownContent" (imageIdChange)="portraitImageId = $event">
name="markdownContent" </app-single-image-picker>
rows="22" </div>
placeholder="# Borin le forgeron&#10;&#10;**Race :** Nain&#10;**Faction :** Clan Feuillefer&#10;**Statut :** Vivant&#10;&#10;## Apparence&#10;Barbe rousse tressée, tablier de cuir brûlé...&#10;&#10;## Motivations&#10;Venger son clan décimé par les orcs il y a 10 hivers.&#10;&#10;## Notes MJ (secret)&#10;Connaît l'emplacement du marteau de Durin..." <div class="field header-field">
></textarea> <label>Bandeau / Header</label>
<app-single-image-picker
[imageId]="headerImageId"
aspectRatio="3 / 1"
hint="Format paysage conseille (1200×400)."
(imageIdChange)="headerImageId = $event">
</app-single-image-picker>
</div>
</div>
<div class="template-fields">
<app-dynamic-fields-form
[fields]="templateFields"
[values]="values"
[imageValues]="imageValues"
(valuesChange)="values = $event"
(imageValuesChange)="imageValues = $event">
</app-dynamic-fields-form>
</div> </div>
<div class="actions"> <div class="actions">

View File

@@ -4,21 +4,23 @@ import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Save, ArrowLeft, Drama, Trash2, Sparkles } from 'lucide-angular'; import { LucideAngularModule, Save, ArrowLeft, Drama, Trash2, Sparkles } from 'lucide-angular';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { CampaignService } from '../../../services/campaign.service';
import { GameSystemService } from '../../../services/game-system.service';
import { TemplateField } from '../../../services/template.model';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component'; import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component';
/** /**
* Éditeur plein écran d'une fiche de PNJ. * Editeur plein ecran d'une fiche de PNJ.
* Double rôle création/édition : * Refonte 2026-04-30 : meme principe que CharacterEditComponent — markdown
* - `/campaigns/:campaignId/npcs/create` → POST * libre remplace par un formulaire dynamique pilote par le npcTemplate du
* - `/campaigns/:campaignId/npcs/:npcId/edit` → PUT * GameSystem associe a la campagne.
*
* MVP : name + markdown libre. L'Assistant IA est branché en mode édition
* (focus entityType="npc") pour proposer apparence, motivations, secrets...
*/ */
@Component({ @Component({
selector: 'app-npc-edit', selector: 'app-npc-edit',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent], imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent],
templateUrl: './npc-edit.component.html', templateUrl: './npc-edit.component.html',
styleUrls: ['./npc-edit.component.scss'] styleUrls: ['./npc-edit.component.scss']
}) })
@@ -29,12 +31,11 @@ export class NpcEditComponent implements OnInit {
readonly Trash2 = Trash2; readonly Trash2 = Trash2;
readonly Sparkles = Sparkles; readonly Sparkles = Sparkles;
/** État drawer chat IA focalisé sur ce PNJ. */
chatOpen = false; chatOpen = false;
readonly chatQuickSuggestions = [ readonly chatQuickSuggestions = [
'Propose une apparence et une posture marquantes', 'Propose une apparence et une posture marquantes',
'Suggère 2 motivations et un secret pour ce PNJ', 'Suggere 2 motivations et un secret pour ce PNJ',
'Imagine 3 répliques signatures qui le caractérisent' 'Imagine 3 repliques signatures qui le caracterisent'
]; ];
toggleChat(): void { this.chatOpen = !this.chatOpen; } toggleChat(): void { this.chatOpen = !this.chatOpen; }
@@ -43,13 +44,19 @@ export class NpcEditComponent implements OnInit {
npcId: string | null = null; npcId: string | null = null;
name = ''; name = '';
markdownContent = ''; portraitImageId: string | null = null;
headerImageId: string | null = null;
values: Record<string, string> = {};
imageValues: Record<string, string[]> = {};
templateFields: TemplateField[] = [];
private order = 0; private order = 0;
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private service: NpcService private service: NpcService,
private campaignService: CampaignService,
private gameSystemService: GameSystemService
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
@@ -57,11 +64,18 @@ export class NpcEditComponent implements OnInit {
this.campaignId = params.get('campaignId'); this.campaignId = params.get('campaignId');
this.npcId = params.get('npcId'); this.npcId = params.get('npcId');
if (this.campaignId) {
this.loadTemplateForCampaign(this.campaignId);
}
if (this.npcId) { if (this.npcId) {
this.service.getById(this.npcId).subscribe({ this.service.getById(this.npcId).subscribe({
next: (n) => { next: (n) => {
this.name = n.name; this.name = n.name;
this.markdownContent = n.markdownContent ?? ''; this.portraitImageId = n.portraitImageId ?? null;
this.headerImageId = n.headerImageId ?? null;
this.values = n.values ?? {};
this.imageValues = n.imageValues ?? {};
this.order = n.order ?? 0; this.order = n.order ?? 0;
}, },
error: () => this.back() error: () => this.back()
@@ -69,21 +83,35 @@ export class NpcEditComponent implements OnInit {
} }
} }
private loadTemplateForCampaign(campaignId: string): void {
this.campaignService.getCampaignById(campaignId).subscribe({
next: (campaign) => {
if (!campaign.gameSystemId) {
this.templateFields = [];
return;
}
this.gameSystemService.getById(campaign.gameSystemId).subscribe({
next: (gs) => { this.templateFields = gs.npcTemplate ?? []; },
error: () => { this.templateFields = []; }
});
},
error: () => { this.templateFields = []; }
});
}
submit(): void { submit(): void {
if (!this.name.trim() || !this.campaignId) return; if (!this.name.trim() || !this.campaignId) return;
const req = this.npcId const payload = {
? this.service.update(this.npcId, {
id: this.npcId,
name: this.name.trim(), name: this.name.trim(),
markdownContent: this.markdownContent || null, portraitImageId: this.portraitImageId,
campaignId: this.campaignId, headerImageId: this.headerImageId,
order: this.order values: this.values,
}) imageValues: this.imageValues,
: this.service.create({
name: this.name.trim(),
markdownContent: this.markdownContent || null,
campaignId: this.campaignId campaignId: this.campaignId
}); };
const req = this.npcId
? this.service.update(this.npcId, { ...payload, id: this.npcId, order: this.order })
: this.service.create(payload);
req.subscribe({ req.subscribe({
next: () => this.back(), next: () => this.back(),
error: () => console.error('Erreur sauvegarde Npc') error: () => console.error('Erreur sauvegarde Npc')
@@ -92,7 +120,7 @@ export class NpcEditComponent implements OnInit {
deleteNpc(): void { deleteNpc(): void {
if (!this.npcId) return; if (!this.npcId) return;
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irréversible.`)) return; if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irreversible.`)) return;
this.service.delete(this.npcId).subscribe({ this.service.delete(this.npcId).subscribe({
next: () => this.back(), next: () => this.back(),
error: () => console.error('Erreur suppression Npc') error: () => console.error('Erreur suppression Npc')

View File

@@ -0,0 +1,28 @@
<div class="nv-page">
<div class="nv-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour
</button>
<span class="spacer"></span>
<button class="btn-ai" (click)="toggleChat()" [class.active]="chatOpen" *ngIf="npcId">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
<button class="btn-edit" (click)="edit()">
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon>
Editer
</button>
</div>
<app-persona-view [persona]="npc" [templateFields]="templateFields"></app-persona-view>
</div>
<app-ai-chat-drawer
*ngIf="npcId && campaignId"
[campaignId]="campaignId"
entityType="npc"
[entityId]="npcId"
[isOpen]="chatOpen"
(close)="chatOpen = false">
</app-ai-chat-drawer>

View File

@@ -0,0 +1,51 @@
.nv-page {
padding: 16px 0 48px;
min-height: 100vh;
}
.nv-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 32px 16px;
max-width: 1100px;
margin: 0 auto;
.spacer { flex: 1; }
}
.btn-back,
.btn-edit,
.btn-ai {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
color: #d1d5db;
font-size: 0.85rem;
cursor: pointer;
transition: all 120ms;
&:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
}
.btn-edit {
border-color: rgba(209, 168, 120, 0.4);
color: #d1a878;
&:hover {
background: rgba(209, 168, 120, 0.15);
}
}
.btn-ai.active {
background: rgba(168, 85, 247, 0.2);
border-color: rgba(168, 85, 247, 0.5);
color: #d8b4fe;
}

View File

@@ -0,0 +1,80 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular';
import { NpcService } from '../../../services/npc.service';
import { CampaignService } from '../../../services/campaign.service';
import { GameSystemService } from '../../../services/game-system.service';
import { TemplateField } from '../../../services/template.model';
import { Npc } from '../../../services/npc.model';
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
/**
* Vue lecture seule "WorldAnvil" d'une fiche PNJ.
* Route : /campaigns/:campaignId/npcs/:npcId
*/
@Component({
selector: 'app-npc-view',
standalone: true,
imports: [CommonModule, LucideAngularModule, PersonaViewComponent, AiChatDrawerComponent],
templateUrl: './npc-view.component.html',
styleUrls: ['./npc-view.component.scss']
})
export class NpcViewComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Edit3 = Edit3;
readonly Sparkles = Sparkles;
campaignId: string | null = null;
npcId: string | null = null;
npc: Npc | null = null;
templateFields: TemplateField[] = [];
chatOpen = false;
toggleChat(): void { this.chatOpen = !this.chatOpen; }
constructor(
private route: ActivatedRoute,
private router: Router,
private service: NpcService,
private campaignService: CampaignService,
private gameSystemService: GameSystemService
) {}
ngOnInit(): void {
const params = this.route.snapshot.paramMap;
this.campaignId = params.get('campaignId');
this.npcId = params.get('npcId');
if (this.npcId) {
this.service.getById(this.npcId).subscribe({
next: n => { this.npc = n; },
error: () => this.back()
});
}
if (this.campaignId) {
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
if (camp.gameSystemId) {
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => {
this.templateFields = gs.npcTemplate ?? [];
});
}
});
}
}
edit(): void {
if (this.campaignId && this.npcId) {
this.router.navigate(['/campaigns', this.campaignId, 'npcs', this.npcId, 'edit']);
}
}
back(): void {
if (this.campaignId) {
this.router.navigate(['/campaigns', this.campaignId]);
} else {
this.router.navigate(['/campaigns']);
}
}
}

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