existingArc = arcRepository.findById(id);
+ if (existingArc.isEmpty()) {
+ throw new IllegalArgumentException("Arc non trouvé avec l'ID: " + id);
+ }
+
+ Arc arc = existingArc.get();
+ arc.setName(updated.getName());
+ arc.setDescription(updated.getDescription());
+ arc.setOrder(updated.getOrder());
+ arc.setThemes(updated.getThemes());
+ arc.setStakes(updated.getStakes());
+ arc.setGmNotes(updated.getGmNotes());
+ arc.setRewards(updated.getRewards());
+ arc.setResolution(updated.getResolution());
+ arc.setRelatedPageIds(updated.getRelatedPageIds());
+ return arcRepository.save(arc);
+ }
+
+ public void deleteArc(String id) {
+ arcRepository.deleteById(id);
+ }
+
+ public boolean arcExists(String id) {
+ return arcRepository.existsById(id);
+ }
+}
diff --git a/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java b/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java
new file mode 100644
index 0000000..48dbcac
--- /dev/null
+++ b/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java
@@ -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.
+ *
+ * {@code loreId} est nullable : une campagne peut exister sans univers associé.
+ */
+ 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 getCampaignById(String id) {
+ return campaignRepository.findById(id);
+ }
+
+ public List getAllCampaigns() {
+ return campaignRepository.findAll();
+ }
+
+ public Campaign updateCampaign(String id, CampaignData data) {
+ Optional 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 searchCampaigns(String query) {
+ if (query == null || query.isBlank()) return List.of();
+ return campaignRepository.searchByName(query.trim());
+ }
+}
diff --git a/core/src/main/java/com/loremind/application/campaigncontext/ChapterService.java b/core/src/main/java/com/loremind/application/campaigncontext/ChapterService.java
new file mode 100644
index 0000000..427e960
--- /dev/null
+++ b/core/src/main/java/com/loremind/application/campaigncontext/ChapterService.java
@@ -0,0 +1,73 @@
+package com.loremind.application.campaigncontext;
+
+import com.loremind.domain.campaigncontext.Chapter;
+import com.loremind.domain.campaigncontext.ports.ChapterRepository;
+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 getChapterById(String id) {
+ return chapterRepository.findById(id);
+ }
+
+ public List getAllChapters() {
+ return chapterRepository.findAll();
+ }
+
+ public List 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 existingChapter = chapterRepository.findById(id);
+ if (existingChapter.isEmpty()) {
+ throw new IllegalArgumentException("Chapter non trouvé avec l'ID: " + id);
+ }
+
+ Chapter chapter = existingChapter.get();
+ chapter.setName(updated.getName());
+ chapter.setDescription(updated.getDescription());
+ chapter.setOrder(updated.getOrder());
+ chapter.setGmNotes(updated.getGmNotes());
+ chapter.setPlayerObjectives(updated.getPlayerObjectives());
+ chapter.setNarrativeStakes(updated.getNarrativeStakes());
+ chapter.setRelatedPageIds(updated.getRelatedPageIds());
+ return chapterRepository.save(chapter);
+ }
+
+ public void deleteChapter(String id) {
+ chapterRepository.deleteById(id);
+ }
+
+ public boolean chapterExists(String id) {
+ return chapterRepository.existsById(id);
+ }
+}
diff --git a/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java b/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java
new file mode 100644
index 0000000..810a977
--- /dev/null
+++ b/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java
@@ -0,0 +1,78 @@
+package com.loremind.application.campaigncontext;
+
+import com.loremind.domain.campaigncontext.Scene;
+import com.loremind.domain.campaigncontext.ports.SceneRepository;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 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 getSceneById(String id) {
+ return sceneRepository.findById(id);
+ }
+
+ public List getAllScenes() {
+ return sceneRepository.findAll();
+ }
+
+ public List 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 existingScene = sceneRepository.findById(id);
+ if (existingScene.isEmpty()) {
+ throw new IllegalArgumentException("Scene non trouvée avec l'ID: " + id);
+ }
+
+ Scene scene = existingScene.get();
+ scene.setName(updated.getName());
+ scene.setDescription(updated.getDescription());
+ scene.setOrder(updated.getOrder());
+ scene.setLocation(updated.getLocation());
+ scene.setTiming(updated.getTiming());
+ scene.setAtmosphere(updated.getAtmosphere());
+ scene.setPlayerNarration(updated.getPlayerNarration());
+ scene.setGmSecretNotes(updated.getGmSecretNotes());
+ scene.setChoicesConsequences(updated.getChoicesConsequences());
+ scene.setCombatDifficulty(updated.getCombatDifficulty());
+ scene.setEnemies(updated.getEnemies());
+ scene.setRelatedPageIds(updated.getRelatedPageIds());
+ return sceneRepository.save(scene);
+ }
+
+ public void deleteScene(String id) {
+ sceneRepository.deleteById(id);
+ }
+
+ public boolean sceneExists(String id) {
+ return sceneRepository.existsById(id);
+ }
+}
diff --git a/core/src/main/java/com/loremind/application/lorecontext/LoreNodeService.java b/core/src/main/java/com/loremind/application/lorecontext/LoreNodeService.java
new file mode 100644
index 0000000..42efb13
--- /dev/null
+++ b/core/src/main/java/com/loremind/application/lorecontext/LoreNodeService.java
@@ -0,0 +1,78 @@
+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 getLoreNodeById(String id) {
+ return loreNodeRepository.findById(id);
+ }
+
+ public List getAllLoreNodes() {
+ return loreNodeRepository.findAll();
+ }
+
+ public List getLoreNodesByLoreId(String loreId) {
+ return loreNodeRepository.findByLoreId(loreId);
+ }
+
+ public List getLoreNodesByParentId(String parentId) {
+ return loreNodeRepository.findByParentId(parentId);
+ }
+
+ public List 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);
+ }
+
+ public boolean loreNodeExists(String id) {
+ return loreNodeRepository.existsById(id);
+ }
+}
diff --git a/core/src/main/java/com/loremind/application/lorecontext/LoreService.java b/core/src/main/java/com/loremind/application/lorecontext/LoreService.java
new file mode 100644
index 0000000..e62be04
--- /dev/null
+++ b/core/src/main/java/com/loremind/application/lorecontext/LoreService.java
@@ -0,0 +1,93 @@
+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.
+ *
+ * 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 getLoreById(String id) {
+ return loreRepository.findById(id).map(this::withCounts);
+ }
+
+ public List 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 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 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);
+ }
+
+ public boolean loreExists(String id) {
+ return loreRepository.existsById(id);
+ }
+}
diff --git a/core/src/main/java/com/loremind/application/lorecontext/PageService.java b/core/src/main/java/com/loremind/application/lorecontext/PageService.java
new file mode 100644
index 0000000..c8d2058
--- /dev/null
+++ b/core/src/main/java/com/loremind/application/lorecontext/PageService.java
@@ -0,0 +1,95 @@
+package com.loremind.application.lorecontext;
+
+import com.loremind.domain.lorecontext.Page;
+import com.loremind.domain.lorecontext.ports.PageRepository;
+import org.springframework.stereotype.Service;
+
+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 getPageById(String id) {
+ return pageRepository.findById(id);
+ }
+
+ public List getAllPages() {
+ return pageRepository.findAll();
+ }
+
+ public List getPagesByLoreId(String loreId) {
+ return pageRepository.findByLoreId(loreId);
+ }
+
+ public List getPagesByNodeId(String nodeId) {
+ return pageRepository.findByNodeId(nodeId);
+ }
+
+ public List 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(changes.getValues() != null
+ ? new HashMap<>(changes.getValues())
+ : new HashMap<>());
+ existing.setNotes(changes.getNotes());
+ existing.setTags(changes.getTags() != null
+ ? new ArrayList<>(changes.getTags())
+ : new ArrayList<>());
+ existing.setRelatedPageIds(changes.getRelatedPageIds() != null
+ ? new ArrayList<>(changes.getRelatedPageIds())
+ : new ArrayList<>());
+ return pageRepository.save(existing);
+ }
+
+ public void deletePage(String id) {
+ pageRepository.deleteById(id);
+ }
+
+ public boolean pageExists(String id) {
+ return pageRepository.existsById(id);
+ }
+}
diff --git a/core/src/main/java/com/loremind/application/lorecontext/TemplateService.java b/core/src/main/java/com/loremind/application/lorecontext/TemplateService.java
new file mode 100644
index 0000000..4d89608
--- /dev/null
+++ b/core/src/main/java/com/loremind/application/lorecontext/TemplateService.java
@@ -0,0 +1,84 @@
+package com.loremind.application.lorecontext;
+
+import com.loremind.domain.lorecontext.Template;
+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 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 getTemplateById(String id) {
+ return templateRepository.findById(id);
+ }
+
+ public List getAllTemplates() {
+ return templateRepository.findAll();
+ }
+
+ public List getTemplatesByLoreId(String loreId) {
+ return templateRepository.findByLoreId(loreId);
+ }
+
+ public List 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);
+ }
+
+ public boolean templateExists(String id) {
+ return templateRepository.existsById(id);
+ }
+}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Arc.java b/core/src/main/java/com/loremind/domain/campaigncontext/Arc.java
new file mode 100644
index 0000000..0b6c64e
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/Arc.java
@@ -0,0 +1,60 @@
+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 relatedPageIds = new ArrayList<>();
+
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+
+ // ─────────────── Méthodes métier ───────────────
+
+ /** Ajoute un lien vers une page du Lore (idempotent : pas de doublon). */
+ public void linkPage(String pageId) {
+ if (relatedPageIds == null) relatedPageIds = new ArrayList<>();
+ if (!relatedPageIds.contains(pageId)) {
+ relatedPageIds.add(pageId);
+ this.updatedAt = LocalDateTime.now();
+ }
+ }
+
+ /** Retire un lien vers une page du Lore (sans erreur si absent). */
+ public void unlinkPage(String pageId) {
+ if (relatedPageIds != null && relatedPageIds.remove(pageId)) {
+ this.updatedAt = LocalDateTime.now();
+ }
+ }
+}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java b/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java
new file mode 100644
index 0000000..55c4dbc
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java
@@ -0,0 +1,59 @@
+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;
+
+ /** Associe cette campagne à un Lore existant. */
+ public void linkToLore(String loreId) {
+ this.loreId = loreId;
+ this.updatedAt = LocalDateTime.now();
+ }
+
+ /** Retire l'association à un Lore (la campagne redevient "universe-agnostic"). */
+ public void unlinkFromLore() {
+ this.loreId = null;
+ this.updatedAt = LocalDateTime.now();
+ }
+
+ public boolean isLinkedToLore() {
+ return this.loreId != null && !this.loreId.isBlank();
+ }
+
+ // Méthode métier pour gérer le nombre d'arcs
+ public void incrementArcsCount() {
+ this.arcsCount++;
+ this.updatedAt = LocalDateTime.now();
+ }
+
+ public void decrementArcsCount() {
+ if (this.arcsCount > 0) {
+ this.arcsCount--;
+ this.updatedAt = LocalDateTime.now();
+ }
+ }
+}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Chapter.java b/core/src/main/java/com/loremind/domain/campaigncontext/Chapter.java
new file mode 100644
index 0000000..1caa4d9
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/Chapter.java
@@ -0,0 +1,54 @@
+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 relatedPageIds = new ArrayList<>();
+
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+
+ // ─────────────── Méthodes métier ───────────────
+
+ public void linkPage(String pageId) {
+ if (relatedPageIds == null) relatedPageIds = new ArrayList<>();
+ if (!relatedPageIds.contains(pageId)) {
+ relatedPageIds.add(pageId);
+ this.updatedAt = LocalDateTime.now();
+ }
+ }
+
+ public void unlinkPage(String pageId) {
+ if (relatedPageIds != null && relatedPageIds.remove(pageId)) {
+ this.updatedAt = LocalDateTime.now();
+ }
+ }
+}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java b/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java
new file mode 100644
index 0000000..1b2c4b5
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java
@@ -0,0 +1,67 @@
+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 relatedPageIds = new ArrayList<>();
+
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+
+ // ─────────────── Méthodes métier ───────────────
+
+ public void linkPage(String pageId) {
+ if (relatedPageIds == null) relatedPageIds = new ArrayList<>();
+ if (!relatedPageIds.contains(pageId)) {
+ relatedPageIds.add(pageId);
+ this.updatedAt = LocalDateTime.now();
+ }
+ }
+
+ public void unlinkPage(String pageId) {
+ if (relatedPageIds != null && relatedPageIds.remove(pageId)) {
+ this.updatedAt = LocalDateTime.now();
+ }
+ }
+}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/ArcRepository.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/ArcRepository.java
new file mode 100644
index 0000000..c39fd8d
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/ArcRepository.java
@@ -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 findById(String id);
+
+ List findByCampaignId(String campaignId);
+
+ List findAll();
+
+ void deleteById(String id);
+
+ boolean existsById(String id);
+}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/CampaignRepository.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/CampaignRepository.java
new file mode 100644
index 0000000..b763726
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/CampaignRepository.java
@@ -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 findById(String id);
+
+ List findAll();
+
+ void deleteById(String id);
+
+ boolean existsById(String id);
+
+ List searchByName(String query);
+}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/ChapterRepository.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/ChapterRepository.java
new file mode 100644
index 0000000..091a1d1
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/ChapterRepository.java
@@ -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 findById(String id);
+
+ List findByArcId(String arcId);
+
+ List findAll();
+
+ void deleteById(String id);
+
+ boolean existsById(String id);
+}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/SceneRepository.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/SceneRepository.java
new file mode 100644
index 0000000..a4c2dd9
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/SceneRepository.java
@@ -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 findById(String id);
+
+ List findByChapterId(String chapterId);
+
+ List findAll();
+
+ void deleteById(String id);
+
+ boolean existsById(String id);
+}
diff --git a/core/src/main/java/com/loremind/domain/lorecontext/Lore.java b/core/src/main/java/com/loremind/domain/lorecontext/Lore.java
new file mode 100644
index 0000000..fa321f6
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/lorecontext/Lore.java
@@ -0,0 +1,48 @@
+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;
+
+ // Méthodes métier pour gérer les métriques
+ public void incrementNodeCount() {
+ this.nodeCount++;
+ this.updatedAt = LocalDateTime.now();
+ }
+
+ public void decrementNodeCount() {
+ if (this.nodeCount > 0) {
+ this.nodeCount--;
+ this.updatedAt = LocalDateTime.now();
+ }
+ }
+
+ public void incrementPageCount() {
+ this.pageCount++;
+ this.updatedAt = LocalDateTime.now();
+ }
+
+ public void decrementPageCount() {
+ if (this.pageCount > 0) {
+ this.pageCount--;
+ this.updatedAt = LocalDateTime.now();
+ }
+ }
+}
diff --git a/core/src/main/java/com/loremind/domain/lorecontext/LoreNode.java b/core/src/main/java/com/loremind/domain/lorecontext/LoreNode.java
new file mode 100644
index 0000000..78a0856
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/lorecontext/LoreNode.java
@@ -0,0 +1,42 @@
+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.
+ *
+ * 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;
+
+ // Méthode métier pour vérifier si c'est un nœud racine
+ public boolean isRoot() {
+ return parentId == null || parentId.isEmpty();
+ }
+
+ // Méthode métier pour vérifier si c'est un nœud feuille (sans enfants)
+ // Note: La vérification réelle nécessite d'accéder aux enfants,
+ // donc cette méthode est indicative et doit être complétée par le repository
+ public boolean isLeaf() {
+ // Cette logique sera implémentée au niveau du service/repository
+ return false;
+ }
+}
diff --git a/core/src/main/java/com/loremind/domain/lorecontext/Page.java b/core/src/main/java/com/loremind/domain/lorecontext/Page.java
new file mode 100644
index 0000000..1cd3039
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/lorecontext/Page.java
@@ -0,0 +1,82 @@
+package com.loremind.domain.lorecontext;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Entité de domaine représentant une page de contenu rattachée à un LoreNode.
+ *
+ * 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.
+ *
+ * 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 définis par le Template. */
+ private Map values;
+
+ /** Notes privées du MJ (non exportées vers FoundryVTT). */
+ private String notes;
+
+ /** Étiquettes libres pour regroupement/recherche. */
+ private List tags;
+
+ /** IDs d'autres Pages liées (mêmes Lore ou cross-lore). */
+ private List relatedPageIds;
+
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+
+ // --- Méthodes métier --------------------------------------------------
+
+ /** Met à jour la valeur d'un champ dynamique (création de la map si absente). */
+ public void setFieldValue(String fieldName, String value) {
+ if (values == null) {
+ values = new HashMap<>();
+ }
+ values.put(fieldName, value);
+ this.updatedAt = LocalDateTime.now();
+ }
+
+ public String getFieldValue(String fieldName) {
+ return values == null ? null : values.get(fieldName);
+ }
+
+ public void addTag(String tag) {
+ if (tag == null || tag.isBlank()) return;
+ if (tags == null) tags = new ArrayList<>();
+ if (!tags.contains(tag)) {
+ tags.add(tag);
+ this.updatedAt = LocalDateTime.now();
+ }
+ }
+
+ public void removeTag(String tag) {
+ if (tags != null && tags.remove(tag)) {
+ this.updatedAt = LocalDateTime.now();
+ }
+ }
+
+ public boolean hasTemplate() {
+ return templateId != null && !templateId.isBlank();
+ }
+}
diff --git a/core/src/main/java/com/loremind/domain/lorecontext/Template.java b/core/src/main/java/com/loremind/domain/lorecontext/Template.java
new file mode 100644
index 0000000..95c7bd5
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/lorecontext/Template.java
@@ -0,0 +1,61 @@
+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.
+ *
+ * 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 noms de champs dynamiques (fields)
+ * qui seront instanciés sur chaque Page produite depuis ce gabarit.
+ *
+ * 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 fields; // Noms des champs dynamiques (ordonnés)
+ 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();
+ }
+
+ /** Ajoute un champ à la fin de la liste (ignore les doublons et les blancs). */
+ public void addField(String fieldName) {
+ if (fieldName == null || fieldName.isBlank()) {
+ return;
+ }
+ if (fields == null) {
+ fields = new ArrayList<>();
+ }
+ if (!fields.contains(fieldName)) {
+ fields.add(fieldName);
+ this.updatedAt = LocalDateTime.now();
+ }
+ }
+
+ /** Retire un champ s'il existe. */
+ public void removeField(String fieldName) {
+ if (fields != null && fields.remove(fieldName)) {
+ this.updatedAt = LocalDateTime.now();
+ }
+ }
+}
diff --git a/core/src/main/java/com/loremind/domain/lorecontext/ports/LoreNodeRepository.java b/core/src/main/java/com/loremind/domain/lorecontext/ports/LoreNodeRepository.java
new file mode 100644
index 0000000..9cf966f
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/lorecontext/ports/LoreNodeRepository.java
@@ -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 findById(String id);
+
+ List findByLoreId(String loreId);
+
+ List findByParentId(String parentId);
+
+ List findAll();
+
+ void deleteById(String id);
+
+ boolean existsById(String id);
+
+ long countByLoreId(String loreId);
+
+ List searchByName(String query);
+}
diff --git a/core/src/main/java/com/loremind/domain/lorecontext/ports/LoreRepository.java b/core/src/main/java/com/loremind/domain/lorecontext/ports/LoreRepository.java
new file mode 100644
index 0000000..140d534
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/lorecontext/ports/LoreRepository.java
@@ -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 findById(String id);
+
+ List findAll();
+
+ void deleteById(String id);
+
+ boolean existsById(String id);
+
+ List searchByName(String query);
+}
diff --git a/core/src/main/java/com/loremind/domain/lorecontext/ports/PageRepository.java b/core/src/main/java/com/loremind/domain/lorecontext/ports/PageRepository.java
new file mode 100644
index 0000000..663e6f9
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/lorecontext/ports/PageRepository.java
@@ -0,0 +1,32 @@
+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 findById(String id);
+
+ List findByLoreId(String loreId);
+
+ List findByNodeId(String nodeId);
+
+ List findByTemplateId(String templateId);
+
+ List findAll();
+
+ void deleteById(String id);
+
+ boolean existsById(String id);
+
+ long countByLoreId(String loreId);
+
+ List searchByTitle(String query);
+}
diff --git a/core/src/main/java/com/loremind/domain/lorecontext/ports/TemplateRepository.java b/core/src/main/java/com/loremind/domain/lorecontext/ports/TemplateRepository.java
new file mode 100644
index 0000000..9305ccd
--- /dev/null
+++ b/core/src/main/java/com/loremind/domain/lorecontext/ports/TemplateRepository.java
@@ -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 findById(String id);
+
+ List findAll();
+
+ /** Tous les templates rattachés à un Lore donné (pour le panneau sidebar). */
+ List findByLoreId(String loreId);
+
+ void deleteById(String id);
+
+ boolean existsById(String id);
+
+ List searchByName(String query);
+}
diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/converter/MapJsonConverter.java b/core/src/main/java/com/loremind/infrastructure/persistence/converter/MapJsonConverter.java
new file mode 100644
index 0000000..a3c47fe
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/persistence/converter/MapJsonConverter.java
@@ -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 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