Mise en ligne de la version 0.2.0
This commit is contained in:
16
core/src/main/java/com/loremind/LoreMindApplication.java
Normal file
16
core/src/main/java/com/loremind/LoreMindApplication.java
Normal file
@@ -0,0 +1,16 @@
|
||||
package com.loremind;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* Classe principale de l'application LoreMind.
|
||||
* Point d'entrée Spring Boot qui démarre l'application.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class LoreMindApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(LoreMindApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Arc;
|
||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Service d'application pour le contexte Arc.
|
||||
* Orchestre la logique métier en utilisant le Port ArcRepository.
|
||||
* Fait partie de la couche Application de l'Architecture Hexagonale.
|
||||
*/
|
||||
@Service
|
||||
public class ArcService {
|
||||
|
||||
private final ArcRepository arcRepository;
|
||||
|
||||
public ArcService(ArcRepository arcRepository) {
|
||||
this.arcRepository = arcRepository;
|
||||
}
|
||||
|
||||
public Arc createArc(String name, String description, String campaignId, int order) {
|
||||
Arc arc = Arc.builder()
|
||||
.name(name)
|
||||
.description(description)
|
||||
.campaignId(campaignId)
|
||||
.order(order)
|
||||
.build();
|
||||
return arcRepository.save(arc);
|
||||
}
|
||||
|
||||
public Optional<Arc> getArcById(String id) {
|
||||
return arcRepository.findById(id);
|
||||
}
|
||||
|
||||
public List<Arc> getAllArcs() {
|
||||
return arcRepository.findAll();
|
||||
}
|
||||
|
||||
public List<Arc> getArcsByCampaignId(String campaignId) {
|
||||
return arcRepository.findByCampaignId(campaignId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour un Arc avec tous ses champs narratifs.
|
||||
* Accepte un objet Arc pour éviter l'explosion de paramètres (Parameter Object pattern).
|
||||
*/
|
||||
public Arc updateArc(String id, Arc updated) {
|
||||
Optional<Arc> existingArc = arcRepository.findById(id);
|
||||
if (existingArc.isEmpty()) {
|
||||
throw new IllegalArgumentException("Arc non trouvé avec l'ID: " + id);
|
||||
}
|
||||
|
||||
Arc arc = existingArc.get();
|
||||
BeanUtils.copyProperties(updated, arc, "id");
|
||||
return arcRepository.save(arc);
|
||||
}
|
||||
|
||||
public void deleteArc(String id) {
|
||||
arcRepository.deleteById(id);
|
||||
}
|
||||
|
||||
public boolean arcExists(String id) {
|
||||
return arcRepository.existsById(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Service d'application pour le contexte Campaign.
|
||||
* Orchestre la logique métier en utilisant le Port CampaignRepository.
|
||||
* Fait partie de la couche Application de l'Architecture Hexagonale.
|
||||
*/
|
||||
@Service
|
||||
public class CampaignService {
|
||||
|
||||
private final CampaignRepository campaignRepository;
|
||||
|
||||
public CampaignService(CampaignRepository campaignRepository) {
|
||||
this.campaignRepository = campaignRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameter Object pour la création / mise à jour d'une Campaign.
|
||||
* Évite une signature à rallonge et rend les évolutions futures (theme,
|
||||
* coverImageUrl, etc.) sans casser les appelants.
|
||||
*
|
||||
* <p>{@code loreId} est nullable : une campagne peut exister sans univers associé.</p>
|
||||
*/
|
||||
public record CampaignData(String name, String description, String loreId) {}
|
||||
|
||||
public Campaign createCampaign(CampaignData data) {
|
||||
Campaign campaign = Campaign.builder()
|
||||
.name(data.name())
|
||||
.description(data.description())
|
||||
.loreId(normalizeLoreId(data.loreId()))
|
||||
.arcsCount(0)
|
||||
.build();
|
||||
return campaignRepository.save(campaign);
|
||||
}
|
||||
|
||||
public Optional<Campaign> getCampaignById(String id) {
|
||||
return campaignRepository.findById(id);
|
||||
}
|
||||
|
||||
public List<Campaign> getAllCampaigns() {
|
||||
return campaignRepository.findAll();
|
||||
}
|
||||
|
||||
public Campaign updateCampaign(String id, CampaignData data) {
|
||||
Optional<Campaign> existingCampaign = campaignRepository.findById(id);
|
||||
if (existingCampaign.isEmpty()) {
|
||||
throw new IllegalArgumentException("Campaign non trouvé avec l'ID: " + id);
|
||||
}
|
||||
|
||||
Campaign campaign = existingCampaign.get();
|
||||
campaign.setName(data.name());
|
||||
campaign.setDescription(data.description());
|
||||
campaign.setLoreId(normalizeLoreId(data.loreId()));
|
||||
return campaignRepository.save(campaign);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un loreId entrant : une chaîne vide/blanche est traitée comme "pas de lien".
|
||||
* Utile car les payloads JSON peuvent envoyer "" au lieu de null.
|
||||
*/
|
||||
private String normalizeLoreId(String loreId) {
|
||||
return (loreId == null || loreId.isBlank()) ? null : loreId;
|
||||
}
|
||||
|
||||
public void deleteCampaign(String id) {
|
||||
campaignRepository.deleteById(id);
|
||||
}
|
||||
|
||||
public boolean campaignExists(String id) {
|
||||
return campaignRepository.existsById(id);
|
||||
}
|
||||
|
||||
public List<Campaign> searchCampaigns(String query) {
|
||||
if (query == null || query.isBlank()) return List.of();
|
||||
return campaignRepository.searchByName(query.trim());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Service d'application pour le contexte Chapter.
|
||||
* Orchestre la logique métier en utilisant le Port ChapterRepository.
|
||||
* Fait partie de la couche Application de l'Architecture Hexagonale.
|
||||
*/
|
||||
@Service
|
||||
public class ChapterService {
|
||||
|
||||
private final ChapterRepository chapterRepository;
|
||||
|
||||
public ChapterService(ChapterRepository chapterRepository) {
|
||||
this.chapterRepository = chapterRepository;
|
||||
}
|
||||
|
||||
public Chapter createChapter(String name, String description, String arcId, int order) {
|
||||
Chapter chapter = Chapter.builder()
|
||||
.name(name)
|
||||
.description(description)
|
||||
.arcId(arcId)
|
||||
.order(order)
|
||||
.build();
|
||||
return chapterRepository.save(chapter);
|
||||
}
|
||||
|
||||
public Optional<Chapter> getChapterById(String id) {
|
||||
return chapterRepository.findById(id);
|
||||
}
|
||||
|
||||
public List<Chapter> getAllChapters() {
|
||||
return chapterRepository.findAll();
|
||||
}
|
||||
|
||||
public List<Chapter> getChaptersByArcId(String arcId) {
|
||||
return chapterRepository.findByArcId(arcId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour un Chapter avec tous ses champs narratifs (Parameter Object pattern).
|
||||
*/
|
||||
public Chapter updateChapter(String id, Chapter updated) {
|
||||
Optional<Chapter> existingChapter = chapterRepository.findById(id);
|
||||
if (existingChapter.isEmpty()) {
|
||||
throw new IllegalArgumentException("Chapter non trouvé avec l'ID: " + id);
|
||||
}
|
||||
|
||||
Chapter chapter = existingChapter.get();
|
||||
BeanUtils.copyProperties(updated, chapter, "id");
|
||||
return chapterRepository.save(chapter);
|
||||
}
|
||||
|
||||
public void deleteChapter(String id) {
|
||||
chapterRepository.deleteById(id);
|
||||
}
|
||||
|
||||
public boolean chapterExists(String id) {
|
||||
return chapterRepository.existsById(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Scene;
|
||||
import com.loremind.domain.campaigncontext.SceneBranch;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Service d'application pour le contexte Scene.
|
||||
* Orchestre la logique métier en utilisant le Port SceneRepository.
|
||||
* Fait partie de la couche Application de l'Architecture Hexagonale.
|
||||
*/
|
||||
@Service
|
||||
public class SceneService {
|
||||
|
||||
private final SceneRepository sceneRepository;
|
||||
|
||||
public SceneService(SceneRepository sceneRepository) {
|
||||
this.sceneRepository = sceneRepository;
|
||||
}
|
||||
|
||||
public Scene createScene(String name, String description, String chapterId, int order) {
|
||||
Scene scene = Scene.builder()
|
||||
.name(name)
|
||||
.description(description)
|
||||
.chapterId(chapterId)
|
||||
.order(order)
|
||||
.build();
|
||||
return sceneRepository.save(scene);
|
||||
}
|
||||
|
||||
public Optional<Scene> getSceneById(String id) {
|
||||
return sceneRepository.findById(id);
|
||||
}
|
||||
|
||||
public List<Scene> getAllScenes() {
|
||||
return sceneRepository.findAll();
|
||||
}
|
||||
|
||||
public List<Scene> getScenesByChapterId(String chapterId) {
|
||||
return sceneRepository.findByChapterId(chapterId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour une Scene avec tous ses champs narratifs (Parameter Object pattern).
|
||||
*/
|
||||
public Scene updateScene(String id, Scene updated) {
|
||||
Optional<Scene> existingScene = sceneRepository.findById(id);
|
||||
if (existingScene.isEmpty()) {
|
||||
throw new IllegalArgumentException("Scene non trouvée avec l'ID: " + id);
|
||||
}
|
||||
|
||||
Scene scene = existingScene.get();
|
||||
BeanUtils.copyProperties(updated, scene, "id");
|
||||
|
||||
// Validation métier : le graphe narratif doit rester cohérent.
|
||||
validateBranches(scene);
|
||||
|
||||
return sceneRepository.save(scene);
|
||||
}
|
||||
|
||||
public void deleteScene(String id) {
|
||||
sceneRepository.deleteById(id);
|
||||
}
|
||||
|
||||
public boolean sceneExists(String id) {
|
||||
return sceneRepository.existsById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie les invariants du graphe narratif :
|
||||
* 1. Pas d'auto-référence (scène qui pointe sur elle-même).
|
||||
* 2. Toutes les branches pointent vers des scènes du MÊME chapitre.
|
||||
* 3. Pas de targetSceneId null/vide.
|
||||
* <p>
|
||||
* Note : on ne vérifie PAS l'existence réelle de chaque scène cible
|
||||
* individuellement (ça serait un N+1). On charge une seule fois les
|
||||
* IDs du chapitre et on compare.
|
||||
*/
|
||||
private void validateBranches(Scene scene) {
|
||||
List<SceneBranch> branches = scene.getBranches();
|
||||
if (branches == null || branches.isEmpty()) return;
|
||||
|
||||
// IDs des scènes du chapitre courant (référentiel de validation)
|
||||
Set<String> chapterSceneIds = sceneRepository.findByChapterId(scene.getChapterId()).stream()
|
||||
.map(Scene::getId)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
for (SceneBranch b : branches) {
|
||||
String target = b.getTargetSceneId();
|
||||
if (target == null || target.isBlank()) {
|
||||
throw new IllegalArgumentException("Une branche doit avoir une scène de destination");
|
||||
}
|
||||
if (target.equals(scene.getId())) {
|
||||
throw new IllegalArgumentException("Une scène ne peut pas se brancher sur elle-même");
|
||||
}
|
||||
if (!chapterSceneIds.contains(target)) {
|
||||
throw new IllegalArgumentException(
|
||||
"La branche pointe vers la scène " + target + " qui n'appartient pas au même chapitre");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.loremind.application.generationcontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Arc;
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import com.loremind.domain.campaigncontext.Scene;
|
||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Service applicatif qui construit un {@link CampaignStructuralContext}
|
||||
* depuis le Campaign Context (projection Campaign → GenerationContext).
|
||||
* <p>
|
||||
* Traverse l'arbre arcs → chapitres → scènes en respectant l'ordre narratif
|
||||
* (tri sur le champ `order` de chaque entité). Charge le NOM + le SYNOPSIS
|
||||
* (description courte) de chaque niveau : l'IA sait donc de quoi parle
|
||||
* chaque scène/chapitre/arc sans qu'on lui passe les notes MJ ou la
|
||||
* narration détaillée — celles-ci restent réservées à l'entité focus via
|
||||
* NarrativeEntityContext.
|
||||
*/
|
||||
@Component
|
||||
public class CampaignStructuralContextBuilder {
|
||||
|
||||
private final CampaignRepository campaignRepository;
|
||||
private final ArcRepository arcRepository;
|
||||
private final ChapterRepository chapterRepository;
|
||||
private final SceneRepository sceneRepository;
|
||||
|
||||
public CampaignStructuralContextBuilder(
|
||||
CampaignRepository campaignRepository,
|
||||
ArcRepository arcRepository,
|
||||
ChapterRepository chapterRepository,
|
||||
SceneRepository sceneRepository) {
|
||||
this.campaignRepository = campaignRepository;
|
||||
this.arcRepository = arcRepository;
|
||||
this.chapterRepository = chapterRepository;
|
||||
this.sceneRepository = sceneRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit la carte narrative d'une Campagne (arcs → chapitres → scènes,
|
||||
* nom + description courte à chaque niveau).
|
||||
* @throws IllegalArgumentException si la Campagne est introuvable
|
||||
*/
|
||||
public CampaignStructuralContext build(String campaignId) {
|
||||
Campaign campaign = campaignRepository.findById(campaignId)
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Campagne non trouvée avec l'ID: " + campaignId));
|
||||
|
||||
List<ArcSummary> arcs = arcRepository.findByCampaignId(campaignId).stream()
|
||||
.sorted(Comparator.comparingInt(Arc::getOrder))
|
||||
.map(this::toArcSummary)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return CampaignStructuralContext.builder()
|
||||
.campaignName(campaign.getName())
|
||||
.campaignDescription(campaign.getDescription())
|
||||
.arcs(arcs)
|
||||
.build();
|
||||
}
|
||||
|
||||
private ArcSummary toArcSummary(Arc arc) {
|
||||
List<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream()
|
||||
.sorted(Comparator.comparingInt(Chapter::getOrder))
|
||||
.map(this::toChapterSummary)
|
||||
.collect(Collectors.toList());
|
||||
return ArcSummary.builder()
|
||||
.name(arc.getName())
|
||||
.description(arc.getDescription())
|
||||
.illustrationCount(countImages(arc.getIllustrationImageIds()))
|
||||
.chapters(chapters)
|
||||
.build();
|
||||
}
|
||||
|
||||
private ChapterSummary toChapterSummary(Chapter chapter) {
|
||||
List<Scene> scenes = sceneRepository.findByChapterId(chapter.getId()).stream()
|
||||
.sorted(Comparator.comparingInt(Scene::getOrder))
|
||||
.toList();
|
||||
|
||||
// Map id -> nom construite en une seule passe pour resoudre les
|
||||
// targetSceneId des branches sans re-interroger le repo (evite N+1).
|
||||
Map<String, String> nameById = scenes.stream()
|
||||
.collect(Collectors.toMap(Scene::getId, Scene::getName));
|
||||
|
||||
List<SceneSummary> summaries = scenes.stream()
|
||||
.map(s -> toSceneSummary(s, nameById))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return ChapterSummary.builder()
|
||||
.name(chapter.getName())
|
||||
.description(chapter.getDescription())
|
||||
.illustrationCount(countImages(chapter.getIllustrationImageIds()))
|
||||
.scenes(summaries)
|
||||
.build();
|
||||
}
|
||||
|
||||
private SceneSummary toSceneSummary(Scene scene, Map<String, String> nameById) {
|
||||
List<BranchHint> hints = scene.getBranches() == null
|
||||
? List.of()
|
||||
: scene.getBranches().stream()
|
||||
.map(b -> BranchHint.builder()
|
||||
.label(b.getLabel())
|
||||
.targetSceneName(nameById.getOrDefault(
|
||||
b.getTargetSceneId(), "(scène inconnue)"))
|
||||
.condition(b.getCondition())
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return SceneSummary.builder()
|
||||
.name(scene.getName())
|
||||
.description(scene.getDescription())
|
||||
.illustrationCount(countImages(scene.getIllustrationImageIds()))
|
||||
.branches(hints)
|
||||
.build();
|
||||
}
|
||||
|
||||
/** Helper defensif : compte les illustrations attachees (null-safe). */
|
||||
private static int countImages(List<String> ids) {
|
||||
return ids == null ? 0 : ids.size();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.loremind.application.generationcontext;
|
||||
|
||||
import com.loremind.domain.generationcontext.GenerationContext;
|
||||
import com.loremind.domain.generationcontext.GenerationResult;
|
||||
import com.loremind.domain.generationcontext.ports.AiProvider;
|
||||
import com.loremind.domain.lorecontext.Lore;
|
||||
import com.loremind.domain.lorecontext.LoreNode;
|
||||
import com.loremind.domain.lorecontext.Page;
|
||||
import com.loremind.domain.lorecontext.Template;
|
||||
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
|
||||
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
||||
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Use case applicatif : génère des suggestions de valeurs pour les champs
|
||||
* d'une Page via l'IA.
|
||||
* <p>
|
||||
* Orchestrateur (couche Application de l'hexagonal). C'est le seul endroit
|
||||
* qui touche simultanément au LoreContext (chargement) et au GenerationContext
|
||||
* (appel IA). Le domaine reste isolé.
|
||||
* <p>
|
||||
* Décision produit : ce use case NE PERSISTE PAS les valeurs générées.
|
||||
* Il renvoie des suggestions que l'utilisateur validera manuellement via
|
||||
* le endpoint PUT /api/pages/{id} existant.
|
||||
*/
|
||||
@Service
|
||||
public class GeneratePageValuesUseCase {
|
||||
|
||||
private final PageRepository pageRepository;
|
||||
private final TemplateRepository templateRepository;
|
||||
private final LoreRepository loreRepository;
|
||||
private final LoreNodeRepository loreNodeRepository;
|
||||
private final AiProvider aiProvider;
|
||||
|
||||
public GeneratePageValuesUseCase(
|
||||
PageRepository pageRepository,
|
||||
TemplateRepository templateRepository,
|
||||
LoreRepository loreRepository,
|
||||
LoreNodeRepository loreNodeRepository,
|
||||
AiProvider aiProvider) {
|
||||
this.pageRepository = pageRepository;
|
||||
this.templateRepository = templateRepository;
|
||||
this.loreRepository = loreRepository;
|
||||
this.loreNodeRepository = loreNodeRepository;
|
||||
this.aiProvider = aiProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère les valeurs suggérées pour les champs dynamiques d'une Page.
|
||||
*
|
||||
* @param pageId identifiant de la Page à enrichir
|
||||
* @return map fieldName -> valeur suggérée (jamais null, peut contenir des chaînes vides)
|
||||
* @throws IllegalArgumentException si la Page est introuvable
|
||||
* @throws IllegalStateException si le Template, le Lore ou le dossier parent sont
|
||||
* incohérents (intégrité BDD cassée) ou si le Template
|
||||
* n'a aucun champ à générer
|
||||
*/
|
||||
public Map<String, String> execute(String pageId) {
|
||||
Page page = loadPage(pageId);
|
||||
Template template = loadTemplate(page.getTemplateId(), pageId);
|
||||
Lore lore = loadLore(page.getLoreId(), pageId);
|
||||
LoreNode folder = loadFolder(page.getNodeId(), pageId);
|
||||
|
||||
requireNonEmptyFields(template);
|
||||
|
||||
GenerationContext context = GenerationContext.builder()
|
||||
.loreName(lore.getName())
|
||||
.loreDescription(lore.getDescription())
|
||||
.folderName(folder.getName())
|
||||
.templateName(template.getName())
|
||||
// Seuls les champs TEXT sont envoyes a l'IA : les champs IMAGE
|
||||
// necessitent un workflow different (pas de generation LLM texte).
|
||||
.templateFields(template.textFieldNames())
|
||||
.pageTitle(page.getTitle())
|
||||
.build();
|
||||
|
||||
GenerationResult result = aiProvider.generatePage(context);
|
||||
return result.values();
|
||||
}
|
||||
|
||||
// --- Helpers de chargement (un lookup = un message d'erreur clair) ------
|
||||
|
||||
private Page loadPage(String pageId) {
|
||||
return pageRepository.findById(pageId)
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Page non trouvée avec l'ID: " + pageId));
|
||||
}
|
||||
|
||||
private Template loadTemplate(String templateId, String pageId) {
|
||||
if (templateId == null || templateId.isBlank()) {
|
||||
throw new IllegalStateException(
|
||||
"La page " + pageId + " n'a pas de template associé.");
|
||||
}
|
||||
return templateRepository.findById(templateId)
|
||||
.orElseThrow(() -> new IllegalStateException(
|
||||
"Template introuvable (id=" + templateId
|
||||
+ ") pour la page " + pageId));
|
||||
}
|
||||
|
||||
private Lore loadLore(String loreId, String pageId) {
|
||||
return loreRepository.findById(loreId)
|
||||
.orElseThrow(() -> new IllegalStateException(
|
||||
"Lore introuvable (id=" + loreId
|
||||
+ ") pour la page " + pageId));
|
||||
}
|
||||
|
||||
private LoreNode loadFolder(String nodeId, String pageId) {
|
||||
return loreNodeRepository.findById(nodeId)
|
||||
.orElseThrow(() -> new IllegalStateException(
|
||||
"Dossier parent introuvable (id=" + nodeId
|
||||
+ ") pour la page " + pageId));
|
||||
}
|
||||
|
||||
private void requireNonEmptyFields(Template template) {
|
||||
// On exige au moins un champ TEXT : les champs IMAGE ne sont pas genereables
|
||||
// par l'IA (pas de text-to-image pour l'instant).
|
||||
if (template.textFieldNames().isEmpty()) {
|
||||
throw new IllegalStateException(
|
||||
"Le template '" + template.getName()
|
||||
+ "' n'a aucun champ texte à générer.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.loremind.application.generationcontext;
|
||||
|
||||
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
||||
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
|
||||
import com.loremind.domain.lorecontext.Lore;
|
||||
import com.loremind.domain.lorecontext.LoreNode;
|
||||
import com.loremind.domain.lorecontext.Page;
|
||||
import com.loremind.domain.lorecontext.Template;
|
||||
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
|
||||
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
||||
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Service applicatif qui construit un {@link LoreStructuralContext}
|
||||
* depuis le Lore Context (Single Responsibility : projection LoreContext → GenerationContext).
|
||||
* <p>
|
||||
* Partagé entre {@link StreamChatForLoreUseCase} (Lore) et
|
||||
* {@link StreamChatForCampaignUseCase} (Campagne liée à un Lore) pour
|
||||
* respecter DRY — la carte structurelle d'un Lore se calcule de la même
|
||||
* manière des deux côtés.
|
||||
* <p>
|
||||
* Depuis b9 : chaque PageSummary embarque values/tags/relatedPageTitles
|
||||
* (résolus en titres), avec troncature à {@value #MAX_VALUE_LENGTH} caractères
|
||||
* par valeur pour garder le prompt sous contrôle.
|
||||
*/
|
||||
@Component
|
||||
public class LoreStructuralContextBuilder {
|
||||
|
||||
/** Garde-fou : évite qu'un champ énorme (ex: "Histoire" de 5000 car.) ne sature le prompt. */
|
||||
private static final int MAX_VALUE_LENGTH = 500;
|
||||
|
||||
private final LoreRepository loreRepository;
|
||||
private final LoreNodeRepository loreNodeRepository;
|
||||
private final PageRepository pageRepository;
|
||||
private final TemplateRepository templateRepository;
|
||||
|
||||
public LoreStructuralContextBuilder(
|
||||
LoreRepository loreRepository,
|
||||
LoreNodeRepository loreNodeRepository,
|
||||
PageRepository pageRepository,
|
||||
TemplateRepository templateRepository) {
|
||||
this.loreRepository = loreRepository;
|
||||
this.loreNodeRepository = loreNodeRepository;
|
||||
this.pageRepository = pageRepository;
|
||||
this.templateRepository = templateRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit la carte structurelle pour un Lore obligatoire.
|
||||
* @throws IllegalArgumentException si le Lore est introuvable
|
||||
*/
|
||||
public LoreStructuralContext build(String loreId) {
|
||||
return buildOptional(loreId).orElseThrow(() ->
|
||||
new IllegalArgumentException("Lore non trouvé avec l'ID: " + loreId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Variante non-strict : renvoie Optional.empty() si le Lore a été supprimé
|
||||
* (cas d'une Campagne dont le loreId pointe sur un Lore effacé entre-temps).
|
||||
*/
|
||||
public Optional<LoreStructuralContext> buildOptional(String loreId) {
|
||||
return loreRepository.findById(loreId).map(this::buildFromLore);
|
||||
}
|
||||
|
||||
private LoreStructuralContext buildFromLore(Lore lore) {
|
||||
List<LoreNode> nodes = loreNodeRepository.findByLoreId(lore.getId());
|
||||
List<Page> pages = pageRepository.findByLoreId(lore.getId());
|
||||
List<Template> templates = templateRepository.findByLoreId(lore.getId());
|
||||
|
||||
// Maps de résolution construites une seule fois — évite les N² en aval.
|
||||
Map<String, String> templateNameById = templates.stream()
|
||||
.collect(Collectors.toMap(Template::getId, Template::getName, (a, b) -> a));
|
||||
Map<String, String> pageTitleById = pages.stream()
|
||||
.collect(Collectors.toMap(Page::getId, Page::getTitle, (a, b) -> a));
|
||||
|
||||
return LoreStructuralContext.builder()
|
||||
.loreName(lore.getName())
|
||||
.loreDescription(lore.getDescription())
|
||||
.folders(buildFoldersMap(nodes, pages, templateNameById, pageTitleById))
|
||||
.tags(extractUniqueTags(pages))
|
||||
.build();
|
||||
}
|
||||
|
||||
private Map<String, List<PageSummary>> buildFoldersMap(
|
||||
List<LoreNode> nodes,
|
||||
List<Page> pages,
|
||||
Map<String, String> templateNameById,
|
||||
Map<String, String> pageTitleById) {
|
||||
// LinkedHashMap : préserve l'ordre d'insertion pour un prompt lisible.
|
||||
Map<String, List<PageSummary>> folders = new LinkedHashMap<>();
|
||||
for (LoreNode node : nodes) {
|
||||
folders.put(node.getName(), pagesInFolder(node.getId(), pages, templateNameById, pageTitleById));
|
||||
}
|
||||
return folders;
|
||||
}
|
||||
|
||||
private List<PageSummary> pagesInFolder(
|
||||
String nodeId,
|
||||
List<Page> allPages,
|
||||
Map<String, String> templateNameById,
|
||||
Map<String, String> pageTitleById) {
|
||||
return allPages.stream()
|
||||
.filter(p -> nodeId.equals(p.getNodeId()))
|
||||
.map(p -> toPageSummary(p, templateNameById, pageTitleById))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private PageSummary toPageSummary(
|
||||
Page page,
|
||||
Map<String, String> templateNameById,
|
||||
Map<String, String> pageTitleById) {
|
||||
return PageSummary.builder()
|
||||
.title(page.getTitle())
|
||||
.templateName(templateNameById.getOrDefault(page.getTemplateId(), "?"))
|
||||
.values(truncatedValues(page.getValues()))
|
||||
.tags(page.getTags() != null ? List.copyOf(page.getTags()) : Collections.emptyList())
|
||||
.relatedPageTitles(resolveRelatedTitles(page.getRelatedPageIds(), pageTitleById))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Copie défensive des values avec troncature par valeur.
|
||||
* Les entrées vides/nulles sont filtrées pour alléger le prompt.
|
||||
*/
|
||||
private Map<String, String> truncatedValues(Map<String, String> source) {
|
||||
if (source == null || source.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
Map<String, String> out = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, String> e : source.entrySet()) {
|
||||
String v = e.getValue();
|
||||
if (v == null || v.isBlank()) continue;
|
||||
out.put(e.getKey(), truncate(v));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private String truncate(String value) {
|
||||
if (value.length() <= MAX_VALUE_LENGTH) return value;
|
||||
return value.substring(0, MAX_VALUE_LENGTH) + "…";
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout les IDs de pages liées en titres. Un ID qui ne matche rien
|
||||
* (page supprimée entre-temps) est silencieusement ignoré — pas de "?"
|
||||
* qui polluerait le prompt.
|
||||
*/
|
||||
private List<String> resolveRelatedTitles(
|
||||
List<String> relatedIds, Map<String, String> pageTitleById) {
|
||||
if (relatedIds == null || relatedIds.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return relatedIds.stream()
|
||||
.map(pageTitleById::get)
|
||||
.filter(title -> title != null && !title.isBlank())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private List<String> extractUniqueTags(List<Page> pages) {
|
||||
return pages.stream()
|
||||
.filter(p -> p.getTags() != null)
|
||||
.flatMap(p -> p.getTags().stream())
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.loremind.application.generationcontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Arc;
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import com.loremind.domain.campaigncontext.Scene;
|
||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Service applicatif qui construit un {@link NarrativeEntityContext}
|
||||
* depuis une entité Arc / Chapter / Scene du Campaign Context.
|
||||
* <p>
|
||||
* Responsabilité unique : mapper les champs textuels spécifiques de chaque
|
||||
* type vers la map uniforme `fields` du VO. Utilise LinkedHashMap pour
|
||||
* préserver l'ordre des champs dans le prompt (lisibilité).
|
||||
*/
|
||||
@Component
|
||||
public class NarrativeEntityContextBuilder {
|
||||
|
||||
private final ArcRepository arcRepository;
|
||||
private final ChapterRepository chapterRepository;
|
||||
private final SceneRepository sceneRepository;
|
||||
|
||||
public NarrativeEntityContextBuilder(
|
||||
ArcRepository arcRepository,
|
||||
ChapterRepository chapterRepository,
|
||||
SceneRepository sceneRepository) {
|
||||
this.arcRepository = arcRepository;
|
||||
this.chapterRepository = chapterRepository;
|
||||
this.sceneRepository = sceneRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge l'entité narrative ciblée et la projette vers un VO du GenerationContext.
|
||||
*
|
||||
* @param entityType "arc", "chapter" ou "scene" (insensible à la casse)
|
||||
* @param entityId l'ID de l'entité
|
||||
* @throws IllegalArgumentException si le type est inconnu ou l'entité introuvable
|
||||
*/
|
||||
public NarrativeEntityContext build(String entityType, String entityId) {
|
||||
String normalized = entityType == null ? "" : entityType.trim().toLowerCase();
|
||||
return switch (normalized) {
|
||||
case "arc" -> fromArc(loadArc(entityId));
|
||||
case "chapter" -> fromChapter(loadChapter(entityId));
|
||||
case "scene" -> fromScene(loadScene(entityId));
|
||||
default -> throw new IllegalArgumentException("Type d'entité narrative inconnu: " + entityType);
|
||||
};
|
||||
}
|
||||
|
||||
// --- Chargement ---------------------------------------------------------
|
||||
|
||||
private Arc loadArc(String id) {
|
||||
return arcRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Arc non trouvé: " + id));
|
||||
}
|
||||
|
||||
private Chapter loadChapter(String id) {
|
||||
return chapterRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Chapitre non trouvé: " + id));
|
||||
}
|
||||
|
||||
private Scene loadScene(String id) {
|
||||
return sceneRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Scène non trouvée: " + id));
|
||||
}
|
||||
|
||||
// --- Mapping entité → VO ------------------------------------------------
|
||||
|
||||
private NarrativeEntityContext fromArc(Arc a) {
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
putField(fields, "description (synopsis)", a.getDescription());
|
||||
putField(fields, "themes", a.getThemes());
|
||||
putField(fields, "stakes", a.getStakes());
|
||||
putField(fields, "rewards", a.getRewards());
|
||||
putField(fields, "resolution", a.getResolution());
|
||||
putField(fields, "gmNotes", a.getGmNotes());
|
||||
return NarrativeEntityContext.builder()
|
||||
.entityType("arc")
|
||||
.title(a.getName())
|
||||
.fields(fields)
|
||||
.build();
|
||||
}
|
||||
|
||||
private NarrativeEntityContext fromChapter(Chapter c) {
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
putField(fields, "description (synopsis)", c.getDescription());
|
||||
putField(fields, "playerObjectives", c.getPlayerObjectives());
|
||||
putField(fields, "narrativeStakes", c.getNarrativeStakes());
|
||||
putField(fields, "gmNotes", c.getGmNotes());
|
||||
return NarrativeEntityContext.builder()
|
||||
.entityType("chapter")
|
||||
.title(c.getName())
|
||||
.fields(fields)
|
||||
.build();
|
||||
}
|
||||
|
||||
private NarrativeEntityContext fromScene(Scene s) {
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
putField(fields, "description", s.getDescription());
|
||||
putField(fields, "location", s.getLocation());
|
||||
putField(fields, "timing", s.getTiming());
|
||||
putField(fields, "atmosphere", s.getAtmosphere());
|
||||
putField(fields, "playerNarration", s.getPlayerNarration());
|
||||
putField(fields, "choicesConsequences", s.getChoicesConsequences());
|
||||
putField(fields, "combatDifficulty", s.getCombatDifficulty());
|
||||
putField(fields, "enemies", s.getEnemies());
|
||||
putField(fields, "gmSecretNotes", s.getGmSecretNotes());
|
||||
return NarrativeEntityContext.builder()
|
||||
.entityType("scene")
|
||||
.title(s.getName())
|
||||
.fields(fields)
|
||||
.build();
|
||||
}
|
||||
|
||||
/** Null/blank devient chaîne vide — uniforme côté prompt, pas de NPE côté LLM. */
|
||||
private static void putField(Map<String, String> target, String key, String value) {
|
||||
target.put(key, value == null ? "" : value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.loremind.application.generationcontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||
import com.loremind.domain.generationcontext.ChatMessage;
|
||||
import com.loremind.domain.generationcontext.ChatRequest;
|
||||
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
||||
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
||||
import com.loremind.domain.generationcontext.ports.AiChatProvider;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Use case applicatif : chat conversationnel pour une Campagne avec Structural Context.
|
||||
* <p>
|
||||
* Orchestre :
|
||||
* 1. Chargement de la carte narrative de la Campagne (arcs → chapitres → scènes).
|
||||
* 2. Si la Campagne est liée à un Lore (`loreId`), chargement également de
|
||||
* la carte du Lore associé (asymétrie métier : Campagne voit son Lore).
|
||||
* 3. Si une entité narrative précise est ciblée (arc/chapter/scene en cours
|
||||
* d'édition), focalisation via `NarrativeEntityContext`.
|
||||
* 4. Délégation au port `AiChatProvider` pour le streaming token par token.
|
||||
* <p>
|
||||
* Zéro persistance : la conversation est éphémère (responsabilité du frontend).
|
||||
*/
|
||||
@Service
|
||||
public class StreamChatForCampaignUseCase {
|
||||
|
||||
private final CampaignRepository campaignRepository;
|
||||
private final CampaignStructuralContextBuilder campaignContextBuilder;
|
||||
private final LoreStructuralContextBuilder loreContextBuilder;
|
||||
private final NarrativeEntityContextBuilder narrativeEntityContextBuilder;
|
||||
private final AiChatProvider aiChatProvider;
|
||||
|
||||
public StreamChatForCampaignUseCase(
|
||||
CampaignRepository campaignRepository,
|
||||
CampaignStructuralContextBuilder campaignContextBuilder,
|
||||
LoreStructuralContextBuilder loreContextBuilder,
|
||||
NarrativeEntityContextBuilder narrativeEntityContextBuilder,
|
||||
AiChatProvider aiChatProvider) {
|
||||
this.campaignRepository = campaignRepository;
|
||||
this.campaignContextBuilder = campaignContextBuilder;
|
||||
this.loreContextBuilder = loreContextBuilder;
|
||||
this.narrativeEntityContextBuilder = narrativeEntityContextBuilder;
|
||||
this.aiChatProvider = aiChatProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streame la réponse du LLM pour la Campagne donnée.
|
||||
* <p>
|
||||
* Méthode bloquante : retourne une fois le stream terminé (onComplete ou onError).
|
||||
* L'appelant (controller SSE) doit l'exécuter dans un thread dédié.
|
||||
*
|
||||
* @param campaignId obligatoire — la campagne concernée
|
||||
* @param entityType optionnel ("arc"|"chapter"|"scene") — si fourni avec entityId,
|
||||
* focalise l'IA sur l'entité narrative en cours d'édition.
|
||||
* @param entityId optionnel — ID de l'entité si `entityType` est fourni
|
||||
* @throws IllegalArgumentException si la Campagne (ou l'entité ciblée) est introuvable
|
||||
*/
|
||||
public void execute(
|
||||
String campaignId,
|
||||
String entityType,
|
||||
String entityId,
|
||||
List<ChatMessage> messages,
|
||||
Consumer<String> onToken,
|
||||
Runnable onComplete,
|
||||
Consumer<Throwable> onError) {
|
||||
|
||||
Campaign campaign = campaignRepository.findById(campaignId)
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Campagne non trouvée avec l'ID: " + campaignId));
|
||||
|
||||
CampaignStructuralContext campaignContext = campaignContextBuilder.build(campaignId);
|
||||
LoreStructuralContext loreContext = loadLinkedLoreContextOrNull(campaign);
|
||||
NarrativeEntityContext narrativeEntity = buildNarrativeEntityOrNull(entityType, entityId);
|
||||
|
||||
ChatRequest request = ChatRequest.builder()
|
||||
.messages(messages)
|
||||
.loreContext(loreContext)
|
||||
.campaignContext(campaignContext)
|
||||
.narrativeEntity(narrativeEntity)
|
||||
.build();
|
||||
|
||||
aiChatProvider.streamChat(request, onToken, onComplete, onError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge le LoreStructuralContext si la campagne est liée ET que le Lore
|
||||
* existe encore (cas dégradé : loreId pointant sur un Lore supprimé →
|
||||
* on continue sans contexte Lore plutôt que d'échouer).
|
||||
*/
|
||||
private LoreStructuralContext loadLinkedLoreContextOrNull(Campaign campaign) {
|
||||
if (!campaign.isLinkedToLore()) return null;
|
||||
return loreContextBuilder.buildOptional(campaign.getLoreId()).orElse(null);
|
||||
}
|
||||
|
||||
private NarrativeEntityContext buildNarrativeEntityOrNull(String entityType, String entityId) {
|
||||
if (entityType == null || entityType.isBlank()) return null;
|
||||
if (entityId == null || entityId.isBlank()) return null;
|
||||
return narrativeEntityContextBuilder.build(entityType, entityId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.loremind.application.generationcontext;
|
||||
|
||||
import com.loremind.domain.generationcontext.ChatMessage;
|
||||
import com.loremind.domain.generationcontext.ChatRequest;
|
||||
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
||||
import com.loremind.domain.generationcontext.PageContext;
|
||||
import com.loremind.domain.generationcontext.ports.AiChatProvider;
|
||||
import com.loremind.domain.lorecontext.Page;
|
||||
import com.loremind.domain.lorecontext.Template;
|
||||
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Use case applicatif : chat conversationnel avec Structural Context d'un Lore.
|
||||
* <p>
|
||||
* Orchestrateur fin — délègue la construction du LoreStructuralContext au
|
||||
* {@link LoreStructuralContextBuilder} (service partagé avec
|
||||
* {@link StreamChatForCampaignUseCase}), charge le PageContext si demandé,
|
||||
* puis délègue au port AiChatProvider pour le streaming.
|
||||
* <p>
|
||||
* Zéro persistance : la conversation est éphémère (responsabilité du frontend).
|
||||
*/
|
||||
@Service
|
||||
public class StreamChatForLoreUseCase {
|
||||
|
||||
private final LoreStructuralContextBuilder loreContextBuilder;
|
||||
private final PageRepository pageRepository;
|
||||
private final TemplateRepository templateRepository;
|
||||
private final AiChatProvider aiChatProvider;
|
||||
|
||||
public StreamChatForLoreUseCase(
|
||||
LoreStructuralContextBuilder loreContextBuilder,
|
||||
PageRepository pageRepository,
|
||||
TemplateRepository templateRepository,
|
||||
AiChatProvider aiChatProvider) {
|
||||
this.loreContextBuilder = loreContextBuilder;
|
||||
this.pageRepository = pageRepository;
|
||||
this.templateRepository = templateRepository;
|
||||
this.aiChatProvider = aiChatProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streame la réponse du LLM pour le Lore donné avec la conversation fournie.
|
||||
* <p>
|
||||
* Méthode bloquante : retourne une fois le stream terminé (onComplete ou onError).
|
||||
* L'appelant (controller SSE) doit l'exécuter dans un thread dédié.
|
||||
*
|
||||
* @param loreId obligatoire — l'univers concerné
|
||||
* @param pageId optionnel (nullable) — si fourni, focalise l'IA sur cette page
|
||||
* précise (template, champs, valeurs actuelles).
|
||||
* @throws IllegalArgumentException si le Lore (ou la Page si pageId fourni) est introuvable
|
||||
*/
|
||||
public void execute(
|
||||
String loreId,
|
||||
String pageId,
|
||||
List<ChatMessage> messages,
|
||||
Consumer<String> onToken,
|
||||
Runnable onComplete,
|
||||
Consumer<Throwable> onError) {
|
||||
|
||||
LoreStructuralContext loreContext = loreContextBuilder.build(loreId);
|
||||
PageContext pageContext = (pageId == null || pageId.isBlank())
|
||||
? null
|
||||
: buildPageContext(pageId);
|
||||
|
||||
ChatRequest request = ChatRequest.builder()
|
||||
.messages(messages)
|
||||
.loreContext(loreContext)
|
||||
.pageContext(pageContext)
|
||||
.build();
|
||||
|
||||
aiChatProvider.streamChat(request, onToken, onComplete, onError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge la Page + son Template et construit un PageContext prêt à injecter.
|
||||
* Si le template est absent (page orpheline), on renvoie un PageContext
|
||||
* minimal (titre + template "?", champs vides) — l'IA reste contextualisée
|
||||
* sur la page sans pouvoir proposer de champs précis.
|
||||
*/
|
||||
private PageContext buildPageContext(String pageId) {
|
||||
Page page = pageRepository.findById(pageId)
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Page non trouvée avec l'ID: " + pageId));
|
||||
|
||||
String templateName = "?";
|
||||
List<String> templateFields = Collections.emptyList();
|
||||
if (page.hasTemplate()) {
|
||||
Template template = templateRepository.findById(page.getTemplateId()).orElse(null);
|
||||
if (template != null) {
|
||||
templateName = template.getName();
|
||||
// On expose uniquement les noms des champs TEXT a l'IA pour le chat.
|
||||
// Les champs IMAGE ne sont pas pertinents pour une generation textuelle.
|
||||
templateFields = template.textFieldNames();
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, String> values = page.getValues() != null
|
||||
? page.getValues()
|
||||
: Collections.emptyMap();
|
||||
|
||||
return PageContext.builder()
|
||||
.title(page.getTitle())
|
||||
.templateName(templateName)
|
||||
.templateFields(templateFields)
|
||||
.values(values)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.loremind.application.images;
|
||||
|
||||
import com.loremind.domain.images.Image;
|
||||
import com.loremind.domain.images.ports.ImageRepository;
|
||||
import com.loremind.domain.images.ports.ImageStorage;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Service d'application pour le Shared Kernel images.
|
||||
* <p>
|
||||
* Orchestre l'upload / download / delete en combinant les deux ports du
|
||||
* domaine : ImageStorage (binaire) et ImageRepository (metadonnees).
|
||||
* <p>
|
||||
* Couche Application de l'Architecture Hexagonale : pas de JPA, pas de HTTP,
|
||||
* pas de MinIO ici. Juste de la logique metier pure.
|
||||
*/
|
||||
@Service
|
||||
public class ImageService {
|
||||
|
||||
/** MIME types autorises a l'upload. Evite les fichiers piegeux deguises en image. */
|
||||
private static final Set<String> ALLOWED_MIME_TYPES = Set.of(
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif"
|
||||
);
|
||||
|
||||
/** Taille max coherente avec la config Spring (application.properties). */
|
||||
private static final long MAX_SIZE_BYTES = 10L * 1024 * 1024; // 10 Mo
|
||||
|
||||
private final ImageRepository imageRepository;
|
||||
private final ImageStorage imageStorage;
|
||||
|
||||
public ImageService(ImageRepository imageRepository, ImageStorage imageStorage) {
|
||||
this.imageRepository = imageRepository;
|
||||
this.imageStorage = imageStorage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use case upload : valide -> envoie le binaire -> persiste les metadonnees.
|
||||
* <p>
|
||||
* En cas d'echec de persistance DB apres un upload MinIO reussi, on tente
|
||||
* une compensation (suppression du binaire orphelin) pour eviter de
|
||||
* laisser trainer un fichier sans reference.
|
||||
*/
|
||||
public Image upload(String filename, String contentType, InputStream data, long sizeBytes) {
|
||||
validateUpload(filename, contentType, sizeBytes);
|
||||
|
||||
String storageKey = imageStorage.upload(filename, contentType, data, sizeBytes);
|
||||
|
||||
try {
|
||||
Image image = Image.builder()
|
||||
.filename(filename)
|
||||
.contentType(contentType)
|
||||
.sizeBytes(sizeBytes)
|
||||
.storageKey(storageKey)
|
||||
.uploadedAt(LocalDateTime.now())
|
||||
.build();
|
||||
return imageRepository.save(image);
|
||||
} catch (RuntimeException ex) {
|
||||
// Compensation : on evite le binaire orphelin en MinIO si la DB a plante.
|
||||
imageStorage.delete(storageKey);
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<Image> getById(String id) {
|
||||
return imageRepository.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupere le flux binaire d'une image via son ID metier.
|
||||
* Utilise par le controller pour servir GET /api/images/:id.
|
||||
*/
|
||||
public Optional<InputStream> downloadById(String id) {
|
||||
return imageRepository.findById(id)
|
||||
.map(img -> imageStorage.download(img.getStorageKey()));
|
||||
}
|
||||
|
||||
/** Suppression symetrique : binaire d'abord, metadonnees ensuite. */
|
||||
public void deleteById(String id) {
|
||||
imageRepository.findById(id).ifPresent(img -> {
|
||||
imageStorage.delete(img.getStorageKey());
|
||||
imageRepository.deleteById(id);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Validation --------------------------------------------------------
|
||||
|
||||
private void validateUpload(String filename, String contentType, long sizeBytes) {
|
||||
if (filename == null || filename.isBlank()) {
|
||||
throw new IllegalArgumentException("Le nom du fichier est requis.");
|
||||
}
|
||||
if (contentType == null || !ALLOWED_MIME_TYPES.contains(contentType.toLowerCase())) {
|
||||
throw new IllegalArgumentException(
|
||||
"Type de fichier non supporte. Types acceptes : " + List.copyOf(ALLOWED_MIME_TYPES));
|
||||
}
|
||||
if (sizeBytes <= 0) {
|
||||
throw new IllegalArgumentException("Le fichier est vide.");
|
||||
}
|
||||
if (sizeBytes > MAX_SIZE_BYTES) {
|
||||
throw new IllegalArgumentException(
|
||||
"Fichier trop volumineux (max " + (MAX_SIZE_BYTES / 1024 / 1024) + " Mo).");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.loremind.application.lorecontext;
|
||||
|
||||
import com.loremind.domain.lorecontext.LoreNode;
|
||||
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Service d'application pour le contexte LoreNode.
|
||||
* Orchestre la logique métier en utilisant le Port LoreNodeRepository.
|
||||
* Fait partie de la couche Application de l'Architecture Hexagonale.
|
||||
*/
|
||||
@Service
|
||||
public class LoreNodeService {
|
||||
|
||||
private final LoreNodeRepository loreNodeRepository;
|
||||
|
||||
public LoreNodeService(LoreNodeRepository loreNodeRepository) {
|
||||
this.loreNodeRepository = loreNodeRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un LoreNode (dossier) à partir d'un "objet changes" porteur des valeurs
|
||||
* souhaitées (pattern Parameter Object) : évite les signatures qui gonflent
|
||||
* à chaque ajout de champ.
|
||||
*/
|
||||
public LoreNode createLoreNode(LoreNode changes) {
|
||||
LoreNode loreNode = LoreNode.builder()
|
||||
.name(changes.getName())
|
||||
.icon(changes.getIcon())
|
||||
.parentId(changes.getParentId())
|
||||
.loreId(changes.getLoreId())
|
||||
.build();
|
||||
return loreNodeRepository.save(loreNode);
|
||||
}
|
||||
|
||||
public Optional<LoreNode> getLoreNodeById(String id) {
|
||||
return loreNodeRepository.findById(id);
|
||||
}
|
||||
|
||||
public List<LoreNode> getAllLoreNodes() {
|
||||
return loreNodeRepository.findAll();
|
||||
}
|
||||
|
||||
public List<LoreNode> getLoreNodesByLoreId(String loreId) {
|
||||
return loreNodeRepository.findByLoreId(loreId);
|
||||
}
|
||||
|
||||
public List<LoreNode> getLoreNodesByParentId(String parentId) {
|
||||
return loreNodeRepository.findByParentId(parentId);
|
||||
}
|
||||
|
||||
public List<LoreNode> searchLoreNodes(String query) {
|
||||
if (query == null || query.isBlank()) return List.of();
|
||||
return loreNodeRepository.searchByName(query.trim());
|
||||
}
|
||||
|
||||
public LoreNode updateLoreNode(String id, LoreNode changes) {
|
||||
LoreNode existing = loreNodeRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("LoreNode non trouvé avec l'ID: " + id));
|
||||
|
||||
existing.setName(changes.getName());
|
||||
existing.setIcon(changes.getIcon());
|
||||
existing.setParentId(changes.getParentId());
|
||||
// loreId volontairement immuable (un dossier ne migre pas d'un Lore à l'autre).
|
||||
return loreNodeRepository.save(existing);
|
||||
}
|
||||
|
||||
public void deleteLoreNode(String id) {
|
||||
loreNodeRepository.deleteById(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.loremind.application.lorecontext;
|
||||
|
||||
import com.loremind.domain.lorecontext.Lore;
|
||||
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
|
||||
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
||||
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Service d'application pour le contexte Lore.
|
||||
* Orchestre la logique métier en utilisant le Port LoreRepository.
|
||||
* Fait partie de la couche Application de l'Architecture Hexagonale.
|
||||
* <p>
|
||||
* Note: les compteurs nodeCount/pageCount sont calculés à la volée via
|
||||
* countByLoreId sur les ports LoreNode et Page, plutôt que stockés en BDD
|
||||
* (la colonne existe encore mais n'est plus fiable). Source of truth = les
|
||||
* tables nodes/pages elles-mêmes, jamais de désync possible.
|
||||
*/
|
||||
@Service
|
||||
public class LoreService {
|
||||
|
||||
private final LoreRepository loreRepository;
|
||||
private final LoreNodeRepository loreNodeRepository;
|
||||
private final PageRepository pageRepository;
|
||||
|
||||
public LoreService(LoreRepository loreRepository,
|
||||
LoreNodeRepository loreNodeRepository,
|
||||
PageRepository pageRepository) {
|
||||
this.loreRepository = loreRepository;
|
||||
this.loreNodeRepository = loreNodeRepository;
|
||||
this.pageRepository = pageRepository;
|
||||
}
|
||||
|
||||
public Lore createLore(String name, String description) {
|
||||
Lore lore = Lore.builder()
|
||||
.name(name)
|
||||
.description(description)
|
||||
.nodeCount(0)
|
||||
.pageCount(0)
|
||||
.build();
|
||||
return loreRepository.save(lore);
|
||||
}
|
||||
|
||||
public Optional<Lore> getLoreById(String id) {
|
||||
return loreRepository.findById(id).map(this::withCounts);
|
||||
}
|
||||
|
||||
public List<Lore> getAllLores() {
|
||||
return loreRepository.findAll().stream()
|
||||
.map(this::withCounts)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrichit un Lore avec les compteurs calculés à la volée.
|
||||
* N+1 acceptable à l'échelle actuelle (quelques dizaines de Lores max).
|
||||
*/
|
||||
private Lore withCounts(Lore lore) {
|
||||
lore.setNodeCount((int) loreNodeRepository.countByLoreId(lore.getId()));
|
||||
lore.setPageCount((int) pageRepository.countByLoreId(lore.getId()));
|
||||
return lore;
|
||||
}
|
||||
|
||||
/** Recherche par nom (ILIKE). Résultats sans compteurs — pas utile pour la command palette. */
|
||||
public List<Lore> searchLores(String query) {
|
||||
if (query == null || query.isBlank()) return List.of();
|
||||
return loreRepository.searchByName(query.trim());
|
||||
}
|
||||
|
||||
public Lore updateLore(String id, String name, String description) {
|
||||
Optional<Lore> existingLore = loreRepository.findById(id);
|
||||
if (existingLore.isEmpty()) {
|
||||
throw new IllegalArgumentException("Lore non trouvé avec l'ID: " + id);
|
||||
}
|
||||
|
||||
Lore lore = existingLore.get();
|
||||
lore.setName(name);
|
||||
lore.setDescription(description);
|
||||
return loreRepository.save(lore);
|
||||
}
|
||||
|
||||
public void deleteLore(String id) {
|
||||
loreRepository.deleteById(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.loremind.application.lorecontext;
|
||||
|
||||
import com.loremind.domain.lorecontext.Page;
|
||||
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.loremind.domain.shared.CollectionUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Service d'application pour le contexte Page.
|
||||
* Orchestre la logique métier via le Port PageRepository.
|
||||
* Couche Application de l'Architecture Hexagonale.
|
||||
*/
|
||||
@Service
|
||||
public class PageService {
|
||||
|
||||
private final PageRepository pageRepository;
|
||||
|
||||
public PageService(PageRepository pageRepository) {
|
||||
this.pageRepository = pageRepository;
|
||||
}
|
||||
|
||||
/** Création MVP : seuls les champs structurels sont requis. Le contenu est
|
||||
* enrichi plus tard depuis l'écran d'édition. */
|
||||
public Page createPage(String loreId, String nodeId, String templateId, String title) {
|
||||
Page page = Page.builder()
|
||||
.loreId(loreId)
|
||||
.nodeId(nodeId)
|
||||
.templateId(templateId)
|
||||
.title(title)
|
||||
.values(new HashMap<>())
|
||||
.tags(new ArrayList<>())
|
||||
.relatedPageIds(new ArrayList<>())
|
||||
.build();
|
||||
return pageRepository.save(page);
|
||||
}
|
||||
|
||||
public Optional<Page> getPageById(String id) {
|
||||
return pageRepository.findById(id);
|
||||
}
|
||||
|
||||
public List<Page> getAllPages() {
|
||||
return pageRepository.findAll();
|
||||
}
|
||||
|
||||
public List<Page> getPagesByLoreId(String loreId) {
|
||||
return pageRepository.findByLoreId(loreId);
|
||||
}
|
||||
|
||||
public List<Page> getPagesByNodeId(String nodeId) {
|
||||
return pageRepository.findByNodeId(nodeId);
|
||||
}
|
||||
|
||||
public List<Page> searchPages(String query) {
|
||||
if (query == null || query.isBlank()) return List.of();
|
||||
return pageRepository.searchByTitle(query.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour une page existante.
|
||||
* Parameter Object pattern : le controller construit une Page "changes"
|
||||
* avec les nouvelles valeurs, le service recharge et applique les champs modifiables.
|
||||
* Les champs structurels immuables sont : id, loreId, templateId.
|
||||
* Le nodeId est mutable (déplacement d'une page d'un noeud à l'autre).
|
||||
*/
|
||||
public Page updatePage(String id, Page changes) {
|
||||
Page existing = pageRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Page non trouvée avec l'ID: " + id));
|
||||
|
||||
existing.setTitle(changes.getTitle());
|
||||
existing.setNodeId(changes.getNodeId());
|
||||
existing.setValues(CollectionUtils.copyMap(changes.getValues()));
|
||||
existing.setImageValues(CollectionUtils.copyMap(changes.getImageValues()));
|
||||
existing.setNotes(changes.getNotes());
|
||||
existing.setTags(CollectionUtils.copyList(changes.getTags()));
|
||||
existing.setRelatedPageIds(CollectionUtils.copyList(changes.getRelatedPageIds()));
|
||||
return pageRepository.save(existing);
|
||||
}
|
||||
|
||||
public void deletePage(String id) {
|
||||
pageRepository.deleteById(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.loremind.application.lorecontext;
|
||||
|
||||
import com.loremind.domain.lorecontext.Template;
|
||||
import com.loremind.domain.lorecontext.TemplateField;
|
||||
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Service d'application pour le contexte Template.
|
||||
* Orchestre la logique métier via le Port TemplateRepository.
|
||||
* Couche Application de l'Architecture Hexagonale.
|
||||
*/
|
||||
@Service
|
||||
public class TemplateService {
|
||||
|
||||
private final TemplateRepository templateRepository;
|
||||
|
||||
public TemplateService(TemplateRepository templateRepository) {
|
||||
this.templateRepository = templateRepository;
|
||||
}
|
||||
|
||||
public Template createTemplate(String loreId,
|
||||
String name,
|
||||
String description,
|
||||
String defaultNodeId,
|
||||
List<TemplateField> fields) {
|
||||
Template template = Template.builder()
|
||||
.loreId(loreId)
|
||||
.name(name)
|
||||
.description(description)
|
||||
.defaultNodeId(defaultNodeId)
|
||||
.fields(fields != null ? new ArrayList<>(fields) : new ArrayList<>())
|
||||
.build();
|
||||
return templateRepository.save(template);
|
||||
}
|
||||
|
||||
public Optional<Template> getTemplateById(String id) {
|
||||
return templateRepository.findById(id);
|
||||
}
|
||||
|
||||
public List<Template> getAllTemplates() {
|
||||
return templateRepository.findAll();
|
||||
}
|
||||
|
||||
public List<Template> getTemplatesByLoreId(String loreId) {
|
||||
return templateRepository.findByLoreId(loreId);
|
||||
}
|
||||
|
||||
public List<Template> searchTemplates(String query) {
|
||||
if (query == null || query.isBlank()) return List.of();
|
||||
return templateRepository.searchByName(query.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour un Template existant.
|
||||
* Pattern Parameter Object via l'entité Template elle-même :
|
||||
* le controller construit un Template avec les champs à modifier, le service
|
||||
* recharge l'existant et applique les changements.
|
||||
*/
|
||||
public Template updateTemplate(String id, Template changes) {
|
||||
Template existing = templateRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Template non trouvé avec l'ID: " + id));
|
||||
|
||||
existing.setName(changes.getName());
|
||||
existing.setDescription(changes.getDescription());
|
||||
existing.setDefaultNodeId(changes.getDefaultNodeId());
|
||||
existing.setFields(changes.getFields() != null
|
||||
? new ArrayList<>(changes.getFields())
|
||||
: new ArrayList<>());
|
||||
// loreId volontairement immuable : un template ne migre pas d'un Lore à l'autre.
|
||||
return templateRepository.save(existing);
|
||||
}
|
||||
|
||||
public void deleteTemplate(String id) {
|
||||
templateRepository.deleteById(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Entité de domaine représentant un Arc narratif.
|
||||
* Division majeure d'une Campaign (ex: "L'arc sombre").
|
||||
* Entité pure du domaine, sans dépendance technique.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class Arc {
|
||||
|
||||
private String id;
|
||||
private String name;
|
||||
private String description; // = Synopsis dans l'UI
|
||||
private String campaignId; // Référence vers la Campaign parente
|
||||
private int order; // Ordre de l'arc dans la campagne
|
||||
|
||||
// Champs narratifs enrichis (voir docs/maquettes/campagne/détail/)
|
||||
private String themes; // Thèmes principaux explorés dans cet arc
|
||||
private String stakes; // Enjeux globaux pour les personnages
|
||||
private String gmNotes; // Notes privées du MJ (non exportées vers FoundryVTT)
|
||||
private String rewards; // Récompenses et progression
|
||||
private String resolution; // Dénouement prévu
|
||||
|
||||
/**
|
||||
* IDs des pages du Lore associées à cet arc (weak cross-context references).
|
||||
* Permet au MJ de lier des PNJ, lieux ou lore items qui jouent un rôle dans cet arc.
|
||||
* Ne contient que des IDs ; pas d'import du Lore Context (respect des Bounded Contexts).
|
||||
* Initialisé en {@link ArrayList} vide dans le builder pour éviter les NPE.
|
||||
*/
|
||||
@Builder.Default
|
||||
private List<String> relatedPageIds = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* IDs des images (Shared Kernel) servant d'illustrations a cet arc.
|
||||
* Galerie ordonnee : la 1ere image est l'illustration principale.
|
||||
*/
|
||||
@Builder.Default
|
||||
private List<String> illustrationImageIds = new ArrayList<>();
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Entité de domaine représentant une Campaign.
|
||||
* Conteneur global pour organiser la narration d'une campagne.
|
||||
* Entité pure du domaine, sans dépendance technique.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class Campaign {
|
||||
|
||||
private String id;
|
||||
private String name;
|
||||
private String description;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
private int arcsCount;
|
||||
|
||||
/**
|
||||
* Référence faible (weak reference) vers un Lore.
|
||||
* Nullable : une campagne peut exister sans univers associé (one-shot, test, pitch libre).
|
||||
* Ce n'est qu'un ID : le Campaign Context ne dépend PAS du Lore Context
|
||||
* (respect des Bounded Contexts en DDD).
|
||||
*/
|
||||
private String loreId;
|
||||
|
||||
public boolean isLinkedToLore() {
|
||||
return this.loreId != null && !this.loreId.isBlank();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Entité de domaine représentant un Chapter.
|
||||
* Subdivision d'un Arc (ex: "Chapitre 1: Le début").
|
||||
* Entité pure du domaine, sans dépendance technique.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class Chapter {
|
||||
|
||||
private String id;
|
||||
private String name;
|
||||
private String description; // = Synopsis du chapitre dans l'UI
|
||||
private String arcId; // Référence vers l'Arc parent
|
||||
private int order; // Ordre du chapitre dans l'arc
|
||||
|
||||
// Champs narratifs enrichis (voir docs/maquettes/campagne/détail/)
|
||||
private String gmNotes; // Notes privées du MJ (non exportées vers FoundryVTT)
|
||||
private String playerObjectives; // Objectifs des joueurs dans ce chapitre
|
||||
private String narrativeStakes; // Enjeux narratifs dramatiques
|
||||
|
||||
/**
|
||||
* IDs des pages du Lore associées à ce chapitre (weak cross-context references).
|
||||
* Permet au MJ de lier des PNJ / lieux / éléments du Lore qui apparaissent dans ce chapitre.
|
||||
*/
|
||||
@Builder.Default
|
||||
private List<String> relatedPageIds = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* IDs des images (Shared Kernel) illustrant ce chapitre.
|
||||
*/
|
||||
@Builder.Default
|
||||
private List<String> illustrationImageIds = new ArrayList<>();
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Entité de domaine représentant une Scene.
|
||||
* Unité de jeu la plus fine, subdivision d'un Chapter (ex: "Scène 1: L'auberge").
|
||||
* Entité pure du domaine, sans dépendance technique.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class Scene {
|
||||
|
||||
private String id;
|
||||
private String name;
|
||||
private String description; // = Description courte dans l'UI
|
||||
private String chapterId; // Référence vers le Chapter parent
|
||||
private int order; // Ordre de la scène dans le chapitre
|
||||
|
||||
// === Contexte et ambiance ===
|
||||
private String location; // Lieu de la scène (ex: Taverne du Dragon d'Or)
|
||||
private String timing; // Moment (ex: Soir, à la tombée de la nuit)
|
||||
private String atmosphere; // Ambiance générale (sons, odeurs, émotions...)
|
||||
|
||||
// === Narration pour les joueurs ===
|
||||
private String playerNarration; // Texte lu directement aux joueurs
|
||||
|
||||
// === Notes et secrets du MJ (privé) ===
|
||||
private String gmSecretNotes; // Informations cachées, non visibles par les joueurs
|
||||
|
||||
// === Choix et conséquences ===
|
||||
private String choicesConsequences; // Options offertes aux joueurs et leurs conséquences
|
||||
|
||||
// === Combat ou rencontre ===
|
||||
private String combatDifficulty; // Difficulté estimée
|
||||
private String enemies; // Liste des ennemis et créatures
|
||||
|
||||
/**
|
||||
* IDs des pages du Lore associées à cette scène (weak cross-context references).
|
||||
* Très utile pour la préparation : épingler un lieu, un PNJ, une créature à une scène.
|
||||
*/
|
||||
@Builder.Default
|
||||
private List<String> relatedPageIds = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* IDs des images (Shared Kernel) illustrant cette scene.
|
||||
* Utile pour carte du lieu, portraits des PNJ principaux, ambiance.
|
||||
*/
|
||||
@Builder.Default
|
||||
private List<String> illustrationImageIds = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Sorties narratives possibles depuis cette scène (graphe intra-chapitre).
|
||||
* Chaque branche décrit un choix des joueurs et la scène de destination.
|
||||
* Liste vide = scène "feuille" (fin de chapitre ou scène linéaire).
|
||||
*/
|
||||
@Builder.Default
|
||||
private List<SceneBranch> branches = new ArrayList<>();
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
import lombok.extern.jackson.Jacksonized;
|
||||
|
||||
/**
|
||||
* Value Object représentant une "sortie" narrative depuis une Scene.
|
||||
* Décrit un choix offert aux joueurs et la scène de destination associée.
|
||||
* <p>
|
||||
* Immuable (@Value) : pour "modifier" une branche on la remplace.
|
||||
* @Jacksonized : permet à Jackson (sérialisation JSON via le converter JPA)
|
||||
* de reconstruire l'objet en passant par le builder malgré l'absence de setters.
|
||||
* <p>
|
||||
* Règle métier : targetSceneId DOIT pointer vers une Scene du MÊME Chapter
|
||||
* (validation portée par SceneService).
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
@Jacksonized
|
||||
public class SceneBranch {
|
||||
|
||||
/** Libellé du choix (ex: "Si les joueurs attaquent le garde"). */
|
||||
String label;
|
||||
|
||||
/** Id de la Scene de destination, intra-chapitre uniquement. */
|
||||
String targetSceneId;
|
||||
|
||||
/** Notes MJ privées sur la condition de déclenchement (optionnel). */
|
||||
String condition;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.loremind.domain.campaigncontext.ports;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Arc;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Port de sortie pour la persistance des Arcs.
|
||||
* Interface définie dans le domaine, implémentée par l'infrastructure.
|
||||
*/
|
||||
public interface ArcRepository {
|
||||
|
||||
Arc save(Arc arc);
|
||||
|
||||
Optional<Arc> findById(String id);
|
||||
|
||||
List<Arc> findByCampaignId(String campaignId);
|
||||
|
||||
List<Arc> findAll();
|
||||
|
||||
void deleteById(String id);
|
||||
|
||||
boolean existsById(String id);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.loremind.domain.campaigncontext.ports;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Port de sortie pour la persistance des Campaigns.
|
||||
* Interface définie dans le domaine, implémentée par l'infrastructure.
|
||||
*/
|
||||
public interface CampaignRepository {
|
||||
|
||||
Campaign save(Campaign campaign);
|
||||
|
||||
Optional<Campaign> findById(String id);
|
||||
|
||||
List<Campaign> findAll();
|
||||
|
||||
void deleteById(String id);
|
||||
|
||||
boolean existsById(String id);
|
||||
|
||||
List<Campaign> searchByName(String query);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.loremind.domain.campaigncontext.ports;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Port de sortie pour la persistance des Chapters.
|
||||
* Interface définie dans le domaine, implémentée par l'infrastructure.
|
||||
*/
|
||||
public interface ChapterRepository {
|
||||
|
||||
Chapter save(Chapter chapter);
|
||||
|
||||
Optional<Chapter> findById(String id);
|
||||
|
||||
List<Chapter> findByArcId(String arcId);
|
||||
|
||||
List<Chapter> findAll();
|
||||
|
||||
void deleteById(String id);
|
||||
|
||||
boolean existsById(String id);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.loremind.domain.campaigncontext.ports;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Scene;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Port de sortie pour la persistance des Scenes.
|
||||
* Interface définie dans le domaine, implémentée par l'infrastructure.
|
||||
*/
|
||||
public interface SceneRepository {
|
||||
|
||||
Scene save(Scene scene);
|
||||
|
||||
Optional<Scene> findById(String id);
|
||||
|
||||
List<Scene> findByChapterId(String chapterId);
|
||||
|
||||
List<Scene> findAll();
|
||||
|
||||
void deleteById(String id);
|
||||
|
||||
boolean existsById(String id);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Singular;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Carte narrative enrichie d'une Campagne pour nourrir l'IA.
|
||||
* <p>
|
||||
* Ceci est un Value Object du Generation Context (Bounded Context IA).
|
||||
* Jumeau de LoreStructuralContext côté Campaign : on décrit l'arbre
|
||||
* arcs → chapitres → scènes avec le NOM + une DESCRIPTION courte à chaque
|
||||
* niveau. Les champs longs (notes MJ, narration joueur, combat) restent
|
||||
* exclus : l'IA les obtient uniquement via {@link NarrativeEntityContext}
|
||||
* pour l'entité focus.
|
||||
* <p>
|
||||
* Objectif : permettre à l'IA de répondre "c'est quoi la scène X ?" même
|
||||
* quand X n'est pas l'entité en cours d'édition, sans exploser le prompt.
|
||||
* Budget typique : ~30 tokens/scène × 100 scènes = 3k tokens (confortable).
|
||||
* <p>
|
||||
* La liste `arcs` préserve l'ordre narratif (tri sur `order` ascendant
|
||||
* fait par le use case côté application layer).
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public class CampaignStructuralContext {
|
||||
|
||||
String campaignName;
|
||||
String campaignDescription;
|
||||
@Singular List<ArcSummary> arcs;
|
||||
|
||||
/** Résumé d'un arc : nom + description courte + ses chapitres. */
|
||||
@Value
|
||||
@Builder
|
||||
public static class ArcSummary {
|
||||
String name;
|
||||
String description;
|
||||
/** Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA). */
|
||||
int illustrationCount;
|
||||
@Singular List<ChapterSummary> chapters;
|
||||
}
|
||||
|
||||
/** Résumé d'un chapitre : nom + description courte + ses scènes. */
|
||||
@Value
|
||||
@Builder
|
||||
public static class ChapterSummary {
|
||||
String name;
|
||||
String description;
|
||||
int illustrationCount;
|
||||
@Singular List<SceneSummary> scenes;
|
||||
}
|
||||
|
||||
/** Résumé d'une scène : nom + description courte + branches narratives. */
|
||||
@Value
|
||||
@Builder
|
||||
public static class SceneSummary {
|
||||
String name;
|
||||
String description;
|
||||
int illustrationCount;
|
||||
@Singular List<BranchHint> branches;
|
||||
}
|
||||
|
||||
/** Indice d'une branche narrative vers une autre scène du même chapitre. */
|
||||
@Value
|
||||
@Builder
|
||||
public static class BranchHint {
|
||||
/** Libellé du choix joueur (ex: "Si les joueurs attaquent le garde"). */
|
||||
String label;
|
||||
/** Nom de la scène cible (résolu depuis targetSceneId côté builder). */
|
||||
String targetSceneName;
|
||||
/** Condition MJ privée (optionnel). */
|
||||
String condition;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
/**
|
||||
* Un message d'une conversation avec le LLM.
|
||||
* <p>
|
||||
* Rôles acceptés : "user", "assistant", "system".
|
||||
* Object de valeur immuable — cohérent avec le reste du domaine.
|
||||
*/
|
||||
public record ChatMessage(String role, String content) {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Object de valeur encapsulant une requête de chat streamé.
|
||||
* <p>
|
||||
* Ceci est un Value Object du Generation Context.
|
||||
* Regroupe l'historique de la conversation et les contextes structurels
|
||||
* (Lore et/ou Campagne) dont l'IA a besoin pour répondre.
|
||||
* <p>
|
||||
* Combinaisons supportées (asymétrie demandée par le métier) :
|
||||
* - loreContext seul → chat Lore (page-edit / page-create)
|
||||
* - loreContext + pageContext → chat Lore focalisé sur une page
|
||||
* - campaignContext (+ loreContext si liée) → chat Campagne, voit son Lore associé
|
||||
* - campaignContext + narrativeEntity → chat Campagne focalisé sur arc/chapter/scene
|
||||
* <p>
|
||||
* Un chat Lore ne reçoit JAMAIS de campaignContext : un Lore ne voit pas
|
||||
* ses campagnes (asymétrie métier : la campagne est l'emprunteur du Lore,
|
||||
* pas l'inverse).
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public class ChatRequest {
|
||||
|
||||
List<ChatMessage> messages;
|
||||
|
||||
/** Optionnel : carte structurelle du Lore. Null si campagne non liée à un Lore. */
|
||||
LoreStructuralContext loreContext;
|
||||
|
||||
/** Optionnel : contexte d'une page précise en cours d'édition (chat Lore uniquement). */
|
||||
PageContext pageContext;
|
||||
|
||||
/** Optionnel : carte narrative d'une Campagne (chat Campagne uniquement). */
|
||||
CampaignStructuralContext campaignContext;
|
||||
|
||||
/** Optionnel : entité narrative en cours d'édition (arc/chapter/scene). */
|
||||
NarrativeEntityContext narrativeEntity;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Object de valeur (immuable) représentant une demande de génération IA
|
||||
* pour remplir une Page à partir d'un Template.
|
||||
* <p>
|
||||
* Équivalent Java du PageGenerationContext Python (brain/app/domain/models.py).
|
||||
* Entité pure du domaine : aucune dépendance technique.
|
||||
* <p>
|
||||
* Immuable via @Value (Lombok) : pas de setters, tous les champs final.
|
||||
* C'est un DTO de domaine entrant dans le port AiProvider.
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public class GenerationContext {
|
||||
|
||||
String loreName;
|
||||
String loreDescription;
|
||||
String folderName; // Nom du LoreNode parent (ex: "PNJ", "Lieux")
|
||||
String templateName;
|
||||
List<String> templateFields; // Champs à générer (clés attendues dans la réponse)
|
||||
String pageTitle;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Résultat d'une génération IA : une map fieldName -> valeur générée.
|
||||
* <p>
|
||||
* Équivalent Java du PageGenerationResult Python.
|
||||
* Immuable : une fois reçu, pas de modification (l'UI pourra faire du merge,
|
||||
* mais pas en mutant cet objet).
|
||||
*/
|
||||
public record GenerationResult(Map<String, String> values) {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Singular;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Carte structurelle enrichie d'un Lore pour nourrir l'IA.
|
||||
* <p>
|
||||
* Équivalent Java du LoreStructuralContext Python. Depuis l'étape b9,
|
||||
* chaque page expose ses valeurs de champs, ses tags et ses pages liées
|
||||
* (résolues en titres) — plus uniquement son nom et son template.
|
||||
* <p>
|
||||
* La map `folders` est indexée par nom de dossier et mappe vers la liste
|
||||
* des pages qu'il contient (liste vide autorisée pour les dossiers vides).
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public class LoreStructuralContext {
|
||||
|
||||
String loreName;
|
||||
String loreDescription;
|
||||
Map<String, List<PageSummary>> folders;
|
||||
@Singular List<String> tags;
|
||||
|
||||
/**
|
||||
* Résumé projeté d'une page pour l'IA.
|
||||
* <p>
|
||||
* Contient le contenu utile au raisonnement LLM :
|
||||
* - title + templateName : identification
|
||||
* - values : contenu des champs dynamiques (tronqué côté builder)
|
||||
* - tags : étiquettes métier
|
||||
* - relatedPageTitles : pages liées DÉJÀ résolues en titres lisibles
|
||||
* (les IDs techniques n'ont aucune utilité dans un prompt LLM).
|
||||
* <p>
|
||||
* Les notes privées du MJ ne figurent PAS ici (choix b9 : exposer
|
||||
* uniquement ce qui est partageable en narration — les secrets MJ
|
||||
* restent confinés à leur page d'édition).
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public static class PageSummary {
|
||||
String title;
|
||||
String templateName;
|
||||
Map<String, String> values;
|
||||
List<String> tags;
|
||||
List<String> relatedPageTitles;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Contexte d'une entité narrative précise en cours d'édition (Arc, Chapter, ou Scene).
|
||||
* <p>
|
||||
* Ceci est un Value Object du Generation Context.
|
||||
* Équivalent de PageContext côté Lore mais appliqué à la Campagne : injecté
|
||||
* dans le system prompt pour orienter l'IA vers CETTE entité précise plutôt
|
||||
* que vers l'arbre narratif global. Modèle uniforme pour les 3 types :
|
||||
* un discriminator `entityType` + un titre + une map de champs textuels.
|
||||
* <p>
|
||||
* `fields` associe le nom d'un champ (ex: "themes", "playerNarration")
|
||||
* à sa valeur actuelle (chaîne vide si non renseigné). Utiliser une
|
||||
* LinkedHashMap à la construction pour un prompt lisible (ordre préservé).
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public class NarrativeEntityContext {
|
||||
|
||||
/** "arc", "chapter" ou "scene" — utilisé pour libeller le bloc du prompt. */
|
||||
String entityType;
|
||||
String title;
|
||||
Map<String, String> fields;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Contexte d'une page spécifique en cours d'édition.
|
||||
* <p>
|
||||
* Complément du LoreStructuralContext : l'un donne la carte générale du
|
||||
* Lore, l'autre zoome sur la page précise en cours de discussion. Permet
|
||||
* à l'IA de focaliser ses suggestions sur les bons champs sans déborder
|
||||
* sur d'autres pages/templates.
|
||||
* <p>
|
||||
* Object de valeur immuable, pur domaine — aucune dépendance technique.
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public class PageContext {
|
||||
|
||||
String title;
|
||||
String templateName;
|
||||
List<String> templateFields;
|
||||
Map<String, String> values;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.loremind.domain.generationcontext.ports;
|
||||
|
||||
import com.loremind.domain.generationcontext.ChatRequest;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Port de sortie pour le chat streamé avec un LLM.
|
||||
* <p>
|
||||
* Distinct de AiProvider (one-shot) par Interface Segregation Principle :
|
||||
* le streaming est une capacité séparée avec un contrat propre. Un même
|
||||
* adapter concret peut satisfaire les deux ports s'il le souhaite.
|
||||
* <p>
|
||||
* API par callbacks (plutôt que Flux/Stream) pour garder le domaine libre
|
||||
* de toute dépendance à Reactor. Les couches supérieures (controller SSE)
|
||||
* s'adaptent naturellement à ce style.
|
||||
*/
|
||||
public interface AiChatProvider {
|
||||
|
||||
/**
|
||||
* Streame la réponse du LLM en invoquant les callbacks au fil de l'eau.
|
||||
* <p>
|
||||
* Cette méthode est bloquante : elle ne rend la main qu'après la fin
|
||||
* du stream (appel à onComplete ou onError). L'appelant est responsable
|
||||
* de l'exécuter dans un thread adapté (ex: thread dédié à la requête
|
||||
* HTTP côté controller SSE).
|
||||
*
|
||||
* @param request messages + contexte Lore
|
||||
* @param onToken invoqué à chaque token reçu du LLM (peut être appelé
|
||||
* de nombreuses fois)
|
||||
* @param onComplete invoqué une fois le stream terminé avec succès
|
||||
* @param onError invoqué en cas d'erreur (Brain injoignable, timeout,
|
||||
* réponse invalide). Exclusif avec onComplete.
|
||||
*/
|
||||
void streamChat(
|
||||
ChatRequest request,
|
||||
Consumer<String> onToken,
|
||||
Runnable onComplete,
|
||||
Consumer<Throwable> onError
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.loremind.domain.generationcontext.ports;
|
||||
|
||||
import com.loremind.domain.generationcontext.GenerationContext;
|
||||
import com.loremind.domain.generationcontext.GenerationResult;
|
||||
|
||||
/**
|
||||
* Port de sortie pour la génération IA.
|
||||
* <p>
|
||||
* Le domaine ne connaît pas l'implémentation (HTTP vers Brain Python,
|
||||
* appel direct à OpenAI, mock en test, etc.). Il manipule uniquement
|
||||
* cette interface.
|
||||
* <p>
|
||||
* C'est l'équivalent Java du Protocol LLMProvider côté Python —
|
||||
* même pattern hexagonal des deux côtés de la frontière réseau.
|
||||
*/
|
||||
public interface AiProvider {
|
||||
|
||||
/**
|
||||
* Génère les valeurs des champs d'une Page à partir du contexte fourni.
|
||||
*
|
||||
* @throws AiProviderException si le fournisseur IA est indisponible,
|
||||
* renvoie une réponse invalide ou dépasse le timeout.
|
||||
*/
|
||||
GenerationResult generatePage(GenerationContext context) throws AiProviderException;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.loremind.domain.generationcontext.ports;
|
||||
|
||||
/**
|
||||
* Exception de domaine signalant un échec du fournisseur IA.
|
||||
* <p>
|
||||
* Équivalent Java de LLMProviderError (Python). Hérite de RuntimeException
|
||||
* pour rester cohérent avec le reste du code (pas d'exceptions checked
|
||||
* qui polluent les signatures de méthodes).
|
||||
* <p>
|
||||
* L'Adapter (BrainAiClient) traduira toute erreur technique (timeout,
|
||||
* 5xx, JSON invalide) en AiProviderException avant de la propager.
|
||||
*/
|
||||
public class AiProviderException extends RuntimeException {
|
||||
|
||||
public AiProviderException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public AiProviderException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
48
core/src/main/java/com/loremind/domain/images/Image.java
Normal file
48
core/src/main/java/com/loremind/domain/images/Image.java
Normal file
@@ -0,0 +1,48 @@
|
||||
package com.loremind.domain.images;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Entite de domaine representant une image uploadee par l'utilisateur.
|
||||
* <p>
|
||||
* Shared Kernel : cette entite vit dans un package transverse (ni LoreContext
|
||||
* ni CampaignContext) car une image peut etre referencee par n'importe quelle
|
||||
* entite de ces deux contextes (Page, Scene, Chapter, Arc). Elle n'appartient
|
||||
* a aucun context en particulier.
|
||||
* <p>
|
||||
* Design :
|
||||
* - Metadata en DB relationnelle (Postgres)
|
||||
* - Binaire sur object storage (MinIO/S3) referencE par `storageKey`
|
||||
* - Le domaine ne connait pas MinIO : il manipule juste une cle opaque.
|
||||
* <p>
|
||||
* Architecture Hexagonale : entite pure, aucune dependance technique.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class Image {
|
||||
|
||||
/** Identifiant stable (String pour rester agnostique vis-a-vis du stockage). */
|
||||
private String id;
|
||||
|
||||
/** Nom original du fichier uploade (ex: "portrait-elfe.jpg"). */
|
||||
private String filename;
|
||||
|
||||
/** Type MIME valide (ex: "image/jpeg", "image/png", "image/webp"). */
|
||||
private String contentType;
|
||||
|
||||
/** Taille en octets, utile pour quotas et affichage UI. */
|
||||
private long sizeBytes;
|
||||
|
||||
/**
|
||||
* Cle opaque dans l'object storage (ex: "images/abc123.jpg").
|
||||
* Le domaine ne fait qu'acheminer cette cle ; seul l'adaptateur MinIO sait
|
||||
* comment la transformer en bucket + path pour recuperer le binaire.
|
||||
*/
|
||||
private String storageKey;
|
||||
|
||||
/** Horodatage de l'upload initial (l'image est immuable apres creation). */
|
||||
private LocalDateTime uploadedAt;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.loremind.domain.images.ports;
|
||||
|
||||
import com.loremind.domain.images.Image;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Port de sortie pour la persistance des metadonnees d'images.
|
||||
* <p>
|
||||
* Architecture Hexagonale : ce port est defini dans le domaine ; il est
|
||||
* implemente par un adaptateur d'infrastructure (PostgresImageRepository).
|
||||
* <p>
|
||||
* Ne manipule QUE les metadonnees (filename, mimeType, storageKey...).
|
||||
* Le binaire est gere par un autre port : ImageStorage.
|
||||
* Cette separation suit le Single Responsibility Principle (SRP).
|
||||
*/
|
||||
public interface ImageRepository {
|
||||
|
||||
Image save(Image image);
|
||||
|
||||
Optional<Image> findById(String id);
|
||||
|
||||
void deleteById(String id);
|
||||
|
||||
boolean existsById(String id);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.loremind.domain.images.ports;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* Port de sortie pour le stockage du BINAIRE des images.
|
||||
* <p>
|
||||
* Separe de ImageRepository (metadonnees) pour respecter le SRP :
|
||||
* - ImageRepository --> Postgres (metadonnees)
|
||||
* - ImageStorage --> MinIO/S3 (fichiers binaires)
|
||||
* <p>
|
||||
* Le domaine raisonne en termes de "cle opaque" (storageKey).
|
||||
* Chaque implementation (MinIO, filesystem, S3...) traduit cette cle selon
|
||||
* sa propre logique physique.
|
||||
*/
|
||||
public interface ImageStorage {
|
||||
|
||||
/**
|
||||
* Envoie un flux binaire et retourne la cle generee.
|
||||
*
|
||||
* @param filename nom d'origine (utilise pour extraire l'extension)
|
||||
* @param contentType MIME type valide
|
||||
* @param data flux binaire a stocker
|
||||
* @param sizeBytes taille en octets (requis par certains backends comme S3)
|
||||
* @return cle opaque utilisable ensuite pour retrouver le binaire
|
||||
*/
|
||||
String upload(String filename, String contentType, InputStream data, long sizeBytes);
|
||||
|
||||
/** Recupere le flux binaire associe a une cle, ou null si inexistante. */
|
||||
InputStream download(String storageKey);
|
||||
|
||||
/** Supprime le binaire. No-op silencieux si la cle n'existe pas. */
|
||||
void delete(String storageKey);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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
|
||||
}
|
||||
23
core/src/main/java/com/loremind/domain/lorecontext/Lore.java
Normal file
23
core/src/main/java/com/loremind/domain/lorecontext/Lore.java
Normal file
@@ -0,0 +1,23 @@
|
||||
package com.loremind.domain.lorecontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Entité de domaine représentant un Univers de jeu (Lore).
|
||||
* Conteneur global pour organiser la connaissance d'un monde.
|
||||
* C'est une entité pure du domaine, sans dépendance technique (pas de JPA).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class Lore {
|
||||
|
||||
private String id;
|
||||
private String name;
|
||||
private String description;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
private int nodeCount;
|
||||
private int pageCount;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.loremind.domain.lorecontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Entité de domaine représentant un dossier dans l'arborescence d'un Lore.
|
||||
* Un dossier organise des Pages et peut contenir des sous-dossiers.
|
||||
* Structure hiérarchique via parentId (auto-référence).
|
||||
* Entité pure du domaine, sans dépendance technique.
|
||||
* <p>
|
||||
* Note : le nom interne reste "LoreNode" pour des raisons historiques et
|
||||
* techniques (compatibilité BDD, couplage avec le code existant). L'UI expose
|
||||
* le concept sous le terme "dossier".
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class LoreNode {
|
||||
|
||||
private String id;
|
||||
private String name;
|
||||
/** Clé de l'icône lucide choisie par l'utilisateur (ex: "users", "map-pin"). */
|
||||
private String icon;
|
||||
private String parentId; // Auto-référence pour l'arborescence
|
||||
private String loreId; // Référence vers le Lore parent
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
57
core/src/main/java/com/loremind/domain/lorecontext/Page.java
Normal file
57
core/src/main/java/com/loremind/domain/lorecontext/Page.java
Normal file
@@ -0,0 +1,57 @@
|
||||
package com.loremind.domain.lorecontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Entité de domaine représentant une page de contenu rattachée à un LoreNode.
|
||||
* <p>
|
||||
* Une page :
|
||||
* - appartient à un Lore (loreId — dénormalisé pour queries rapides)
|
||||
* - est rangée sous un LoreNode (nodeId)
|
||||
* - est générée depuis un Template (templateId) dont elle suit les `fields`
|
||||
* - stocke les valeurs de ses champs dynamiques dans `values` (fieldName → value)
|
||||
* - porte des métadonnées éditoriales : notes privées MJ, tags, liens inter-pages.
|
||||
* <p>
|
||||
* Entité pure du domaine : aucune dépendance technique.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class Page {
|
||||
|
||||
private String id;
|
||||
private String loreId;
|
||||
private String nodeId;
|
||||
private String templateId;
|
||||
private String title;
|
||||
|
||||
/** Valeurs des champs dynamiques TEXT définis par le Template. */
|
||||
private Map<String, String> values;
|
||||
|
||||
/**
|
||||
* Valeurs des champs dynamiques IMAGE : pour chaque nom de champ IMAGE du
|
||||
* template, la liste ordonnee des IDs d'images uploadees (Shared Kernel images).
|
||||
* Structure separee de `values` pour garder des types homogenes par map.
|
||||
*/
|
||||
private Map<String, List<String>> imageValues;
|
||||
|
||||
/** Notes privées du MJ (non exportées vers FoundryVTT). */
|
||||
private String notes;
|
||||
|
||||
/** Étiquettes libres pour regroupement/recherche. */
|
||||
private List<String> tags;
|
||||
|
||||
/** IDs d'autres Pages liées (mêmes Lore ou cross-lore). */
|
||||
private List<String> relatedPageIds;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public boolean hasTemplate() {
|
||||
return templateId != null && !templateId.isBlank();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.loremind.domain.lorecontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Entité de domaine représentant un Template pour la génération de Pages.
|
||||
* <p>
|
||||
* Un Template :
|
||||
* - appartient à un Lore (loreId)
|
||||
* - définit le noeud par défaut où seront rangées les Pages créées (defaultNodeId)
|
||||
* - porte une liste ordonnée de {@link TemplateField} (nom + type TEXT/IMAGE)
|
||||
* qui seront instanciés sur chaque Page produite depuis ce gabarit.
|
||||
* <p>
|
||||
* Evolution : les `fields` etaient autrefois de simples `List<String>` (noms seuls).
|
||||
* Depuis l'ajout du support des images, chaque champ a un type discriminant pour
|
||||
* piloter le rendu UI et la logique IA.
|
||||
* <p>
|
||||
* Entité pure du domaine : aucune dépendance technique (Spring, JPA, etc.).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class Template {
|
||||
|
||||
private String id;
|
||||
private String loreId; // Rattachement au Lore propriétaire
|
||||
private String name;
|
||||
private String description;
|
||||
private String defaultNodeId; // Noeud cible des Pages générées
|
||||
private List<TemplateField> fields; // Champs dynamiques ordonnes (nom + type)
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
// --- Méthodes métier ---------------------------------------------------
|
||||
|
||||
/** Nombre de champs dynamiques définis (affiché dans le sidebar "X champs"). */
|
||||
public int fieldCount() {
|
||||
return fields == null ? 0 : fields.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne uniquement les noms des champs de type TEXT.
|
||||
* Utilise par l'IA : seul le texte peut etre genere, pas les images.
|
||||
*/
|
||||
public List<String> textFieldNames() {
|
||||
if (fields == null) return new ArrayList<>();
|
||||
return fields.stream()
|
||||
.filter(f -> f.getType() == FieldType.TEXT)
|
||||
.map(TemplateField::getName)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.loremind.domain.lorecontext;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Value Object d'un champ de Template.
|
||||
* <p>
|
||||
* Un champ a un nom (affiche dans l'UI) et un type (TEXT ou IMAGE, extensible).
|
||||
* Le type pilote le rendu cote front (textarea vs galerie d'images) ET
|
||||
* la logique metier (seuls les champs TEXT sont envoyes a l'IA pour generation).
|
||||
* <p>
|
||||
* Evolution de `List<String> fields` vers `List<TemplateField> fields` :
|
||||
* refactor propre (DDD Value Object polymorphism) permettant d'ajouter
|
||||
* facilement d'autres types de champs (DATE, NUMBER, RICH_TEXT...) sans
|
||||
* casser le contrat.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TemplateField {
|
||||
/** Nom du champ tel qu'affiche dans l'UI (ex: "Histoire", "Portrait"). */
|
||||
private String name;
|
||||
/** Type du champ, pilote le rendu et la generation IA. */
|
||||
private FieldType type;
|
||||
|
||||
/** Raccourci : construit un champ de type TEXT (cas le plus courant). */
|
||||
public static TemplateField text(String name) {
|
||||
return new TemplateField(name, FieldType.TEXT);
|
||||
}
|
||||
|
||||
/** Raccourci : construit un champ de type IMAGE. */
|
||||
public static TemplateField image(String name) {
|
||||
return new TemplateField(name, FieldType.IMAGE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.loremind.domain.lorecontext.ports;
|
||||
|
||||
import com.loremind.domain.lorecontext.LoreNode;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Port de sortie pour la persistance des LoreNodes.
|
||||
* Interface définie dans le domaine, implémentée par l'infrastructure.
|
||||
*/
|
||||
public interface LoreNodeRepository {
|
||||
|
||||
LoreNode save(LoreNode loreNode);
|
||||
|
||||
Optional<LoreNode> findById(String id);
|
||||
|
||||
List<LoreNode> findByLoreId(String loreId);
|
||||
|
||||
List<LoreNode> findByParentId(String parentId);
|
||||
|
||||
List<LoreNode> findAll();
|
||||
|
||||
void deleteById(String id);
|
||||
|
||||
boolean existsById(String id);
|
||||
|
||||
long countByLoreId(String loreId);
|
||||
|
||||
List<LoreNode> searchByName(String query);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.loremind.domain.lorecontext.ports;
|
||||
|
||||
import com.loremind.domain.lorecontext.Lore;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Port de sortie pour la persistance des Lores.
|
||||
* Interface définie dans le domaine, implémentée par l'infrastructure.
|
||||
* C'est un Port de sortie (Output Port) selon l'Architecture Hexagonale.
|
||||
*/
|
||||
public interface LoreRepository {
|
||||
|
||||
Lore save(Lore lore);
|
||||
|
||||
Optional<Lore> findById(String id);
|
||||
|
||||
List<Lore> findAll();
|
||||
|
||||
void deleteById(String id);
|
||||
|
||||
boolean existsById(String id);
|
||||
|
||||
List<Lore> searchByName(String query);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.loremind.domain.lorecontext.ports;
|
||||
|
||||
import com.loremind.domain.lorecontext.Page;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Port de sortie pour la persistance des Pages.
|
||||
* Interface définie dans le domaine, implémentée par l'infrastructure.
|
||||
*/
|
||||
public interface PageRepository {
|
||||
|
||||
Page save(Page page);
|
||||
|
||||
Optional<Page> findById(String id);
|
||||
|
||||
List<Page> findByLoreId(String loreId);
|
||||
|
||||
List<Page> findByNodeId(String nodeId);
|
||||
|
||||
List<Page> findAll();
|
||||
|
||||
void deleteById(String id);
|
||||
|
||||
boolean existsById(String id);
|
||||
|
||||
long countByLoreId(String loreId);
|
||||
|
||||
List<Page> searchByTitle(String query);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.loremind.domain.lorecontext.ports;
|
||||
|
||||
import com.loremind.domain.lorecontext.Template;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Port de sortie pour la persistance des Templates.
|
||||
* Interface définie dans le domaine, implémentée par l'infrastructure.
|
||||
*/
|
||||
public interface TemplateRepository {
|
||||
|
||||
Template save(Template template);
|
||||
|
||||
Optional<Template> findById(String id);
|
||||
|
||||
List<Template> findAll();
|
||||
|
||||
/** Tous les templates rattachés à un Lore donné (pour le panneau sidebar). */
|
||||
List<Template> findByLoreId(String loreId);
|
||||
|
||||
void deleteById(String id);
|
||||
|
||||
boolean existsById(String id);
|
||||
|
||||
List<Template> searchByName(String query);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.loremind.domain.shared;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Utilitaires pour la manipulation défensive des collections.
|
||||
* Cœur du domaine - aucune dépendance technique.
|
||||
*/
|
||||
public final class CollectionUtils {
|
||||
|
||||
private CollectionUtils() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Copie défensive d'une Map. Retourne une Map vide si source est null.
|
||||
*/
|
||||
public static <K, V> Map<K, V> copyMap(Map<K, V> source) {
|
||||
return source != null ? new HashMap<>(source) : new HashMap<>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Copie défensive d'une List. Retourne une List vide si source est null.
|
||||
*/
|
||||
public static <T> List<T> copyList(List<T> source) {
|
||||
return source != null ? new ArrayList<>(source) : new ArrayList<>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package com.loremind.infrastructure.ai;
|
||||
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
|
||||
import com.loremind.domain.generationcontext.ChatMessage;
|
||||
import com.loremind.domain.generationcontext.ChatRequest;
|
||||
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
||||
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
|
||||
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
||||
import com.loremind.domain.generationcontext.PageContext;
|
||||
import com.loremind.domain.generationcontext.ports.AiChatProvider;
|
||||
import com.loremind.domain.generationcontext.ports.AiProviderException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.codec.ServerSentEvent;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Adapter de sortie (Architecture Hexagonale) : implémente AiChatProvider
|
||||
* en appelant le Brain Python via WebClient + SSE (Server-Sent Events).
|
||||
* <p>
|
||||
* Responsabilités :
|
||||
* 1. Traduire ChatRequest (domaine) -> JSON attendu par /chat/stream.
|
||||
* Sérialise lore_context, page_context, campaign_context et
|
||||
* narrative_entity de façon conditionnelle selon le scénario d'appel
|
||||
* (chat Lore / chat Lore focalisé page / chat Campagne / chat Campagne
|
||||
* focalisé arc-chapter-scene).
|
||||
* 2. Consommer le flux SSE token par token.
|
||||
* 3. Invoquer onToken / onComplete / onError au bon moment.
|
||||
* 4. Traduire toute erreur technique en AiProviderException.
|
||||
* <p>
|
||||
* Le domaine ne voit JAMAIS WebClient, Flux, ni la moindre URL.
|
||||
*/
|
||||
@Component
|
||||
public class BrainAiChatClient implements AiChatProvider {
|
||||
|
||||
private static final String CHAT_STREAM_PATH = "/chat/stream";
|
||||
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE =
|
||||
new ParameterizedTypeReference<>() {};
|
||||
|
||||
private final WebClient webClient;
|
||||
|
||||
public BrainAiChatClient(
|
||||
WebClient.Builder builder,
|
||||
@Value("${brain.base-url}") String baseUrl) {
|
||||
this.webClient = builder.baseUrl(baseUrl).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void streamChat(
|
||||
ChatRequest request,
|
||||
Consumer<String> onToken,
|
||||
Runnable onComplete,
|
||||
Consumer<Throwable> onError) {
|
||||
|
||||
Map<String, Object> payload = toPayload(request);
|
||||
|
||||
Flux<ServerSentEvent<String>> flux = webClient.post()
|
||||
.uri(CHAT_STREAM_PATH)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.accept(MediaType.TEXT_EVENT_STREAM)
|
||||
.bodyValue(payload)
|
||||
.retrieve()
|
||||
.bodyToFlux(SSE_STRING_TYPE);
|
||||
|
||||
try {
|
||||
// blockLast() : transforme le flux réactif en appel bloquant conforme
|
||||
// au contrat synchrone du port. L'appelant choisit le thread.
|
||||
flux
|
||||
.timeout(Duration.ofSeconds(120))
|
||||
.doOnNext(sse -> handleEvent(sse, onToken, onError))
|
||||
.blockLast();
|
||||
onComplete.run();
|
||||
} catch (Exception e) {
|
||||
onError.accept(new AiProviderException(
|
||||
"Erreur lors du streaming chat depuis le Brain.", e));
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispatch selon le type d'événement SSE (data par défaut, done, error). */
|
||||
private void handleEvent(
|
||||
ServerSentEvent<String> sse,
|
||||
Consumer<String> onToken,
|
||||
Consumer<Throwable> onError) {
|
||||
String event = sse.event(); // null si pas d'event: xxx -> c'est un data par défaut
|
||||
String data = sse.data();
|
||||
|
||||
if ("error".equals(event)) {
|
||||
onError.accept(new AiProviderException(
|
||||
"Le Brain a signalé une erreur : " + data));
|
||||
return;
|
||||
}
|
||||
if ("done".equals(event)) {
|
||||
return; // la fin est gérée par blockLast + onComplete
|
||||
}
|
||||
// Défaut : événement data avec JSON {"token":"..."}.
|
||||
String token = extractToken(data);
|
||||
if (token != null && !token.isEmpty()) {
|
||||
onToken.accept(token);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse minimaliste du JSON {"token":"..."} sans pull Jackson ici.
|
||||
* Si le format se complexifie, on remplacera par un DTO Jackson.
|
||||
*/
|
||||
private String extractToken(String json) {
|
||||
if (json == null) return null;
|
||||
int idx = json.indexOf("\"token\"");
|
||||
if (idx < 0) return null;
|
||||
int colon = json.indexOf(':', idx);
|
||||
int firstQuote = json.indexOf('"', colon + 1);
|
||||
int lastQuote = json.lastIndexOf('"');
|
||||
if (firstQuote < 0 || lastQuote <= firstQuote) return null;
|
||||
return json.substring(firstQuote + 1, lastQuote)
|
||||
.replace("\\n", "\n")
|
||||
.replace("\\\"", "\"")
|
||||
.replace("\\\\", "\\");
|
||||
}
|
||||
|
||||
// --- Construction du payload JSON vers le Brain -------------------------
|
||||
|
||||
/**
|
||||
* Construit le payload JSON. Chaque contexte optionnel est omis s'il est
|
||||
* null, pour s'aligner sur le schéma Pydantic côté Brain (champs
|
||||
* Optional qui restent absents du dict transmis au LLM).
|
||||
*/
|
||||
private Map<String, Object> toPayload(ChatRequest request) {
|
||||
Map<String, Object> root = new LinkedHashMap<>();
|
||||
root.put("messages", request.getMessages().stream()
|
||||
.map(this::messageToMap)
|
||||
.collect(Collectors.toList()));
|
||||
|
||||
if (request.getLoreContext() != null) {
|
||||
root.put("lore_context", loreContextToMap(request.getLoreContext()));
|
||||
}
|
||||
if (request.getPageContext() != null) {
|
||||
root.put("page_context", pageContextToMap(request.getPageContext()));
|
||||
}
|
||||
if (request.getCampaignContext() != null) {
|
||||
root.put("campaign_context", campaignContextToMap(request.getCampaignContext()));
|
||||
}
|
||||
if (request.getNarrativeEntity() != null) {
|
||||
root.put("narrative_entity", narrativeEntityToMap(request.getNarrativeEntity()));
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
private Map<String, Object> messageToMap(ChatMessage m) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("role", m.role());
|
||||
map.put("content", m.content());
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> loreContextToMap(LoreStructuralContext ctx) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("lore_name", ctx.getLoreName());
|
||||
map.put("lore_description", ctx.getLoreDescription());
|
||||
|
||||
Map<String, Object> foldersMap = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, List<PageSummary>> e : ctx.getFolders().entrySet()) {
|
||||
foldersMap.put(e.getKey(), e.getValue().stream()
|
||||
.map(this::pageSummaryToMap)
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
map.put("folders", foldersMap);
|
||||
map.put("tags", ctx.getTags());
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> pageSummaryToMap(PageSummary ps) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("title", ps.getTitle());
|
||||
map.put("template_name", ps.getTemplateName());
|
||||
// values/tags/related_page_titles ne sont sérialisés que s'ils contiennent
|
||||
// de l'info — payload réseau plus léger quand la page est vierge.
|
||||
if (ps.getValues() != null && !ps.getValues().isEmpty()) {
|
||||
map.put("values", ps.getValues());
|
||||
}
|
||||
if (ps.getTags() != null && !ps.getTags().isEmpty()) {
|
||||
map.put("tags", ps.getTags());
|
||||
}
|
||||
if (ps.getRelatedPageTitles() != null && !ps.getRelatedPageTitles().isEmpty()) {
|
||||
map.put("related_page_titles", ps.getRelatedPageTitles());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> pageContextToMap(PageContext pc) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("title", pc.getTitle());
|
||||
map.put("template_name", pc.getTemplateName());
|
||||
map.put("template_fields", pc.getTemplateFields());
|
||||
map.put("values", pc.getValues());
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> campaignContextToMap(CampaignStructuralContext ctx) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("campaign_name", ctx.getCampaignName());
|
||||
map.put("campaign_description", ctx.getCampaignDescription());
|
||||
map.put("arcs", ctx.getArcs().stream()
|
||||
.map(this::arcSummaryToMap)
|
||||
.collect(Collectors.toList()));
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper generic pour serialiser les entites structurelles (Arc/Chapter/Scene)
|
||||
* avec name, description et illustration_count conditionnel.
|
||||
*/
|
||||
private <T> Map<String, Object> structuralSummaryToMap(
|
||||
T entity,
|
||||
java.util.function.Function<T, String> nameExtractor,
|
||||
java.util.function.Function<T, String> descriptionExtractor,
|
||||
java.util.function.Function<T, Integer> illustrationCountExtractor,
|
||||
java.util.function.BiConsumer<Map<String, Object>, T> childSerializer) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("name", nameExtractor.apply(entity));
|
||||
map.put("description", descriptionExtractor.apply(entity));
|
||||
// Envoye au Python pour enrichir le prompt ("N illustrations attachees").
|
||||
// Serialise uniquement si > 0 pour economiser le payload sur les entites sans images.
|
||||
if (illustrationCountExtractor.apply(entity) > 0) {
|
||||
map.put("illustration_count", illustrationCountExtractor.apply(entity));
|
||||
}
|
||||
childSerializer.accept(map, entity);
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> arcSummaryToMap(ArcSummary a) {
|
||||
return structuralSummaryToMap(
|
||||
a,
|
||||
ArcSummary::getName,
|
||||
ArcSummary::getDescription,
|
||||
ArcSummary::getIllustrationCount,
|
||||
(map, arc) -> map.put("chapters", arc.getChapters().stream()
|
||||
.map(this::chapterSummaryToMap)
|
||||
.collect(Collectors.toList())));
|
||||
}
|
||||
|
||||
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {
|
||||
return structuralSummaryToMap(
|
||||
c,
|
||||
ChapterSummary::getName,
|
||||
ChapterSummary::getDescription,
|
||||
ChapterSummary::getIllustrationCount,
|
||||
(map, chapter) -> map.put("scenes", chapter.getScenes().stream()
|
||||
.map(this::sceneSummaryToMap)
|
||||
.collect(Collectors.toList())));
|
||||
}
|
||||
|
||||
private Map<String, Object> sceneSummaryToMap(SceneSummary s) {
|
||||
return structuralSummaryToMap(
|
||||
s,
|
||||
SceneSummary::getName,
|
||||
SceneSummary::getDescription,
|
||||
SceneSummary::getIllustrationCount,
|
||||
(map, scene) -> {
|
||||
// Branches narratives : serialise uniquement si presentes, pour garder
|
||||
// un payload leger sur les scenes lineaires classiques.
|
||||
if (s.getBranches() != null && !s.getBranches().isEmpty()) {
|
||||
map.put("branches", s.getBranches().stream()
|
||||
.map(this::branchHintToMap)
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Map<String, Object> branchHintToMap(BranchHint b) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("label", b.getLabel());
|
||||
map.put("target_scene_name", b.getTargetSceneName());
|
||||
if (b.getCondition() != null && !b.getCondition().isBlank()) {
|
||||
map.put("condition", b.getCondition());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("entity_type", ne.getEntityType());
|
||||
map.put("title", ne.getTitle());
|
||||
map.put("fields", ne.getFields());
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.loremind.infrastructure.ai;
|
||||
|
||||
import com.loremind.domain.generationcontext.GenerationContext;
|
||||
import com.loremind.domain.generationcontext.GenerationResult;
|
||||
import com.loremind.domain.generationcontext.ports.AiProvider;
|
||||
import com.loremind.domain.generationcontext.ports.AiProviderException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
import org.springframework.web.client.RestClientResponseException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Adapter de sortie : implémente le port AiProvider en appelant
|
||||
* le Brain Python via HTTP (RestTemplate).
|
||||
* <p>
|
||||
* Responsabilités exclusives de cette classe :
|
||||
* 1. Traduire GenerationContext (domaine) -> BrainGeneratePageRequest (wire).
|
||||
* 2. Exécuter l'appel HTTP POST /generate-page.
|
||||
* 3. Traduire BrainGeneratePageResponse (wire) -> GenerationResult (domaine).
|
||||
* 4. Traduire toute erreur technique en AiProviderException (exception de domaine).
|
||||
* <p>
|
||||
* Le domaine ne voit JAMAIS RestTemplate, Jackson, ni la moindre URL.
|
||||
*/
|
||||
@Component
|
||||
public class BrainAiClient implements AiProvider {
|
||||
|
||||
private static final String GENERATE_PAGE_PATH = "/generate-page";
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final String baseUrl;
|
||||
|
||||
public BrainAiClient(
|
||||
RestTemplate restTemplate,
|
||||
@Value("${brain.base-url}") String baseUrl) {
|
||||
this.restTemplate = restTemplate;
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public GenerationResult generatePage(GenerationContext context) {
|
||||
BrainGeneratePageRequest request = toBrainRequest(context);
|
||||
BrainGeneratePageResponse response = callBrain(request);
|
||||
return toDomainResult(response);
|
||||
}
|
||||
|
||||
// --- Traduction domaine -> wire -----------------------------------------
|
||||
|
||||
private BrainGeneratePageRequest toBrainRequest(GenerationContext context) {
|
||||
return new BrainGeneratePageRequest(
|
||||
context.getLoreName(),
|
||||
context.getLoreDescription(),
|
||||
context.getFolderName(),
|
||||
context.getTemplateName(),
|
||||
context.getTemplateFields(),
|
||||
context.getPageTitle()
|
||||
);
|
||||
}
|
||||
|
||||
// --- Appel HTTP + traduction d'erreurs ----------------------------------
|
||||
|
||||
private BrainGeneratePageResponse callBrain(BrainGeneratePageRequest request) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
HttpEntity<BrainGeneratePageRequest> entity = new HttpEntity<>(request, headers);
|
||||
|
||||
try {
|
||||
BrainGeneratePageResponse response = restTemplate.postForObject(
|
||||
baseUrl + GENERATE_PAGE_PATH,
|
||||
entity,
|
||||
BrainGeneratePageResponse.class
|
||||
);
|
||||
if (response == null || response.getValues() == null) {
|
||||
throw new AiProviderException("Le Brain a renvoyé une réponse vide.");
|
||||
}
|
||||
return response;
|
||||
} catch (ResourceAccessException e) {
|
||||
// Timeout ou connexion impossible (Brain down)
|
||||
throw new AiProviderException(
|
||||
"Le Brain est injoignable (timeout ou service arrêté).", e);
|
||||
} catch (RestClientResponseException e) {
|
||||
// Code HTTP 4xx/5xx renvoyé par le Brain
|
||||
throw new AiProviderException(
|
||||
"Le Brain a répondu avec une erreur HTTP " + e.getStatusCode().value(), e);
|
||||
} catch (AiProviderException e) {
|
||||
throw e; // déjà traduite, ne pas ré-envelopper
|
||||
} catch (Exception e) {
|
||||
// Filet de sécurité (JSON invalide, etc.)
|
||||
throw new AiProviderException(
|
||||
"Erreur inattendue lors de l'appel au Brain.", e);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Traduction wire -> domaine -----------------------------------------
|
||||
|
||||
private GenerationResult toDomainResult(BrainGeneratePageResponse response) {
|
||||
return new GenerationResult(Map.copyOf(response.getValues()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.loremind.infrastructure.ai;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* DTO interne de l'Adapter : format JSON envoyé au Brain Python.
|
||||
* Package-private : n'existe que pour la couche infrastructure.
|
||||
* <p>
|
||||
* Le contrat HTTP côté Python utilise snake_case — on le matche ici
|
||||
* pour éviter de configurer Jackson globalement (impact sur le reste du projet).
|
||||
*/
|
||||
record BrainGeneratePageRequest(@JsonProperty("lore_name") String loreName,
|
||||
@JsonProperty("lore_description") String loreDescription,
|
||||
@JsonProperty("folder_name") String folderName,
|
||||
@JsonProperty("template_name") String templateName,
|
||||
@JsonProperty("template_fields") List<String> templateFields,
|
||||
@JsonProperty("page_title") String pageTitle) {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.loremind.infrastructure.ai;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* DTO interne de l'Adapter : format JSON reçu du Brain Python.
|
||||
*
|
||||
* @Data + @NoArgsConstructor : nécessaire à Jackson pour la désérialisation.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
class BrainGeneratePageResponse {
|
||||
|
||||
@JsonProperty("values")
|
||||
private Map<String, String> values;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.loremind.infrastructure.ai;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.boot.web.reactive.function.client.WebClientCustomizer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Configuration Spring fournissant un RestTemplate et un WebClient avec timeout
|
||||
* adapté aux appels vers le Brain (LLM local parfois lent) et ajout automatique
|
||||
* de l'entete X-Internal-Secret (auth inter-service Core <-> Brain).
|
||||
* <p>
|
||||
* Sans cette entete, le Brain refuse la requete (401) — defense contre
|
||||
* l'acces direct au Brain depuis un attaquant qui atteindrait son port.
|
||||
*/
|
||||
@Configuration
|
||||
public class RestTemplateConfig {
|
||||
|
||||
private static final String INTERNAL_SECRET_HEADER = "X-Internal-Secret";
|
||||
|
||||
@Bean
|
||||
public RestTemplate brainRestTemplate(
|
||||
RestTemplateBuilder builder,
|
||||
@Value("${brain.timeout-seconds}") long timeoutSeconds,
|
||||
@Value("${brain.internal-secret}") String internalSecret) {
|
||||
return builder
|
||||
.setConnectTimeout(Duration.ofSeconds(10))
|
||||
.setReadTimeout(Duration.ofSeconds(timeoutSeconds))
|
||||
.additionalInterceptors((request, body, execution) -> {
|
||||
if (internalSecret != null && !internalSecret.isBlank()) {
|
||||
request.getHeaders().set(INTERNAL_SECRET_HEADER, internalSecret);
|
||||
}
|
||||
return execution.execute(request, body);
|
||||
})
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute X-Internal-Secret comme header par defaut a tous les WebClient
|
||||
* construits via le builder auto-configure par Spring Boot. Evite de
|
||||
* recreer un builder (qui perdrait les codecs/logging auto-configures).
|
||||
*/
|
||||
@Bean
|
||||
public WebClientCustomizer internalSecretWebClientCustomizer(
|
||||
@Value("${brain.internal-secret}") String internalSecret) {
|
||||
return builder -> {
|
||||
if (internalSecret != null && !internalSecret.isBlank()) {
|
||||
builder.defaultHeader(INTERNAL_SECRET_HEADER, internalSecret);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.loremind.infrastructure.persistence.converter;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Converter JPA pour convertir Map<String, Object> en String (JSON).
|
||||
* Compatible avec PostgreSQL (JSONB peut stocker du JSON dans TEXT).
|
||||
* Utilisé pour le champ structure de Template, mais peut servir pour tout champ JSON.
|
||||
* C'est un converter générique réutilisable.
|
||||
*/
|
||||
@Converter(autoApply = false)
|
||||
public class MapJsonConverter implements AttributeConverter<Map<String, Object>, String> {
|
||||
|
||||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(Map<String, Object> attribute) {
|
||||
if (attribute == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.writeValueAsString(attribute);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalArgumentException("Erreur lors de la conversion Map vers JSON String", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> convertToEntityAttribute(String dbData) {
|
||||
if (dbData == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(dbData, Map.class);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalArgumentException("Erreur lors de la conversion JSON String vers Map", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.loremind.infrastructure.persistence.converter;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.loremind.domain.campaigncontext.SceneBranch;
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Convertit une List<SceneBranch> du domaine en chaîne JSON stockée en base,
|
||||
* et inversement. Même pattern que StringListJsonConverter mais typé sur
|
||||
* le Value Object SceneBranch.
|
||||
* <p>
|
||||
* Adaptateur d'infrastructure : le domaine reste pur (List<SceneBranch>)
|
||||
* pendant que PostgreSQL reçoit un TEXT JSON.
|
||||
*/
|
||||
@Converter
|
||||
public class SceneBranchListJsonConverter implements AttributeConverter<List<SceneBranch>, String> {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(List<SceneBranch> attribute) {
|
||||
if (attribute == null || attribute.isEmpty()) {
|
||||
return "[]";
|
||||
}
|
||||
try {
|
||||
return MAPPER.writeValueAsString(attribute);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Erreur sérialisation List<SceneBranch> → JSON", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SceneBranch> convertToEntityAttribute(String dbData) {
|
||||
if (dbData == null || dbData.isBlank()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
try {
|
||||
return MAPPER.readValue(dbData, new TypeReference<>() {
|
||||
});
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Erreur désérialisation JSON → List<SceneBranch>", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.loremind.infrastructure.persistence.converter;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Convertit une List<String> du domaine en chaîne JSON stockée en base, et
|
||||
* inversement. Utilisé pour les listes simples (ex: Template.fields).
|
||||
* <p>
|
||||
* Ceci est un adaptateur technique d'infrastructure : il permet au domaine de
|
||||
* rester pur (juste une List<String>) pendant que JPA parle JSON à PostgreSQL.
|
||||
*/
|
||||
@Converter
|
||||
public class StringListJsonConverter implements AttributeConverter<List<String>, String> {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(List<String> attribute) {
|
||||
if (attribute == null || attribute.isEmpty()) {
|
||||
return "[]";
|
||||
}
|
||||
try {
|
||||
return MAPPER.writeValueAsString(attribute);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Erreur sérialisation List<String> → JSON", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> convertToEntityAttribute(String dbData) {
|
||||
if (dbData == null || dbData.isBlank()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
try {
|
||||
return MAPPER.readValue(dbData, new TypeReference<>() {
|
||||
});
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Erreur désérialisation JSON → List<String>", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.loremind.infrastructure.persistence.converter;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Convertit une Map<String, List<String>> du domaine en chaine JSON et inversement.
|
||||
* <p>
|
||||
* Utilise pour Page.imageValues : pour chaque champ IMAGE du template
|
||||
* (ex: "Portrait"), la map stocke la liste ordonnee des IDs d'images uploadees.
|
||||
* <p>
|
||||
* Exemple de JSON produit :
|
||||
* {"Portrait": ["42","17"], "Carte": ["99"]}
|
||||
* <p>
|
||||
* Adaptateur technique d'infrastructure : le domaine ne connait jamais ce converter.
|
||||
*/
|
||||
@Converter
|
||||
public class StringListMapJsonConverter
|
||||
implements AttributeConverter<Map<String, List<String>>, String> {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(Map<String, List<String>> attribute) {
|
||||
if (attribute == null || attribute.isEmpty()) {
|
||||
return "{}";
|
||||
}
|
||||
try {
|
||||
return MAPPER.writeValueAsString(attribute);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(
|
||||
"Erreur serialisation Map<String, List<String>> -> JSON", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, List<String>> convertToEntityAttribute(String dbData) {
|
||||
if (dbData == null || dbData.isBlank()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
try {
|
||||
return MAPPER.readValue(dbData, new TypeReference<>() {
|
||||
});
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(
|
||||
"Erreur deserialisation JSON -> Map<String, List<String>>", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.loremind.infrastructure.persistence.converter;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Convertit une Map<String,String> du domaine en chaîne JSON et inversement.
|
||||
* Utilisé pour les maps clé-valeur simples (ex: Page.values — stocke les valeurs
|
||||
* des champs dynamiques définis par le Template).
|
||||
*/
|
||||
@Converter
|
||||
public class StringMapJsonConverter implements AttributeConverter<Map<String, String>, String> {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(Map<String, String> attribute) {
|
||||
if (attribute == null || attribute.isEmpty()) {
|
||||
return "{}";
|
||||
}
|
||||
try {
|
||||
return MAPPER.writeValueAsString(attribute);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Erreur sérialisation Map<String,String> → JSON", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String> convertToEntityAttribute(String dbData) {
|
||||
if (dbData == null || dbData.isBlank()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
try {
|
||||
return MAPPER.readValue(dbData, new TypeReference<>() {
|
||||
});
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Erreur désérialisation JSON → Map<String,String>", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.loremind.infrastructure.persistence.converter;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.loremind.domain.lorecontext.FieldType;
|
||||
import com.loremind.domain.lorecontext.TemplateField;
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Convertisseur JPA pour {@code List<TemplateField>}.
|
||||
*
|
||||
* <h3>Backward compatibility (CRITIQUE)</h3>
|
||||
* Les templates crees avant l'introduction de {@link TemplateField} sont
|
||||
* persistes au format legacy : {@code ["Nom", "Histoire", "Portrait"]}.
|
||||
* Les nouveaux templates utilisent le format : {@code [{"name":"Nom","type":"TEXT"}, ...]}.
|
||||
* <p>
|
||||
* Ce converter sait lire les DEUX formats en lecture (tolerant) mais ecrit
|
||||
* toujours au nouveau format. Cela evite une migration de donnees risquee :
|
||||
* la premiere ecriture d'un template legacy suffit a le convertir.
|
||||
*
|
||||
* <h3>Responsabilite</h3>
|
||||
* Adaptateur technique pur : le domaine ne connait jamais ce converter.
|
||||
*/
|
||||
@Converter
|
||||
public class TemplateFieldListJsonConverter
|
||||
implements AttributeConverter<List<TemplateField>, String> {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(List<TemplateField> attribute) {
|
||||
if (attribute == null || attribute.isEmpty()) {
|
||||
return "[]";
|
||||
}
|
||||
try {
|
||||
return MAPPER.writeValueAsString(attribute);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(
|
||||
"Erreur serialisation List<TemplateField> -> JSON", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TemplateField> convertToEntityAttribute(String dbData) {
|
||||
if (dbData == null || dbData.isBlank()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
try {
|
||||
JsonNode root = MAPPER.readTree(dbData);
|
||||
if (!root.isArray()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<TemplateField> result = new ArrayList<>();
|
||||
for (JsonNode item : root) {
|
||||
if (item.isTextual()) {
|
||||
// Format legacy : chaine simple, on suppose TEXT par defaut.
|
||||
result.add(TemplateField.text(item.asText()));
|
||||
} else if (item.isObject()) {
|
||||
// Nouveau format : {name, type}
|
||||
String name = item.path("name").asText(null);
|
||||
String typeStr = item.path("type").asText("TEXT");
|
||||
FieldType type;
|
||||
try {
|
||||
type = FieldType.valueOf(typeStr);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
// Type inconnu (ajoute par une version future) : fallback TEXT.
|
||||
type = FieldType.TEXT;
|
||||
}
|
||||
if (name != null && !name.isBlank()) {
|
||||
result.add(new TemplateField(name, type));
|
||||
}
|
||||
}
|
||||
// Autres types de noeuds (nombre, booleen...) : ignores silencieusement.
|
||||
}
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(
|
||||
"Erreur deserialisation JSON -> List<TemplateField>", e);
|
||||
}
|
||||
}
|
||||
|
||||
// typeRef garde pour reference future si on veut deserialiser directement.
|
||||
@SuppressWarnings("unused")
|
||||
private static final TypeReference<List<TemplateField>> TYPE_REF =
|
||||
new TypeReference<>() {};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Entité JPA pour la persistance des Arcs en base de données PostgreSQL.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "arcs")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ArcJpaEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String description;
|
||||
|
||||
@Column(name = "campaign_id", nullable = false)
|
||||
private Long campaignId;
|
||||
|
||||
@Column(name = "\"order\"", nullable = false)
|
||||
private int order;
|
||||
|
||||
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update)
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String themes;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String stakes;
|
||||
|
||||
@Column(name = "gm_notes", columnDefinition = "TEXT")
|
||||
private String gmNotes;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String rewards;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String resolution;
|
||||
|
||||
/**
|
||||
* IDs des pages du Lore liées à cet arc, stockés en JSON dans une colonne TEXT.
|
||||
* Pas de FK cross-context : respect des Bounded Contexts.
|
||||
*/
|
||||
@Column(name = "related_page_ids", columnDefinition = "TEXT")
|
||||
@Convert(converter = StringListJsonConverter.class)
|
||||
@Builder.Default
|
||||
private List<String> relatedPageIds = new ArrayList<>();
|
||||
|
||||
/** IDs des images (Shared Kernel) illustrant cet arc. JSON dans colonne TEXT. */
|
||||
@Column(name = "illustration_image_ids", columnDefinition = "TEXT")
|
||||
@Convert(converter = StringListJsonConverter.class)
|
||||
@Builder.Default
|
||||
private List<String> illustrationImageIds = new ArrayList<>();
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Entité JPA pour la persistance des Campaigns en base de données PostgreSQL.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "campaigns")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class CampaignJpaEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String description;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@Column(name = "arcs_count", nullable = false)
|
||||
private int arcsCount;
|
||||
|
||||
/**
|
||||
* ID du Lore associé (nullable).
|
||||
* Pas de @ManyToOne / pas de FK : c'est une weak reference inter-contexte.
|
||||
* Le Campaign Context et le Lore Context doivent pouvoir évoluer indépendamment.
|
||||
*/
|
||||
@Column(name = "lore_id")
|
||||
private String loreId;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Entité JPA pour la persistance des Chapters en base de données PostgreSQL.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "chapters")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ChapterJpaEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String description;
|
||||
|
||||
@Column(name = "arc_id", nullable = false)
|
||||
private Long arcId;
|
||||
|
||||
@Column(name = "\"order\"", nullable = false)
|
||||
private int order;
|
||||
|
||||
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update)
|
||||
@Column(name = "gm_notes", columnDefinition = "TEXT")
|
||||
private String gmNotes;
|
||||
|
||||
@Column(name = "player_objectives", columnDefinition = "TEXT")
|
||||
private String playerObjectives;
|
||||
|
||||
@Column(name = "narrative_stakes", columnDefinition = "TEXT")
|
||||
private String narrativeStakes;
|
||||
|
||||
@Column(name = "related_page_ids", columnDefinition = "TEXT")
|
||||
@Convert(converter = StringListJsonConverter.class)
|
||||
@Builder.Default
|
||||
private List<String> relatedPageIds = new ArrayList<>();
|
||||
|
||||
@Column(name = "illustration_image_ids", columnDefinition = "TEXT")
|
||||
@Convert(converter = StringListJsonConverter.class)
|
||||
@Builder.Default
|
||||
private List<String> illustrationImageIds = new ArrayList<>();
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Entite JPA pour les metadonnees d'images en PostgreSQL.
|
||||
* Le binaire est stocke cote MinIO (reference par storage_key).
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "images")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ImageJpaEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String filename;
|
||||
|
||||
@Column(name = "content_type", nullable = false)
|
||||
private String contentType;
|
||||
|
||||
@Column(name = "size_bytes", nullable = false)
|
||||
private long sizeBytes;
|
||||
|
||||
/** Cle opaque dans MinIO, unique. */
|
||||
@Column(name = "storage_key", nullable = false, unique = true)
|
||||
private String storageKey;
|
||||
|
||||
@Column(name = "uploaded_at", nullable = false, updatable = false)
|
||||
private LocalDateTime uploadedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
if (uploadedAt == null) {
|
||||
uploadedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Entité JPA pour la persistance des Lores en base de données PostgreSQL.
|
||||
* Cette classe contient des annotations JPA, donc elle est dans infrastructure, pas dans le domaine.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "lores")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class LoreJpaEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String description;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@Column(name = "node_count", nullable = false)
|
||||
private int nodeCount;
|
||||
|
||||
@Column(name = "page_count", nullable = false)
|
||||
private int pageCount;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Entité JPA pour la persistance des LoreNodes en base de données PostgreSQL.
|
||||
* Structure hiérarchique via parentId (auto-référence).
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "lore_nodes")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class LoreNodeJpaEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
/** Clé de l'icône lucide-angular (ex: "users", "map-pin"). Nullable : un dossier peut ne pas avoir d'icône. */
|
||||
@Column(length = 64)
|
||||
private String icon;
|
||||
|
||||
@Column(name = "parent_id")
|
||||
private Long parentId; // Auto-référence pour l'arborescence
|
||||
|
||||
@Column(name = "lore_id", nullable = false)
|
||||
private Long loreId;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
|
||||
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
|
||||
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Entité JPA pour la persistance des Pages en PostgreSQL.
|
||||
* - lore_id dénormalisé (accès rapide aux pages d'un lore sans passer par nodes).
|
||||
* - values / tags / related_page_ids stockés en JSON (TEXT via converters).
|
||||
* - Note : colonne `values_json` car `values` est un mot-clé SQL réservé.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "pages")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PageJpaEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "lore_id", nullable = false)
|
||||
private Long loreId;
|
||||
|
||||
@Column(name = "node_id", nullable = false)
|
||||
private Long nodeId;
|
||||
|
||||
@Column(name = "template_id")
|
||||
private Long templateId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String title;
|
||||
|
||||
@Column(name = "values_json", columnDefinition = "TEXT")
|
||||
@Convert(converter = StringMapJsonConverter.class)
|
||||
private Map<String, String> values;
|
||||
|
||||
/** Stocke les IDs d'images par champ IMAGE du template. JSON dans colonne TEXT. */
|
||||
@Column(name = "image_values_json", columnDefinition = "TEXT")
|
||||
@Convert(converter = StringListMapJsonConverter.class)
|
||||
private Map<String, List<String>> imageValues;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String notes;
|
||||
|
||||
@Column(name = "tags", columnDefinition = "TEXT")
|
||||
@Convert(converter = StringListJsonConverter.class)
|
||||
private List<String> tags;
|
||||
|
||||
@Column(name = "related_page_ids", columnDefinition = "TEXT")
|
||||
@Convert(converter = StringListJsonConverter.class)
|
||||
private List<String> relatedPageIds;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import com.loremind.domain.campaigncontext.SceneBranch;
|
||||
import com.loremind.infrastructure.persistence.converter.SceneBranchListJsonConverter;
|
||||
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Entité JPA pour la persistance des Scenes en base de données PostgreSQL.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "scenes")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SceneJpaEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String description;
|
||||
|
||||
@Column(name = "chapter_id", nullable = false)
|
||||
private Long chapterId;
|
||||
|
||||
@Column(name = "\"order\"", nullable = false)
|
||||
private int order;
|
||||
|
||||
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate (ddl-auto=update)
|
||||
|
||||
// Contexte et ambiance
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String location;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String timing;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String atmosphere;
|
||||
|
||||
// Narration
|
||||
@Column(name = "player_narration", columnDefinition = "TEXT")
|
||||
private String playerNarration;
|
||||
|
||||
// Secrets MJ
|
||||
@Column(name = "gm_secret_notes", columnDefinition = "TEXT")
|
||||
private String gmSecretNotes;
|
||||
|
||||
// Choix et conséquences
|
||||
@Column(name = "choices_consequences", columnDefinition = "TEXT")
|
||||
private String choicesConsequences;
|
||||
|
||||
// Combat
|
||||
@Column(name = "combat_difficulty", columnDefinition = "TEXT")
|
||||
private String combatDifficulty;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String enemies;
|
||||
|
||||
@Column(name = "related_page_ids", columnDefinition = "TEXT")
|
||||
@Convert(converter = StringListJsonConverter.class)
|
||||
@Builder.Default
|
||||
private List<String> relatedPageIds = new ArrayList<>();
|
||||
|
||||
@Column(name = "illustration_image_ids", columnDefinition = "TEXT")
|
||||
@Convert(converter = StringListJsonConverter.class)
|
||||
@Builder.Default
|
||||
private List<String> illustrationImageIds = new ArrayList<>();
|
||||
|
||||
// Graphe narratif intra-chapitre : sorties possibles vers d'autres scènes.
|
||||
// Persisté en TEXT JSON via converter (pattern homogène avec les autres listes).
|
||||
@Column(name = "branches", columnDefinition = "TEXT")
|
||||
@Convert(converter = SceneBranchListJsonConverter.class)
|
||||
@Builder.Default
|
||||
private List<SceneBranch> branches = new ArrayList<>();
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import com.loremind.domain.lorecontext.TemplateField;
|
||||
import com.loremind.infrastructure.persistence.converter.TemplateFieldListJsonConverter;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Entité JPA pour la persistance des Templates en PostgreSQL.
|
||||
* - loreId et defaultNodeId : colonnes typées (FK logiques, pas de @ManyToOne
|
||||
* pour respecter l'isolation des Bounded Contexts).
|
||||
* - fields : stocké en JSON (TEXT) via TemplateFieldListJsonConverter.
|
||||
* Les anciens templates (format legacy ["a","b"]) sont lus de maniere tolerante.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "templates")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TemplateJpaEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "lore_id", nullable = false)
|
||||
private Long loreId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String description;
|
||||
|
||||
@Column(name = "default_node_id")
|
||||
private Long defaultNodeId;
|
||||
|
||||
@Column(name = "fields", columnDefinition = "TEXT")
|
||||
@Convert(converter = TemplateFieldListJsonConverter.class)
|
||||
private List<TemplateField> fields;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.loremind.infrastructure.persistence.jpa;
|
||||
|
||||
import com.loremind.infrastructure.persistence.entity.ArcJpaEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Repository Spring Data JPA pour ArcJpaEntity.
|
||||
*/
|
||||
@Repository
|
||||
public interface ArcJpaRepository extends JpaRepository<ArcJpaEntity, Long> {
|
||||
|
||||
List<ArcJpaEntity> findByCampaignId(Long campaignId);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.loremind.infrastructure.persistence.jpa;
|
||||
|
||||
import com.loremind.infrastructure.persistence.entity.CampaignJpaEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Repository Spring Data JPA pour CampaignJpaEntity.
|
||||
*/
|
||||
@Repository
|
||||
public interface CampaignJpaRepository extends JpaRepository<CampaignJpaEntity, Long> {
|
||||
|
||||
@Query("SELECT c FROM CampaignJpaEntity c WHERE LOWER(c.name) LIKE LOWER(CONCAT('%', :query, '%'))")
|
||||
List<CampaignJpaEntity> findByNameContainingIgnoreCase(@Param("query") String query);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.loremind.infrastructure.persistence.jpa;
|
||||
|
||||
import com.loremind.infrastructure.persistence.entity.ChapterJpaEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Repository Spring Data JPA pour ChapterJpaEntity.
|
||||
*/
|
||||
@Repository
|
||||
public interface ChapterJpaRepository extends JpaRepository<ChapterJpaEntity, Long> {
|
||||
|
||||
List<ChapterJpaEntity> findByArcId(Long arcId);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.loremind.infrastructure.persistence.jpa;
|
||||
|
||||
import com.loremind.infrastructure.persistence.entity.ImageJpaEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/**
|
||||
* Repository Spring Data JPA pour ImageJpaEntity.
|
||||
* Ne contient aucune requete custom pour l'instant : CRUD standard suffit.
|
||||
*/
|
||||
@Repository
|
||||
public interface ImageJpaRepository extends JpaRepository<ImageJpaEntity, Long> {
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.loremind.infrastructure.persistence.jpa;
|
||||
|
||||
import com.loremind.infrastructure.persistence.entity.LoreJpaEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Repository Spring Data JPA pour LoreJpaEntity.
|
||||
* Utilisé par l'adaptateur PostgresLoreRepository.
|
||||
*/
|
||||
@Repository
|
||||
public interface LoreJpaRepository extends JpaRepository<LoreJpaEntity, Long> {
|
||||
|
||||
List<LoreJpaEntity> findByNameContainingIgnoreCase(String query);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.loremind.infrastructure.persistence.jpa;
|
||||
|
||||
import com.loremind.infrastructure.persistence.entity.LoreNodeJpaEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Repository Spring Data JPA pour LoreNodeJpaEntity.
|
||||
*/
|
||||
@Repository
|
||||
public interface LoreNodeJpaRepository extends JpaRepository<LoreNodeJpaEntity, Long> {
|
||||
|
||||
List<LoreNodeJpaEntity> findByLoreId(Long loreId);
|
||||
|
||||
List<LoreNodeJpaEntity> findByParentId(Long parentId);
|
||||
|
||||
long countByLoreId(Long loreId);
|
||||
|
||||
List<LoreNodeJpaEntity> findByNameContainingIgnoreCase(String query);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.loremind.infrastructure.persistence.jpa;
|
||||
|
||||
import com.loremind.infrastructure.persistence.entity.PageJpaEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Repository Spring Data JPA pour PageJpaEntity.
|
||||
*/
|
||||
@Repository
|
||||
public interface PageJpaRepository extends JpaRepository<PageJpaEntity, Long> {
|
||||
|
||||
List<PageJpaEntity> findByLoreId(Long loreId);
|
||||
|
||||
List<PageJpaEntity> findByNodeId(Long nodeId);
|
||||
|
||||
List<PageJpaEntity> findByTemplateId(Long templateId);
|
||||
|
||||
long countByLoreId(Long loreId);
|
||||
|
||||
List<PageJpaEntity> findByTitleContainingIgnoreCase(String query);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.loremind.infrastructure.persistence.jpa;
|
||||
|
||||
import com.loremind.infrastructure.persistence.entity.SceneJpaEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Repository Spring Data JPA pour SceneJpaEntity.
|
||||
*/
|
||||
@Repository
|
||||
public interface SceneJpaRepository extends JpaRepository<SceneJpaEntity, Long> {
|
||||
|
||||
List<SceneJpaEntity> findByChapterId(Long chapterId);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.loremind.infrastructure.persistence.jpa;
|
||||
|
||||
import com.loremind.infrastructure.persistence.entity.TemplateJpaEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Repository Spring Data JPA pour TemplateJpaEntity.
|
||||
*/
|
||||
@Repository
|
||||
public interface TemplateJpaRepository extends JpaRepository<TemplateJpaEntity, Long> {
|
||||
|
||||
List<TemplateJpaEntity> findByLoreId(Long loreId);
|
||||
|
||||
List<TemplateJpaEntity> findByNameContainingIgnoreCase(String query);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.loremind.infrastructure.persistence.postgres;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Arc;
|
||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||
import com.loremind.infrastructure.persistence.entity.ArcJpaEntity;
|
||||
import com.loremind.infrastructure.persistence.jpa.ArcJpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Adaptateur d'infrastructure qui implémente le Port ArcRepository.
|
||||
*/
|
||||
@Repository
|
||||
public class PostgresArcRepository implements ArcRepository {
|
||||
|
||||
private final ArcJpaRepository jpaRepository;
|
||||
|
||||
public PostgresArcRepository(ArcJpaRepository jpaRepository) {
|
||||
this.jpaRepository = jpaRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Arc save(Arc arc) {
|
||||
ArcJpaEntity jpaEntity = toJpaEntity(arc);
|
||||
ArcJpaEntity saved = jpaRepository.save(jpaEntity);
|
||||
return toDomainEntity(saved);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Arc> findById(String id) {
|
||||
Long longId = Long.parseLong(id);
|
||||
return jpaRepository.findById(longId)
|
||||
.map(this::toDomainEntity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Arc> findByCampaignId(String campaignId) {
|
||||
Long longCampaignId = Long.parseLong(campaignId);
|
||||
return jpaRepository.findByCampaignId(longCampaignId).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Arc> findAll() {
|
||||
return jpaRepository.findAll().stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteById(String id) {
|
||||
Long longId = Long.parseLong(id);
|
||||
jpaRepository.deleteById(longId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsById(String id) {
|
||||
Long longId = Long.parseLong(id);
|
||||
return jpaRepository.existsById(longId);
|
||||
}
|
||||
|
||||
private Arc toDomainEntity(ArcJpaEntity jpaEntity) {
|
||||
return Arc.builder()
|
||||
.id(jpaEntity.getId().toString())
|
||||
.name(jpaEntity.getName())
|
||||
.description(jpaEntity.getDescription())
|
||||
.campaignId(jpaEntity.getCampaignId().toString())
|
||||
.order(jpaEntity.getOrder())
|
||||
.themes(jpaEntity.getThemes())
|
||||
.stakes(jpaEntity.getStakes())
|
||||
.gmNotes(jpaEntity.getGmNotes())
|
||||
.rewards(jpaEntity.getRewards())
|
||||
.resolution(jpaEntity.getResolution())
|
||||
// Defensive copy : jamais de null en domaine pour simplifier les appelants.
|
||||
.relatedPageIds(jpaEntity.getRelatedPageIds() != null
|
||||
? new ArrayList<>(jpaEntity.getRelatedPageIds())
|
||||
: new ArrayList<>())
|
||||
.illustrationImageIds(jpaEntity.getIllustrationImageIds() != null
|
||||
? new ArrayList<>(jpaEntity.getIllustrationImageIds())
|
||||
: new ArrayList<>())
|
||||
.createdAt(jpaEntity.getCreatedAt())
|
||||
.updatedAt(jpaEntity.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
private ArcJpaEntity toJpaEntity(Arc arc) {
|
||||
Long id = arc.getId() != null ? Long.parseLong(arc.getId()) : null;
|
||||
return ArcJpaEntity.builder()
|
||||
.id(id)
|
||||
.name(arc.getName())
|
||||
.description(arc.getDescription())
|
||||
.campaignId(Long.parseLong(arc.getCampaignId()))
|
||||
.order(arc.getOrder())
|
||||
.themes(arc.getThemes())
|
||||
.stakes(arc.getStakes())
|
||||
.gmNotes(arc.getGmNotes())
|
||||
.rewards(arc.getRewards())
|
||||
.resolution(arc.getResolution())
|
||||
.relatedPageIds(arc.getRelatedPageIds() != null
|
||||
? new ArrayList<>(arc.getRelatedPageIds())
|
||||
: new ArrayList<>())
|
||||
.illustrationImageIds(arc.getIllustrationImageIds() != null
|
||||
? new ArrayList<>(arc.getIllustrationImageIds())
|
||||
: new ArrayList<>())
|
||||
.createdAt(arc.getCreatedAt())
|
||||
.updatedAt(arc.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.loremind.infrastructure.persistence.postgres;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||
import com.loremind.infrastructure.persistence.entity.CampaignJpaEntity;
|
||||
import com.loremind.infrastructure.persistence.jpa.CampaignJpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Adaptateur d'infrastructure qui implémente le Port CampaignRepository.
|
||||
*/
|
||||
@Repository
|
||||
public class PostgresCampaignRepository implements CampaignRepository {
|
||||
|
||||
private final CampaignJpaRepository jpaRepository;
|
||||
|
||||
public PostgresCampaignRepository(CampaignJpaRepository jpaRepository) {
|
||||
this.jpaRepository = jpaRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Campaign save(Campaign campaign) {
|
||||
CampaignJpaEntity jpaEntity = toJpaEntity(campaign);
|
||||
CampaignJpaEntity saved = jpaRepository.save(jpaEntity);
|
||||
return toDomainEntity(saved);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Campaign> findById(String id) {
|
||||
Long longId = Long.parseLong(id);
|
||||
return jpaRepository.findById(longId)
|
||||
.map(this::toDomainEntity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Campaign> findAll() {
|
||||
return jpaRepository.findAll().stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteById(String id) {
|
||||
Long longId = Long.parseLong(id);
|
||||
jpaRepository.deleteById(longId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsById(String id) {
|
||||
Long longId = Long.parseLong(id);
|
||||
return jpaRepository.existsById(longId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Campaign> searchByName(String query) {
|
||||
return jpaRepository.findByNameContainingIgnoreCase(query).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private Campaign toDomainEntity(CampaignJpaEntity jpaEntity) {
|
||||
return Campaign.builder()
|
||||
.id(jpaEntity.getId().toString())
|
||||
.name(jpaEntity.getName())
|
||||
.description(jpaEntity.getDescription())
|
||||
.createdAt(jpaEntity.getCreatedAt())
|
||||
.updatedAt(jpaEntity.getUpdatedAt())
|
||||
.arcsCount(jpaEntity.getArcsCount())
|
||||
.loreId(jpaEntity.getLoreId())
|
||||
.build();
|
||||
}
|
||||
|
||||
private CampaignJpaEntity toJpaEntity(Campaign campaign) {
|
||||
Long id = campaign.getId() != null ? Long.parseLong(campaign.getId()) : null;
|
||||
return CampaignJpaEntity.builder()
|
||||
.id(id)
|
||||
.name(campaign.getName())
|
||||
.description(campaign.getDescription())
|
||||
.createdAt(campaign.getCreatedAt())
|
||||
.updatedAt(campaign.getUpdatedAt())
|
||||
.arcsCount(campaign.getArcsCount())
|
||||
.loreId(campaign.getLoreId())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.loremind.infrastructure.persistence.postgres;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||
import com.loremind.infrastructure.persistence.entity.ChapterJpaEntity;
|
||||
import com.loremind.infrastructure.persistence.jpa.ChapterJpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Adaptateur d'infrastructure qui implémente le Port ChapterRepository.
|
||||
*/
|
||||
@Repository
|
||||
public class PostgresChapterRepository implements ChapterRepository {
|
||||
|
||||
private final ChapterJpaRepository jpaRepository;
|
||||
|
||||
public PostgresChapterRepository(ChapterJpaRepository jpaRepository) {
|
||||
this.jpaRepository = jpaRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Chapter save(Chapter chapter) {
|
||||
ChapterJpaEntity jpaEntity = toJpaEntity(chapter);
|
||||
ChapterJpaEntity saved = jpaRepository.save(jpaEntity);
|
||||
return toDomainEntity(saved);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Chapter> findById(String id) {
|
||||
Long longId = Long.parseLong(id);
|
||||
return jpaRepository.findById(longId)
|
||||
.map(this::toDomainEntity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Chapter> findByArcId(String arcId) {
|
||||
Long longArcId = Long.parseLong(arcId);
|
||||
return jpaRepository.findByArcId(longArcId).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Chapter> findAll() {
|
||||
return jpaRepository.findAll().stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteById(String id) {
|
||||
Long longId = Long.parseLong(id);
|
||||
jpaRepository.deleteById(longId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsById(String id) {
|
||||
Long longId = Long.parseLong(id);
|
||||
return jpaRepository.existsById(longId);
|
||||
}
|
||||
|
||||
private Chapter toDomainEntity(ChapterJpaEntity jpaEntity) {
|
||||
return Chapter.builder()
|
||||
.id(jpaEntity.getId().toString())
|
||||
.name(jpaEntity.getName())
|
||||
.description(jpaEntity.getDescription())
|
||||
.arcId(jpaEntity.getArcId().toString())
|
||||
.order(jpaEntity.getOrder())
|
||||
.gmNotes(jpaEntity.getGmNotes())
|
||||
.playerObjectives(jpaEntity.getPlayerObjectives())
|
||||
.narrativeStakes(jpaEntity.getNarrativeStakes())
|
||||
.relatedPageIds(jpaEntity.getRelatedPageIds() != null
|
||||
? new ArrayList<>(jpaEntity.getRelatedPageIds())
|
||||
: new ArrayList<>())
|
||||
.illustrationImageIds(jpaEntity.getIllustrationImageIds() != null
|
||||
? new ArrayList<>(jpaEntity.getIllustrationImageIds())
|
||||
: new ArrayList<>())
|
||||
.createdAt(jpaEntity.getCreatedAt())
|
||||
.updatedAt(jpaEntity.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
private ChapterJpaEntity toJpaEntity(Chapter chapter) {
|
||||
Long id = chapter.getId() != null ? Long.parseLong(chapter.getId()) : null;
|
||||
return ChapterJpaEntity.builder()
|
||||
.id(id)
|
||||
.name(chapter.getName())
|
||||
.description(chapter.getDescription())
|
||||
.arcId(Long.parseLong(chapter.getArcId()))
|
||||
.order(chapter.getOrder())
|
||||
.gmNotes(chapter.getGmNotes())
|
||||
.playerObjectives(chapter.getPlayerObjectives())
|
||||
.narrativeStakes(chapter.getNarrativeStakes())
|
||||
.relatedPageIds(chapter.getRelatedPageIds() != null
|
||||
? new ArrayList<>(chapter.getRelatedPageIds())
|
||||
: new ArrayList<>())
|
||||
.illustrationImageIds(chapter.getIllustrationImageIds() != null
|
||||
? new ArrayList<>(chapter.getIllustrationImageIds())
|
||||
: new ArrayList<>())
|
||||
.createdAt(chapter.getCreatedAt())
|
||||
.updatedAt(chapter.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.loremind.infrastructure.persistence.postgres;
|
||||
|
||||
import com.loremind.domain.images.Image;
|
||||
import com.loremind.domain.images.ports.ImageRepository;
|
||||
import com.loremind.infrastructure.persistence.entity.ImageJpaEntity;
|
||||
import com.loremind.infrastructure.persistence.jpa.ImageJpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Adaptateur de sortie : implemente le port ImageRepository du domaine.
|
||||
* Fait la traduction Image (domaine) <-> ImageJpaEntity (JPA).
|
||||
*/
|
||||
@Repository
|
||||
public class PostgresImageRepository implements ImageRepository {
|
||||
|
||||
private final ImageJpaRepository jpaRepository;
|
||||
|
||||
public PostgresImageRepository(ImageJpaRepository jpaRepository) {
|
||||
this.jpaRepository = jpaRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Image save(Image image) {
|
||||
ImageJpaEntity saved = jpaRepository.save(toJpa(image));
|
||||
return toDomain(saved);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Image> findById(String id) {
|
||||
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteById(String id) {
|
||||
jpaRepository.deleteById(Long.parseLong(id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsById(String id) {
|
||||
return jpaRepository.existsById(Long.parseLong(id));
|
||||
}
|
||||
|
||||
// --- Conversions -------------------------------------------------------
|
||||
|
||||
private Image toDomain(ImageJpaEntity e) {
|
||||
return Image.builder()
|
||||
.id(e.getId().toString())
|
||||
.filename(e.getFilename())
|
||||
.contentType(e.getContentType())
|
||||
.sizeBytes(e.getSizeBytes())
|
||||
.storageKey(e.getStorageKey())
|
||||
.uploadedAt(e.getUploadedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
private ImageJpaEntity toJpa(Image img) {
|
||||
Long id = img.getId() != null ? Long.parseLong(img.getId()) : null;
|
||||
return ImageJpaEntity.builder()
|
||||
.id(id)
|
||||
.filename(img.getFilename())
|
||||
.contentType(img.getContentType())
|
||||
.sizeBytes(img.getSizeBytes())
|
||||
.storageKey(img.getStorageKey())
|
||||
.uploadedAt(img.getUploadedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.loremind.infrastructure.persistence.postgres;
|
||||
|
||||
import com.loremind.domain.lorecontext.LoreNode;
|
||||
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
|
||||
import com.loremind.infrastructure.persistence.entity.LoreNodeJpaEntity;
|
||||
import com.loremind.infrastructure.persistence.jpa.LoreNodeJpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Adaptateur d'infrastructure qui implémente le Port LoreNodeRepository.
|
||||
*/
|
||||
@Repository
|
||||
public class PostgresLoreNodeRepository implements LoreNodeRepository {
|
||||
|
||||
private final LoreNodeJpaRepository jpaRepository;
|
||||
|
||||
public PostgresLoreNodeRepository(LoreNodeJpaRepository jpaRepository) {
|
||||
this.jpaRepository = jpaRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public LoreNode save(LoreNode loreNode) {
|
||||
LoreNodeJpaEntity jpaEntity = toJpaEntity(loreNode);
|
||||
LoreNodeJpaEntity saved = jpaRepository.save(jpaEntity);
|
||||
return toDomainEntity(saved);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<LoreNode> findById(String id) {
|
||||
Long longId = Long.parseLong(id);
|
||||
return jpaRepository.findById(longId)
|
||||
.map(this::toDomainEntity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<LoreNode> findByLoreId(String loreId) {
|
||||
Long longLoreId = Long.parseLong(loreId);
|
||||
return jpaRepository.findByLoreId(longLoreId).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<LoreNode> findByParentId(String parentId) {
|
||||
if (parentId == null || parentId.isEmpty()) {
|
||||
return jpaRepository.findByParentId(null).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
Long longParentId = Long.parseLong(parentId);
|
||||
return jpaRepository.findByParentId(longParentId).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<LoreNode> findAll() {
|
||||
return jpaRepository.findAll().stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteById(String id) {
|
||||
Long longId = Long.parseLong(id);
|
||||
jpaRepository.deleteById(longId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsById(String id) {
|
||||
Long longId = Long.parseLong(id);
|
||||
return jpaRepository.existsById(longId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long countByLoreId(String loreId) {
|
||||
return jpaRepository.countByLoreId(Long.parseLong(loreId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<LoreNode> searchByName(String query) {
|
||||
return jpaRepository.findByNameContainingIgnoreCase(query).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private LoreNode toDomainEntity(LoreNodeJpaEntity jpaEntity) {
|
||||
return LoreNode.builder()
|
||||
.id(jpaEntity.getId().toString())
|
||||
.name(jpaEntity.getName())
|
||||
.icon(jpaEntity.getIcon())
|
||||
.parentId(jpaEntity.getParentId() != null ? jpaEntity.getParentId().toString() : null)
|
||||
.loreId(jpaEntity.getLoreId().toString())
|
||||
.createdAt(jpaEntity.getCreatedAt())
|
||||
.updatedAt(jpaEntity.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
private LoreNodeJpaEntity toJpaEntity(LoreNode loreNode) {
|
||||
Long id = loreNode.getId() != null ? Long.parseLong(loreNode.getId()) : null;
|
||||
Long parentId = loreNode.getParentId() != null && !loreNode.getParentId().isEmpty()
|
||||
? Long.parseLong(loreNode.getParentId())
|
||||
: null;
|
||||
return LoreNodeJpaEntity.builder()
|
||||
.id(id)
|
||||
.name(loreNode.getName())
|
||||
.icon(loreNode.getIcon())
|
||||
.parentId(parentId)
|
||||
.loreId(Long.parseLong(loreNode.getLoreId()))
|
||||
.createdAt(loreNode.getCreatedAt())
|
||||
.updatedAt(loreNode.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.loremind.infrastructure.persistence.postgres;
|
||||
|
||||
import com.loremind.domain.lorecontext.Lore;
|
||||
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
||||
import com.loremind.infrastructure.persistence.entity.LoreJpaEntity;
|
||||
import com.loremind.infrastructure.persistence.jpa.LoreJpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Adaptateur d'infrastructure qui implémente le Port LoreRepository.
|
||||
* Utilise LoreJpaRepository pour interagir avec PostgreSQL.
|
||||
* Fait la conversion entre Lore (domaine) et LoreJpaEntity (JPA).
|
||||
*/
|
||||
@Repository
|
||||
public class PostgresLoreRepository implements LoreRepository {
|
||||
|
||||
private final LoreJpaRepository jpaRepository;
|
||||
|
||||
public PostgresLoreRepository(LoreJpaRepository jpaRepository) {
|
||||
this.jpaRepository = jpaRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Lore save(Lore lore) {
|
||||
LoreJpaEntity jpaEntity = toJpaEntity(lore);
|
||||
LoreJpaEntity saved = jpaRepository.save(jpaEntity);
|
||||
return toDomainEntity(saved);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Lore> findById(String id) {
|
||||
Long longId = Long.parseLong(id);
|
||||
return jpaRepository.findById(longId)
|
||||
.map(this::toDomainEntity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Lore> findAll() {
|
||||
return jpaRepository.findAll().stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteById(String id) {
|
||||
Long longId = Long.parseLong(id);
|
||||
jpaRepository.deleteById(longId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsById(String id) {
|
||||
Long longId = Long.parseLong(id);
|
||||
return jpaRepository.existsById(longId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Lore> searchByName(String query) {
|
||||
return jpaRepository.findByNameContainingIgnoreCase(query).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
// Méthodes de conversion
|
||||
private Lore toDomainEntity(LoreJpaEntity jpaEntity) {
|
||||
return Lore.builder()
|
||||
.id(jpaEntity.getId().toString())
|
||||
.name(jpaEntity.getName())
|
||||
.description(jpaEntity.getDescription())
|
||||
.createdAt(jpaEntity.getCreatedAt())
|
||||
.updatedAt(jpaEntity.getUpdatedAt())
|
||||
.nodeCount(jpaEntity.getNodeCount())
|
||||
.pageCount(jpaEntity.getPageCount())
|
||||
.build();
|
||||
}
|
||||
|
||||
private LoreJpaEntity toJpaEntity(Lore lore) {
|
||||
Long id = lore.getId() != null ? Long.parseLong(lore.getId()) : null;
|
||||
return LoreJpaEntity.builder()
|
||||
.id(id)
|
||||
.name(lore.getName())
|
||||
.description(lore.getDescription())
|
||||
.createdAt(lore.getCreatedAt())
|
||||
.updatedAt(lore.getUpdatedAt())
|
||||
.nodeCount(lore.getNodeCount())
|
||||
.pageCount(lore.getPageCount())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.loremind.infrastructure.persistence.postgres;
|
||||
|
||||
import com.loremind.domain.lorecontext.Page;
|
||||
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||
import com.loremind.infrastructure.persistence.entity.PageJpaEntity;
|
||||
import com.loremind.infrastructure.persistence.jpa.PageJpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Adaptateur d'infrastructure qui implémente le Port PageRepository.
|
||||
*/
|
||||
@Repository
|
||||
public class PostgresPageRepository implements PageRepository {
|
||||
|
||||
private final PageJpaRepository jpaRepository;
|
||||
|
||||
public PostgresPageRepository(PageJpaRepository jpaRepository) {
|
||||
this.jpaRepository = jpaRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page save(Page page) {
|
||||
PageJpaEntity jpaEntity = toJpaEntity(page);
|
||||
PageJpaEntity saved = jpaRepository.save(jpaEntity);
|
||||
return toDomainEntity(saved);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Page> findById(String id) {
|
||||
Long longId = Long.parseLong(id);
|
||||
return jpaRepository.findById(longId)
|
||||
.map(this::toDomainEntity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Page> findByLoreId(String loreId) {
|
||||
return jpaRepository.findByLoreId(Long.parseLong(loreId)).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Page> findByNodeId(String nodeId) {
|
||||
Long longNodeId = Long.parseLong(nodeId);
|
||||
return jpaRepository.findByNodeId(longNodeId).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Page> findAll() {
|
||||
return jpaRepository.findAll().stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteById(String id) {
|
||||
Long longId = Long.parseLong(id);
|
||||
jpaRepository.deleteById(longId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsById(String id) {
|
||||
Long longId = Long.parseLong(id);
|
||||
return jpaRepository.existsById(longId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long countByLoreId(String loreId) {
|
||||
return jpaRepository.countByLoreId(Long.parseLong(loreId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Page> searchByTitle(String query) {
|
||||
return jpaRepository.findByTitleContainingIgnoreCase(query).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private Page toDomainEntity(PageJpaEntity e) {
|
||||
return Page.builder()
|
||||
.id(e.getId().toString())
|
||||
.loreId(e.getLoreId() != null ? e.getLoreId().toString() : null)
|
||||
.nodeId(e.getNodeId() != null ? e.getNodeId().toString() : null)
|
||||
.templateId(e.getTemplateId() != null ? e.getTemplateId().toString() : null)
|
||||
.title(e.getTitle())
|
||||
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
|
||||
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
|
||||
.notes(e.getNotes())
|
||||
.tags(e.getTags() != null ? new ArrayList<>(e.getTags()) : new ArrayList<>())
|
||||
.relatedPageIds(e.getRelatedPageIds() != null ? new ArrayList<>(e.getRelatedPageIds()) : new ArrayList<>())
|
||||
.createdAt(e.getCreatedAt())
|
||||
.updatedAt(e.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
private PageJpaEntity toJpaEntity(Page p) {
|
||||
return PageJpaEntity.builder()
|
||||
.id(p.getId() != null ? Long.parseLong(p.getId()) : null)
|
||||
.loreId(p.getLoreId() != null ? Long.parseLong(p.getLoreId()) : null)
|
||||
.nodeId(p.getNodeId() != null ? Long.parseLong(p.getNodeId()) : null)
|
||||
.templateId(p.getTemplateId() != null && !p.getTemplateId().isBlank()
|
||||
? Long.parseLong(p.getTemplateId()) : null)
|
||||
.title(p.getTitle())
|
||||
.values(p.getValues() != null ? new HashMap<>(p.getValues()) : new HashMap<>())
|
||||
.imageValues(p.getImageValues() != null ? new HashMap<>(p.getImageValues()) : new HashMap<>())
|
||||
.notes(p.getNotes())
|
||||
.tags(p.getTags() != null ? new ArrayList<>(p.getTags()) : new ArrayList<>())
|
||||
.relatedPageIds(p.getRelatedPageIds() != null ? new ArrayList<>(p.getRelatedPageIds()) : new ArrayList<>())
|
||||
.createdAt(p.getCreatedAt())
|
||||
.updatedAt(p.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.loremind.infrastructure.persistence.postgres;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Scene;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import com.loremind.infrastructure.persistence.entity.SceneJpaEntity;
|
||||
import com.loremind.infrastructure.persistence.jpa.SceneJpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Adaptateur d'infrastructure qui implémente le Port SceneRepository.
|
||||
*/
|
||||
@Repository
|
||||
public class PostgresSceneRepository implements SceneRepository {
|
||||
|
||||
private final SceneJpaRepository jpaRepository;
|
||||
|
||||
public PostgresSceneRepository(SceneJpaRepository jpaRepository) {
|
||||
this.jpaRepository = jpaRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Scene save(Scene scene) {
|
||||
SceneJpaEntity jpaEntity = toJpaEntity(scene);
|
||||
SceneJpaEntity saved = jpaRepository.save(jpaEntity);
|
||||
return toDomainEntity(saved);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Scene> findById(String id) {
|
||||
Long longId = Long.parseLong(id);
|
||||
return jpaRepository.findById(longId)
|
||||
.map(this::toDomainEntity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Scene> findByChapterId(String chapterId) {
|
||||
Long longChapterId = Long.parseLong(chapterId);
|
||||
return jpaRepository.findByChapterId(longChapterId).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Scene> findAll() {
|
||||
return jpaRepository.findAll().stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteById(String id) {
|
||||
Long longId = Long.parseLong(id);
|
||||
jpaRepository.deleteById(longId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsById(String id) {
|
||||
Long longId = Long.parseLong(id);
|
||||
return jpaRepository.existsById(longId);
|
||||
}
|
||||
|
||||
private Scene toDomainEntity(SceneJpaEntity jpaEntity) {
|
||||
return Scene.builder()
|
||||
.id(jpaEntity.getId().toString())
|
||||
.name(jpaEntity.getName())
|
||||
.description(jpaEntity.getDescription())
|
||||
.chapterId(jpaEntity.getChapterId().toString())
|
||||
.order(jpaEntity.getOrder())
|
||||
.location(jpaEntity.getLocation())
|
||||
.timing(jpaEntity.getTiming())
|
||||
.atmosphere(jpaEntity.getAtmosphere())
|
||||
.playerNarration(jpaEntity.getPlayerNarration())
|
||||
.gmSecretNotes(jpaEntity.getGmSecretNotes())
|
||||
.choicesConsequences(jpaEntity.getChoicesConsequences())
|
||||
.combatDifficulty(jpaEntity.getCombatDifficulty())
|
||||
.enemies(jpaEntity.getEnemies())
|
||||
.relatedPageIds(jpaEntity.getRelatedPageIds() != null
|
||||
? new ArrayList<>(jpaEntity.getRelatedPageIds())
|
||||
: new ArrayList<>())
|
||||
.illustrationImageIds(jpaEntity.getIllustrationImageIds() != null
|
||||
? new ArrayList<>(jpaEntity.getIllustrationImageIds())
|
||||
: new ArrayList<>())
|
||||
.branches(jpaEntity.getBranches() != null
|
||||
? new ArrayList<>(jpaEntity.getBranches())
|
||||
: new ArrayList<>())
|
||||
.createdAt(jpaEntity.getCreatedAt())
|
||||
.updatedAt(jpaEntity.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
private SceneJpaEntity toJpaEntity(Scene scene) {
|
||||
Long id = scene.getId() != null ? Long.parseLong(scene.getId()) : null;
|
||||
return SceneJpaEntity.builder()
|
||||
.id(id)
|
||||
.name(scene.getName())
|
||||
.description(scene.getDescription())
|
||||
.chapterId(Long.parseLong(scene.getChapterId()))
|
||||
.order(scene.getOrder())
|
||||
.location(scene.getLocation())
|
||||
.timing(scene.getTiming())
|
||||
.atmosphere(scene.getAtmosphere())
|
||||
.playerNarration(scene.getPlayerNarration())
|
||||
.gmSecretNotes(scene.getGmSecretNotes())
|
||||
.choicesConsequences(scene.getChoicesConsequences())
|
||||
.combatDifficulty(scene.getCombatDifficulty())
|
||||
.enemies(scene.getEnemies())
|
||||
.relatedPageIds(scene.getRelatedPageIds() != null
|
||||
? new ArrayList<>(scene.getRelatedPageIds())
|
||||
: new ArrayList<>())
|
||||
.illustrationImageIds(scene.getIllustrationImageIds() != null
|
||||
? new ArrayList<>(scene.getIllustrationImageIds())
|
||||
: new ArrayList<>())
|
||||
.branches(scene.getBranches() != null
|
||||
? new ArrayList<>(scene.getBranches())
|
||||
: new ArrayList<>())
|
||||
.createdAt(scene.getCreatedAt())
|
||||
.updatedAt(scene.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.loremind.infrastructure.persistence.postgres;
|
||||
|
||||
import com.loremind.domain.lorecontext.Template;
|
||||
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||
import com.loremind.infrastructure.persistence.entity.TemplateJpaEntity;
|
||||
import com.loremind.infrastructure.persistence.jpa.TemplateJpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Adaptateur d'infrastructure qui implémente le Port TemplateRepository.
|
||||
* Responsable du mapping bidirectionnel entre l'entité de domaine (Template)
|
||||
* et l'entité JPA (TemplateJpaEntity).
|
||||
*/
|
||||
@Repository
|
||||
public class PostgresTemplateRepository implements TemplateRepository {
|
||||
|
||||
private final TemplateJpaRepository jpaRepository;
|
||||
|
||||
public PostgresTemplateRepository(TemplateJpaRepository jpaRepository) {
|
||||
this.jpaRepository = jpaRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Template save(Template template) {
|
||||
TemplateJpaEntity saved = jpaRepository.save(toJpaEntity(template));
|
||||
return toDomainEntity(saved);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Template> findById(String id) {
|
||||
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Template> findAll() {
|
||||
return jpaRepository.findAll().stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Template> findByLoreId(String loreId) {
|
||||
return jpaRepository.findByLoreId(Long.parseLong(loreId)).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteById(String id) {
|
||||
jpaRepository.deleteById(Long.parseLong(id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsById(String id) {
|
||||
return jpaRepository.existsById(Long.parseLong(id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Template> searchByName(String query) {
|
||||
return jpaRepository.findByNameContainingIgnoreCase(query).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
// --- Mapping ----------------------------------------------------------
|
||||
|
||||
private Template toDomainEntity(TemplateJpaEntity e) {
|
||||
return Template.builder()
|
||||
.id(e.getId().toString())
|
||||
.loreId(e.getLoreId() != null ? e.getLoreId().toString() : null)
|
||||
.name(e.getName())
|
||||
.description(e.getDescription())
|
||||
.defaultNodeId(e.getDefaultNodeId() != null ? e.getDefaultNodeId().toString() : null)
|
||||
.fields(e.getFields() != null
|
||||
? new ArrayList<>(e.getFields())
|
||||
: new ArrayList<>())
|
||||
.createdAt(e.getCreatedAt())
|
||||
.updatedAt(e.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
private TemplateJpaEntity toJpaEntity(Template t) {
|
||||
return TemplateJpaEntity.builder()
|
||||
.id(t.getId() != null ? Long.parseLong(t.getId()) : null)
|
||||
.loreId(t.getLoreId() != null ? Long.parseLong(t.getLoreId()) : null)
|
||||
.name(t.getName())
|
||||
.description(t.getDescription())
|
||||
.defaultNodeId(t.getDefaultNodeId() != null ? Long.parseLong(t.getDefaultNodeId()) : null)
|
||||
.fields(t.getFields() != null
|
||||
? new ArrayList<>(t.getFields())
|
||||
: new ArrayList<>())
|
||||
.createdAt(t.getCreatedAt())
|
||||
.updatedAt(t.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.loremind.infrastructure.storage;
|
||||
|
||||
import io.minio.BucketExistsArgs;
|
||||
import io.minio.MakeBucketArgs;
|
||||
import io.minio.MinioClient;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Configuration Spring pour le client MinIO (S3-compatible).
|
||||
* <p>
|
||||
* Expose un bean MinioClient singleton injecte dans MinioImageStorageAdapter.
|
||||
* S'assure au demarrage que le bucket configure existe (filet de securite :
|
||||
* normalement docker-compose/minio-init l'a deja cree).
|
||||
*/
|
||||
@Configuration
|
||||
public class MinioConfig {
|
||||
|
||||
@Value("${minio.endpoint}")
|
||||
private String endpoint;
|
||||
|
||||
@Value("${minio.access-key}")
|
||||
private String accessKey;
|
||||
|
||||
@Value("${minio.secret-key}")
|
||||
private String secretKey;
|
||||
|
||||
@Value("${minio.bucket}")
|
||||
private String bucket;
|
||||
|
||||
@Bean
|
||||
public MinioClient minioClient() {
|
||||
return MinioClient.builder()
|
||||
.endpoint(endpoint)
|
||||
.credentials(accessKey, secretKey)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Garantit l'existence du bucket au demarrage. Si MinIO n'est pas joignable,
|
||||
* on loggue juste l'erreur sans planter l'application : le developpeur
|
||||
* recevra une erreur claire au premier upload plutot qu'au boot.
|
||||
*/
|
||||
@PostConstruct
|
||||
public void ensureBucketExists() {
|
||||
try {
|
||||
MinioClient client = minioClient();
|
||||
boolean exists = client.bucketExists(BucketExistsArgs.builder().bucket(bucket).build());
|
||||
if (!exists) {
|
||||
client.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
|
||||
System.out.println("[MinIO] Bucket '" + bucket + "' cree.");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.err.println("[MinIO] Initialisation impossible (endpoint=" + endpoint
|
||||
+ "). Les uploads d'images echoueront tant que MinIO n'est pas joignable. "
|
||||
+ "Cause : " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.loremind.infrastructure.storage;
|
||||
|
||||
import com.loremind.domain.images.ports.ImageStorage;
|
||||
import io.minio.GetObjectArgs;
|
||||
import io.minio.MinioClient;
|
||||
import io.minio.PutObjectArgs;
|
||||
import io.minio.RemoveObjectArgs;
|
||||
import io.minio.errors.ErrorResponseException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Adaptateur d'infrastructure : implemente le port ImageStorage en utilisant
|
||||
* MinIO (compatible S3) comme backend de stockage d'objets.
|
||||
* <p>
|
||||
* Le domaine ne sait rien de MinIO : il manipule juste des cles opaques.
|
||||
*/
|
||||
@Component
|
||||
public class MinioImageStorageAdapter implements ImageStorage {
|
||||
|
||||
private final MinioClient minioClient;
|
||||
private final String bucket;
|
||||
|
||||
public MinioImageStorageAdapter(MinioClient minioClient,
|
||||
@Value("${minio.bucket}") String bucket) {
|
||||
this.minioClient = minioClient;
|
||||
this.bucket = bucket;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String upload(String filename, String contentType, InputStream data, long sizeBytes) {
|
||||
String storageKey = generateStorageKey(filename);
|
||||
try {
|
||||
minioClient.putObject(
|
||||
PutObjectArgs.builder()
|
||||
.bucket(bucket)
|
||||
.object(storageKey)
|
||||
.stream(data, sizeBytes, -1)
|
||||
.contentType(contentType)
|
||||
.build()
|
||||
);
|
||||
return storageKey;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Echec de l'upload de l'image vers MinIO : " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream download(String storageKey) {
|
||||
try {
|
||||
return minioClient.getObject(
|
||||
GetObjectArgs.builder().bucket(bucket).object(storageKey).build()
|
||||
);
|
||||
} catch (ErrorResponseException e) {
|
||||
// Objet inexistant (cle orpheline) : on retourne null plutot que de propager.
|
||||
if ("NoSuchKey".equals(e.errorResponse().code())) {
|
||||
return null;
|
||||
}
|
||||
throw new RuntimeException("Echec du download MinIO : " + e.getMessage(), e);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Echec du download MinIO : " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String storageKey) {
|
||||
try {
|
||||
minioClient.removeObject(
|
||||
RemoveObjectArgs.builder().bucket(bucket).object(storageKey).build()
|
||||
);
|
||||
} catch (Exception e) {
|
||||
// Suppression idempotente : on loggue mais on ne propage pas.
|
||||
System.err.println("[MinIO] Erreur suppression (non bloquante) : " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Genere une cle unique tout en gardant l'extension d'origine (utile pour
|
||||
* le Content-Disposition et les outils comme Foundry qui s'en servent).
|
||||
*/
|
||||
private String generateStorageKey(String originalFilename) {
|
||||
String ext = extractExtension(originalFilename);
|
||||
return "images/" + UUID.randomUUID() + ext;
|
||||
}
|
||||
|
||||
private String extractExtension(String filename) {
|
||||
if (filename == null) return "";
|
||||
int dot = filename.lastIndexOf('.');
|
||||
if (dot < 0 || dot == filename.length() - 1) return "";
|
||||
String ext = filename.substring(dot).toLowerCase();
|
||||
// On n'accepte que les extensions connues pour eviter les injections de path.
|
||||
return ext.matches("\\.(jpg|jpeg|png|webp|gif)") ? ext : "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.loremind.infrastructure.web.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
import org.springframework.web.filter.CorsFilter;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* Configuration CORS. Origines configurables via la propriete
|
||||
* `app.cors.allowed-origins` (liste separee par virgules) ou l'env var
|
||||
* APP_CORS_ALLOWED_ORIGINS. Defaut : Angular dev server + port Docker par defaut.
|
||||
*/
|
||||
@Configuration
|
||||
public class CorsConfig {
|
||||
|
||||
@Value("${app.cors.allowed-origins:http://localhost:4200,http://localhost:8081}")
|
||||
private String allowedOrigins;
|
||||
|
||||
@Bean
|
||||
public CorsFilter corsFilter() {
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
|
||||
Arrays.stream(allowedOrigins.split(","))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.forEach(config::addAllowedOrigin);
|
||||
config.addAllowedHeader("*");
|
||||
config.addAllowedMethod("*");
|
||||
config.setAllowCredentials(true);
|
||||
|
||||
source.registerCorsConfiguration("/**", config);
|
||||
return new CorsFilter(source);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.loremind.infrastructure.web.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
/**
|
||||
* Configuration Spring Security.
|
||||
* <p>
|
||||
* Strategie : HTTP Basic sur /api/settings/** uniquement (muter la config
|
||||
* = action sensible qui autorise SSRF + vol de cle LLM si non protegee).
|
||||
* Le reste de /api/** reste permitAll — l'app est self-hosted mono-utilisateur,
|
||||
* l'UI elle-meme n'est pas authentifiee (trust model : reseau local ou
|
||||
* reverse-proxy front amont).
|
||||
* <p>
|
||||
* Fail-closed : refuse de demarrer si admin.password n'est pas defini.
|
||||
*/
|
||||
@Configuration
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public UserDetailsService userDetailsService(
|
||||
@Value("${admin.username}") String username,
|
||||
@Value("${admin.password}") String password,
|
||||
PasswordEncoder encoder) {
|
||||
if (password == null || password.isBlank()) {
|
||||
throw new IllegalStateException(
|
||||
"ADMIN_PASSWORD must be set in environment — refusing to start "
|
||||
+ "with empty admin credentials. Set ADMIN_PASSWORD in .env "
|
||||
+ "before launching.");
|
||||
}
|
||||
UserDetails admin = User.builder()
|
||||
.username(username)
|
||||
.password(encoder.encode(password))
|
||||
.roles("ADMIN")
|
||||
.build();
|
||||
return new InMemoryUserDetailsManager(admin);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
// API stateless : pas de session, pas de cookie => CSRF sans objet.
|
||||
// Les credentials HTTP Basic sont envoyes a chaque requete, donc
|
||||
// immunise contre CSRF (le browser ne les attache pas cross-origin).
|
||||
.cors(cors -> {}) // delegue au CorsFilter bean (CorsConfig)
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
// Preflight CORS toujours libre (le browser n'envoie pas Authorization sur OPTIONS)
|
||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
.requestMatchers("/api/settings/**").hasRole("ADMIN")
|
||||
.anyRequest().permitAll()
|
||||
)
|
||||
.httpBasic(basic -> {});
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package com.loremind.infrastructure.web.controller;
|
||||
|
||||
import com.loremind.application.generationcontext.StreamChatForCampaignUseCase;
|
||||
import com.loremind.application.generationcontext.StreamChatForLoreUseCase;
|
||||
import com.loremind.domain.generationcontext.ChatMessage;
|
||||
import com.loremind.infrastructure.web.dto.generationcontext.ChatMessageDTO;
|
||||
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamCampaignRequestDTO;
|
||||
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamRequestDTO;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.core.task.TaskExecutor;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* REST Controller pour le chat IA streamé (Server-Sent Events).
|
||||
* <p>
|
||||
* Deux endpoints :
|
||||
* - POST /api/ai/chat/stream → chat ancré sur un Lore
|
||||
* - POST /api/ai/chat/stream-campaign → chat ancré sur une Campagne
|
||||
* (qui tire automatiquement son Lore)
|
||||
* <p>
|
||||
* Le streaming est lancé dans un thread séparé (AsyncTaskExecutor) pour
|
||||
* ne pas bloquer le thread servlet pendant toute la durée de la génération.
|
||||
* SseEmitter est thread-safe : les callbacks du port AiChatProvider peuvent
|
||||
* écrire directement dessus depuis n'importe quel thread.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/ai")
|
||||
public class AiChatController {
|
||||
|
||||
/** Timeout SSE long — les modèles LLM locaux peuvent générer pendant quelques minutes. */
|
||||
private static final long SSE_TIMEOUT_MS = 5 * 60 * 1000L;
|
||||
|
||||
private final StreamChatForLoreUseCase streamChatForLoreUseCase;
|
||||
private final StreamChatForCampaignUseCase streamChatForCampaignUseCase;
|
||||
private final TaskExecutor taskExecutor;
|
||||
|
||||
public AiChatController(
|
||||
StreamChatForLoreUseCase streamChatForLoreUseCase,
|
||||
StreamChatForCampaignUseCase streamChatForCampaignUseCase,
|
||||
@Qualifier("applicationTaskExecutor") TaskExecutor taskExecutor) {
|
||||
this.streamChatForLoreUseCase = streamChatForLoreUseCase;
|
||||
this.streamChatForCampaignUseCase = streamChatForCampaignUseCase;
|
||||
this.taskExecutor = taskExecutor;
|
||||
}
|
||||
|
||||
// --- Endpoints ----------------------------------------------------------
|
||||
|
||||
@PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public SseEmitter chatStream(@RequestBody ChatStreamRequestDTO body) {
|
||||
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
|
||||
List<ChatMessage> messages = toDomainMessages(body.getMessages());
|
||||
|
||||
taskExecutor.execute(() -> runLoreStreaming(emitter, body.getLoreId(), body.getPageId(), messages));
|
||||
return emitter;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/chat/stream-campaign", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public SseEmitter chatStreamCampaign(@RequestBody ChatStreamCampaignRequestDTO body) {
|
||||
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
|
||||
List<ChatMessage> messages = toDomainMessages(body.getMessages());
|
||||
|
||||
taskExecutor.execute(() -> runCampaignStreaming(
|
||||
emitter, body.getCampaignId(), body.getEntityType(), body.getEntityId(), messages));
|
||||
return emitter;
|
||||
}
|
||||
|
||||
// --- Exécution du streaming dans un thread dédié ------------------------
|
||||
|
||||
private void runLoreStreaming(
|
||||
SseEmitter emitter, String loreId, String pageId, List<ChatMessage> messages) {
|
||||
try {
|
||||
streamChatForLoreUseCase.execute(
|
||||
loreId, pageId, messages,
|
||||
token -> sendToken(emitter, token),
|
||||
() -> complete(emitter),
|
||||
error -> fail(emitter, error));
|
||||
} catch (IllegalArgumentException e) {
|
||||
// Lore ou Page introuvable : on envoie un event error puis on termine proprement.
|
||||
fail(emitter, e);
|
||||
} catch (Exception e) {
|
||||
fail(emitter, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void runCampaignStreaming(
|
||||
SseEmitter emitter,
|
||||
String campaignId,
|
||||
String entityType,
|
||||
String entityId,
|
||||
List<ChatMessage> messages) {
|
||||
try {
|
||||
streamChatForCampaignUseCase.execute(
|
||||
campaignId, entityType, entityId, messages,
|
||||
token -> sendToken(emitter, token),
|
||||
() -> complete(emitter),
|
||||
error -> fail(emitter, error));
|
||||
} catch (Exception e) {
|
||||
fail(emitter, e);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers SSE (un seul point d'écriture par type d'événement) --------
|
||||
|
||||
private void sendToken(SseEmitter emitter, String token) {
|
||||
try {
|
||||
emitter.send(SseEmitter.event()
|
||||
.data("{\"token\":" + jsonEscape(token) + "}"));
|
||||
} catch (IOException e) {
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void complete(SseEmitter emitter) {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().name("done").data("{}"));
|
||||
emitter.complete();
|
||||
} catch (IOException e) {
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void fail(SseEmitter emitter, Throwable error) {
|
||||
try {
|
||||
String message = error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName();
|
||||
emitter.send(SseEmitter.event()
|
||||
.name("error")
|
||||
.data("{\"message\":" + jsonEscape(message) + "}"));
|
||||
emitter.complete();
|
||||
} catch (IOException ioe) {
|
||||
emitter.completeWithError(ioe);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Utilitaires --------------------------------------------------------
|
||||
|
||||
/** Encadre une chaîne de guillemets et échappe les caractères JSON dangereux. */
|
||||
private String jsonEscape(String raw) {
|
||||
if (raw == null) return "\"\"";
|
||||
StringBuilder sb = new StringBuilder(raw.length() + 2);
|
||||
sb.append('"');
|
||||
for (int i = 0; i < raw.length(); i++) {
|
||||
char c = raw.charAt(i);
|
||||
switch (c) {
|
||||
case '"': sb.append("\\\""); break;
|
||||
case '\\': sb.append("\\\\"); break;
|
||||
case '\n': sb.append("\\n"); break;
|
||||
case '\r': sb.append("\\r"); break;
|
||||
case '\t': sb.append("\\t"); break;
|
||||
default:
|
||||
if (c < 0x20) {
|
||||
sb.append(String.format("\\u%04x", (int) c));
|
||||
} else {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.append('"');
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private List<ChatMessage> toDomainMessages(List<ChatMessageDTO> dtos) {
|
||||
if (dtos == null) return List.of();
|
||||
return dtos.stream()
|
||||
.map(dto -> new ChatMessage(dto.getRole(), dto.getContent()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.loremind.infrastructure.web.controller;
|
||||
|
||||
import com.loremind.application.campaigncontext.ArcService;
|
||||
import com.loremind.domain.campaigncontext.Arc;
|
||||
import com.loremind.infrastructure.web.dto.campaigncontext.ArcDTO;
|
||||
import com.loremind.infrastructure.web.mapper.ArcMapper;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* REST Controller pour le contexte Arc.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/arcs")
|
||||
public class ArcController {
|
||||
|
||||
private final ArcService arcService;
|
||||
private final ArcMapper arcMapper;
|
||||
|
||||
public ArcController(ArcService arcService, ArcMapper arcMapper) {
|
||||
this.arcService = arcService;
|
||||
this.arcMapper = arcMapper;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ArcDTO> createArc(@RequestBody ArcDTO arcDTO) {
|
||||
Arc arc = arcMapper.toDomain(arcDTO);
|
||||
Arc createdArc = arcService.createArc(arc.getName(), arc.getDescription(), arc.getCampaignId(), arc.getOrder());
|
||||
return ResponseEntity.ok(arcMapper.toDTO(createdArc));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ArcDTO> getArcById(@PathVariable String id) {
|
||||
return arcService.getArcById(id)
|
||||
.map(arc -> ResponseEntity.ok(arcMapper.toDTO(arc)))
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<ArcDTO>> getAllArcs() {
|
||||
List<Arc> arcs = arcService.getAllArcs();
|
||||
List<ArcDTO> arcDTOs = arcs.stream()
|
||||
.map(arcMapper::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
return ResponseEntity.ok(arcDTOs);
|
||||
}
|
||||
|
||||
@GetMapping("/campaign/{campaignId}")
|
||||
public ResponseEntity<List<ArcDTO>> getArcsByCampaignId(@PathVariable String campaignId) {
|
||||
List<Arc> arcs = arcService.getArcsByCampaignId(campaignId);
|
||||
List<ArcDTO> arcDTOs = arcs.stream()
|
||||
.map(arcMapper::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
return ResponseEntity.ok(arcDTOs);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<ArcDTO> updateArc(@PathVariable String id, @RequestBody ArcDTO arcDTO) {
|
||||
Arc updatedArc = arcService.updateArc(id, arcMapper.toDomain(arcDTO));
|
||||
return ResponseEntity.ok(arcMapper.toDTO(updatedArc));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> deleteArc(@PathVariable String id) {
|
||||
arcService.deleteArc(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.loremind.infrastructure.web.controller;
|
||||
|
||||
import com.loremind.application.campaigncontext.CampaignService;
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
import com.loremind.infrastructure.web.dto.campaigncontext.CampaignDTO;
|
||||
import com.loremind.infrastructure.web.mapper.CampaignMapper;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* REST Controller pour le contexte Campaign.
|
||||
* Adaptateur d'infrastructure qui expose l'API REST.
|
||||
* Utilise le Service d'application et le Mapper selon l'Architecture Hexagonale.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/campaigns")
|
||||
public class CampaignController {
|
||||
|
||||
private final CampaignService campaignService;
|
||||
private final CampaignMapper campaignMapper;
|
||||
|
||||
public CampaignController(CampaignService campaignService, CampaignMapper campaignMapper) {
|
||||
this.campaignService = campaignService;
|
||||
this.campaignMapper = campaignMapper;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<CampaignDTO> createCampaign(@RequestBody CampaignDTO campaignDTO) {
|
||||
Campaign campaign = campaignMapper.toDomain(campaignDTO);
|
||||
Campaign createdCampaign = campaignService.createCampaign(
|
||||
new CampaignService.CampaignData(campaign.getName(), campaign.getDescription(), campaign.getLoreId())
|
||||
);
|
||||
return ResponseEntity.ok(campaignMapper.toDTO(createdCampaign));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<CampaignDTO> getCampaignById(@PathVariable String id) {
|
||||
return campaignService.getCampaignById(id)
|
||||
.map(campaign -> ResponseEntity.ok(campaignMapper.toDTO(campaign)))
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<CampaignDTO>> getAllCampaigns() {
|
||||
List<Campaign> campaigns = campaignService.getAllCampaigns();
|
||||
List<CampaignDTO> campaignDTOs = campaigns.stream()
|
||||
.map(campaignMapper::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
return ResponseEntity.ok(campaignDTOs);
|
||||
}
|
||||
|
||||
@GetMapping("/search")
|
||||
public ResponseEntity<List<CampaignDTO>> searchCampaigns(@RequestParam("q") String query) {
|
||||
List<CampaignDTO> dtos = campaignService.searchCampaigns(query).stream()
|
||||
.map(campaignMapper::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
return ResponseEntity.ok(dtos);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<CampaignDTO> updateCampaign(@PathVariable String id, @RequestBody CampaignDTO campaignDTO) {
|
||||
Campaign updatedCampaign = campaignService.updateCampaign(
|
||||
id,
|
||||
new CampaignService.CampaignData(campaignDTO.getName(), campaignDTO.getDescription(), campaignDTO.getLoreId())
|
||||
);
|
||||
return ResponseEntity.ok(campaignMapper.toDTO(updatedCampaign));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> deleteCampaign(@PathVariable String id) {
|
||||
campaignService.deleteCampaign(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.loremind.infrastructure.web.controller;
|
||||
|
||||
import com.loremind.application.campaigncontext.ChapterService;
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import com.loremind.infrastructure.web.dto.campaigncontext.ChapterDTO;
|
||||
import com.loremind.infrastructure.web.mapper.ChapterMapper;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* REST Controller pour le contexte Chapter.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/chapters")
|
||||
public class ChapterController {
|
||||
|
||||
private final ChapterService chapterService;
|
||||
private final ChapterMapper chapterMapper;
|
||||
|
||||
public ChapterController(ChapterService chapterService, ChapterMapper chapterMapper) {
|
||||
this.chapterService = chapterService;
|
||||
this.chapterMapper = chapterMapper;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ChapterDTO> createChapter(@RequestBody ChapterDTO chapterDTO) {
|
||||
Chapter chapter = chapterMapper.toDomain(chapterDTO);
|
||||
Chapter createdChapter = chapterService.createChapter(chapter.getName(), chapter.getDescription(), chapter.getArcId(), chapter.getOrder());
|
||||
return ResponseEntity.ok(chapterMapper.toDTO(createdChapter));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ChapterDTO> getChapterById(@PathVariable String id) {
|
||||
return chapterService.getChapterById(id)
|
||||
.map(chapter -> ResponseEntity.ok(chapterMapper.toDTO(chapter)))
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<ChapterDTO>> getAllChapters() {
|
||||
List<Chapter> chapters = chapterService.getAllChapters();
|
||||
List<ChapterDTO> chapterDTOs = chapters.stream()
|
||||
.map(chapterMapper::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
return ResponseEntity.ok(chapterDTOs);
|
||||
}
|
||||
|
||||
@GetMapping("/arc/{arcId}")
|
||||
public ResponseEntity<List<ChapterDTO>> getChaptersByArcId(@PathVariable String arcId) {
|
||||
List<Chapter> chapters = chapterService.getChaptersByArcId(arcId);
|
||||
List<ChapterDTO> chapterDTOs = chapters.stream()
|
||||
.map(chapterMapper::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
return ResponseEntity.ok(chapterDTOs);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<ChapterDTO> updateChapter(@PathVariable String id, @RequestBody ChapterDTO chapterDTO) {
|
||||
Chapter updatedChapter = chapterService.updateChapter(id, chapterMapper.toDomain(chapterDTO));
|
||||
return ResponseEntity.ok(chapterMapper.toDTO(updatedChapter));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> deleteChapter(@PathVariable String id) {
|
||||
chapterService.deleteChapter(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.loremind.infrastructure.web.controller;
|
||||
|
||||
import com.loremind.application.images.ImageService;
|
||||
import com.loremind.domain.images.Image;
|
||||
import com.loremind.infrastructure.web.dto.images.ImageDTO;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* REST Controller pour le Shared Kernel images.
|
||||
* <p>
|
||||
* Expose :
|
||||
* - POST /api/images (multipart/form-data, champ "file")
|
||||
* - GET /api/images/{id} (metadonnees JSON)
|
||||
* - GET /api/images/{id}/content (binaire, pour <img src=...>)
|
||||
* - DELETE /api/images/{id}
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/images")
|
||||
public class ImageController {
|
||||
|
||||
private final ImageService imageService;
|
||||
|
||||
public ImageController(ImageService imageService) {
|
||||
this.imageService = imageService;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ImageDTO> upload(@RequestParam("file") MultipartFile file) throws IOException {
|
||||
if (file.isEmpty()) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
try (InputStream in = file.getInputStream()) {
|
||||
Image saved = imageService.upload(
|
||||
file.getOriginalFilename(),
|
||||
file.getContentType(),
|
||||
in,
|
||||
file.getSize()
|
||||
);
|
||||
return ResponseEntity.ok(toDTO(saved));
|
||||
} catch (IllegalArgumentException ex) {
|
||||
// Validation metier : MIME non autorise, fichier vide, taille excessive...
|
||||
return ResponseEntity.badRequest().body(null);
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ImageDTO> getMetadata(@PathVariable String id) {
|
||||
return imageService.getById(id)
|
||||
.map(img -> ResponseEntity.ok(toDTO(img)))
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming du binaire. Le navigateur pourra directement l'utiliser dans
|
||||
* une balise <img src="/api/images/42/content">.
|
||||
*/
|
||||
@GetMapping("/{id}/content")
|
||||
public ResponseEntity<InputStreamResource> getContent(@PathVariable String id) {
|
||||
Optional<Image> metadata = imageService.getById(id);
|
||||
if (metadata.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
Image img = metadata.get();
|
||||
InputStream stream = imageService.downloadById(id).orElse(null);
|
||||
if (stream == null) {
|
||||
// Metadonnees presentes mais binaire perdu -> incoherence, on renvoie 404.
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType(img.getContentType()))
|
||||
.contentLength(img.getSizeBytes())
|
||||
.header(HttpHeaders.CACHE_CONTROL, "public, max-age=31536000, immutable")
|
||||
.body(new InputStreamResource(stream));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(@PathVariable String id) {
|
||||
imageService.deleteById(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// --- Mapping -----------------------------------------------------------
|
||||
|
||||
private ImageDTO toDTO(Image img) {
|
||||
ImageDTO dto = new ImageDTO();
|
||||
dto.setId(img.getId());
|
||||
dto.setFilename(img.getFilename());
|
||||
dto.setContentType(img.getContentType());
|
||||
dto.setSizeBytes(img.getSizeBytes());
|
||||
dto.setUrl("/api/images/" + img.getId() + "/content");
|
||||
dto.setUploadedAt(img.getUploadedAt());
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.loremind.infrastructure.web.controller;
|
||||
|
||||
import com.loremind.application.lorecontext.LoreService;
|
||||
import com.loremind.domain.lorecontext.Lore;
|
||||
import com.loremind.infrastructure.web.dto.lorecontext.LoreDTO;
|
||||
import com.loremind.infrastructure.web.mapper.LoreMapper;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* REST Controller pour le contexte Lore.
|
||||
* Adaptateur d'infrastructure qui expose l'API REST.
|
||||
* Utilise le Service d'application et le Mapper selon l'Architecture Hexagonale.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/lores")
|
||||
public class LoreController {
|
||||
|
||||
private final LoreService loreService;
|
||||
private final LoreMapper loreMapper;
|
||||
|
||||
public LoreController(LoreService loreService, LoreMapper loreMapper) {
|
||||
this.loreService = loreService;
|
||||
this.loreMapper = loreMapper;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<LoreDTO> createLore(@RequestBody LoreDTO loreDTO) {
|
||||
Lore lore = loreMapper.toDomain(loreDTO);
|
||||
Lore createdLore = loreService.createLore(lore.getName(), lore.getDescription());
|
||||
return ResponseEntity.ok(loreMapper.toDTO(createdLore));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<LoreDTO> getLoreById(@PathVariable String id) {
|
||||
return loreService.getLoreById(id)
|
||||
.map(lore -> ResponseEntity.ok(loreMapper.toDTO(lore)))
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<LoreDTO>> getAllLores() {
|
||||
List<Lore> lores = loreService.getAllLores();
|
||||
List<LoreDTO> loreDTOs = lores.stream()
|
||||
.map(loreMapper::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
return ResponseEntity.ok(loreDTOs);
|
||||
}
|
||||
|
||||
@GetMapping("/search")
|
||||
public ResponseEntity<List<LoreDTO>> searchLores(@RequestParam("q") String query) {
|
||||
List<LoreDTO> dtos = loreService.searchLores(query).stream()
|
||||
.map(loreMapper::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
return ResponseEntity.ok(dtos);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<LoreDTO> updateLore(@PathVariable String id, @RequestBody LoreDTO loreDTO) {
|
||||
Lore updatedLore = loreService.updateLore(id, loreDTO.getName(), loreDTO.getDescription());
|
||||
return ResponseEntity.ok(loreMapper.toDTO(updatedLore));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> deleteLore(@PathVariable String id) {
|
||||
loreService.deleteLore(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.loremind.infrastructure.web.controller;
|
||||
|
||||
import com.loremind.application.lorecontext.LoreNodeService;
|
||||
import com.loremind.domain.lorecontext.LoreNode;
|
||||
import com.loremind.infrastructure.web.dto.lorecontext.LoreNodeDTO;
|
||||
import com.loremind.infrastructure.web.mapper.LoreNodeMapper;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* REST Controller pour le contexte LoreNode.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/lore-nodes")
|
||||
public class LoreNodeController {
|
||||
|
||||
private final LoreNodeService loreNodeService;
|
||||
private final LoreNodeMapper loreNodeMapper;
|
||||
|
||||
public LoreNodeController(LoreNodeService loreNodeService, LoreNodeMapper loreNodeMapper) {
|
||||
this.loreNodeService = loreNodeService;
|
||||
this.loreNodeMapper = loreNodeMapper;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<LoreNodeDTO> createLoreNode(@RequestBody LoreNodeDTO loreNodeDTO) {
|
||||
LoreNode changes = loreNodeMapper.toDomain(loreNodeDTO);
|
||||
LoreNode created = loreNodeService.createLoreNode(changes);
|
||||
return ResponseEntity.ok(loreNodeMapper.toDTO(created));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<LoreNodeDTO> getLoreNodeById(@PathVariable String id) {
|
||||
return loreNodeService.getLoreNodeById(id)
|
||||
.map(loreNode -> ResponseEntity.ok(loreNodeMapper.toDTO(loreNode)))
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/lore-nodes → tous les dossiers (usage admin/debug)
|
||||
* GET /api/lore-nodes?loreId=X → uniquement les dossiers du Lore X
|
||||
* <p>
|
||||
* Sans ce filtre, le frontend recevait TOUS les dossiers de TOUS les lores,
|
||||
* ce qui polluait l'affichage quand on switche entre deux lores.
|
||||
* Pattern aligné sur TemplateController et PageController.
|
||||
*/
|
||||
@GetMapping
|
||||
public ResponseEntity<List<LoreNodeDTO>> getLoreNodes(
|
||||
@RequestParam(value = "loreId", required = false) String loreId) {
|
||||
List<LoreNode> loreNodes = (loreId != null && !loreId.isBlank())
|
||||
? loreNodeService.getLoreNodesByLoreId(loreId)
|
||||
: loreNodeService.getAllLoreNodes();
|
||||
List<LoreNodeDTO> loreNodeDTOs = loreNodes.stream()
|
||||
.map(loreNodeMapper::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
return ResponseEntity.ok(loreNodeDTOs);
|
||||
}
|
||||
|
||||
@GetMapping("/search")
|
||||
public ResponseEntity<List<LoreNodeDTO>> searchLoreNodes(@RequestParam("q") String query) {
|
||||
List<LoreNodeDTO> dtos = loreNodeService.searchLoreNodes(query).stream()
|
||||
.map(loreNodeMapper::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
return ResponseEntity.ok(dtos);
|
||||
}
|
||||
|
||||
@GetMapping("/lore/{loreId}")
|
||||
public ResponseEntity<List<LoreNodeDTO>> getLoreNodesByLoreId(@PathVariable String loreId) {
|
||||
List<LoreNode> loreNodes = loreNodeService.getLoreNodesByLoreId(loreId);
|
||||
List<LoreNodeDTO> loreNodeDTOs = loreNodes.stream()
|
||||
.map(loreNodeMapper::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
return ResponseEntity.ok(loreNodeDTOs);
|
||||
}
|
||||
|
||||
@GetMapping("/parent/{parentId}")
|
||||
public ResponseEntity<List<LoreNodeDTO>> getLoreNodesByParentId(@PathVariable String parentId) {
|
||||
List<LoreNode> loreNodes = loreNodeService.getLoreNodesByParentId(parentId);
|
||||
List<LoreNodeDTO> loreNodeDTOs = loreNodes.stream()
|
||||
.map(loreNodeMapper::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
return ResponseEntity.ok(loreNodeDTOs);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<LoreNodeDTO> updateLoreNode(@PathVariable String id, @RequestBody LoreNodeDTO loreNodeDTO) {
|
||||
LoreNode changes = loreNodeMapper.toDomain(loreNodeDTO);
|
||||
LoreNode updated = loreNodeService.updateLoreNode(id, changes);
|
||||
return ResponseEntity.ok(loreNodeMapper.toDTO(updated));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> deleteLoreNode(@PathVariable String id) {
|
||||
loreNodeService.deleteLoreNode(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.loremind.infrastructure.web.controller;
|
||||
|
||||
import com.loremind.application.lorecontext.PageService;
|
||||
import com.loremind.domain.lorecontext.Page;
|
||||
import com.loremind.infrastructure.web.dto.lorecontext.PageDTO;
|
||||
import com.loremind.infrastructure.web.mapper.PageMapper;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* REST Controller pour le contexte Page.
|
||||
* - GET /api/pages?loreId=X → toutes les pages d'un lore
|
||||
* - GET /api/pages?nodeId=Y → toutes les pages d'un noeud
|
||||
* - GET /api/pages/{id} → détail
|
||||
* - POST /api/pages → création (loreId, nodeId, templateId, title)
|
||||
* - PUT /api/pages/{id} → mise à jour complète (title, nodeId, values, notes, tags, relatedPageIds)
|
||||
* - DELETE /api/pages/{id} → suppression
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/pages")
|
||||
public class PageController {
|
||||
|
||||
private final PageService pageService;
|
||||
private final PageMapper pageMapper;
|
||||
|
||||
public PageController(PageService pageService, PageMapper pageMapper) {
|
||||
this.pageService = pageService;
|
||||
this.pageMapper = pageMapper;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<PageDTO> createPage(@RequestBody PageDTO dto) {
|
||||
Page created = pageService.createPage(
|
||||
dto.getLoreId(),
|
||||
dto.getNodeId(),
|
||||
dto.getTemplateId(),
|
||||
dto.getTitle()
|
||||
);
|
||||
return ResponseEntity.ok(pageMapper.toDTO(created));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<PageDTO> getPageById(@PathVariable String id) {
|
||||
return pageService.getPageById(id)
|
||||
.map(page -> ResponseEntity.ok(pageMapper.toDTO(page)))
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<PageDTO>> getPages(
|
||||
@RequestParam(value = "loreId", required = false) String loreId,
|
||||
@RequestParam(value = "nodeId", required = false) String nodeId) {
|
||||
List<Page> pages;
|
||||
if (loreId != null && !loreId.isBlank()) {
|
||||
pages = pageService.getPagesByLoreId(loreId);
|
||||
} else if (nodeId != null && !nodeId.isBlank()) {
|
||||
pages = pageService.getPagesByNodeId(nodeId);
|
||||
} else {
|
||||
pages = pageService.getAllPages();
|
||||
}
|
||||
List<PageDTO> dtos = pages.stream().map(pageMapper::toDTO).collect(Collectors.toList());
|
||||
return ResponseEntity.ok(dtos);
|
||||
}
|
||||
|
||||
@GetMapping("/search")
|
||||
public ResponseEntity<List<PageDTO>> searchPages(@RequestParam("q") String query) {
|
||||
List<PageDTO> dtos = pageService.searchPages(query).stream()
|
||||
.map(pageMapper::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
return ResponseEntity.ok(dtos);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<PageDTO> updatePage(@PathVariable String id,
|
||||
@RequestBody PageDTO dto) {
|
||||
Page changes = pageMapper.toDomain(dto);
|
||||
Page updated = pageService.updatePage(id, changes);
|
||||
return ResponseEntity.ok(pageMapper.toDTO(updated));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> deletePage(@PathVariable String id) {
|
||||
pageService.deletePage(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user