Mise en place de la possibilité de supprimer des lores / campagnes d'un seul coup

This commit is contained in:
2026-04-23 11:51:03 +02:00
parent 5f5b2ecb87
commit 5121e2d2e3
15 changed files with 585 additions and 54 deletions

View File

@@ -1,8 +1,15 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter;
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.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@@ -16,9 +23,22 @@ import java.util.Optional;
public class CampaignService {
private final CampaignRepository campaignRepository;
private final ArcRepository arcRepository;
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
private final CharacterRepository characterRepository;
public CampaignService(CampaignRepository campaignRepository) {
public CampaignService(
CampaignRepository campaignRepository,
ArcRepository arcRepository,
ChapterRepository chapterRepository,
SceneRepository sceneRepository,
CharacterRepository characterRepository) {
this.campaignRepository = campaignRepository;
this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository;
this.characterRepository = characterRepository;
}
/**
@@ -30,6 +50,12 @@ public class CampaignService {
*/
public record CampaignData(String name, String description, String loreId, String gameSystemId) {}
/**
* Compte des entités qui seront supprimées en cascade si la campagne est effacée.
* Utilisé par l'UI pour afficher un récapitulatif dans le dialogue de confirmation.
*/
public record DeletionImpact(int arcs, int chapters, int scenes, int characters) {}
public Campaign createCampaign(CampaignData data) {
Campaign campaign = Campaign.builder()
.name(data.name())
@@ -71,7 +97,48 @@ public class CampaignService {
return (id == null || id.isBlank()) ? null : id;
}
/**
* Calcule l'impact d'une suppression en cascade : nombre d'arcs, chapitres,
* scènes et personnages qui disparaîtront avec la campagne. Utilisé par l'UI
* pour afficher "X arcs, Y chapitres, Z scènes seront supprimés".
*/
public DeletionImpact getDeletionImpact(String id) {
List<Arc> arcs = arcRepository.findByCampaignId(id);
int chapterTotal = 0;
int sceneTotal = 0;
for (Arc arc : arcs) {
List<Chapter> chapters = chapterRepository.findByArcId(arc.getId());
chapterTotal += chapters.size();
for (Chapter chapter : chapters) {
sceneTotal += sceneRepository.findByChapterId(chapter.getId()).size();
}
}
int characterTotal = characterRepository.findByCampaignId(id).size();
return new DeletionImpact(arcs.size(), chapterTotal, sceneTotal, characterTotal);
}
/**
* Supprime la campagne et toutes ses entités dépendantes (arcs → chapitres →
* scènes, plus les personnages). L'opération est transactionnelle : soit
* tout disparaît, soit rien ne change. Les FKs applicatives n'ayant pas
* de contrainte CASCADE au niveau DB, on orchestre la cascade ici.
*/
@Transactional
public void deleteCampaign(String id) {
List<Arc> arcs = arcRepository.findByCampaignId(id);
for (Arc arc : arcs) {
List<Chapter> chapters = chapterRepository.findByArcId(arc.getId());
for (Chapter chapter : chapters) {
for (var scene : sceneRepository.findByChapterId(chapter.getId())) {
sceneRepository.deleteById(scene.getId());
}
chapterRepository.deleteById(chapter.getId());
}
arcRepository.deleteById(arc.getId());
}
for (var character : characterRepository.findByCampaignId(id)) {
characterRepository.deleteById(character.getId());
}
campaignRepository.deleteById(id);
}

View File

@@ -1,9 +1,13 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@@ -16,11 +20,20 @@ import java.util.Optional;
public class LoreNodeService {
private final LoreNodeRepository loreNodeRepository;
private final PageRepository pageRepository;
public LoreNodeService(LoreNodeRepository loreNodeRepository) {
public LoreNodeService(LoreNodeRepository loreNodeRepository, PageRepository pageRepository) {
this.loreNodeRepository = loreNodeRepository;
this.pageRepository = pageRepository;
}
/**
* Compte des entités qui seront supprimées en cascade si le dossier est effacé :
* le dossier lui-même n'est pas compté, seuls les descendants (sous-dossiers
* récursifs + pages de l'ensemble du sous-arbre).
*/
public record DeletionImpact(int folders, int pages) {}
/**
* Crée un LoreNode (dossier) à partir d'un "objet changes" porteur des valeurs
* souhaitées (pattern Parameter Object) : évite les signatures qui gonflent
@@ -68,7 +81,64 @@ public class LoreNodeService {
return loreNodeRepository.save(existing);
}
/**
* Calcule l'impact d'une suppression en cascade : nombre de sous-dossiers
* (récursif, sans compter la racine) et de pages dans l'ensemble du sous-arbre.
*/
public DeletionImpact getDeletionImpact(String id) {
List<LoreNode> descendants = collectDescendants(id);
int pageTotal = pageRepository.findByNodeId(id).size();
for (LoreNode descendant : descendants) {
pageTotal += pageRepository.findByNodeId(descendant.getId()).size();
}
return new DeletionImpact(descendants.size(), pageTotal);
}
/**
* Supprime le dossier et tout son sous-arbre (sous-dossiers récursifs + pages).
* Suppression en profondeur d'abord (feuilles → racine) pour limiter les
* références orphelines en cours de transaction. Les FKs applicatives n'ayant
* pas de CASCADE en DB, on orchestre la descente ici.
*/
@Transactional
public void deleteLoreNode(String id) {
List<LoreNode> descendants = collectDescendants(id);
// Descendants retournés en ordre BFS (haut → bas) : on inverse pour
// supprimer les feuilles en premier, puis on finit par la racine.
for (int i = descendants.size() - 1; i >= 0; i--) {
String descendantId = descendants.get(i).getId();
deletePagesOfNode(descendantId);
loreNodeRepository.deleteById(descendantId);
}
deletePagesOfNode(id);
loreNodeRepository.deleteById(id);
}
private void deletePagesOfNode(String nodeId) {
for (Page page : pageRepository.findByNodeId(nodeId)) {
pageRepository.deleteById(page.getId());
}
}
/**
* Retourne tous les descendants (hors racine) d'un dossier, en ordre BFS.
* Parcours itératif pour éviter tout risque de débordement de pile sur
* une arborescence profonde malicieuse.
*/
private List<LoreNode> collectDescendants(String rootId) {
List<LoreNode> result = new ArrayList<>();
List<String> frontier = new ArrayList<>();
frontier.add(rootId);
while (!frontier.isEmpty()) {
List<String> nextFrontier = new ArrayList<>();
for (String parentId : frontier) {
for (LoreNode child : loreNodeRepository.findByParentId(parentId)) {
result.add(child);
nextFrontier.add(child.getId());
}
}
frontier = nextFrontier;
}
return result;
}
}

View File

@@ -1,10 +1,17 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
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 org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@@ -26,15 +33,28 @@ public class LoreService {
private final LoreRepository loreRepository;
private final LoreNodeRepository loreNodeRepository;
private final PageRepository pageRepository;
private final TemplateRepository templateRepository;
private final CampaignRepository campaignRepository;
public LoreService(LoreRepository loreRepository,
LoreNodeRepository loreNodeRepository,
PageRepository pageRepository) {
PageRepository pageRepository,
TemplateRepository templateRepository,
CampaignRepository campaignRepository) {
this.loreRepository = loreRepository;
this.loreNodeRepository = loreNodeRepository;
this.pageRepository = pageRepository;
this.templateRepository = templateRepository;
this.campaignRepository = campaignRepository;
}
/**
* Compte des entités qui seront supprimées / détachées en cascade si le Lore
* est effacé. `detachedCampaigns` : campagnes qui perdront leur référence à
* ce Lore (leur loreId sera nullé) mais resteront présentes.
*/
public record DeletionImpact(int folders, int pages, int templates, int detachedCampaigns) {}
public Lore createLore(String name, String description) {
Lore lore = Lore.builder()
.name(name)
@@ -76,14 +96,61 @@ public class LoreService {
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);
}
/**
* Calcule l'impact d'une suppression de Lore en cascade : dossiers + pages
* + templates supprimés, et campagnes qui seront détachées (loreId → null
* sans être supprimées, car une campagne peut vivre sans univers).
*/
public DeletionImpact getDeletionImpact(String id) {
int folders = (int) loreNodeRepository.countByLoreId(id);
int pages = (int) pageRepository.countByLoreId(id);
int templates = templateRepository.findByLoreId(id).size();
int detached = countCampaignsReferencingLore(id);
return new DeletionImpact(folders, pages, templates, detached);
}
/**
* Supprime le Lore et toutes ses entités dépendantes (dossiers, pages, templates).
* Les campagnes qui référençaient ce Lore sont conservées — leur loreId est
* mis à null (une campagne peut légitimement exister sans univers associé).
* Opération transactionnelle : atomique.
*/
@Transactional
public void deleteLore(String id) {
// Pages d'abord : elles référencent nodeId ET loreId, on les supprime
// globalement via loreId pour éviter d'en rater une rattachée à un
// node orphelin (ne devrait pas arriver, mais ceinture+bretelles).
for (Page page : pageRepository.findByLoreId(id)) {
pageRepository.deleteById(page.getId());
}
for (LoreNode node : loreNodeRepository.findByLoreId(id)) {
loreNodeRepository.deleteById(node.getId());
}
for (Template template : templateRepository.findByLoreId(id)) {
templateRepository.deleteById(template.getId());
}
// Détache les campagnes : on garde la campagne, on nulle juste la référence.
for (Campaign campaign : campaignRepository.findAll()) {
if (id.equals(campaign.getLoreId())) {
campaign.setLoreId(null);
campaignRepository.save(campaign);
}
}
loreRepository.deleteById(id);
}
private int countCampaignsReferencingLore(String id) {
int count = 0;
for (Campaign campaign : campaignRepository.findAll()) {
if (id.equals(campaign.getLoreId())) count++;
}
return count;
}
}

View File

@@ -74,4 +74,16 @@ public class CampaignController {
campaignService.deleteCampaign(id);
return ResponseEntity.noContent().build();
}
/**
* Récapitulatif des entités qui seront supprimées en cascade : utilisé par
* l'UI pour afficher "X arcs, Y chapitres, Z scènes..." dans la confirmation.
*/
@GetMapping("/{id}/deletion-impact")
public ResponseEntity<CampaignService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
if (!campaignService.campaignExists(id)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(campaignService.getDeletionImpact(id));
}
}

View File

@@ -69,4 +69,17 @@ public class LoreController {
loreService.deleteLore(id);
return ResponseEntity.noContent().build();
}
/**
* Récapitulatif des entités qui seront supprimées / détachées en cascade.
* Utilisé par l'UI pour afficher "X dossiers, Y pages, Z templates,
* N campagne(s) détachée(s)" dans la confirmation.
*/
@GetMapping("/{id}/deletion-impact")
public ResponseEntity<LoreService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
if (loreService.getLoreById(id).isEmpty()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(loreService.getDeletionImpact(id));
}
}

View File

@@ -97,4 +97,16 @@ public class LoreNodeController {
loreNodeService.deleteLoreNode(id);
return ResponseEntity.noContent().build();
}
/**
* Récapitulatif des entités qui seront supprimées en cascade : utilisé par
* l'UI pour afficher "X sous-dossiers, Y pages..." dans la confirmation.
*/
@GetMapping("/{id}/deletion-impact")
public ResponseEntity<LoreNodeService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
if (loreNodeService.getLoreNodeById(id).isEmpty()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(loreNodeService.getDeletionImpact(id));
}
}