diff --git a/core/pom.xml b/core/pom.xml new file mode 100644 index 0000000..f697cb7 --- /dev/null +++ b/core/pom.xml @@ -0,0 +1,83 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.0 + + + + com.loremind + loremind-core + 1.0.0 + LoreMind Core + Backend Core - Architecture Hexagonale + + + 17 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.postgresql + postgresql + runtime + + + + + com.h2database + h2 + test + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/core/src/main/java/com/loremind/LoreMindApplication.java b/core/src/main/java/com/loremind/LoreMindApplication.java new file mode 100644 index 0000000..5ad7700 --- /dev/null +++ b/core/src/main/java/com/loremind/LoreMindApplication.java @@ -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); + } +} diff --git a/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java b/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java new file mode 100644 index 0000000..691ac43 --- /dev/null +++ b/core/src/main/java/com/loremind/application/campaigncontext/ArcService.java @@ -0,0 +1,76 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.domain.campaigncontext.Arc; +import com.loremind.domain.campaigncontext.ports.ArcRepository; +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 getArcById(String id) { + return arcRepository.findById(id); + } + + public List getAllArcs() { + return arcRepository.findAll(); + } + + public List 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 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