Mise en place de la possibilité de supprimer des lores / campagnes d'un seul coup
This commit is contained in:
@@ -1,8 +1,15 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Arc;
|
||||||
import com.loremind.domain.campaigncontext.Campaign;
|
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.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.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -16,9 +23,22 @@ import java.util.Optional;
|
|||||||
public class CampaignService {
|
public class CampaignService {
|
||||||
|
|
||||||
private final CampaignRepository campaignRepository;
|
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.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) {}
|
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) {
|
public Campaign createCampaign(CampaignData data) {
|
||||||
Campaign campaign = Campaign.builder()
|
Campaign campaign = Campaign.builder()
|
||||||
.name(data.name())
|
.name(data.name())
|
||||||
@@ -71,7 +97,48 @@ public class CampaignService {
|
|||||||
return (id == null || id.isBlank()) ? null : id;
|
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) {
|
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);
|
campaignRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package com.loremind.application.lorecontext;
|
package com.loremind.application.lorecontext;
|
||||||
|
|
||||||
import com.loremind.domain.lorecontext.LoreNode;
|
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.LoreNodeRepository;
|
||||||
|
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -16,11 +20,20 @@ import java.util.Optional;
|
|||||||
public class LoreNodeService {
|
public class LoreNodeService {
|
||||||
|
|
||||||
private final LoreNodeRepository loreNodeRepository;
|
private final LoreNodeRepository loreNodeRepository;
|
||||||
|
private final PageRepository pageRepository;
|
||||||
|
|
||||||
public LoreNodeService(LoreNodeRepository loreNodeRepository) {
|
public LoreNodeService(LoreNodeRepository loreNodeRepository, PageRepository pageRepository) {
|
||||||
this.loreNodeRepository = loreNodeRepository;
|
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
|
* Crée un LoreNode (dossier) à partir d'un "objet changes" porteur des valeurs
|
||||||
* souhaitées (pattern Parameter Object) : évite les signatures qui gonflent
|
* souhaitées (pattern Parameter Object) : évite les signatures qui gonflent
|
||||||
@@ -68,7 +81,64 @@ public class LoreNodeService {
|
|||||||
return loreNodeRepository.save(existing);
|
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) {
|
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);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
package com.loremind.application.lorecontext;
|
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.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.LoreNodeRepository;
|
||||||
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
||||||
import com.loremind.domain.lorecontext.ports.PageRepository;
|
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||||
|
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -26,15 +33,28 @@ public class LoreService {
|
|||||||
private final LoreRepository loreRepository;
|
private final LoreRepository loreRepository;
|
||||||
private final LoreNodeRepository loreNodeRepository;
|
private final LoreNodeRepository loreNodeRepository;
|
||||||
private final PageRepository pageRepository;
|
private final PageRepository pageRepository;
|
||||||
|
private final TemplateRepository templateRepository;
|
||||||
|
private final CampaignRepository campaignRepository;
|
||||||
|
|
||||||
public LoreService(LoreRepository loreRepository,
|
public LoreService(LoreRepository loreRepository,
|
||||||
LoreNodeRepository loreNodeRepository,
|
LoreNodeRepository loreNodeRepository,
|
||||||
PageRepository pageRepository) {
|
PageRepository pageRepository,
|
||||||
|
TemplateRepository templateRepository,
|
||||||
|
CampaignRepository campaignRepository) {
|
||||||
this.loreRepository = loreRepository;
|
this.loreRepository = loreRepository;
|
||||||
this.loreNodeRepository = loreNodeRepository;
|
this.loreNodeRepository = loreNodeRepository;
|
||||||
this.pageRepository = pageRepository;
|
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) {
|
public Lore createLore(String name, String description) {
|
||||||
Lore lore = Lore.builder()
|
Lore lore = Lore.builder()
|
||||||
.name(name)
|
.name(name)
|
||||||
@@ -83,7 +103,54 @@ public class LoreService {
|
|||||||
return loreRepository.save(lore);
|
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) {
|
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);
|
loreRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int countCampaignsReferencingLore(String id) {
|
||||||
|
int count = 0;
|
||||||
|
for (Campaign campaign : campaignRepository.findAll()) {
|
||||||
|
if (id.equals(campaign.getLoreId())) count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,4 +74,16 @@ public class CampaignController {
|
|||||||
campaignService.deleteCampaign(id);
|
campaignService.deleteCampaign(id);
|
||||||
return ResponseEntity.noContent().build();
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,4 +69,17 @@ public class LoreController {
|
|||||||
loreService.deleteLore(id);
|
loreService.deleteLore(id);
|
||||||
return ResponseEntity.noContent().build();
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,4 +97,16 @@ public class LoreNodeController {
|
|||||||
loreNodeService.deleteLoreNode(id);
|
loreNodeService.deleteLoreNode(id);
|
||||||
return ResponseEntity.noContent().build();
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Arc;
|
||||||
import com.loremind.domain.campaigncontext.Campaign;
|
import com.loremind.domain.campaigncontext.Campaign;
|
||||||
|
import com.loremind.domain.campaigncontext.Chapter;
|
||||||
|
import com.loremind.domain.campaigncontext.Character;
|
||||||
|
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.CampaignRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@@ -27,6 +35,14 @@ public class CampaignServiceTest {
|
|||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private CampaignRepository campaignRepository;
|
private CampaignRepository campaignRepository;
|
||||||
|
@Mock
|
||||||
|
private ArcRepository arcRepository;
|
||||||
|
@Mock
|
||||||
|
private ChapterRepository chapterRepository;
|
||||||
|
@Mock
|
||||||
|
private SceneRepository sceneRepository;
|
||||||
|
@Mock
|
||||||
|
private CharacterRepository characterRepository;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private CampaignService campaignService;
|
private CampaignService campaignService;
|
||||||
@@ -196,15 +212,75 @@ public class CampaignServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testDeleteCampaign() {
|
void testDeleteCampaign_EmptyCampaign() {
|
||||||
// Arrange
|
// Arrange : aucune dépendance ; Mockito renvoie List.of() par défaut.
|
||||||
doNothing().when(campaignRepository).deleteById("campaign-1");
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
campaignService.deleteCampaign("campaign-1");
|
campaignService.deleteCampaign("campaign-1");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
verify(campaignRepository, times(1)).deleteById("campaign-1");
|
verify(campaignRepository, times(1)).deleteById("campaign-1");
|
||||||
|
verify(arcRepository, never()).deleteById(anyString());
|
||||||
|
verify(chapterRepository, never()).deleteById(anyString());
|
||||||
|
verify(sceneRepository, never()).deleteById(anyString());
|
||||||
|
verify(characterRepository, never()).deleteById(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testDeleteCampaign_CascadesArcsChaptersScenes() {
|
||||||
|
// Arrange : campagne avec 1 arc → 1 chapitre → 2 scènes.
|
||||||
|
Arc arc = Arc.builder().id("arc-1").campaignId("campaign-1").name("Arc 1").build();
|
||||||
|
Chapter chapter = Chapter.builder().id("chap-1").arcId("arc-1").name("Chap 1").build();
|
||||||
|
Scene scene1 = Scene.builder().id("scene-1").chapterId("chap-1").name("Scene 1").build();
|
||||||
|
Scene scene2 = Scene.builder().id("scene-2").chapterId("chap-1").name("Scene 2").build();
|
||||||
|
|
||||||
|
when(arcRepository.findByCampaignId("campaign-1")).thenReturn(List.of(arc));
|
||||||
|
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(chapter));
|
||||||
|
when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of(scene1, scene2));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
campaignService.deleteCampaign("campaign-1");
|
||||||
|
|
||||||
|
// Assert : tout disparaît, dans l'ordre feuilles → racine.
|
||||||
|
verify(sceneRepository).deleteById("scene-1");
|
||||||
|
verify(sceneRepository).deleteById("scene-2");
|
||||||
|
verify(chapterRepository).deleteById("chap-1");
|
||||||
|
verify(arcRepository).deleteById("arc-1");
|
||||||
|
verify(campaignRepository).deleteById("campaign-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testDeleteCampaign_CascadesCharacters() {
|
||||||
|
Character pc = Character.builder().id("char-1").campaignId("campaign-1").name("Alric").build();
|
||||||
|
when(characterRepository.findByCampaignId("campaign-1")).thenReturn(List.of(pc));
|
||||||
|
|
||||||
|
campaignService.deleteCampaign("campaign-1");
|
||||||
|
|
||||||
|
verify(characterRepository).deleteById("char-1");
|
||||||
|
verify(campaignRepository).deleteById("campaign-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGetDeletionImpact() {
|
||||||
|
Arc arc = Arc.builder().id("arc-1").campaignId("campaign-1").name("Arc 1").build();
|
||||||
|
Chapter c1 = Chapter.builder().id("chap-1").arcId("arc-1").name("C1").build();
|
||||||
|
Chapter c2 = Chapter.builder().id("chap-2").arcId("arc-1").name("C2").build();
|
||||||
|
Scene s1 = Scene.builder().id("s-1").chapterId("chap-1").name("S1").build();
|
||||||
|
Scene s2 = Scene.builder().id("s-2").chapterId("chap-2").name("S2").build();
|
||||||
|
Scene s3 = Scene.builder().id("s-3").chapterId("chap-2").name("S3").build();
|
||||||
|
Character pc = Character.builder().id("char-1").campaignId("campaign-1").name("Alric").build();
|
||||||
|
|
||||||
|
when(arcRepository.findByCampaignId("campaign-1")).thenReturn(List.of(arc));
|
||||||
|
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(c1, c2));
|
||||||
|
when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of(s1));
|
||||||
|
when(sceneRepository.findByChapterId("chap-2")).thenReturn(List.of(s2, s3));
|
||||||
|
when(characterRepository.findByCampaignId("campaign-1")).thenReturn(List.of(pc));
|
||||||
|
|
||||||
|
CampaignService.DeletionImpact impact = campaignService.getDeletionImpact("campaign-1");
|
||||||
|
|
||||||
|
assertEquals(1, impact.arcs());
|
||||||
|
assertEquals(2, impact.chapters());
|
||||||
|
assertEquals(3, impact.scenes());
|
||||||
|
assertEquals(1, impact.characters());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.loremind.application.lorecontext;
|
package com.loremind.application.lorecontext;
|
||||||
|
|
||||||
import com.loremind.domain.lorecontext.LoreNode;
|
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.LoreNodeRepository;
|
||||||
|
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@@ -15,6 +17,7 @@ import java.util.Optional;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,6 +29,7 @@ import static org.mockito.Mockito.*;
|
|||||||
public class LoreNodeServiceTest {
|
public class LoreNodeServiceTest {
|
||||||
|
|
||||||
@Mock private LoreNodeRepository loreNodeRepository;
|
@Mock private LoreNodeRepository loreNodeRepository;
|
||||||
|
@Mock private PageRepository pageRepository;
|
||||||
|
|
||||||
@InjectMocks private LoreNodeService loreNodeService;
|
@InjectMocks private LoreNodeService loreNodeService;
|
||||||
|
|
||||||
@@ -118,8 +122,66 @@ public class LoreNodeServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testDelete() {
|
void testDelete_LeafFolder() {
|
||||||
|
// Aucun descendant, aucune page : seul le dossier est supprimé.
|
||||||
loreNodeService.deleteLoreNode("n-1");
|
loreNodeService.deleteLoreNode("n-1");
|
||||||
verify(loreNodeRepository).deleteById("n-1");
|
verify(loreNodeRepository).deleteById("n-1");
|
||||||
|
verify(pageRepository, never()).deleteById(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testDelete_CascadesPagesOfRoot() {
|
||||||
|
Page p1 = Page.builder().id("p-1").nodeId("n-1").title("P1").build();
|
||||||
|
Page p2 = Page.builder().id("p-2").nodeId("n-1").title("P2").build();
|
||||||
|
when(pageRepository.findByNodeId("n-1")).thenReturn(List.of(p1, p2));
|
||||||
|
|
||||||
|
loreNodeService.deleteLoreNode("n-1");
|
||||||
|
|
||||||
|
verify(pageRepository).deleteById("p-1");
|
||||||
|
verify(pageRepository).deleteById("p-2");
|
||||||
|
verify(loreNodeRepository).deleteById("n-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testDelete_CascadesSubfoldersRecursive() {
|
||||||
|
// n-1 → n-1a → n-1a1 ; chaque feuille a une page.
|
||||||
|
LoreNode mid = LoreNode.builder().id("n-1a").parentId("n-1").loreId("lore-1").name("mid").build();
|
||||||
|
LoreNode leaf = LoreNode.builder().id("n-1a1").parentId("n-1a").loreId("lore-1").name("leaf").build();
|
||||||
|
Page pageOnLeaf = Page.builder().id("p-leaf").nodeId("n-1a1").title("P").build();
|
||||||
|
|
||||||
|
when(loreNodeRepository.findByParentId("n-1")).thenReturn(List.of(mid));
|
||||||
|
when(loreNodeRepository.findByParentId("n-1a")).thenReturn(List.of(leaf));
|
||||||
|
when(pageRepository.findByNodeId("n-1a1")).thenReturn(List.of(pageOnLeaf));
|
||||||
|
|
||||||
|
loreNodeService.deleteLoreNode("n-1");
|
||||||
|
|
||||||
|
// Feuilles d'abord (pages puis dossier leaf), puis mid, puis la racine.
|
||||||
|
verify(pageRepository).deleteById("p-leaf");
|
||||||
|
verify(loreNodeRepository).deleteById("n-1a1");
|
||||||
|
verify(loreNodeRepository).deleteById("n-1a");
|
||||||
|
verify(loreNodeRepository).deleteById("n-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGetDeletionImpact_CountsSubfoldersAndPages() {
|
||||||
|
LoreNode sub1 = LoreNode.builder().id("s-1").parentId("n-1").loreId("lore-1").name("s1").build();
|
||||||
|
LoreNode sub2 = LoreNode.builder().id("s-2").parentId("n-1").loreId("lore-1").name("s2").build();
|
||||||
|
LoreNode subsub = LoreNode.builder().id("s-1a").parentId("s-1").loreId("lore-1").name("s1a").build();
|
||||||
|
Page p1 = Page.builder().id("p-1").nodeId("n-1").title("P1").build();
|
||||||
|
Page p2 = Page.builder().id("p-2").nodeId("s-1").title("P2").build();
|
||||||
|
Page p3 = Page.builder().id("p-3").nodeId("s-1a").title("P3").build();
|
||||||
|
|
||||||
|
when(loreNodeRepository.findByParentId("n-1")).thenReturn(List.of(sub1, sub2));
|
||||||
|
when(loreNodeRepository.findByParentId("s-1")).thenReturn(List.of(subsub));
|
||||||
|
when(pageRepository.findByNodeId("n-1")).thenReturn(List.of(p1));
|
||||||
|
when(pageRepository.findByNodeId("s-1")).thenReturn(List.of(p2));
|
||||||
|
when(pageRepository.findByNodeId("s-2")).thenReturn(List.of());
|
||||||
|
when(pageRepository.findByNodeId("s-1a")).thenReturn(List.of(p3));
|
||||||
|
|
||||||
|
LoreNodeService.DeletionImpact impact = loreNodeService.getDeletionImpact("n-1");
|
||||||
|
|
||||||
|
// 3 sous-dossiers (sub1, sub2, subsub) — on ne compte pas la racine n-1.
|
||||||
|
assertEquals(3, impact.folders());
|
||||||
|
assertEquals(3, impact.pages());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
package com.loremind.application.lorecontext;
|
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.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.LoreNodeRepository;
|
||||||
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
||||||
import com.loremind.domain.lorecontext.ports.PageRepository;
|
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||||
|
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@@ -17,6 +23,7 @@ import java.util.Optional;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,6 +37,8 @@ public class LoreServiceTest {
|
|||||||
@Mock private LoreRepository loreRepository;
|
@Mock private LoreRepository loreRepository;
|
||||||
@Mock private LoreNodeRepository loreNodeRepository;
|
@Mock private LoreNodeRepository loreNodeRepository;
|
||||||
@Mock private PageRepository pageRepository;
|
@Mock private PageRepository pageRepository;
|
||||||
|
@Mock private TemplateRepository templateRepository;
|
||||||
|
@Mock private CampaignRepository campaignRepository;
|
||||||
|
|
||||||
@InjectMocks private LoreService loreService;
|
@InjectMocks private LoreService loreService;
|
||||||
|
|
||||||
@@ -134,8 +143,67 @@ public class LoreServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testDeleteLore_DelegatesToRepository() {
|
void testDeleteLore_EmptyLore() {
|
||||||
|
// Aucun dossier / page / template / campagne : seul le Lore est supprimé.
|
||||||
loreService.deleteLore("lore-1");
|
loreService.deleteLore("lore-1");
|
||||||
verify(loreRepository).deleteById("lore-1");
|
verify(loreRepository).deleteById("lore-1");
|
||||||
|
verify(loreNodeRepository, never()).deleteById(anyString());
|
||||||
|
verify(pageRepository, never()).deleteById(anyString());
|
||||||
|
verify(templateRepository, never()).deleteById(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testDeleteLore_CascadesFoldersPagesTemplates() {
|
||||||
|
LoreNode node = LoreNode.builder().id("n-1").loreId("lore-1").name("F").build();
|
||||||
|
Page page = Page.builder().id("p-1").loreId("lore-1").nodeId("n-1").title("P").build();
|
||||||
|
Template template = Template.builder().id("t-1").loreId("lore-1").name("T").build();
|
||||||
|
|
||||||
|
when(pageRepository.findByLoreId("lore-1")).thenReturn(List.of(page));
|
||||||
|
when(loreNodeRepository.findByLoreId("lore-1")).thenReturn(List.of(node));
|
||||||
|
when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of(template));
|
||||||
|
|
||||||
|
loreService.deleteLore("lore-1");
|
||||||
|
|
||||||
|
verify(pageRepository).deleteById("p-1");
|
||||||
|
verify(loreNodeRepository).deleteById("n-1");
|
||||||
|
verify(templateRepository).deleteById("t-1");
|
||||||
|
verify(loreRepository).deleteById("lore-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testDeleteLore_DetachesCampaignsInsteadOfDeleting() {
|
||||||
|
Campaign attached = Campaign.builder().id("c-1").loreId("lore-1").name("C1").build();
|
||||||
|
Campaign other = Campaign.builder().id("c-2").loreId("lore-other").name("C2").build();
|
||||||
|
Campaign orphan = Campaign.builder().id("c-3").loreId(null).name("C3").build();
|
||||||
|
when(campaignRepository.findAll()).thenReturn(List.of(attached, other, orphan));
|
||||||
|
when(campaignRepository.save(any(Campaign.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
loreService.deleteLore("lore-1");
|
||||||
|
|
||||||
|
// Seule la campagne attachée est re-sauvegardée (avec loreId=null).
|
||||||
|
ArgumentCaptor<Campaign> captor = ArgumentCaptor.forClass(Campaign.class);
|
||||||
|
verify(campaignRepository, times(1)).save(captor.capture());
|
||||||
|
assertEquals("c-1", captor.getValue().getId());
|
||||||
|
assertNull(captor.getValue().getLoreId());
|
||||||
|
// Aucune campagne n'est supprimée.
|
||||||
|
verify(campaignRepository, never()).deleteById(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGetDeletionImpact() {
|
||||||
|
Template t1 = Template.builder().id("t-1").loreId("lore-1").name("T").build();
|
||||||
|
Campaign attached = Campaign.builder().id("c-1").loreId("lore-1").name("C").build();
|
||||||
|
Campaign unrelated = Campaign.builder().id("c-2").loreId("lore-other").name("C2").build();
|
||||||
|
when(loreNodeRepository.countByLoreId("lore-1")).thenReturn(4L);
|
||||||
|
when(pageRepository.countByLoreId("lore-1")).thenReturn(12L);
|
||||||
|
when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of(t1));
|
||||||
|
when(campaignRepository.findAll()).thenReturn(List.of(attached, unrelated));
|
||||||
|
|
||||||
|
LoreService.DeletionImpact impact = loreService.getDeletionImpact("lore-1");
|
||||||
|
|
||||||
|
assertEquals(4, impact.folders());
|
||||||
|
assertEquals(12, impact.pages());
|
||||||
|
assertEquals(1, impact.templates());
|
||||||
|
assertEquals(1, impact.detachedCampaigns());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,23 +257,38 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Suppression protégée : refus si la campagne contient des arcs.
|
* Suppression en cascade : récupère d'abord le détail de ce qui sera effacé
|
||||||
* Les arcs contiennent potentiellement des chapitres/scènes construits longuement.
|
* (arcs / chapitres / scènes / personnages), affiche le récapitulatif dans
|
||||||
|
* la confirmation, puis supprime. Le cascade est orchestré côté backend dans
|
||||||
|
* une seule transaction.
|
||||||
*/
|
*/
|
||||||
deleteCampaign(): void {
|
deleteCampaign(): void {
|
||||||
if (!this.campaign) return;
|
if (!this.campaign) return;
|
||||||
if (this.arcs.length > 0) {
|
const campaign = this.campaign;
|
||||||
alert(
|
this.campaignService.getCampaignDeletionImpact(campaign.id!).subscribe({
|
||||||
`Impossible de supprimer "${this.campaign.name}" : elle contient encore ${this.arcs.length} arc(s).\n` +
|
next: impact => {
|
||||||
`Videz la campagne (arcs et chapitres) avant de la supprimer.`
|
const parts: string[] = [];
|
||||||
);
|
if (impact.arcs > 0) parts.push(`${impact.arcs} arc${impact.arcs > 1 ? 's' : ''}`);
|
||||||
return;
|
if (impact.chapters > 0) parts.push(`${impact.chapters} chapitre${impact.chapters > 1 ? 's' : ''}`);
|
||||||
|
if (impact.scenes > 0) parts.push(`${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}`);
|
||||||
|
if (impact.characters > 0) parts.push(`${impact.characters} personnage${impact.characters > 1 ? 's' : ''}`);
|
||||||
|
|
||||||
|
const lines = [`Supprimer définitivement la campagne "${campaign.name}" ?`];
|
||||||
|
if (parts.length) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
|
||||||
}
|
}
|
||||||
if (!confirm(`Supprimer définitivement la campagne "${this.campaign.name}" ?`)) return;
|
lines.push('');
|
||||||
this.campaignService.deleteCampaign(this.campaign.id!).subscribe({
|
lines.push('Cette action est irréversible.');
|
||||||
|
|
||||||
|
if (!confirm(lines.join('\n'))) return;
|
||||||
|
this.campaignService.deleteCampaign(campaign.id!).subscribe({
|
||||||
next: () => this.router.navigate(['/campaigns']),
|
next: () => this.router.navigate(['/campaigns']),
|
||||||
error: () => console.error('Erreur lors de la suppression de la campagne')
|
error: () => console.error('Erreur lors de la suppression de la campagne')
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
error: () => console.error('Impossible de récupérer les dépendances de la campagne')
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
|
|||||||
@@ -110,25 +110,44 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Suppression protégée : refus si le Lore contient encore des dossiers
|
* Suppression en cascade : récupère le détail de ce qui tombera (dossiers,
|
||||||
* ou des pages. Protège contre un clic accidentel sur des données
|
* pages, templates) et de ce qui sera détaché (campagnes conservées mais
|
||||||
* construites longuement. Logique côté frontend (pas d'appel HTTP
|
* sans lien vers ce Lore), affiche le récapitulatif dans la confirmation,
|
||||||
* supplémentaire) car les données sont déjà chargées.
|
* puis délègue au backend (transaction atomique).
|
||||||
*/
|
*/
|
||||||
deleteLore(): void {
|
deleteLore(): void {
|
||||||
if (!this.lore) return;
|
if (!this.lore) return;
|
||||||
if (this.allNodes.length > 0) {
|
const lore = this.lore;
|
||||||
alert(
|
this.loreService.getLoreDeletionImpact(lore.id!).subscribe({
|
||||||
`Impossible de supprimer "${this.lore.name}" : il contient encore ${this.allNodes.length} dossier(s).\n` +
|
next: impact => {
|
||||||
`Videz le Lore (dossiers et pages) avant de le supprimer.`
|
const deleted: string[] = [];
|
||||||
);
|
if (impact.folders > 0) deleted.push(`${impact.folders} dossier${impact.folders > 1 ? 's' : ''}`);
|
||||||
return;
|
if (impact.pages > 0) deleted.push(`${impact.pages} page${impact.pages > 1 ? 's' : ''}`);
|
||||||
|
if (impact.templates > 0) deleted.push(`${impact.templates} template${impact.templates > 1 ? 's' : ''}`);
|
||||||
|
|
||||||
|
const lines = [`Supprimer définitivement le Lore "${lore.name}" ?`];
|
||||||
|
if (deleted.length) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`Cette action supprimera aussi : ${deleted.join(', ')}.`);
|
||||||
}
|
}
|
||||||
if (!confirm(`Supprimer définitivement le Lore "${this.lore.name}" ?`)) return;
|
if (impact.detachedCampaigns > 0) {
|
||||||
this.loreService.deleteLore(this.lore.id!).subscribe({
|
lines.push('');
|
||||||
|
lines.push(
|
||||||
|
`${impact.detachedCampaigns} campagne${impact.detachedCampaigns > 1 ? 's' : ''} ${impact.detachedCampaigns > 1 ? 'seront conservées' : 'sera conservée'} ` +
|
||||||
|
`mais ${impact.detachedCampaigns > 1 ? 'perdront' : 'perdra'} leur lien vers cet univers.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Cette action est irréversible.');
|
||||||
|
|
||||||
|
if (!confirm(lines.join('\n'))) return;
|
||||||
|
this.loreService.deleteLore(lore.id!).subscribe({
|
||||||
next: () => this.router.navigate(['/lore']),
|
next: () => this.router.navigate(['/lore']),
|
||||||
error: () => console.error('Erreur lors de la suppression du Lore')
|
error: () => console.error('Erreur lors de la suppression du Lore')
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
error: () => console.error('Impossible de récupérer les dépendances du Lore')
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
|
|||||||
@@ -12,8 +12,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-danger"
|
class="btn-danger"
|
||||||
[disabled]="!canDelete"
|
title="Supprimer le dossier et tout son contenu"
|
||||||
[title]="canDelete ? 'Supprimer le dossier' : 'Impossible : le dossier contient des éléments'"
|
|
||||||
(click)="delete()">
|
(click)="delete()">
|
||||||
Supprimer
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
@@ -57,11 +56,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-box" *ngIf="!canDelete">
|
|
||||||
⚠️ Pour supprimer ce dossier, videz-le d'abord : déplacez ou supprimez ses
|
|
||||||
{{ childFolderCount }} sous-dossier(s) et ses {{ pageCount }} page(s).
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -134,17 +134,36 @@ export class LoreNodeEditComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get canDelete(): boolean {
|
/**
|
||||||
return this.childFolderCount === 0 && this.pageCount === 0;
|
* Suppression en cascade : on va chercher le compte exact de sous-dossiers et
|
||||||
}
|
* de pages (qui tombent avec le dossier), on l'annonce dans la confirmation,
|
||||||
|
* puis on délègue au backend — l'atomicité est garantie côté transaction.
|
||||||
|
*/
|
||||||
delete(): void {
|
delete(): void {
|
||||||
if (!this.canDelete || !this.node) return;
|
if (!this.node) return;
|
||||||
if (!confirm(`Supprimer le dossier "${this.node.name}" ?`)) return;
|
const node = this.node;
|
||||||
|
this.loreService.getLoreNodeDeletionImpact(this.folderId).subscribe({
|
||||||
|
next: impact => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (impact.folders > 0) parts.push(`${impact.folders} sous-dossier${impact.folders > 1 ? 's' : ''}`);
|
||||||
|
if (impact.pages > 0) parts.push(`${impact.pages} page${impact.pages > 1 ? 's' : ''}`);
|
||||||
|
|
||||||
|
const lines = [`Supprimer le dossier "${node.name}" ?`];
|
||||||
|
if (parts.length) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Cette action est irréversible.');
|
||||||
|
|
||||||
|
if (!confirm(lines.join('\n'))) return;
|
||||||
this.loreService.deleteLoreNode(this.folderId).subscribe({
|
this.loreService.deleteLoreNode(this.folderId).subscribe({
|
||||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
next: () => this.router.navigate(['/lore', this.loreId]),
|
||||||
error: () => console.error('Erreur lors de la suppression du dossier')
|
error: () => console.error('Erreur lors de la suppression du dossier')
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
error: () => console.error('Impossible de récupérer les dépendances du dossier')
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel(): void {
|
cancel(): void {
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
|||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { Campaign, CampaignCreate, Arc, ArcCreate, Chapter, ChapterCreate, Scene, SceneCreate } from './campaign.model';
|
import { Campaign, CampaignCreate, Arc, ArcCreate, Chapter, ChapterCreate, Scene, SceneCreate } from './campaign.model';
|
||||||
|
|
||||||
|
/** Compte des entités qui seront supprimées en cascade avec la campagne. */
|
||||||
|
export interface CampaignDeletionImpact {
|
||||||
|
arcs: number;
|
||||||
|
chapters: number;
|
||||||
|
scenes: number;
|
||||||
|
characters: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service HTTP pour la gestion des Campagnes.
|
* Service HTTP pour la gestion des Campagnes.
|
||||||
* Port de sortie vers le Backend Java (Architecture Hexagonale).
|
* Port de sortie vers le Backend Java (Architecture Hexagonale).
|
||||||
@@ -35,6 +43,10 @@ export class CampaignService {
|
|||||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getCampaignDeletionImpact(id: string): Observable<CampaignDeletionImpact> {
|
||||||
|
return this.http.get<CampaignDeletionImpact>(`${this.apiUrl}/${id}/deletion-impact`);
|
||||||
|
}
|
||||||
|
|
||||||
// ========== ARC ==========
|
// ========== ARC ==========
|
||||||
getArcs(campaignId: string): Observable<Arc[]> {
|
getArcs(campaignId: string): Observable<Arc[]> {
|
||||||
return this.http.get<Arc[]>(`http://localhost:8080/api/arcs/campaign/${campaignId}`);
|
return this.http.get<Arc[]>(`http://localhost:8080/api/arcs/campaign/${campaignId}`);
|
||||||
|
|||||||
@@ -3,6 +3,23 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
|||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { Lore, LoreCreate, LoreNode, LoreNodeCreate } from './lore.model';
|
import { Lore, LoreCreate, LoreNode, LoreNodeCreate } from './lore.model';
|
||||||
|
|
||||||
|
/** Compte des entités qui seront supprimées en cascade avec un dossier. */
|
||||||
|
export interface LoreNodeDeletionImpact {
|
||||||
|
/** Sous-dossiers (récursif, sans compter le dossier racine lui-même). */
|
||||||
|
folders: number;
|
||||||
|
/** Pages dans l'ensemble du sous-arbre. */
|
||||||
|
pages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compte des entités qui seront supprimées / détachées en cascade avec un Lore. */
|
||||||
|
export interface LoreDeletionImpact {
|
||||||
|
folders: number;
|
||||||
|
pages: number;
|
||||||
|
templates: number;
|
||||||
|
/** Campagnes qui perdront leur référence au Lore (mais resteront présentes). */
|
||||||
|
detachedCampaigns: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service HTTP pour la gestion des Lores.
|
* Service HTTP pour la gestion des Lores.
|
||||||
* Port de sortie vers le Backend Java (Architecture Hexagonale).
|
* Port de sortie vers le Backend Java (Architecture Hexagonale).
|
||||||
@@ -36,6 +53,10 @@ export class LoreService {
|
|||||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getLoreDeletionImpact(id: string): Observable<LoreDeletionImpact> {
|
||||||
|
return this.http.get<LoreDeletionImpact>(`${this.apiUrl}/${id}/deletion-impact`);
|
||||||
|
}
|
||||||
|
|
||||||
getLoreNodes(loreId: string): Observable<LoreNode[]> {
|
getLoreNodes(loreId: string): Observable<LoreNode[]> {
|
||||||
return this.http.get<LoreNode[]>(`${this.nodesUrl}?loreId=${loreId}`);
|
return this.http.get<LoreNode[]>(`${this.nodesUrl}?loreId=${loreId}`);
|
||||||
}
|
}
|
||||||
@@ -57,6 +78,10 @@ export class LoreService {
|
|||||||
return this.http.delete<void>(`${this.nodesUrl}/${id}`);
|
return this.http.delete<void>(`${this.nodesUrl}/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getLoreNodeDeletionImpact(id: string): Observable<LoreNodeDeletionImpact> {
|
||||||
|
return this.http.get<LoreNodeDeletionImpact>(`${this.nodesUrl}/${id}/deletion-impact`);
|
||||||
|
}
|
||||||
|
|
||||||
searchLores(q: string): Observable<Lore[]> {
|
searchLores(q: string): Observable<Lore[]> {
|
||||||
const params = new HttpParams().set('q', q);
|
const params = new HttpParams().set('q', q);
|
||||||
return this.http.get<Lore[]>(`${this.apiUrl}/search`, { params });
|
return this.http.get<Lore[]>(`${this.apiUrl}/search`, { params });
|
||||||
|
|||||||
Reference in New Issue
Block a user