From 0c44e42a22ca090498d3912957f6323c62cf6f41 Mon Sep 17 00:00:00 2001
From: "IETM_FIXE\\ietm6"
Date: Fri, 10 Jul 2026 09:19:26 +0200
Subject: [PATCH] =?UTF-8?q?Qu=C3=AAtes=20libres=20:=20fin=20des=20conteneu?=
=?UTF-8?q?rs=20fant=C3=B4mes=20(cascade,=20impact=20annonc=C3=A9,=20expor?=
=?UTF-8?q?t=20filtr=C3=A9,=20migration=20V24)=20Sidebar=20Lore=20:=20le?=
=?UTF-8?q?=20panneau=20Templates=20ne=20peut=20plus=20=C3=A9craser=20l'ar?=
=?UTF-8?q?bre?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Plafonné à 45 % de la sidebar avec ascenseur interne sur la liste —
l'en-tête, le repli et le bouton + restent toujours visibles, l'arbre
garde son propre scroll (remonté par un utilisateur avec ~30 templates)
Passage v1.0.3
---
.gitignore | 2 +
brain/app/main.py | 2 +-
core/pom.xml | 2 +-
.../campaigncontext/QuestService.java | 54 ++++++--
.../foundry/FoundryExportService.java | 32 ++++-
.../web/controller/QuestController.java | 10 ++
...Reattach_orphan_free_quest_containers.java | 118 ++++++++++++++++++
.../campaigncontext/QuestServiceTest.java | 103 ++++++++++++++-
.../persistence/FlywayMigrationTest.java | 75 +++++++++++
.../foundry/FoundryExportServiceTest.java | 60 +++++++++
web/package-lock.json | 4 +-
web/package.json | 2 +-
.../quest/quest-view/quest-view.component.ts | 24 +++-
web/src/app/services/quest.service.ts | 9 ++
.../secondary-sidebar.component.scss | 9 ++
web/src/assets/i18n/en.json | 1 +
web/src/assets/i18n/fr.json | 1 +
17 files changed, 488 insertions(+), 20 deletions(-)
create mode 100644 core/src/main/java/db/migration/V24__Reattach_orphan_free_quest_containers.java
diff --git a/.gitignore b/.gitignore
index 936d052..48a1c5e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -129,3 +129,5 @@ web/coverage/
brain/htmlcov/
brain/.coverage
foundry-module/
+plan-promotion-loremind.md
+post-reddit-foundryvtt.md
diff --git a/brain/app/main.py b/brain/app/main.py
index 75805c6..ebdb0c4 100644
--- a/brain/app/main.py
+++ b/brain/app/main.py
@@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo
app = FastAPI(
title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.",
- version="1.0.2-beta",
+ version="1.0.3",
)
logger = logging.getLogger(__name__)
diff --git a/core/pom.xml b/core/pom.xml
index 30eaf1a..d02a981 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -14,7 +14,7 @@
com.loremind
loremind-core
- 1.0.2-beta
+ 1.0.3
LoreMind Core
Backend Core - Architecture Hexagonale
diff --git a/core/src/main/java/com/loremind/application/campaigncontext/QuestService.java b/core/src/main/java/com/loremind/application/campaigncontext/QuestService.java
index 6d54aff..a0be75e 100644
--- a/core/src/main/java/com/loremind/application/campaigncontext/QuestService.java
+++ b/core/src/main/java/com/loremind/application/campaigncontext/QuestService.java
@@ -106,6 +106,11 @@ public class QuestService {
/** Le chapitre est-il un CONTENEUR de cette quête (jumeau hub ou hébergé en arc SYSTEM) ? */
private boolean isContainerOf(Quest quest, Chapter chapter) {
if (Objects.equals(quest.getArcId(), chapter.getArcId())) return true;
+ return inSystemArc(chapter);
+ }
+
+ /** Le chapitre vit-il dans l'arc technique SYSTEM (masqué partout dans l'appli) ? */
+ private boolean inSystemArc(Chapter chapter) {
return chapter.getArcId() != null && arcRepository.findById(chapter.getArcId())
.map(a -> a.getType() == ArcType.SYSTEM)
.orElse(false);
@@ -156,6 +161,18 @@ public class QuestService {
/**
* Supprime la quête et, en cascade, ses {@code QuestProgression} dans toutes les Parties.
*
+ * Nettoyage du CONTENEUR (chapitre jumeau, jamais un chapitre simplement LIÉ —
+ * isContainerOf exclut les liens transversaux) :
+ *
+ * - jumeau de HUB non vide : GARDÉ — il redevient un chapitre visible de l'arc,
+ * aucune perte de contenu ;
+ * - conteneur d'arc SYSTEM (quête libre) : supprimé AVEC ses scènes — une fois la
+ * quête partie il est invisible partout dans l'appli et pourrirait en fantôme
+ * (réapparitions dans les exports). L'impact est annoncé au préalable par
+ * {@link #getDeletionImpact} (dialogue de confirmation côté front) ;
+ * - conteneur encore référencé par une autre quête : jamais touché.
+ *
+ *
* Limite connue (nettoyage prévu Phase 5) : les {@code Prerequisite.QuestCompleted}
* d'autres quêtes qui pointaient celle-ci restent pendants, sans être signalés ni
* nettoyés. Échec sûr aujourd'hui : un prérequis vers une quête supprimée n'est jamais
@@ -167,28 +184,47 @@ public class QuestService {
progressionRepository.deleteByQuestId(id);
questRepository.deleteById(id);
if (quest == null) return;
- // Nettoyage du CONTENEUR (jumeau hub ou hébergé en arc SYSTEM) : un chapitre VIDE
- // (aucune scène), plus référencé par aucune autre quête, ne doit pas réapparaître
- // comme « chapitre vide » fantôme. S'il contient des scènes, on le GARDE (aucune
- // perte de contenu). Les chapitres simplement LIÉS (quête transversale pointant du
- // contenu réel d'un autre arc) ne sont JAMAIS touchés — isContainerOf les exclut.
List remaining = questRepository.findByCampaignId(quest.getCampaignId());
for (QuestNodeRef node : nullSafeNodes(quest.getNodes())) {
if (node.nodeType() != NodeType.CHAPTER) continue;
chapterRepository.findById(node.nodeId()).ifPresent(ch -> {
boolean container = isContainerOf(quest, ch);
- boolean empty = sceneRepository.findByChapterId(ch.getId()).isEmpty();
boolean referencedElsewhere = remaining.stream()
.anyMatch(q -> nullSafeNodes(q.getNodes()).stream()
.anyMatch(n -> n.nodeType() == NodeType.CHAPTER
&& ch.getId().equals(n.nodeId())));
- if (container && empty && !referencedElsewhere) {
- chapterRepository.deleteById(ch.getId());
- }
+ if (!container || referencedElsewhere) return;
+ var scenes = sceneRepository.findByChapterId(ch.getId());
+ if (!scenes.isEmpty() && !inSystemArc(ch)) return; // jumeau de hub : reste visible
+ for (var scene : scenes) sceneRepository.deleteById(scene.getId());
+ chapterRepository.deleteById(ch.getId());
});
}
}
+ /** Scènes qui tomberont avec la quête (conteneurs d'arc SYSTEM exclusifs à cette quête). */
+ public record DeletionImpact(int scenes) {}
+
+ public DeletionImpact getDeletionImpact(String questId) {
+ Quest quest = questRepository.findById(questId).orElse(null);
+ if (quest == null) return new DeletionImpact(0);
+ List others = questRepository.findByCampaignId(quest.getCampaignId()).stream()
+ .filter(q -> !questId.equals(q.getId()))
+ .toList();
+ int scenes = 0;
+ for (QuestNodeRef node : nullSafeNodes(quest.getNodes())) {
+ if (node.nodeType() != NodeType.CHAPTER) continue;
+ Chapter ch = chapterRepository.findById(node.nodeId()).orElse(null);
+ if (ch == null || !isContainerOf(quest, ch) || !inSystemArc(ch)) continue;
+ boolean referencedElsewhere = others.stream()
+ .anyMatch(q -> nullSafeNodes(q.getNodes()).stream()
+ .anyMatch(n -> n.nodeType() == NodeType.CHAPTER
+ && ch.getId().equals(n.nodeId())));
+ if (!referencedElsewhere) scenes += sceneRepository.findByChapterId(ch.getId()).size();
+ }
+ return new DeletionImpact(scenes);
+ }
+
private static List nullSafeNodes(List nodes) {
return nodes != null ? nodes : List.of();
}
diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryExportService.java b/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryExportService.java
index 120ec2f..6875751 100644
--- a/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryExportService.java
+++ b/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryExportService.java
@@ -3,6 +3,9 @@ package com.loremind.infrastructure.transfer.foundry;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
+import com.loremind.domain.campaigncontext.quest.NodeType;
+import com.loremind.domain.campaigncontext.quest.QuestNodeRef;
+import com.loremind.domain.campaigncontext.structure.ArcType;
import com.loremind.domain.campaigncontext.structure.Room;
import com.loremind.domain.campaigncontext.structure.RoomBranch;
import com.loremind.domain.campaigncontext.structure.SceneBattlemap;
@@ -40,6 +43,7 @@ public class FoundryExportService {
private final CampaignJpaRepository campaignRepo;
private final ArcJpaRepository arcRepo;
private final ChapterJpaRepository chapterRepo;
+ private final QuestJpaRepository questRepo;
private final SceneJpaRepository sceneRepo;
private final NpcJpaRepository npcRepo;
private final EnemyJpaRepository enemyRepo;
@@ -55,6 +59,7 @@ public class FoundryExportService {
public FoundryExportService(CampaignJpaRepository campaignRepo,
ArcJpaRepository arcRepo,
ChapterJpaRepository chapterRepo,
+ QuestJpaRepository questRepo,
SceneJpaRepository sceneRepo,
NpcJpaRepository npcRepo,
EnemyJpaRepository enemyRepo,
@@ -69,6 +74,7 @@ public class FoundryExportService {
this.campaignRepo = campaignRepo;
this.arcRepo = arcRepo;
this.chapterRepo = chapterRepo;
+ this.questRepo = questRepo;
this.sceneRepo = sceneRepo;
this.npcRepo = npcRepo;
this.enemyRepo = enemyRepo;
@@ -154,7 +160,20 @@ public class FoundryExportService {
List quests = new ArrayList<>();
List scenes = new ArrayList<>();
+ Set liveContainerIds = liveQuestContainerIds(campaign);
for (ArcJpaEntity arc : sortByOrder(arcRepo.findByCampaignId(campaign.getId()), ArcJpaEntity::getOrder)) {
+ List chapters = sortByOrder(chapterRepo.findByArcId(arc.getId()), ChapterJpaEntity::getOrder);
+ // L'arc technique SYSTEM (« Quetes libres ») est masque dans l'appli : seuls
+ // les conteneurs des quetes VIVANTES partent dans l'export. Un conteneur
+ // orphelin (quete supprimee, chapitre garde par deleteQuest car il contenait
+ // des scenes) est invisible cote LoreMind -> il ne doit pas reapparaitre
+ // cote Foundry. Arc omis entierement s'il ne reste rien.
+ if (arc.getType() == ArcType.SYSTEM) {
+ chapters = chapters.stream()
+ .filter(ch -> liveContainerIds.contains(str(ch.getId())))
+ .toList();
+ if (chapters.isEmpty()) continue;
+ }
// Sans journaux, arcs/quetes ne servent que d'ossature (dossiers des Scenes) :
// leurs illustrations ne sont pas embarquees.
arcs.add(new FoundryBundle.Arc(
@@ -163,7 +182,7 @@ public class FoundryExportService {
arc.getThemes(), arc.getStakes(), arc.getGmNotes(), arc.getRewards(), arc.getResolution(),
opts.journals() ? assets.images(arc.getIllustrationImageIds()) : List.of()));
- for (ChapterJpaEntity ch : sortByOrder(chapterRepo.findByArcId(arc.getId()), ChapterJpaEntity::getOrder)) {
+ for (ChapterJpaEntity ch : chapters) {
quests.add(new FoundryBundle.Quest(
str(ch.getId()), str(arc.getId()), ch.getName(), ch.getDescription(), ch.getOrder(),
ch.getIcon(), ch.getPlayerObjectives(), ch.getNarrativeStakes(), ch.getGmNotes(),
@@ -177,6 +196,17 @@ public class FoundryExportService {
return new ArcsQuestsScenes(arcs, quests, scenes);
}
+ /** Ids des chapitres-conteneurs references par les quetes vivantes de la campagne. */
+ private Set liveQuestContainerIds(CampaignJpaEntity campaign) {
+ Set ids = new HashSet<>();
+ for (QuestJpaEntity q : questRepo.findByCampaignId(campaign.getId())) {
+ for (QuestNodeRef n : q.getNodes() != null ? q.getNodes() : List.of()) {
+ if (n.nodeType() == NodeType.CHAPTER) ids.add(n.nodeId());
+ }
+ }
+ return ids;
+ }
+
/** PNJ : purement journal — hors perimetre sans les journaux. */
private List buildNpcs(CampaignJpaEntity campaign, ExportOptions opts,
List npcTemplate, AssetRegistry assets) {
diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/QuestController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/QuestController.java
index a23227f..45ac5c3 100644
--- a/core/src/main/java/com/loremind/infrastructure/web/controller/QuestController.java
+++ b/core/src/main/java/com/loremind/infrastructure/web/controller/QuestController.java
@@ -86,6 +86,16 @@ public class QuestController {
return ResponseEntity.noContent().build();
}
+ /** Impact d'une suppression : scènes du conteneur (quête libre) qui partiront avec. */
+ @GetMapping("/{questId}/deletion-impact")
+ public ResponseEntity getDeletionImpact(@PathVariable String campaignId,
+ @PathVariable String questId) {
+ if (questService.getQuestById(questId).isEmpty()) {
+ return ResponseEntity.notFound().build();
+ }
+ return ResponseEntity.ok(questService.getDeletionImpact(questId));
+ }
+
/** Réordonne les quêtes de la campagne : order = position. */
@PutMapping("/reorder")
public ResponseEntity reorder(@PathVariable String campaignId, @RequestBody ReorderRequest req) {
diff --git a/core/src/main/java/db/migration/V24__Reattach_orphan_free_quest_containers.java b/core/src/main/java/db/migration/V24__Reattach_orphan_free_quest_containers.java
new file mode 100644
index 0000000..3902251
--- /dev/null
+++ b/core/src/main/java/db/migration/V24__Reattach_orphan_free_quest_containers.java
@@ -0,0 +1,118 @@
+package db.migration;
+
+import org.flywaydb.core.api.migration.BaseJavaMigration;
+import org.flywaydb.core.api.migration.Context;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Nettoyage des CONTENEURS ORPHELINS des quêtes libres (arc technique SYSTEM).
+ *
+ * Jusqu'à la 1.0.2, supprimer une quête libre GARDAIT son chapitre-conteneur
+ * s'il contenait des scènes (garde anti-perte de contenu) — mais l'arc SYSTEM
+ * étant masqué partout dans l'appli, ce contenu devenait INVISIBLE et
+ * irrécupérable, tout en ressortant dans l'export Foundry (« quêtes fantômes »).
+ * {@code QuestService.deleteQuest} cascade désormais ces conteneurs ; cette
+ * migration répare l'existant SANS perte :
+ *
+ * - conteneur orphelin AVEC scènes → une quête libre est RECRÉÉE dessus (même
+ * nom que le chapitre) : le contenu redevient visible sous « Quêtes », et
+ * l'utilisateur le garde ou le supprime via le flux normal (qui annonce
+ * l'impact) ;
+ * - conteneur orphelin VIDE → supprimé (fantôme sans contenu).
+ *
+ *
+ * « Orphelin » = chapitre d'un arc SYSTEM dont l'id n'apparaît dans les
+ * {@code nodes} d'AUCUNE quête de la campagne. Détection prudente par motif
+ * {@code "nodeId":""} : une collision d'id avec un nœud SCENE fait considérer
+ * le chapitre comme référencé → il est laissé en place (aucun risque de perte).
+ *
+ * Migration en Java (et non SQL) : la corrélation chapitre ↔ nodes JSON des
+ * quêtes demande une itération par candidat, illisible en SQL portable H2/PG.
+ */
+@SuppressWarnings("java:S101") // Nommage V24__... IMPOSE par la convention Flyway des migrations Java.
+public class V24__Reattach_orphan_free_quest_containers extends BaseJavaMigration {
+
+ private record Candidate(long chapterId, String name, long campaignId) {}
+
+ @Override
+ public void migrate(Context context) throws Exception {
+ Connection conn = context.getConnection();
+
+ List candidates = new ArrayList<>();
+ try (PreparedStatement ps = conn.prepareStatement(
+ "SELECT c.id, c.name, a.campaign_id FROM chapters c "
+ + "JOIN arcs a ON c.arc_id = a.id WHERE a.type = 'SYSTEM'");
+ ResultSet rs = ps.executeQuery()) {
+ while (rs.next()) {
+ candidates.add(new Candidate(rs.getLong(1), rs.getString(2), rs.getLong(3)));
+ }
+ }
+
+ for (Candidate ch : candidates) {
+ if (isReferencedByAQuest(conn, ch)) continue; // conteneur d'une quête vivante
+ if (countScenes(conn, ch.chapterId()) == 0) {
+ deleteEmptyChapter(conn, ch.chapterId());
+ } else {
+ recreateFreeQuest(conn, ch);
+ }
+ }
+ }
+
+ private boolean isReferencedByAQuest(Connection conn, Candidate ch) throws Exception {
+ try (PreparedStatement ps = conn.prepareStatement(
+ "SELECT COUNT(*) FROM quests WHERE campaign_id = ? AND nodes LIKE ?")) {
+ ps.setLong(1, ch.campaignId());
+ ps.setString(2, "%\"nodeId\":\"" + ch.chapterId() + "\"%");
+ try (ResultSet rs = ps.executeQuery()) {
+ rs.next();
+ return rs.getLong(1) > 0;
+ }
+ }
+ }
+
+ private int countScenes(Connection conn, long chapterId) throws Exception {
+ try (PreparedStatement ps = conn.prepareStatement(
+ "SELECT COUNT(*) FROM scenes WHERE chapter_id = ?")) {
+ ps.setLong(1, chapterId);
+ try (ResultSet rs = ps.executeQuery()) {
+ rs.next();
+ return rs.getInt(1);
+ }
+ }
+ }
+
+ private void deleteEmptyChapter(Connection conn, long chapterId) throws Exception {
+ try (PreparedStatement ps = conn.prepareStatement("DELETE FROM chapters WHERE id = ?")) {
+ ps.setLong(1, chapterId);
+ ps.executeUpdate();
+ }
+ }
+
+ /** Recrée une quête LIBRE (arc_id NULL) pointant le conteneur, à la suite de l'ordre. */
+ private void recreateFreeQuest(Connection conn, Candidate ch) throws Exception {
+ int order;
+ try (PreparedStatement ps = conn.prepareStatement(
+ "SELECT COALESCE(MAX(\"order\"), -1) + 1 FROM quests WHERE campaign_id = ?")) {
+ ps.setLong(1, ch.campaignId());
+ try (ResultSet rs = ps.executeQuery()) {
+ rs.next();
+ order = rs.getInt(1);
+ }
+ }
+ try (PreparedStatement ps = conn.prepareStatement(
+ "INSERT INTO quests (campaign_id, \"order\", name, description, prerequisites, "
+ + "nodes, related_page_ids, illustration_image_ids, created_at, updated_at) "
+ + "VALUES (?, ?, ?, '', '[]', ?, '[]', '[]', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)")) {
+ ps.setLong(1, ch.campaignId());
+ ps.setInt(2, order);
+ ps.setString(3, ch.name());
+ ps.setString(4, "[{\"nodeType\":\"CHAPTER\",\"nodeId\":\"" + ch.chapterId() + "\",\"order\":0}]");
+ ps.executeUpdate();
+ }
+ }
+}
diff --git a/core/src/test/java/com/loremind/application/campaigncontext/QuestServiceTest.java b/core/src/test/java/com/loremind/application/campaigncontext/QuestServiceTest.java
index 4857928..d6bde94 100644
--- a/core/src/test/java/com/loremind/application/campaigncontext/QuestServiceTest.java
+++ b/core/src/test/java/com/loremind/application/campaigncontext/QuestServiceTest.java
@@ -230,13 +230,112 @@ class QuestServiceTest {
.thenReturn(Optional.of(Chapter.builder().id("chap-real").arcId("arc-lin").name("Réel").build()));
when(arcRepository.findById("arc-lin"))
.thenReturn(Optional.of(Arc.builder().id("arc-lin").type(ArcType.LINEAR).build()));
- when(sceneRepository.findByChapterId("chap-real")).thenReturn(List.of());
+ // NB : pas de stub sceneRepository — un chapitre non-conteneur est écarté avant toute lecture des scènes.
service.deleteQuest("q-1");
verify(chapterRepository, never()).deleteById(anyString());
}
+ @Test
+ void deleteQuest_freeQuest_cascadesScenesOfSystemContainer() {
+ // Conteneur d'arc SYSTEM : INVISIBLE une fois la quête supprimée -> il part avec
+ // ses scènes (sinon il pourrit en fantôme inaccessible qui ressort dans les exports).
+ Quest quest = Quest.builder().id("q-1").campaignId("camp").name("Libre")
+ .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-free", 0))).build();
+ when(questRepository.findById("q-1")).thenReturn(Optional.of(quest));
+ when(questRepository.findByCampaignId("camp")).thenReturn(List.of());
+ when(chapterRepository.findById("chap-free"))
+ .thenReturn(Optional.of(Chapter.builder().id("chap-free").arcId("arc-sys").name("Libre").build()));
+ when(arcRepository.findById("arc-sys"))
+ .thenReturn(Optional.of(Arc.builder().id("arc-sys").type(ArcType.SYSTEM).build()));
+ when(sceneRepository.findByChapterId("chap-free")).thenReturn(List.of(
+ Scene.builder().id("s-1").chapterId("chap-free").name("S1").build(),
+ Scene.builder().id("s-2").chapterId("chap-free").name("S2").build()));
+
+ service.deleteQuest("q-1");
+
+ verify(sceneRepository).deleteById("s-1");
+ verify(sceneRepository).deleteById("s-2");
+ verify(chapterRepository).deleteById("chap-free");
+ }
+
+ @Test
+ void deletionImpact_freeQuestWithScenes_countsThem() {
+ Quest quest = Quest.builder().id("q-1").campaignId("camp").name("Libre")
+ .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-free", 0))).build();
+ when(questRepository.findById("q-1")).thenReturn(Optional.of(quest));
+ when(questRepository.findByCampaignId("camp")).thenReturn(List.of(quest)); // filtrée par id
+ when(chapterRepository.findById("chap-free"))
+ .thenReturn(Optional.of(Chapter.builder().id("chap-free").arcId("arc-sys").name("Libre").build()));
+ when(arcRepository.findById("arc-sys"))
+ .thenReturn(Optional.of(Arc.builder().id("arc-sys").type(ArcType.SYSTEM).build()));
+ when(sceneRepository.findByChapterId("chap-free")).thenReturn(List.of(
+ Scene.builder().id("s-1").chapterId("chap-free").name("S1").build(),
+ Scene.builder().id("s-2").chapterId("chap-free").name("S2").build()));
+
+ assertEquals(2, service.getDeletionImpact("q-1").scenes());
+ }
+
+ @Test
+ void deletionImpact_unknownQuest_returnsZero() {
+ when(questRepository.findById("inconnue")).thenReturn(Optional.empty());
+
+ assertEquals(0, service.getDeletionImpact("inconnue").scenes());
+ }
+
+ @Test
+ void deletionImpact_systemContainerReferencedByAnotherQuest_reportsZero() {
+ // Conteneur PARTAGÉ (autre quête le référence) : il survivra -> impact 0.
+ Quest quest = Quest.builder().id("q-1").campaignId("camp").name("Libre")
+ .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-free", 0))).build();
+ Quest other = Quest.builder().id("q-2").campaignId("camp").name("Autre")
+ .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-free", 0))).build();
+ when(questRepository.findById("q-1")).thenReturn(Optional.of(quest));
+ when(questRepository.findByCampaignId("camp")).thenReturn(List.of(quest, other));
+ when(chapterRepository.findById("chap-free"))
+ .thenReturn(Optional.of(Chapter.builder().id("chap-free").arcId("arc-sys").name("Libre").build()));
+ when(arcRepository.findById("arc-sys"))
+ .thenReturn(Optional.of(Arc.builder().id("arc-sys").type(ArcType.SYSTEM).build()));
+
+ assertEquals(0, service.getDeletionImpact("q-1").scenes());
+ }
+
+ @Test
+ void deleteQuest_freeQuest_systemContainerReferencedElsewhere_keptWithScenes() {
+ // Même partagé en arc SYSTEM, un conteneur encore référencé n'est JAMAIS cascadé.
+ Quest quest = Quest.builder().id("q-1").campaignId("camp").name("Libre")
+ .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-free", 0))).build();
+ Quest other = Quest.builder().id("q-2").campaignId("camp").name("Autre")
+ .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-free", 0))).build();
+ when(questRepository.findById("q-1")).thenReturn(Optional.of(quest));
+ when(questRepository.findByCampaignId("camp")).thenReturn(List.of(other));
+ when(chapterRepository.findById("chap-free"))
+ .thenReturn(Optional.of(Chapter.builder().id("chap-free").arcId("arc-sys").name("Libre").build()));
+ when(arcRepository.findById("arc-sys"))
+ .thenReturn(Optional.of(Arc.builder().id("arc-sys").type(ArcType.SYSTEM).build()));
+
+ service.deleteQuest("q-1");
+
+ verify(chapterRepository, never()).deleteById(anyString());
+ verify(sceneRepository, never()).deleteById(anyString());
+ }
+
+ @Test
+ void deletionImpact_hubQuest_reportsZero() {
+ // Jumeau de hub : GARDÉ à la suppression (il reste visible dans l'arc) -> impact 0.
+ Quest quest = Quest.builder().id("q-1").campaignId("camp").arcId("arc-h").name("Q")
+ .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-1", 0))).build();
+ when(questRepository.findById("q-1")).thenReturn(Optional.of(quest));
+ when(questRepository.findByCampaignId("camp")).thenReturn(List.of(quest));
+ when(chapterRepository.findById("chap-1"))
+ .thenReturn(Optional.of(Chapter.builder().id("chap-1").arcId("arc-h").name("Q").build()));
+ when(arcRepository.findById("arc-h"))
+ .thenReturn(Optional.of(Arc.builder().id("arc-h").type(ArcType.HUB).build()));
+
+ assertEquals(0, service.getDeletionImpact("q-1").scenes());
+ }
+
@Test
void deleteQuest_keepsContainerReferencedByAnotherQuest() {
Quest quest = Quest.builder().id("q-1").campaignId("camp").arcId("arc-h").name("Q")
@@ -247,7 +346,7 @@ class QuestServiceTest {
when(questRepository.findByCampaignId("camp")).thenReturn(List.of(other));
when(chapterRepository.findById("chap-1"))
.thenReturn(Optional.of(Chapter.builder().id("chap-1").arcId("arc-h").name("Q").build()));
- when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of());
+ // NB : pas de stub sceneRepository — un conteneur encore référencé est écarté avant toute lecture.
service.deleteQuest("q-1");
diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/FlywayMigrationTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/FlywayMigrationTest.java
index b6edea3..b8d16c9 100644
--- a/core/src/test/java/com/loremind/infrastructure/persistence/FlywayMigrationTest.java
+++ b/core/src/test/java/com/loremind/infrastructure/persistence/FlywayMigrationTest.java
@@ -12,6 +12,8 @@ import java.sql.SQLException;
import java.sql.Statement;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -119,4 +121,77 @@ class FlywayMigrationTest {
assertEquals("[]", rs.getString(2));
}
}
+
+ /**
+ * V24 : les conteneurs ORPHELINS de l'arc SYSTEM (quête libre supprimée avant que
+ * la suppression ne cascade) sont réparés — rattachés à une quête recréée s'ils
+ * ont des scènes (contenu redevenu visible), supprimés s'ils sont vides. Le
+ * conteneur d'une quête vivante n'est pas touché.
+ */
+ @Test
+ void v24_reattachesOrphanSystemContainers_andDropsEmptyOnes() throws SQLException {
+ String url = "jdbc:h2:mem:flyway_v24_test;MODE=PostgreSQL;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1";
+
+ // 1) Schéma arrêté AVANT V24…
+ Flyway.configure()
+ .dataSource(url, "sa", "")
+ .locations("classpath:db/migration")
+ .target(MigrationVersion.fromVersion("23"))
+ .load()
+ .migrate();
+
+ // 2) …peuplé : un arc SYSTEM avec un orphelin PLEIN (1 scène), un orphelin
+ // VIDE, et le conteneur d'une quête VIVANTE.
+ try (Connection conn = DriverManager.getConnection(url, "sa", "");
+ Statement st = conn.createStatement()) {
+ st.executeUpdate("insert into campaigns (id, name, arcs_count, created_at, updated_at) "
+ + "values (1, 'C', 0, now(), now())");
+ st.executeUpdate("insert into arcs (id, name, campaign_id, \"order\", type, created_at, updated_at) "
+ + "values (9, 'Quêtes libres', 1, 9999, 'SYSTEM', now(), now())");
+ st.executeUpdate("insert into chapters (id, name, arc_id, \"order\", created_at, updated_at) "
+ + "values (41, 'Orphelin plein', 9, 0, now(), now())");
+ st.executeUpdate("insert into chapters (id, name, arc_id, \"order\", created_at, updated_at) "
+ + "values (42, 'Orphelin vide', 9, 1, now(), now())");
+ st.executeUpdate("insert into chapters (id, name, arc_id, \"order\", created_at, updated_at) "
+ + "values (43, 'Vivant', 9, 2, now(), now())");
+ st.executeUpdate("insert into scenes (id, name, chapter_id, \"order\", created_at, updated_at) "
+ + "values (1, 'S', 41, 0, now(), now())");
+ st.executeUpdate("insert into quests (campaign_id, \"order\", name, nodes, created_at, updated_at) "
+ + "values (1, 0, 'Vivant', '[{\"nodeType\":\"CHAPTER\",\"nodeId\":\"43\",\"order\":0}]', now(), now())");
+ }
+
+ // 3) Fin de la chaîne : V24 répare.
+ Flyway.configure()
+ .dataSource(url, "sa", "")
+ .locations("classpath:db/migration")
+ .load()
+ .migrate();
+
+ try (Connection conn = DriverManager.getConnection(url, "sa", "");
+ Statement st = conn.createStatement()) {
+
+ // L'orphelin PLEIN est rattaché : quête LIBRE (arc_id NULL) recréée dessus.
+ try (ResultSet rs = st.executeQuery(
+ "select arc_id, nodes from quests where name = 'Orphelin plein'")) {
+ assertTrue(rs.next(), "une quête aurait dû être recréée sur le conteneur orphelin");
+ assertNull(rs.getObject(1));
+ assertEquals("[{\"nodeType\":\"CHAPTER\",\"nodeId\":\"41\",\"order\":0}]", rs.getString(2));
+ }
+
+ // L'orphelin VIDE a disparu ; l'orphelin plein et le conteneur vivant restent.
+ try (ResultSet rs = st.executeQuery("select id from chapters where arc_id = 9 order by id")) {
+ assertTrue(rs.next());
+ assertEquals(41, rs.getInt(1));
+ assertTrue(rs.next());
+ assertEquals(43, rs.getInt(1));
+ assertFalse(rs.next(), "l'orphelin vide (42) aurait dû être supprimé");
+ }
+
+ // Pas de doublon : la quête vivante n'a pas été re-rattachée.
+ try (ResultSet rs = st.executeQuery("select count(*) from quests")) {
+ rs.next();
+ assertEquals(2, rs.getInt(1));
+ }
+ }
+ }
}
diff --git a/core/src/test/java/com/loremind/infrastructure/transfer/foundry/FoundryExportServiceTest.java b/core/src/test/java/com/loremind/infrastructure/transfer/foundry/FoundryExportServiceTest.java
index 8890b39..17fe9d1 100644
--- a/core/src/test/java/com/loremind/infrastructure/transfer/foundry/FoundryExportServiceTest.java
+++ b/core/src/test/java/com/loremind/infrastructure/transfer/foundry/FoundryExportServiceTest.java
@@ -1,5 +1,8 @@
package com.loremind.infrastructure.transfer.foundry;
+import com.loremind.domain.campaigncontext.quest.NodeType;
+import com.loremind.domain.campaigncontext.quest.QuestNodeRef;
+import com.loremind.domain.campaigncontext.structure.ArcType;
import com.loremind.domain.campaigncontext.structure.SceneBattlemap;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.persistence.entity.*;
@@ -30,6 +33,7 @@ class FoundryExportServiceTest {
@Autowired private CampaignJpaRepository campaignRepo;
@Autowired private ArcJpaRepository arcRepo;
@Autowired private ChapterJpaRepository chapterRepo;
+ @Autowired private QuestJpaRepository questRepo;
@Autowired private SceneJpaRepository sceneRepo;
@Autowired private NpcJpaRepository npcRepo;
@Autowired private GameSystemJpaRepository gameSystemRepo;
@@ -251,4 +255,60 @@ class FoundryExportServiceTest {
assertThrows(java.util.NoSuchElementException.class,
() -> service.buildBundle("999999999", "2026-06-25T00:00:00Z"));
}
+
+ /**
+ * L'arc technique SYSTEM heberge les conteneurs des quetes libres. Un conteneur
+ * ORPHELIN (quete supprimee, chapitre garde par deleteQuest car il contenait des
+ * scenes) est invisible dans l'appli : il ne doit pas partir dans le bundle.
+ */
+ @Test
+ void buildBundle_systemArc_exportsOnlyLiveQuestContainers() {
+ CampaignJpaEntity camp = campaignRepo.save(CampaignJpaEntity.builder()
+ .name("Quetes libres").description("d").arcsCount(1).build());
+ ArcJpaEntity system = arcRepo.save(ArcJpaEntity.builder()
+ .campaignId(camp.getId()).name("Quêtes libres").order(9999)
+ .type(ArcType.SYSTEM).build());
+ ChapterJpaEntity live = chapterRepo.save(ChapterJpaEntity.builder()
+ .arcId(system.getId()).name("Quete vivante").order(0).build());
+ ChapterJpaEntity orphan = chapterRepo.save(ChapterJpaEntity.builder()
+ .arcId(system.getId()).name("test de quete").order(1).build());
+ sceneRepo.save(SceneJpaEntity.builder()
+ .chapterId(live.getId()).name("Scene vivante").order(0).build());
+ sceneRepo.save(SceneJpaEntity.builder()
+ .chapterId(orphan.getId()).name("Scene fantome").order(0).build());
+ questRepo.save(QuestJpaEntity.builder()
+ .campaignId(camp.getId()).name("Quete vivante").order(0)
+ .nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, String.valueOf(live.getId()), 0)))
+ .build());
+
+ FoundryBundle.Data data = service.buildBundle(
+ String.valueOf(camp.getId()), "2026-07-09T00:00:00Z").data();
+
+ assertEquals(1, data.arcs().size());
+ assertEquals(1, data.quests().size());
+ assertEquals("Quete vivante", data.quests().get(0).name());
+ assertEquals(1, data.scenes().size());
+ assertEquals("Scene vivante", data.scenes().get(0).name());
+ }
+
+ /** Sans quete vivante, l'arc SYSTEM disparait entierement du bundle. */
+ @Test
+ void buildBundle_systemArcWithoutLiveQuest_isDroppedEntirely() {
+ CampaignJpaEntity camp = campaignRepo.save(CampaignJpaEntity.builder()
+ .name("Fantomes").description("d").arcsCount(1).build());
+ ArcJpaEntity system = arcRepo.save(ArcJpaEntity.builder()
+ .campaignId(camp.getId()).name("Quêtes libres").order(9999)
+ .type(ArcType.SYSTEM).build());
+ ChapterJpaEntity orphan = chapterRepo.save(ChapterJpaEntity.builder()
+ .arcId(system.getId()).name("test").order(0).build());
+ sceneRepo.save(SceneJpaEntity.builder()
+ .chapterId(orphan.getId()).name("test").order(0).build());
+
+ FoundryBundle.Data data = service.buildBundle(
+ String.valueOf(camp.getId()), "2026-07-09T00:00:00Z").data();
+
+ assertTrue(data.arcs().isEmpty());
+ assertTrue(data.quests().isEmpty());
+ assertTrue(data.scenes().isEmpty());
+ }
}
diff --git a/web/package-lock.json b/web/package-lock.json
index 145e00b..a02887a 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "loremind-web",
- "version": "1.0.2-beta",
+ "version": "1.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "loremind-web",
- "version": "1.0.2-beta",
+ "version": "1.0.3",
"dependencies": {
"@angular/animations": "^21.2.17",
"@angular/cdk": "^21.2.14",
diff --git a/web/package.json b/web/package.json
index 59a3e1b..54f849c 100644
--- a/web/package.json
+++ b/web/package.json
@@ -1,6 +1,6 @@
{
"name": "loremind-web",
- "version": "1.0.2-beta",
+ "version": "1.0.3",
"description": "LoreMind Frontend - Angular",
"scripts": {
"ng": "ng",
diff --git a/web/src/app/campaigns/quest/quest-view/quest-view.component.ts b/web/src/app/campaigns/quest/quest-view/quest-view.component.ts
index 48582c8..66d9ed9 100644
--- a/web/src/app/campaigns/quest/quest-view/quest-view.component.ts
+++ b/web/src/app/campaigns/quest/quest-view/quest-view.component.ts
@@ -181,14 +181,32 @@ export class QuestViewComponent implements OnInit {
this.router.navigate(['/campaigns', this.campaignId, 'quests', this.questId, 'edit']);
}
- /** Suppression simple : une quête n'a pas d'enfants (les nœuds sont des références faibles). */
+ /**
+ * Suppression avec annonce de l'impact réel : le conteneur de scènes d'une quête
+ * LIBRE (arc SYSTEM, invisible dans l'arbre) part en cascade avec ses scènes —
+ * le nombre est affiché dans la confirmation. Les nœuds simplement liés et les
+ * jumeaux de hub (qui restent visibles dans leur arc) ne sont pas concernés.
+ */
deleteQuest(): void {
- if (!this.quest) return;
const quest = this.quest;
+ if (!quest?.id) return;
+ this.questService.deletionImpact(this.campaignId, quest.id).subscribe({
+ next: impact => this.confirmDeleteQuest(quest, impact.scenes),
+ error: () => this.confirmDeleteQuest(quest, 0) // impact indisponible : confirmation simple
+ });
+ }
+
+ private confirmDeleteQuest(quest: Quest, scenes: number): void {
+ const details = [
+ ...(scenes > 0
+ ? [this.translate.instant('questView.deleteImpactScenes', { count: scenes })]
+ : []),
+ this.translate.instant('questView.irreversible')
+ ];
this.confirmDialog.confirm({
title: this.translate.instant('questView.deleteTitle'),
message: this.translate.instant('questView.deleteMessage', { name: quest.name }),
- details: [this.translate.instant('questView.irreversible')],
+ details,
confirmLabel: this.translate.instant('common.delete'),
variant: 'danger'
}).then(ok => {
diff --git a/web/src/app/services/quest.service.ts b/web/src/app/services/quest.service.ts
index 6ea0d82..7d36202 100644
--- a/web/src/app/services/quest.service.ts
+++ b/web/src/app/services/quest.service.ts
@@ -3,6 +3,11 @@ import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Quest, QuestCreate } from './campaign.model';
+/** Impact d'une suppression : scènes du conteneur (quête libre) qui partiront avec. */
+export interface QuestDeletionImpact {
+ scenes: number;
+}
+
/**
* Service HTTP pour les Quêtes (Niveau 1). API nestée sous la campagne :
* /api/campaigns/{campaignId}/quests. Si {@code playthroughId} est fourni, les
@@ -39,6 +44,10 @@ export class QuestService {
return this.http.delete(`${this.base(campaignId)}/${questId}`);
}
+ deletionImpact(campaignId: string, questId: string): Observable {
+ return this.http.get(`${this.base(campaignId)}/${questId}/deletion-impact`);
+ }
+
reorder(campaignId: string, orderedIds: string[]): Observable {
return this.http.put(`${this.base(campaignId)}/reorder`, { orderedIds });
}
diff --git a/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.scss b/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.scss
index 74a7eca..116145f 100644
--- a/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.scss
+++ b/web/src/app/shared/secondary-sidebar/secondary-sidebar.component.scss
@@ -148,6 +148,11 @@
display: flex;
flex-direction: column;
gap: 0.25rem;
+ // PLAFONNÉ : avec beaucoup de templates, c'est la liste du panneau qui défile
+ // (cf. .panel-list) — sans plafond, le panneau grandissait jusqu'à écraser
+ // complètement l'arbre au-dessus (sidebar en overflow: hidden).
+ flex-shrink: 0;
+ max-height: 45%;
}
.panel-header-row {
@@ -202,6 +207,10 @@
display: flex;
flex-direction: column;
gap: 0.1rem;
+ // Ascenseur interne, symétrique de celui de l'arbre (.tree) : l'en-tête du
+ // panneau et son bouton « + » restent toujours visibles.
+ overflow-y: auto;
+ min-height: 0;
}
.panel-item {
diff --git a/web/src/assets/i18n/en.json b/web/src/assets/i18n/en.json
index ca86951..8756656 100644
--- a/web/src/assets/i18n/en.json
+++ b/web/src/assets/i18n/en.json
@@ -1707,6 +1707,7 @@
"notProvided": "Not provided",
"deleteTitle": "Delete quest",
"deleteMessage": "Delete the quest \"{{name}}\"?",
+ "deleteImpactScenes": "{{count}} scene(s) of this quest will be deleted with it.",
"irreversible": "This action is irreversible.",
"deletedPage": "(deleted page)",
"deletedQuest": "(deleted quest)",
diff --git a/web/src/assets/i18n/fr.json b/web/src/assets/i18n/fr.json
index 41f8960..3aa1c9a 100644
--- a/web/src/assets/i18n/fr.json
+++ b/web/src/assets/i18n/fr.json
@@ -1707,6 +1707,7 @@
"notProvided": "Non renseigné",
"deleteTitle": "Supprimer la quête",
"deleteMessage": "Supprimer la quête « {{name}} » ?",
+ "deleteImpactScenes": "{{count}} scène(s) de cette quête seront supprimées avec elle.",
"irreversible": "Cette action est irréversible.",
"deletedPage": "(page supprimée)",
"deletedQuest": "(quête supprimée)",