From 9f0359d8c04ff38950297a63bb28a905b12629eb Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Tue, 7 Jul 2026 00:11:52 +0200 Subject: [PATCH] =?UTF-8?q?Derni=C3=A8re=20fourn=C3=A9e=20de=20corrections?= =?UTF-8?q?=20Sonar=20Mise=20en=20place=20de=20MegaLinter=20;=20web-lint?= =?UTF-8?q?=20pour=20la=20partie=20code=20quality=20sur=20la=20CI,=20Trivy?= =?UTF-8?q?=20pour=20la=20partie=20CVE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/quality.yml | 77 + .mega-linter.yml | 37 + .../application/campaigncontext/sedFH3XAk | 45 - .../generation/CampaignImportProposal.java | 11 +- .../campaigncontext/quest/Prerequisite.java | 2 - .../quest/PrerequisiteEvaluator.java | 18 +- .../quest/ProgressionStatus.java | 2 - .../campaigncontext/quest/QuestStatus.java | 1 - .../domain/gamesystemcontext/GameSystem.java | 2 +- .../ai/BrainCampaignAdaptClient.java | 9 +- .../ai/BrainCampaignImportClient.java | 56 +- .../ai/BrainChatPayloadBuilder.java | 52 +- .../ai/BrainConversationTitleClient.java | 3 +- .../ai/BrainItemCatalogClient.java | 64 +- .../ai/BrainNarrativeFieldClient.java | 78 +- .../ai/BrainNotebookChatClient.java | 16 +- .../ai/BrainNotebookIndexClient.java | 20 +- .../ai/BrainRandomTableClient.java | 51 +- .../ai/BrainRulesImportClient.java | 58 +- .../ai/BrainSceneDraftClient.java | 33 +- .../infrastructure/ai/BrainSidecar.java | 10 +- .../ai/BrainSseImportSupport.java | 11 +- .../desktop/DesktopBrowserOpener.java | 2 + .../desktop/DesktopSingleInstance.java | 47 +- .../desktop/DesktopUserConfig.java | 27 +- .../licensing/FileDockerConfigWriter.java | 9 +- .../licensing/NimbusJwtVerifier.java | 71 +- .../CharacterNpcMarkdownBackfill.java | 35 +- .../persistence/GameSystemSeeder.java | 22 +- .../converter/MapJsonConverter.java | 8 +- .../TemplateFieldListJsonConverter.java | 162 +- .../persistence/entity/ArcJpaEntity.java | 7 +- .../persistence/entity/CampaignJpaEntity.java | 7 +- .../persistence/entity/ChapterJpaEntity.java | 7 +- .../entity/CharacterJpaEntity.java | 7 +- .../persistence/entity/ClockJpaEntity.java | 5 +- .../entity/ConversationJpaEntity.java | 5 +- .../entity/ConversationMessageJpaEntity.java | 3 +- .../persistence/entity/EnemyJpaEntity.java | 7 +- .../persistence/entity/FrontJpaEntity.java | 5 +- .../entity/GameSystemJpaEntity.java | 7 +- .../persistence/entity/ImageJpaEntity.java | 3 +- .../entity/ItemCatalogJpaEntity.java | 7 +- .../persistence/entity/LoreJpaEntity.java | 7 +- .../persistence/entity/LoreNodeJpaEntity.java | 7 +- .../persistence/entity/NotebookJpaEntity.java | 7 +- .../entity/NotebookMessageJpaEntity.java | 3 +- .../entity/NotebookSourceJpaEntity.java | 3 +- .../persistence/entity/NpcJpaEntity.java | 7 +- .../persistence/entity/PageJpaEntity.java | 7 +- .../entity/PlaythroughJpaEntity.java | 7 +- .../persistence/entity/QuestJpaEntity.java | 7 +- .../entity/RandomTableJpaEntity.java | 7 +- .../persistence/entity/SceneJpaEntity.java | 7 +- .../entity/SessionEntryJpaEntity.java | 5 +- .../persistence/entity/SessionJpaEntity.java | 5 +- .../entity/StoredFileJpaEntity.java | 3 +- .../persistence/entity/TemplateJpaEntity.java | 7 +- .../migration/PlaythroughMigrationRunner.java | 17 +- .../postgres/PostgresArcRepository.java | 5 +- .../postgres/PostgresCampaignRepository.java | 5 +- .../postgres/PostgresChapterRepository.java | 5 +- .../postgres/PostgresCharacterRepository.java | 5 +- .../postgres/PostgresClockRepository.java | 3 +- .../PostgresConversationRepository.java | 10 +- .../postgres/PostgresEnemyRepository.java | 5 +- .../postgres/PostgresFrontRepository.java | 3 +- .../PostgresGameSystemRepository.java | 5 +- .../PostgresItemCatalogRepository.java | 4 +- .../postgres/PostgresLoreNodeRepository.java | 11 +- .../postgres/PostgresLoreRepository.java | 5 +- .../postgres/PostgresNotebookRepository.java | 12 +- .../postgres/PostgresNpcRepository.java | 5 +- .../postgres/PostgresPageRepository.java | 9 +- .../PostgresPlaythroughRepository.java | 5 +- .../PostgresQuestProgressionRepository.java | 3 +- .../postgres/PostgresQuestRepository.java | 7 +- .../PostgresRandomTableRepository.java | 4 +- .../postgres/PostgresSceneRepository.java | 5 +- .../PostgresSessionEntryRepository.java | 3 +- .../postgres/PostgresSessionRepository.java | 5 +- .../postgres/PostgresTemplateRepository.java | 7 +- .../storage/FilesystemFileStorageAdapter.java | 8 +- .../FilesystemImageStorageAdapter.java | 8 +- .../storage/MinioFileStorageAdapter.java | 6 +- .../storage/MinioImageStorageAdapter.java | 6 +- .../transfer/CampaignContentInserter.java | 90 +- .../transfer/ExportService.java | 338 +-- .../infrastructure/transfer/IdRemapper.java | 16 +- .../transfer/ImageImporter.java | 70 +- .../transfer/ImportReferenceRemapper.java | 59 +- .../transfer/LegacyQuestConverter.java | 2 + .../transfer/StoredFileImporter.java | 71 +- .../foundry/FoundryExportService.java | 277 ++- .../transfer/pdf/PdfExportService.java | 1097 ++++------ .../infrastructure/transfer/pdf/PdfHtml.java | 178 ++ .../infrastructure/transfer/pdf/PdfImage.java | 4 + .../transfer/pdf/PdfImageEncoder.java | 111 + .../transfer/pdf/PdfStructureLoader.java | 134 ++ .../transfer/pdf/Structure.java | 37 + .../updates/SemverComparator.java | 3 +- .../updates/UpdateCheckService.java | 13 +- .../updates/WwwAuthenticate.java | 51 +- .../web/GlobalExceptionHandler.java | 15 +- .../web/config/SecurityConfig.java | 12 +- .../web/config/UploadLimitsConfig.java | 33 + .../web/controller/AiChatController.java | 8 +- .../web/controller/ArcController.java | 3 +- .../web/controller/CampaignController.java | 5 +- .../controller/CampaignTreeController.java | 18 +- .../web/controller/ChapterController.java | 3 +- .../web/controller/CharacterController.java | 5 +- .../web/controller/ClockController.java | 3 +- .../controller/ConversationController.java | 3 +- .../controller/DataTransferController.java | 7 +- .../controller/FoundryExportController.java | 5 +- .../web/controller/FrontController.java | 3 +- .../web/controller/GameSystemController.java | 7 +- .../web/controller/ItemCatalogController.java | 5 +- .../web/controller/LicenseController.java | 29 +- .../web/controller/LoreController.java | 5 +- .../web/controller/LoreNodeController.java | 9 +- .../web/controller/NotebookController.java | 18 +- .../web/controller/NpcController.java | 5 +- .../web/controller/PageController.java | 5 +- .../web/controller/PdfExportController.java | 5 +- .../web/controller/PlaythroughController.java | 3 +- .../web/controller/QuestController.java | 3 +- .../web/controller/RandomTableController.java | 5 +- .../web/controller/SceneController.java | 3 +- .../web/controller/SceneDraftController.java | 3 +- .../web/controller/SessionController.java | 3 +- .../controller/SessionEntryController.java | 3 +- .../web/controller/TemplateController.java | 5 +- .../web/mapper/ConversationMapper.java | 3 +- .../web/mapper/ItemCatalogMapper.java | 5 +- .../web/mapper/RandomTableMapper.java | 5 +- .../web/mapper/SceneMapper.java | 17 +- .../V10__Backfill_quests_from_chapters.java | 5 + .../V21__Drop_arc_type_check_constraint.java | 9 +- .../resources/application-local.properties | 11 +- .../src/main/resources/application.properties | 5 +- core/src/main/resources/pdf/export.css | 108 + .../campaigncontext/ArcServiceTest.java | 2 +- .../CampaignAdaptServiceTest.java | 7 +- .../CampaignBriefBuilderTest.java | 2 +- .../CampaignReadinessServiceTest.java | 6 +- .../CampaignReferencedFlagsServiceTest.java | 2 +- .../campaigncontext/CampaignServiceTest.java | 6 +- .../campaigncontext/ChapterServiceTest.java | 2 +- .../campaigncontext/CharacterServiceTest.java | 5 +- .../ItemCatalogServiceTest.java | 10 +- .../campaigncontext/NotebookServiceTest.java | 9 +- .../campaigncontext/NpcServiceTest.java | 7 +- .../QuestStatusEnricherTest.java | 2 +- .../RandomTableServiceTest.java | 9 +- .../campaigncontext/SceneServiceTest.java | 2 +- .../CampaignStructuralContextBuilderTest.java | 2 +- .../GeneratePageValuesUseCaseTest.java | 2 +- .../LoreStructuralContextBuilderTest.java | 2 +- .../NarrativeEntityContextBuilderTest.java | 2 +- .../StreamChatForCampaignUseCaseTest.java | 2 +- .../StreamChatForLoreUseCaseTest.java | 2 +- .../application/images/ImageServiceTest.java | 2 +- .../lorecontext/LoreNodeServiceTest.java | 2 +- .../lorecontext/LoreServiceTest.java | 2 +- .../lorecontext/PageServiceTest.java | 2 +- .../lorecontext/TemplateServiceTest.java | 2 +- .../playcontext/SessionPrepServiceTest.java | 12 +- .../playcontext/SessionRecapServiceTest.java | 7 +- .../ConversationMessageTest.java | 9 +- .../gamesystemcontext/GameSystemTest.java | 18 +- .../com/loremind/domain/images/ImageTest.java | 3 +- .../ai/BrainAiChatClientTest.java | 59 +- .../infrastructure/ai/BrainAiClientTest.java | 18 +- .../ai/BrainCampaignAdaptClientTest.java | 45 +- .../ai/BrainCampaignImportClientTest.java | 201 +- .../ai/BrainChatPayloadBuilderTest.java | 3 +- .../ai/BrainConversationTitleClientTest.java | 36 +- .../ai/BrainMultipartFilenameTest.java | 49 +- .../ai/BrainNotebookChatClientTest.java | 70 +- .../ai/BrainRulesImportClientTest.java | 56 +- .../converter/MapJsonConverterTest.java | 15 +- .../postgres/PostgresImageRepositoryTest.java | 6 +- .../postgres/PostgresLoreRepositoryTest.java | 26 +- .../PostgresStoredFileRepositoryTest.java | 8 +- .../ContentExportImportRoundTripTest.java | 222 +- .../transfer/IdRemapperTest.java | 12 +- .../transfer/ImageRefRemapOnImportTest.java | 3 +- .../foundry/FoundryExportServiceTest.java | 251 ++- .../transfer/pdf/PdfExportPreviewTest.java | 35 +- .../pdf/PdfExportServiceRichTest.java | 7 +- .../updates/UpdateCheckServiceTest.java | 6 +- .../CampaignImportControllerTest.java | 4 +- .../CampaignImportControllerUnitTest.java | 18 +- .../controller/GameSystemControllerTest.java | 1 - .../GameSystemControllerUnitTest.java | 6 +- .../web/controller/ImageControllerTest.java | 3 +- .../web/controller/LicenseControllerTest.java | 8 +- .../PageGenerationControllerTest.java | 11 +- .../controller/PlaythroughControllerTest.java | 2 +- .../web/controller/SessionControllerTest.java | 8 +- .../SessionEntryControllerTest.java | 11 +- .../SettingsControllerPullTest.java | 6 +- .../controller/SettingsControllerTest.java | 44 +- .../web/controller/UpdatesControllerTest.java | 6 +- web/angular.json | 14 + web/eslint.config.js | 63 + web/package-lock.json | 1821 ++++++++++++++++- web/package.json | 8 +- 210 files changed, 5345 insertions(+), 2291 deletions(-) create mode 100644 .gitea/workflows/quality.yml create mode 100644 .mega-linter.yml delete mode 100644 core/src/main/java/com/loremind/application/campaigncontext/sedFH3XAk create mode 100644 core/src/main/java/com/loremind/infrastructure/transfer/pdf/PdfHtml.java create mode 100644 core/src/main/java/com/loremind/infrastructure/transfer/pdf/PdfImage.java create mode 100644 core/src/main/java/com/loremind/infrastructure/transfer/pdf/PdfImageEncoder.java create mode 100644 core/src/main/java/com/loremind/infrastructure/transfer/pdf/PdfStructureLoader.java create mode 100644 core/src/main/java/com/loremind/infrastructure/transfer/pdf/Structure.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/config/UploadLimitsConfig.java create mode 100644 core/src/main/resources/pdf/export.css create mode 100644 web/eslint.config.js diff --git a/.gitea/workflows/quality.yml b/.gitea/workflows/quality.yml new file mode 100644 index 0000000..582ab42 --- /dev/null +++ b/.gitea/workflows/quality.yml @@ -0,0 +1,77 @@ +name: Qualité & Sécurité + +# Analyse statique (MegaLinter, config racine .mega-linter.yml) + CVE des +# dépendances (Trivy). Workflow SÉPARÉ de ci.yml : un rouge qualité ne bloque +# pas la chaîne tests → release pendant la phase de rodage. Une fois la base +# assainie, on pourra l'ajouter aux checks requis de la branch protection. +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + megalinter: + name: MegaLinter (PMD · Ruff · Bandit · gitleaks · hadolint) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # Historique complet : requis par gitleaks (scan de l'historique) + # et par le mode "fichiers modifiés seulement" en PR. + fetch-depth: 0 + # Flavor "cupcake" : image allégée couvrant les langages courants + # (Java/Python/TS inclus). Si un linter activé manquait à la flavor, + # MegaLinter échoue en l'indiquant → remplacer par oxsecurity/megalinter@v9 + # (image complète, plus lourde). + - name: MegaLinter + uses: oxsecurity/megalinter/flavors/cupcake@v9 + env: + # push sur main → tout le dépôt ; PR → seulement les fichiers modifiés. + VALIDATE_ALL_CODEBASE: ${{ github.event_name == 'push' }} + - name: Publier les rapports + if: always() + uses: actions/upload-artifact@v4 + with: + name: megalinter-reports + path: megalinter-reports/ + + web-lint: + name: Web (ESLint · angular-eslint + sonarjs) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: web/package-lock.json + - name: npm ci + working-directory: web + run: npm ci --no-audit --no-fund + # Lint via la toolchain du projet (et non via MegaLinter : ESLint a + # besoin des plugins de web/node_modules et du contexte Angular). + # Config + règles : web/eslint.config.js (sonarjs = règles "à la Sonar"). + - name: ng lint + working-directory: web + run: npm run lint + + trivy: + name: Trivy (CVE dépendances Maven / pip / npm) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Installer Trivy + run: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin + # Scanne les manifestes du dépôt (pom.xml, requirements*.txt, + # package-lock.json) contre les bases CVE. --ignore-unfixed : on ne + # bloque que sur les vulnérabilités qui ONT un correctif publié. + - name: Scan des dépendances (HIGH/CRITICAL bloquants) + run: | + trivy fs . \ + --scanners vuln \ + --severity HIGH,CRITICAL \ + --ignore-unfixed \ + --skip-dirs node_modules \ + --skip-dirs docusaurus \ + --exit-code 1 diff --git a/.mega-linter.yml b/.mega-linter.yml new file mode 100644 index 0000000..27b73fb --- /dev/null +++ b/.mega-linter.yml @@ -0,0 +1,37 @@ +# Configuration MegaLinter — https://megalinter.io/latest/config-file/ +# +# Périmètre volontairement resserré (liste ENABLE_LINTERS exclusive) : on +# active les linters au fil de l'eau plutôt que de subir les ~100 par défaut. +# Le scan CVE des dépendances n'est PAS ici : il est porté par le job Trivy +# (cf. .gitea/workflows/quality.yml). + +APPLY_FIXES: none +DEFAULT_BRANCH: main +SHOW_ELAPSED_TIME: true +PRINT_ALPACA: false + +# Liste EXCLUSIVE : tout linter absent d'ici est désactivé. +ENABLE_LINTERS: + # --- core (Java) : bugs + code smells "à la Sonar" sur les sources. + # (Sonar for IDE reste pertinent en local pour l'analyse de flux fine.) + - JAVA_PMD + # --- brain (Python) --- + - PYTHON_RUFF # remplace flake8/pylint/isort, très rapide + - PYTHON_BANDIT # sécurité du code Python + # - PYTHON_MYPY # à activer quand le brain aura des annotations de types + # --- repo entier --- + - REPOSITORY_GITLEAKS # secrets committés (tokens, mots de passe…) + - DOCKERFILE_HADOLINT # bonnes pratiques Dockerfile + # --- web (TypeScript) : PAS via MegaLinter. Le lint tourne avec la + # toolchain du projet (job "web-lint" de quality.yml → ng lint, + # config web/eslint.config.js avec angular-eslint + sonarjs). + +# Jamais d'analyse des artefacts de build, dépendances et sites docs. +FILTER_REGEX_EXCLUDE: '(^|/)(node_modules|target|dist|coverage|\.angular|\.mvn|docusaurus)/' + +# Rapports déposés là où le workflow les publie en artefact CI. +REPORT_OUTPUT_FOLDER: megalinter-reports + +# Phase de rodage : passer temporairement à true pour rendre le job +# informatif (rapport sans échec CI) le temps de purger l'existant. +DISABLE_ERRORS: false diff --git a/core/src/main/java/com/loremind/application/campaigncontext/sedFH3XAk b/core/src/main/java/com/loremind/application/campaigncontext/sedFH3XAk deleted file mode 100644 index 06f148d..0000000 --- a/core/src/main/java/com/loremind/application/campaigncontext/sedFH3XAk +++ /dev/null @@ -1,45 +0,0 @@ -package com.loremind.application.campaigncontext; - -import com.loremind.domain.campaigncontext.Prerequisite; -import com.loremind.domain.campaigncontext.quest.Quest; -import com.loremind.domain.campaigncontext.ports.QuestRepository; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.TreeSet; - -/** - * Service applicatif : énumère les noms de faits ({@link Prerequisite.FlagSet}) - * référencés par les quêtes d'une Campagne. - * - *

Modèle "déclaration implicite" : il n'existe pas de table de faits déclarés - * globalement. Un fait existe dès qu'au moins une quête le référence dans ses - * prérequis. Ce service expose la liste dédupliquée pour les UIs (toggle dans - * la Partie, autocomplete dans l'éditeur de prérequis de quête).

- * - *

Niveau 1 : lit désormais les quêtes (entité de première classe), plus les - * chapitres HUB.

- */ -@Service -public class CampaignReferencedFlagsService { - - private final QuestRepository questRepository; - - public CampaignReferencedFlagsService(QuestRepository questRepository) { - this.questRepository = questRepository; - } - - /** Retourne la liste triée alphabétiquement des noms de faits référencés. */ - public List listForCampaign(String campaignId) { - TreeSet unique = new TreeSet<>(); - for (Quest quest : questRepository.findByCampaignId(campaignId)) { - if (quest.getPrerequisites() == null) continue; - for (Prerequisite p : quest.getPrerequisites()) { - if (p instanceof Prerequisite.FlagSet f && f.flagName() != null && !f.flagName().isBlank()) { - unique.add(f.flagName()); - } - } - } - return List.copyOf(unique); - } -} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/generation/CampaignImportProposal.java b/core/src/main/java/com/loremind/domain/campaigncontext/generation/CampaignImportProposal.java index b222ca7..96454a2 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/generation/CampaignImportProposal.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/generation/CampaignImportProposal.java @@ -11,12 +11,13 @@ import java.util.List; public record CampaignImportProposal(List arcs, List npcs) { /** - * {@code existingId} (nullable) : si présent, le nœud existe DÉJÀ dans la - * campagne (rempli côté UI lors de la revue pré-chargée) → l'apply ne le - * recrée pas, il l'utilise comme parent des nouveaux enfants. Null = à créer. + * {@code type} = "LINEAR" ou "HUB" (mappé sur {@link ArcType} à l'apply). + *

+ * {@code existingId} (nullable, porté aussi par Chapter/SceneProposal) : si présent, + * le nœud existe DÉJÀ dans la campagne (rempli côté UI lors de la revue pré-chargée) + * → l'apply ne le recrée pas, il l'utilise comme parent des nouveaux enfants. + * Null = à créer. */ - - /** {@code type} = "LINEAR" ou "HUB" (mappé sur {@link ArcType} à l'apply). */ public record ArcProposal( String name, String description, String type, List chapters, String existingId) { diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/quest/Prerequisite.java b/core/src/main/java/com/loremind/domain/campaigncontext/quest/Prerequisite.java index 0f8fa8f..9a68915 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/quest/Prerequisite.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/quest/Prerequisite.java @@ -2,11 +2,9 @@ package com.loremind.domain.campaigncontext.quest; /** * Condition de déblocage d'une quête (Chapter dans un Arc HUB). - * * Sealed : la liste des types est CLOSE et connue à la compilation. Pour ajouter * un nouveau type (ex : NpcMet), il faudra l'ajouter ici ET dans * {@link PrerequisiteEvaluator}. - * * Sémantique MVP : une quête a une LISTE de prérequis, tous combinés en ET logique * (pas de OR pour le moment). */ diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/quest/PrerequisiteEvaluator.java b/core/src/main/java/com/loremind/domain/campaigncontext/quest/PrerequisiteEvaluator.java index e47fb6f..b2fc96b 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/quest/PrerequisiteEvaluator.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/quest/PrerequisiteEvaluator.java @@ -7,7 +7,6 @@ import java.util.Set; /** * Service de domaine (pur, sans effet de bord) : évalue les prérequis d'une quête * et en dérive le {@link QuestStatus} effectif. - * * NB Java 17 : on utilise instanceof pattern matching (finalisé en Java 16) plutôt que * switch pattern matching (preview en 17, final en 21). La perte de l'exhaustivité * compile-time est compensée par le throw final qui fait crasher tout nouvel @@ -51,15 +50,12 @@ public final class PrerequisiteEvaluator { List prerequisites, EvaluationContext ctx ) { - switch (progression) { - case COMPLETED: return QuestStatus.COMPLETED; - case IN_PROGRESS: return QuestStatus.IN_PROGRESS; - case NOT_STARTED: - return areAllSatisfied(prerequisites, ctx) - ? QuestStatus.AVAILABLE - : QuestStatus.LOCKED; - default: - throw new IllegalStateException("ProgressionStatus non géré : " + progression); - } + return switch (progression) { + case COMPLETED -> QuestStatus.COMPLETED; + case IN_PROGRESS -> QuestStatus.IN_PROGRESS; + case NOT_STARTED -> areAllSatisfied(prerequisites, ctx) + ? QuestStatus.AVAILABLE + : QuestStatus.LOCKED; + }; } } diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/quest/ProgressionStatus.java b/core/src/main/java/com/loremind/domain/campaigncontext/quest/ProgressionStatus.java index 9ae0fbd..daad8c1 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/quest/ProgressionStatus.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/quest/ProgressionStatus.java @@ -2,12 +2,10 @@ package com.loremind.domain.campaigncontext.quest; /** * Statut de progression d'une quête (= Chapter dans un Arc HUB), piloté manuellement par le MJ. - * * NOT_STARTED : pas encore commencée. Peut être visible (AVAILABLE) ou cachée (LOCKED) * selon les prérequis — voir {@link QuestStatus}. * IN_PROGRESS : démarrée par le MJ via le bouton "Démarrer cette quête". * COMPLETED : marquée terminée par le MJ. - * * NB : un Chapter d'Arc LINEAR conserve NOT_STARTED par défaut sans impact visible. */ public enum ProgressionStatus { diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/quest/QuestStatus.java b/core/src/main/java/com/loremind/domain/campaigncontext/quest/QuestStatus.java index c4cfe0a..28b6281 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/quest/QuestStatus.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/quest/QuestStatus.java @@ -4,7 +4,6 @@ package com.loremind.domain.campaigncontext.quest; * Statut effectif d'une quête tel qu'affiché dans la vue Hub. * DÉRIVÉ — jamais persisté. Calculé par {@link PrerequisiteEvaluator} à partir * de la {@link ProgressionStatus} persistée et de l'évaluation des prérequis. - * * Table de vérité : * NOT_STARTED + prérequis non remplis -> LOCKED * NOT_STARTED + prérequis remplis -> AVAILABLE diff --git a/core/src/main/java/com/loremind/domain/gamesystemcontext/GameSystem.java b/core/src/main/java/com/loremind/domain/gamesystemcontext/GameSystem.java index 881d204..2f9915a 100644 --- a/core/src/main/java/com/loremind/domain/gamesystemcontext/GameSystem.java +++ b/core/src/main/java/com/loremind/domain/gamesystemcontext/GameSystem.java @@ -158,7 +158,7 @@ public class GameSystem { } private static boolean equalsIgnoreCase(String a, String b) { - if (a == null || b == null) return a == b; + if (a == null || b == null) return a == null && b == null; return a.toLowerCase(Locale.ROOT).equals(b.toLowerCase(Locale.ROOT)); } } diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignAdaptClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignAdaptClient.java index 0f48853..b941aa9 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignAdaptClient.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignAdaptClient.java @@ -26,22 +26,25 @@ import java.util.function.Consumer; @Component public class BrainCampaignAdaptClient implements CampaignPdfAdvisor { - private static final String ADAPT_PATH = "/adapt/campaign/stream"; private static final ParameterizedTypeReference> SSE_STRING_TYPE = new ParameterizedTypeReference<>() {}; private final WebClient webClient; private final ObjectMapper objectMapper; private final long timeoutSeconds; + // Route du Brain surchargeable par config (défaut = contrat d'API actuel). + private final String adaptPath; public BrainCampaignAdaptClient( WebClient.Builder webClientBuilder, ObjectMapper objectMapper, @Value("${brain.base-url}") String baseUrl, - @Value("${brain.import-timeout-seconds:600}") long timeoutSeconds) { + @Value("${brain.import-timeout-seconds:600}") long timeoutSeconds, + @Value("${brain.paths.adapt-campaign:/adapt/campaign/stream}") String adaptPath) { this.webClient = webClientBuilder.baseUrl(baseUrl).build(); this.objectMapper = objectMapper; this.timeoutSeconds = timeoutSeconds; + this.adaptPath = adaptPath; } @Override @@ -65,7 +68,7 @@ public class BrainCampaignAdaptClient implements CampaignPdfAdvisor { parts.part("messages", (messagesJson == null || messagesJson.isBlank()) ? "[]" : messagesJson); Flux> flux = webClient.post() - .uri(ADAPT_PATH) + .uri(adaptPath) .header(UserLanguageHolder.HEADER, UserLanguageHolder.get()) .contentType(MediaType.MULTIPART_FORM_DATA) .accept(MediaType.TEXT_EVENT_STREAM) diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignImportClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignImportClient.java index b9717c2..0cfc4c0 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignImportClient.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignImportClient.java @@ -36,24 +36,37 @@ import java.util.function.Consumer; @Component public class BrainCampaignImportClient implements CampaignPdfImporter { - private static final String IMPORT_CAMPAIGN_STREAM_PATH = "/import/campaign/stream"; private static final ParameterizedTypeReference> SSE_STRING_TYPE = new ParameterizedTypeReference<>() {}; + /** Champ JSON répété du proposal d'arbre (arc/chapitre/scène/salle/PNJ). */ + private static final String FIELD_DESCRIPTION = "description"; private final WebClient webClient; private final BrainSseImportSupport sse; private final long importTimeoutSeconds; + // Route du Brain surchargeable par config (défaut = contrat d'API actuel). + private final String importCampaignStreamPath; public BrainCampaignImportClient( WebClient.Builder webClientBuilder, ObjectMapper objectMapper, @Value("${brain.base-url}") String baseUrl, - @Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds) { + @Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds, + @Value("${brain.paths.import-campaign:/import/campaign/stream}") String importCampaignStreamPath) { this.webClient = webClientBuilder.baseUrl(baseUrl).build(); this.sse = new BrainSseImportSupport(objectMapper); this.importTimeoutSeconds = importTimeoutSeconds; + this.importCampaignStreamPath = importCampaignStreamPath; } + /** Callbacks de streaming groupés (réduit le nombre de paramètres de handleEvent). */ + private record ImportCallbacks( + Consumer onProgress, + Runnable onHeartbeat, + Consumer onStatus, + Consumer onDone, + Consumer onError) {} + @Override public void importCampaignStreaming( byte[] pdfBytes, @@ -69,7 +82,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter { .filename(filename == null || filename.isBlank() ? "campaign.pdf" : filename); Flux> flux = webClient.post() - .uri(IMPORT_CAMPAIGN_STREAM_PATH) + .uri(importCampaignStreamPath) .header(UserLanguageHolder.HEADER, UserLanguageHolder.get()) .contentType(MediaType.MULTIPART_FORM_DATA) .accept(MediaType.TEXT_EVENT_STREAM) @@ -80,12 +93,11 @@ public class BrainCampaignImportClient implements CampaignPdfImporter { int[] pageCount = {0}; int[] ocrPageCount = {0}; boolean[] terminated = {false}; + ImportCallbacks callbacks = new ImportCallbacks(onProgress, onHeartbeat, onStatus, onDone, onError); sse.runStream( flux, importTimeoutSeconds, terminated, - event -> handleEvent( - event, pageCount, ocrPageCount, terminated, - onProgress, onHeartbeat, onStatus, onDone, onError), + event -> handleEvent(event, pageCount, ocrPageCount, terminated, callbacks), onError, CampaignImportException::new); } @@ -94,11 +106,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter { int[] pageCount, int[] ocrPageCount, boolean[] terminated, - Consumer onProgress, - Runnable onHeartbeat, - Consumer onStatus, - Consumer onDone, - Consumer onError) { + ImportCallbacks callbacks) { String event = ssEvent.event(); String data = ssEvent.data() == null ? "" : ssEvent.data(); @@ -106,27 +114,27 @@ public class BrainCampaignImportClient implements CampaignPdfImporter { if ("heartbeat".equals(event)) { // Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au // navigateur, sinon nginx (proxy_read_timeout) coupe le SSE Core→front. - onHeartbeat.run(); + callbacks.onHeartbeat().run(); return; } if ("status".equals(event)) { // Message d'attente lisible (retry sur fournisseur saturé, morceau // re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs. - onStatus.accept(sse.readMessage(data)); + callbacks.onStatus().accept(sse.readMessage(data)); return; } if ("chunk_failed".equals(event)) { - onStatus.accept(sse.chunkFailedStatus(data)); + callbacks.onStatus().accept(sse.chunkFailedStatus(data)); return; } if ("error".equals(event)) { terminated[0] = true; - onError.accept(new CampaignImportException( + callbacks.onError().accept(new CampaignImportException( "Le Brain a signalé une erreur : " + sse.readMessage(data))); return; } if ("extracting".equals(event)) { - onProgress.accept(new CampaignImportProgress(0, 0, 0, 0, 0, 0, 0, 0)); + callbacks.onProgress().accept(new CampaignImportProgress(0, 0, 0, 0, 0, 0, 0, 0)); return; } @@ -136,10 +144,10 @@ public class BrainCampaignImportClient implements CampaignPdfImporter { if ("start".equals(event)) { pageCount[0] = node.path("page_count").asInt(); ocrPageCount[0] = node.path("ocr_page_count").asInt(); - onProgress.accept(new CampaignImportProgress( + callbacks.onProgress().accept(new CampaignImportProgress( 0, node.path("total").asInt(), pageCount[0], ocrPageCount[0], 0, 0, 0, 0)); } else if ("progress".equals(event)) { - onProgress.accept(new CampaignImportProgress( + callbacks.onProgress().accept(new CampaignImportProgress( node.path("current").asInt(), node.path("total").asInt(), pageCount[0], @@ -150,7 +158,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter { node.path("npc_count").asInt())); } else if ("done".equals(event)) { terminated[0] = true; - onDone.accept(new CampaignImportProposal( + callbacks.onDone().accept(new CampaignImportProposal( toArcs(node.path("arcs")), toNpcs(node.path("npcs")))); } } @@ -163,7 +171,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter { for (JsonNode arc : arcsNode) { arcs.add(new ArcProposal( text(arc, "name"), - text(arc, "description"), + text(arc, FIELD_DESCRIPTION), text(arc, "type"), toChapters(arc.path("chapters")), null)); @@ -178,7 +186,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter { for (JsonNode ch : chaptersNode) { chapters.add(new ChapterProposal( text(ch, "name"), - text(ch, "description"), + text(ch, FIELD_DESCRIPTION), toScenes(ch.path("scenes")), null)); } @@ -191,7 +199,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter { if (scenesNode != null && scenesNode.isArray()) { for (JsonNode sc : scenesNode) { scenes.add(new SceneProposal( - text(sc, "name"), text(sc, "description"), + text(sc, "name"), text(sc, FIELD_DESCRIPTION), text(sc, "player_narration"), text(sc, "gm_notes"), toRooms(sc.path("rooms")), null)); } @@ -204,7 +212,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter { if (roomsNode != null && roomsNode.isArray()) { for (JsonNode rm : roomsNode) { rooms.add(new RoomProposal( - text(rm, "name"), text(rm, "description"), + text(rm, "name"), text(rm, FIELD_DESCRIPTION), text(rm, "enemies"), text(rm, "loot"))); } } @@ -215,7 +223,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter { List npcs = new ArrayList<>(); if (npcsNode != null && npcsNode.isArray()) { for (JsonNode n : npcsNode) { - npcs.add(new NpcProposal(text(n, "name"), text(n, "description"))); + npcs.add(new NpcProposal(text(n, "name"), text(n, FIELD_DESCRIPTION))); } } return npcs; diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java index 7cbaa35..776cfd7 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java @@ -24,7 +24,7 @@ import java.util.List; import java.util.Map; import java.util.function.BiConsumer; import java.util.function.Function; -import java.util.stream.Collectors; +import java.util.function.ToIntFunction; /** * Helper d'infrastructure : traduit un ChatRequest (domaine) vers le dict JSON @@ -40,11 +40,14 @@ import java.util.stream.Collectors; @Component public class BrainChatPayloadBuilder { + private static final String KEY_DESCRIPTION = "description"; + private static final String KEY_TITLE = "title"; + public Map build(ChatRequest request) { Map root = new LinkedHashMap<>(); root.put("messages", request.messages().stream() .map(this::messageToMap) - .collect(Collectors.toList())); + .toList()); if (request.loreContext() != null) { root.put("lore_context", loreContextToMap(request.loreContext())); @@ -75,25 +78,25 @@ public class BrainChatPayloadBuilder { map.put("started_at", sc.startedAt().toString()); } map.put("entries", sc.entries() != null - ? sc.entries().stream().map(this::journalEntryToMap).collect(Collectors.toList()) + ? sc.entries().stream().map(this::journalEntryToMap).toList() : List.of()); // Évènements des sessions précédentes : omis si vide (campagne sur sa 1re session). if (sc.previousEvents() != null && !sc.previousEvents().isEmpty()) { map.put("previous_events", sc.previousEvents().stream() .map(this::journalEntryToMap) - .collect(Collectors.toList())); + .toList()); } // État Hub (quêtes / flags). Toutes les listes sont omises si vides pour ne pas // saturer le prompt sur les campagnes sans Hub. if (sc.availableQuests() != null && !sc.availableQuests().isEmpty()) { map.put("available_quests", sc.availableQuests().stream() .map(this::questSummaryToMap) - .collect(Collectors.toList())); + .toList()); } if (sc.inProgressQuests() != null && !sc.inProgressQuests().isEmpty()) { map.put("in_progress_quests", sc.inProgressQuests().stream() .map(this::questSummaryToMap) - .collect(Collectors.toList())); + .toList()); } if (sc.lockedQuestTitles() != null && !sc.lockedQuestTitles().isEmpty()) { map.put("locked_quest_titles", sc.lockedQuestTitles()); @@ -108,7 +111,7 @@ public class BrainChatPayloadBuilder { Map map = new LinkedHashMap<>(); map.put("name", q.name()); map.put("arc_name", q.arcName()); - putIfText(map, "description", q.description()); + putIfText(map, KEY_DESCRIPTION, q.description()); return map; } @@ -147,7 +150,7 @@ public class BrainChatPayloadBuilder { for (Map.Entry> e : ctx.folders().entrySet()) { foldersMap.put(e.getKey(), e.getValue().stream() .map(this::pageSummaryToMap) - .collect(Collectors.toList())); + .toList()); } map.put("folders", foldersMap); map.put("tags", ctx.tags()); @@ -156,7 +159,7 @@ public class BrainChatPayloadBuilder { private Map pageSummaryToMap(PageSummary ps) { Map map = new LinkedHashMap<>(); - map.put("title", ps.title()); + map.put(KEY_TITLE, ps.title()); map.put("template_name", ps.templateName()); // values/tags/related_page_titles : omis si vides pour alléger le payload. if (ps.values() != null && !ps.values().isEmpty()) { @@ -173,7 +176,7 @@ public class BrainChatPayloadBuilder { private Map pageContextToMap(PageContext pc) { Map map = new LinkedHashMap<>(); - map.put("title", pc.title()); + map.put(KEY_TITLE, pc.title()); map.put("template_name", pc.templateName()); map.put("template_fields", pc.templateFields()); map.put("values", pc.values()); @@ -186,18 +189,18 @@ public class BrainChatPayloadBuilder { map.put("campaign_description", ctx.campaignDescription()); map.put("arcs", ctx.arcs().stream() .map(this::arcSummaryToMap) - .collect(Collectors.toList())); + .toList()); // Liste des PJ : omise si aucun pour alléger le prompt des campagnes sans fiches. if (ctx.characters() != null && !ctx.characters().isEmpty()) { map.put("characters", ctx.characters().stream() .map(this::characterSummaryToMap) - .collect(Collectors.toList())); + .toList()); } // Liste des PNJ : symétrique aux PJ, omise si vide pour alléger le payload. if (ctx.npcs() != null && !ctx.npcs().isEmpty()) { map.put("npcs", ctx.npcs().stream() .map(this::npcSummaryToMap) - .collect(Collectors.toList())); + .toList()); } return map; } @@ -224,13 +227,14 @@ public class BrainChatPayloadBuilder { T entity, Function nameExtractor, Function descriptionExtractor, - Function illustrationCountExtractor, + ToIntFunction illustrationCountExtractor, BiConsumer, T> childSerializer) { Map map = new LinkedHashMap<>(); map.put("name", nameExtractor.apply(entity)); - map.put("description", descriptionExtractor.apply(entity)); - if (illustrationCountExtractor.apply(entity) > 0) { - map.put("illustration_count", illustrationCountExtractor.apply(entity)); + map.put(KEY_DESCRIPTION, descriptionExtractor.apply(entity)); + int illustrationCount = illustrationCountExtractor.applyAsInt(entity); + if (illustrationCount > 0) { + map.put("illustration_count", illustrationCount); } childSerializer.accept(map, entity); return map; @@ -249,7 +253,7 @@ public class BrainChatPayloadBuilder { } map.put("chapters", arc.chapters().stream() .map(this::chapterSummaryToMap) - .collect(Collectors.toList())); + .toList()); }); } @@ -261,7 +265,7 @@ public class BrainChatPayloadBuilder { ChapterSummary::illustrationCount, (map, chapter) -> map.put("scenes", chapter.scenes().stream() .map(this::sceneSummaryToMap) - .collect(Collectors.toList()))); + .toList())); } private Map sceneSummaryToMap(SceneSummary s) { @@ -275,13 +279,13 @@ public class BrainChatPayloadBuilder { if (s.branches() != null && !s.branches().isEmpty()) { map.put("branches", s.branches().stream() .map(this::branchHintToMap) - .collect(Collectors.toList())); + .toList()); } // Pièces du lieu explorable : omises si scène classique. if (s.rooms() != null && !s.rooms().isEmpty()) { map.put("rooms", s.rooms().stream() .map(this::roomSummaryToMap) - .collect(Collectors.toList())); + .toList()); } }); } @@ -298,7 +302,7 @@ public class BrainChatPayloadBuilder { Map map = new LinkedHashMap<>(); map.put("name", r.name()); if (r.floor() != null) map.put("floor", r.floor()); - putIfText(map, "description", r.description()); + putIfText(map, KEY_DESCRIPTION, r.description()); putIfText(map, "enemies", r.enemies()); if (r.branches() != null && !r.branches().isEmpty()) { map.put("branches", r.branches().stream().map(b -> { @@ -307,7 +311,7 @@ public class BrainChatPayloadBuilder { bm.put("target_room_name", b.targetRoomName()); putIfText(bm, "condition", b.condition()); return bm; - }).collect(Collectors.toList())); + }).toList()); } return map; } @@ -315,7 +319,7 @@ public class BrainChatPayloadBuilder { private Map narrativeEntityToMap(NarrativeEntityContext ne) { Map map = new LinkedHashMap<>(); map.put("entity_type", ne.entityType()); - map.put("title", ne.title()); + map.put(KEY_TITLE, ne.title()); map.put("fields", ne.fields()); return map; } diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainConversationTitleClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainConversationTitleClient.java index 7db93eb..3f99011 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainConversationTitleClient.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainConversationTitleClient.java @@ -12,7 +12,6 @@ import java.time.Duration; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; /** * Adaptateur : appelle le Brain POST /summarize/conversation-title pour @@ -45,7 +44,7 @@ public class BrainConversationTitleClient implements ConversationTitleGenerator .map(m -> Map.of( "role", m.getRole(), "content", m.getContent() == null ? "" : m.getContent())) - .collect(Collectors.toList())); + .toList()); try { @SuppressWarnings("unchecked") diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainItemCatalogClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainItemCatalogClient.java index d93e120..6cf46b1 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainItemCatalogClient.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainItemCatalogClient.java @@ -24,6 +24,7 @@ import java.util.Map; public class BrainItemCatalogClient implements ItemCatalogGenerator { private static final String GENERATE_PATH = "/generate/item-catalog"; + private static final String KEY_DESCRIPTION = "description"; private final RestTemplate restTemplate; private final String baseUrl; @@ -36,10 +37,25 @@ public class BrainItemCatalogClient implements ItemCatalogGenerator { } @Override - @SuppressWarnings("unchecked") public GeneratedCatalog generate(String description, String context) { + Map resp = callBrain(description, context); + + List items = parseItems(resp.get("items")); + if (items.isEmpty()) { + throw new ItemCatalogGenerationException("Aucun objet généré — réessaie ou reformule."); + } + String name = asString(resp.get("name")); + return new GeneratedCatalog( + name != null && !name.isBlank() ? name : description, + asString(resp.get(KEY_DESCRIPTION)), + items); + } + + /** POST one-shot vers le Brain ; toute erreur devient une ItemCatalogGenerationException parlante. */ + @SuppressWarnings("unchecked") + private Map callBrain(String description, String context) { Map req = new LinkedHashMap<>(); - req.put("description", description == null ? "" : description); + req.put(KEY_DESCRIPTION, description == null ? "" : description); req.put("context", context == null ? "" : context); HttpHeaders headers = new HttpHeaders(); @@ -60,30 +76,38 @@ public class BrainItemCatalogClient implements ItemCatalogGenerator { if (resp == null) { throw new ItemCatalogGenerationException("Le Brain a renvoyé une réponse vide."); } + return resp; + } + /** Items valides de la réponse (entrées malformées ou sans nom ignorées). */ + private static List parseItems(Object rawItems) { List items = new ArrayList<>(); - Object rawItems = resp.get("items"); if (rawItems instanceof List list) { - for (Object item : list) { - if (!(item instanceof Map m)) continue; - String name = asString(m.get("name")); - if (name == null || name.isBlank()) continue; - items.add(CatalogItem.builder() - .name(name) - .price(asString(m.get("price"))) - .category(asString(m.get("category"))) - .description(asString(m.get("description"))) - .build()); + for (Object raw : list) { + CatalogItem item = toItem(raw); + if (item != null) { + items.add(item); + } } } - if (items.isEmpty()) { - throw new ItemCatalogGenerationException("Aucun objet généré — réessaie ou reformule."); + return items; + } + + /** Un CatalogItem depuis une entrée brute, ou null si malformée / sans nom. */ + private static CatalogItem toItem(Object raw) { + if (!(raw instanceof Map m)) { + return null; } - String name = asString(resp.get("name")); - return new GeneratedCatalog( - name != null && !name.isBlank() ? name : description, - asString(resp.get("description")), - items); + String name = asString(m.get("name")); + if (name == null || name.isBlank()) { + return null; + } + return CatalogItem.builder() + .name(name) + .price(asString(m.get("price"))) + .category(asString(m.get("category"))) + .description(asString(m.get(KEY_DESCRIPTION))) + .build(); } private static String asString(Object o) { diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainNarrativeFieldClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainNarrativeFieldClient.java index 5c557f1..4fe4e7a 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainNarrativeFieldClient.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainNarrativeFieldClient.java @@ -39,20 +39,9 @@ public class BrainNarrativeFieldClient implements NarrativeFieldAssistant { } @Override - @SuppressWarnings("unchecked") public List assist(String entityType, String context, String instruction, List fields) { - List> fieldPayload = new ArrayList<>(); - Set allowed = new HashSet<>(); - if (fields != null) { - for (FieldSpec f : fields) { - if (f == null || f.key() == null) continue; - allowed.add(f.key()); - Map fm = new LinkedHashMap<>(); - fm.put("key", f.key()); - fm.put("label", f.label() == null ? f.key() : f.label()); - fieldPayload.add(fm); - } - } + List> fieldPayload = buildFieldPayload(fields); + Set allowed = allowedKeys(fields); Map req = new LinkedHashMap<>(); req.put("entity_type", entityType == null ? "" : entityType); @@ -60,6 +49,38 @@ public class BrainNarrativeFieldClient implements NarrativeFieldAssistant { req.put("instruction", instruction == null ? "" : instruction); req.put("fields", fieldPayload); + Map resp = callBrain(req); + return parseProposedFields(resp.get("fields"), allowed); + } + + /** Payload des champs à étoffer : {key, label} par champ valide (libellé = clé à défaut). */ + private static List> buildFieldPayload(List fields) { + List> payload = new ArrayList<>(); + for (FieldSpec f : nullSafe(fields)) { + if (f != null && f.key() != null) { + Map fm = new LinkedHashMap<>(); + fm.put("key", f.key()); + fm.put("label", f.label() == null ? f.key() : f.label()); + payload.add(fm); + } + } + return payload; + } + + /** Whitelist des clés autorisées en retour (garde-fou contre les clés inventées). */ + private static Set allowedKeys(List fields) { + Set allowed = new HashSet<>(); + for (FieldSpec f : nullSafe(fields)) { + if (f != null && f.key() != null) { + allowed.add(f.key()); + } + } + return allowed; + } + + /** POST one-shot vers le Brain ; toute erreur devient une NarrativeAssistException parlante. */ + @SuppressWarnings("unchecked") + private Map callBrain(Map req) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity> entity = new HttpEntity<>(req, headers); @@ -78,18 +99,37 @@ public class BrainNarrativeFieldClient implements NarrativeFieldAssistant { if (resp == null) { throw new NarrativeAssistException("Le Brain a renvoyé une réponse vide."); } + return resp; + } + /** Champs proposés retenus : clé dans la whitelist ET valeur non vide (trim). */ + private static List parseProposedFields(Object rawFields, Set allowed) { List out = new ArrayList<>(); - Object rawFields = resp.get("fields"); if (rawFields instanceof Map m) { for (Map.Entry e : m.entrySet()) { - String key = e.getKey() == null ? null : e.getKey().toString(); - if (key == null || !allowed.contains(key)) continue; // whitelist stricte - String value = e.getValue() == null ? null : e.getValue().toString(); - if (value == null || value.isBlank()) continue; // pas de remplissage vide - out.add(new ProposedField(key, value.trim())); + ProposedField field = toProposedField(e, allowed); + if (field != null) { + out.add(field); + } } } return out; } + + /** Un ProposedField depuis une entrée brute, ou null si clé hors whitelist / valeur vide. */ + private static ProposedField toProposedField(Map.Entry e, Set allowed) { + String key = e.getKey() == null ? null : e.getKey().toString(); + if (key == null || !allowed.contains(key)) { + return null; // whitelist stricte + } + String value = e.getValue() == null ? null : e.getValue().toString(); + if (value == null || value.isBlank()) { + return null; // pas de remplissage vide + } + return new ProposedField(key, value.trim()); + } + + private static List nullSafe(List fields) { + return fields != null ? fields : List.of(); + } } diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainNotebookChatClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainNotebookChatClient.java index a2f415e..51d3176 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainNotebookChatClient.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainNotebookChatClient.java @@ -17,7 +17,6 @@ import java.time.Duration; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; /** * Adapter de sortie : relaie le chat ANCRÉ (RAG) du Brain via SSE (WebClient). @@ -26,23 +25,28 @@ import java.util.stream.Collectors; @Component public class BrainNotebookChatClient implements NotebookChatStreamer { - private static final String PATH = "/chat/notebook/stream"; - private static final String DEEP_PATH = "/chat/notebook/deep/stream"; private static final ParameterizedTypeReference> SSE_STRING_TYPE = new ParameterizedTypeReference<>() {}; private final WebClient webClient; private final ObjectMapper objectMapper; private final long timeoutSeconds; + // Routes du Brain surchargeables par config (défauts = contrat d'API actuel). + private final String chatPath; + private final String deepChatPath; public BrainNotebookChatClient( WebClient.Builder webClientBuilder, ObjectMapper objectMapper, @Value("${brain.base-url}") String baseUrl, - @Value("${brain.import-timeout-seconds:600}") long timeoutSeconds) { + @Value("${brain.import-timeout-seconds:600}") long timeoutSeconds, + @Value("${brain.paths.notebook-chat:/chat/notebook/stream}") String chatPath, + @Value("${brain.paths.notebook-chat-deep:/chat/notebook/deep/stream}") String deepChatPath) { this.webClient = webClientBuilder.baseUrl(baseUrl).build(); this.objectMapper = objectMapper; this.timeoutSeconds = timeoutSeconds; + this.chatPath = chatPath; + this.deepChatPath = deepChatPath; } @Override @@ -57,11 +61,11 @@ public class BrainNotebookChatClient implements NotebookChatStreamer { payload.put("source_ids", sourceIds); payload.put("messages", messages.stream() .map(m -> Map.of("role", m.role(), "content", m.content())) - .collect(Collectors.toList())); + .toList()); payload.put("context", context == null ? "" : context); Flux> flux = webClient.post() - .uri(deep ? DEEP_PATH : PATH) + .uri(deep ? deepChatPath : chatPath) .header(UserLanguageHolder.HEADER, UserLanguageHolder.get()) .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.TEXT_EVENT_STREAM) diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainNotebookIndexClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainNotebookIndexClient.java index fe19fca..e6238ac 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainNotebookIndexClient.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainNotebookIndexClient.java @@ -55,7 +55,7 @@ public class BrainNotebookIndexClient implements NotebookIndexer { if (resp == null) { throw new NotebookException("Le Brain a renvoyé une réponse vide à l'indexation."); } - return new IndexResult(resp.chunks, resp.pageCount, resp.ocrPageCount); + return new IndexResult(resp.getChunks(), resp.getPageCount(), resp.getOcrPageCount()); } catch (ResourceAccessException e) { throw new NotebookException("Le Brain est injoignable (timeout ou arrêté).", e); } catch (RestClientResponseException e) { @@ -89,12 +89,22 @@ public class BrainNotebookIndexClient implements NotebookIndexer { }; } - /** Réponse JSON du Brain (snake_case). */ + /** + * Réponse JSON du Brain (snake_case). Champs privés + @JsonProperty explicite + * sur CHAQUE champ : Jackson n'auto-détecte que les champs publics par défaut, + * l'annotation reste nécessaire pour que la désérialisation continue de fonctionner + * une fois les champs rendus privés. + */ private static class IndexResponse { - public int chunks; + @JsonProperty("chunks") + private int chunks; @JsonProperty("page_count") - public int pageCount; + private int pageCount; @JsonProperty("ocr_page_count") - public int ocrPageCount; + private int ocrPageCount; + + int getChunks() { return chunks; } + int getPageCount() { return pageCount; } + int getOcrPageCount() { return ocrPageCount; } } } diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainRandomTableClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainRandomTableClient.java index 820a3b8..79e9996 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainRandomTableClient.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainRandomTableClient.java @@ -49,23 +49,7 @@ public class BrainRandomTableClient implements RandomTableGenerator { if (resp == null) { throw new RandomTableGenerationException("Le Brain a renvoyé une réponse vide."); } - List entries = new ArrayList<>(); - Object rawEntries = resp.get("entries"); - if (rawEntries instanceof List list) { - for (Object item : list) { - if (!(item instanceof Map m)) continue; - Integer min = asInt(m.get("min_roll")); - Integer max = asInt(m.get("max_roll")); - String label = asString(m.get("label")); - if (min == null || max == null || label == null || label.isBlank()) continue; - entries.add(RandomTableEntry.builder() - .minRoll(min) - .maxRoll(Math.max(min, max)) - .label(label) - .detail(asString(m.get("detail"))) - .build()); - } - } + List entries = parseEntries(resp.get("entries")); if (entries.isEmpty()) { throw new RandomTableGenerationException("Aucune entrée générée — réessaie ou reformule."); } @@ -76,6 +60,39 @@ public class BrainRandomTableClient implements RandomTableGenerator { entries); } + /** Entrées valides de la réponse (entrées malformées ou incomplètes ignorées). */ + private static List parseEntries(Object rawEntries) { + List entries = new ArrayList<>(); + if (rawEntries instanceof List list) { + for (Object raw : list) { + RandomTableEntry entry = toEntry(raw); + if (entry != null) { + entries.add(entry); + } + } + } + return entries; + } + + /** Une RandomTableEntry depuis une entrée brute, ou null si malformée / bornes ou label absents. */ + private static RandomTableEntry toEntry(Object raw) { + if (!(raw instanceof Map m)) { + return null; + } + Integer min = asInt(m.get("min_roll")); + Integer max = asInt(m.get("max_roll")); + String label = asString(m.get("label")); + if (min == null || max == null || label == null || label.isBlank()) { + return null; + } + return RandomTableEntry.builder() + .minRoll(min) + .maxRoll(Math.max(min, max)) + .label(label) + .detail(asString(m.get("detail"))) + .build(); + } + @Override public String improvise(String tableName, String resultLabel, String resultDetail, String context) { Map req = new LinkedHashMap<>(); diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainRulesImportClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainRulesImportClient.java index 82b5cfc..82c6a3b 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainRulesImportClient.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainRulesImportClient.java @@ -47,30 +47,45 @@ import java.util.function.Consumer; @Component public class BrainRulesImportClient implements RulesPdfImporter { - private static final String IMPORT_RULES_PATH = "/import/rules"; - private static final String IMPORT_RULES_STREAM_PATH = "/import/rules/stream"; private static final ParameterizedTypeReference> SSE_STRING_TYPE = new ParameterizedTypeReference<>() {}; + /** Nom de fichier par défaut du PDF de règles (filename absent/vide). */ + private static final String DEFAULT_FILENAME = "rules.pdf"; private final RestTemplate restTemplate; private final WebClient webClient; private final BrainSseImportSupport sse; private final String baseUrl; private final long importTimeoutSeconds; + // Routes du Brain surchargeables par config (défauts = contrat d'API actuel). + private final String importRulesPath; + private final String importRulesStreamPath; public BrainRulesImportClient( @Qualifier("brainImportRestTemplate") RestTemplate restTemplate, WebClient.Builder webClientBuilder, ObjectMapper objectMapper, @Value("${brain.base-url}") String baseUrl, - @Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds) { + @Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds, + @Value("${brain.paths.import-rules:/import/rules}") String importRulesPath, + @Value("${brain.paths.import-rules-stream:/import/rules/stream}") String importRulesStreamPath) { this.restTemplate = restTemplate; this.webClient = webClientBuilder.baseUrl(baseUrl).build(); this.sse = new BrainSseImportSupport(objectMapper); this.baseUrl = baseUrl; this.importTimeoutSeconds = importTimeoutSeconds; + this.importRulesPath = importRulesPath; + this.importRulesStreamPath = importRulesStreamPath; } + /** Callbacks de streaming groupés (réduit le nombre de paramètres de handleEvent). */ + private record ImportCallbacks( + Consumer onProgress, + Runnable onHeartbeat, + Consumer onStatus, + Consumer onDone, + Consumer onError) {} + // --- One-shot (bloquant) ------------------------------------------------- @Override @@ -79,12 +94,12 @@ public class BrainRulesImportClient implements RulesPdfImporter { headers.setContentType(MediaType.MULTIPART_FORM_DATA); MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("file", sse.filePart(pdfBytes, filename, "rules.pdf")); + body.add("file", sse.filePart(pdfBytes, filename, DEFAULT_FILENAME)); HttpEntity> entity = new HttpEntity<>(body, headers); try { BrainRulesImportResponse response = restTemplate.postForObject( - baseUrl + IMPORT_RULES_PATH, entity, BrainRulesImportResponse.class); + baseUrl + importRulesPath, entity, BrainRulesImportResponse.class); if (response == null || response.getSections() == null) { throw new RulesImportException("Le Brain a renvoyé une réponse vide."); } @@ -119,11 +134,11 @@ public class BrainRulesImportClient implements RulesPdfImporter { Consumer onError) { MultipartBodyBuilder parts = new MultipartBodyBuilder(); - parts.part("file", sse.filePart(pdfBytes, filename, "rules.pdf")) - .filename(filename == null || filename.isBlank() ? "rules.pdf" : filename); + parts.part("file", sse.filePart(pdfBytes, filename, DEFAULT_FILENAME)) + .filename(filename == null || filename.isBlank() ? DEFAULT_FILENAME : filename); Flux> flux = webClient.post() - .uri(IMPORT_RULES_STREAM_PATH) + .uri(importRulesStreamPath) .header(UserLanguageHolder.HEADER, UserLanguageHolder.get()) .contentType(MediaType.MULTIPART_FORM_DATA) .accept(MediaType.TEXT_EVENT_STREAM) @@ -136,12 +151,11 @@ public class BrainRulesImportClient implements RulesPdfImporter { int[] pageCount = {0}; int[] ocrPageCount = {0}; boolean[] terminated = {false}; + ImportCallbacks callbacks = new ImportCallbacks(onProgress, onHeartbeat, onStatus, onDone, onError); sse.runStream( flux, importTimeoutSeconds, terminated, - event -> handleEvent( - event, pageCount, ocrPageCount, terminated, - onProgress, onHeartbeat, onStatus, onDone, onError), + event -> handleEvent(event, pageCount, ocrPageCount, terminated, callbacks), onError, RulesImportException::new); } @@ -150,11 +164,7 @@ public class BrainRulesImportClient implements RulesPdfImporter { int[] pageCount, int[] ocrPageCount, boolean[] terminated, - Consumer onProgress, - Runnable onHeartbeat, - Consumer onStatus, - Consumer onDone, - Consumer onError) { + ImportCallbacks callbacks) { String event = ssEvent.event(); String data = ssEvent.data() == null ? "" : ssEvent.data(); @@ -163,28 +173,28 @@ public class BrainRulesImportClient implements RulesPdfImporter { // Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au // navigateur, sinon nginx (proxy_read_timeout) coupe le SSE Core→front // resté silencieux pendant tout le traitement du morceau. - onHeartbeat.run(); + callbacks.onHeartbeat().run(); return; } if ("status".equals(event)) { // Message d'attente lisible (retry sur fournisseur saturé, morceau // re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs. - onStatus.accept(sse.readMessage(data)); + callbacks.onStatus().accept(sse.readMessage(data)); return; } if ("chunk_failed".equals(event)) { - onStatus.accept(sse.chunkFailedStatus(data)); + callbacks.onStatus().accept(sse.chunkFailedStatus(data)); return; } if ("error".equals(event)) { terminated[0] = true; - onError.accept(new RulesImportException( + callbacks.onError().accept(new RulesImportException( "Le Brain a signalé une erreur : " + sse.readMessage(data))); return; } if ("extracting".equals(event)) { // Phase d'extraction : total inconnu (0) → l'UI affiche "Extraction…". - onProgress.accept(new RulesImportProgress(0, 0, 0, 0, List.of())); + callbacks.onProgress().accept(new RulesImportProgress(0, 0, 0, 0, List.of())); return; } @@ -194,10 +204,10 @@ public class BrainRulesImportClient implements RulesPdfImporter { if ("start".equals(event)) { pageCount[0] = node.path("page_count").asInt(); ocrPageCount[0] = node.path("ocr_page_count").asInt(); - onProgress.accept(new RulesImportProgress( + callbacks.onProgress().accept(new RulesImportProgress( 0, node.path("total").asInt(), pageCount[0], ocrPageCount[0], List.of())); } else if ("progress".equals(event)) { - onProgress.accept(new RulesImportProgress( + callbacks.onProgress().accept(new RulesImportProgress( node.path("current").asInt(), node.path("total").asInt(), pageCount[0], @@ -205,7 +215,7 @@ public class BrainRulesImportClient implements RulesPdfImporter { toStringList(node.path("new_sections")))); } else if ("done".equals(event)) { terminated[0] = true; - onDone.accept(new RulesImportResult( + callbacks.onDone().accept(new RulesImportResult( toStringMap(node.path("sections")), node.path("page_count").asInt(), node.path("ocr_page_count").asInt())); diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainSceneDraftClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainSceneDraftClient.java index dd18f7d..02f0573 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainSceneDraftClient.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainSceneDraftClient.java @@ -62,23 +62,38 @@ public class BrainSceneDraftClient implements SceneDraftAssistant { if (resp == null) { throw new NarrativeAssistException("Le Brain a renvoyé une réponse vide."); } + return parseScenes(resp.get("scenes")); + } + /** Ébauches valides de la réponse (entrées malformées ou sans titre ignorées). */ + private static List parseScenes(Object rawScenes) { List out = new ArrayList<>(); - Object rawScenes = resp.get("scenes"); if (rawScenes instanceof List list) { - for (Object item : list) { - if (!(item instanceof Map m)) continue; - String name = asString(m.get("name")); - if (name == null || name.isBlank()) continue; // un titre est obligatoire - out.add(new SceneDraft( - name.trim(), - trimOrNull(asString(m.get("description"))), - trimOrNull(asString(m.get("playerNarration"))))); + for (Object raw : list) { + SceneDraft draft = toSceneDraft(raw); + if (draft != null) { + out.add(draft); + } } } return out; } + /** Une SceneDraft depuis une entrée brute, ou null si malformée / sans titre. */ + private static SceneDraft toSceneDraft(Object raw) { + if (!(raw instanceof Map m)) { + return null; + } + String name = asString(m.get("name")); + if (name == null || name.isBlank()) { + return null; // un titre est obligatoire + } + return new SceneDraft( + name.trim(), + trimOrNull(asString(m.get("description"))), + trimOrNull(asString(m.get("playerNarration")))); + } + private static String asString(Object o) { return o != null ? o.toString() : null; } diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainSidecar.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainSidecar.java index 5f27c62..5252146 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainSidecar.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainSidecar.java @@ -11,6 +11,7 @@ import org.springframework.stereotype.Component; import java.io.File; import java.io.IOException; +import java.util.concurrent.atomic.AtomicReference; import java.nio.file.Files; import java.nio.file.Path; import java.util.concurrent.TimeUnit; @@ -41,7 +42,7 @@ public class BrainSidecar { private final BrainSidecarProperties props; private final String internalSecret; - private volatile Process process; + private final AtomicReference process = new AtomicReference<>(); public BrainSidecar(BrainSidecarProperties props, @Value("${brain.internal-secret:}") String internalSecret) { @@ -75,9 +76,10 @@ public class BrainSidecar { // (cf. Settings.internal_shared_secret cote Python -> env INTERNAL_SHARED_SECRET) pb.environment().put("INTERNAL_SHARED_SECRET", internalSecret); - this.process = pb.start(); + Process started = pb.start(); + this.process.set(started); log.info("[Brain] Sidecar demarre (pid={}, cwd={}).", - process.pid(), workingDir != null ? workingDir : ""); + started.pid(), workingDir != null ? workingDir : ""); } catch (IOException e) { log.error("[Brain] Echec du lancement du sidecar (commande={}). " + "Les fonctions IA seront indisponibles. Cause : {}", @@ -87,7 +89,7 @@ public class BrainSidecar { @PreDestroy public void stop() { - Process p = this.process; + Process p = this.process.get(); if (p == null || !p.isAlive()) { return; } diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainSseImportSupport.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainSseImportSupport.java index 17361c9..d6cc33f 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainSseImportSupport.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainSseImportSupport.java @@ -22,6 +22,9 @@ import java.util.function.Consumer; */ final class BrainSseImportSupport { + /** Champ JSON standard des events SSE du Brain portant un message lisible. */ + private static final String MESSAGE_FIELD = "message"; + private final ObjectMapper objectMapper; BrainSseImportSupport(ObjectMapper objectMapper) { @@ -50,8 +53,8 @@ final class BrainSseImportSupport { /** Champ {@code message} du JSON, ou la {@code data} brute si non-JSON / champ absent. */ String readMessage(String data) { JsonNode node = readJson(data); - if (node != null && node.hasNonNull("message")) { - return node.get("message").asText(); + if (node != null && node.hasNonNull(MESSAGE_FIELD)) { + return node.get(MESSAGE_FIELD).asText(); } return data; } @@ -59,8 +62,8 @@ final class BrainSseImportSupport { /** Statut lisible « Morceau x/y ignoré[ : message] » depuis un payload {@code chunk_failed}. */ String chunkFailedStatus(String data) { JsonNode node = readJson(data); - String msg = node != null && node.hasNonNull("message") - ? node.get("message").asText() : ""; + String msg = node != null && node.hasNonNull(MESSAGE_FIELD) + ? node.get(MESSAGE_FIELD).asText() : ""; int current = node != null ? node.path("current").asInt() : 0; int total = node != null ? node.path("total").asInt() : 0; return "Morceau " + current + "/" + total + " ignoré" diff --git a/core/src/main/java/com/loremind/infrastructure/desktop/DesktopBrowserOpener.java b/core/src/main/java/com/loremind/infrastructure/desktop/DesktopBrowserOpener.java index 20677a2..ec6e3fd 100644 --- a/core/src/main/java/com/loremind/infrastructure/desktop/DesktopBrowserOpener.java +++ b/core/src/main/java/com/loremind/infrastructure/desktop/DesktopBrowserOpener.java @@ -26,6 +26,8 @@ public class DesktopBrowserOpener { private static final Logger log = LoggerFactory.getLogger(DesktopBrowserOpener.class); @EventListener(ApplicationReadyEvent.class) + // S125 : faux positif — commentaire explicatif (prose), pas de code mort. + @SuppressWarnings("java:S125") public void onReady() { // Ferme le splash natif (affiche par la JVM via -splash des le double-clic) // juste avant d'ouvrir le navigateur : le relais visuel est assure. diff --git a/core/src/main/java/com/loremind/infrastructure/desktop/DesktopSingleInstance.java b/core/src/main/java/com/loremind/infrastructure/desktop/DesktopSingleInstance.java index 79306cd..5d58845 100644 --- a/core/src/main/java/com/loremind/infrastructure/desktop/DesktopSingleInstance.java +++ b/core/src/main/java/com/loremind/infrastructure/desktop/DesktopSingleInstance.java @@ -1,5 +1,8 @@ package com.loremind.infrastructure.desktop; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.io.IOException; import java.nio.channels.FileChannel; import java.nio.channels.FileLock; @@ -26,12 +29,26 @@ import java.nio.file.StandardOpenOption; * ({@code java.desktop}) pourrait etre absent du runtime reduit par jlink. * On passe donc par la commande systeme d'ouverture d'URL. */ +// S4036 : app desktop locale mono-utilisateur — lancement d'utilitaires systeme via PATH +// assume, sans contexte d'elevation ni d'input externe. +@SuppressWarnings("java:S4036") public final class DesktopSingleInstance { - /** Conserve le verrou ouvert pour TOUTE la duree de vie du process (sinon GC = relache). */ + // Utilisé avant SpringApplication.run() : Logback démarre en config console par + // défaut — même destination que l'ancien System.err (cf. DesktopUserConfig). + private static final Logger log = LoggerFactory.getLogger(DesktopSingleInstance.class); + + private static final String OS_NAME_PROPERTY = "os.name"; + /** Ouvre URL/fichier/dossier avec l'application par défaut sur Linux. */ + private static final String XDG_OPEN = "xdg-open"; + + /** + * Conserve le CHANNEL ouvert pour toute la duree de vie du process : un FileLock + * reste detenu tant que son channel est ouvert (le verrou est libere a la fermeture + * du channel ou a la sortie de la JVM, pas au GC de l'objet FileLock). + */ @SuppressWarnings("unused") private static FileChannel lockChannel; - private static FileLock lock; private DesktopSingleInstance() {} @@ -71,11 +88,11 @@ public final class DesktopSingleInstance { Path lockFile = dir.resolve(".instance.lock"); lockChannel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE); - lock = lockChannel.tryLock(); + FileLock lock = lockChannel.tryLock(); return lock != null; // null = deja verrouille par une autre instance } catch (IOException e) { - System.err.println("[Desktop] Verrou d'instance indisponible (" + e.getMessage() - + ") — on tente de demarrer quand meme."); + log.warn("[Desktop] Verrou d'instance indisponible ({}) — on tente de demarrer quand meme.", + e.getMessage()); return true; } } @@ -88,7 +105,7 @@ public final class DesktopSingleInstance { /** Ouvre le navigateur par defaut sur une URL quelconque (sans dependance AWT). */ public static void openUrl(String url) { try { - String os = System.getProperty("os.name", "").toLowerCase(); + String os = System.getProperty(OS_NAME_PROPERTY, "").toLowerCase(); ProcessBuilder pb; if (os.contains("win")) { // rundll32 : ouverture d'URL fiable sans dependance graphique Java. @@ -96,12 +113,12 @@ public final class DesktopSingleInstance { } else if (os.contains("mac")) { pb = new ProcessBuilder("open", url); } else { - pb = new ProcessBuilder("xdg-open", url); + pb = new ProcessBuilder(XDG_OPEN, url); } pb.start(); } catch (IOException e) { - System.err.println("[Desktop] Impossible d'ouvrir le navigateur sur " + url - + " : " + e.getMessage() + ". Ouvrez-le manuellement."); + log.warn("[Desktop] Impossible d'ouvrir le navigateur sur {} : {}. Ouvrez-le manuellement.", + url, e.getMessage()); } } @@ -109,25 +126,25 @@ public final class DesktopSingleInstance { public static void openFolder(Path dir) { try { Files.createDirectories(dir); - String os = System.getProperty("os.name", "").toLowerCase(); + String os = System.getProperty(OS_NAME_PROPERTY, "").toLowerCase(); ProcessBuilder pb; if (os.contains("win")) { pb = new ProcessBuilder("explorer.exe", dir.toString()); } else if (os.contains("mac")) { pb = new ProcessBuilder("open", dir.toString()); } else { - pb = new ProcessBuilder("xdg-open", dir.toString()); + pb = new ProcessBuilder(XDG_OPEN, dir.toString()); } pb.start(); } catch (IOException e) { - System.err.println("[Desktop] Ouverture du dossier impossible : " + e.getMessage()); + log.warn("[Desktop] Ouverture du dossier impossible : {}", e.getMessage()); } } /** Ouvre un fichier texte dans l'editeur par defaut (Bloc-notes sous Windows). */ public static void openInEditor(Path file) { try { - String os = System.getProperty("os.name", "").toLowerCase(); + String os = System.getProperty(OS_NAME_PROPERTY, "").toLowerCase(); ProcessBuilder pb; if (os.contains("win")) { // notepad : toujours present, ouvre proprement un .properties @@ -136,11 +153,11 @@ public final class DesktopSingleInstance { } else if (os.contains("mac")) { pb = new ProcessBuilder("open", "-t", file.toString()); } else { - pb = new ProcessBuilder("xdg-open", file.toString()); + pb = new ProcessBuilder(XDG_OPEN, file.toString()); } pb.start(); } catch (IOException e) { - System.err.println("[Desktop] Ouverture du fichier impossible : " + e.getMessage()); + log.warn("[Desktop] Ouverture du fichier impossible : {}", e.getMessage()); } } diff --git a/core/src/main/java/com/loremind/infrastructure/desktop/DesktopUserConfig.java b/core/src/main/java/com/loremind/infrastructure/desktop/DesktopUserConfig.java index 8a89e96..9711c9f 100644 --- a/core/src/main/java/com/loremind/infrastructure/desktop/DesktopUserConfig.java +++ b/core/src/main/java/com/loremind/infrastructure/desktop/DesktopUserConfig.java @@ -1,5 +1,8 @@ package com.loremind.infrastructure.desktop; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.io.IOException; import java.io.InputStream; import java.net.InetAddress; @@ -30,6 +33,14 @@ import java.util.Properties; */ public final class DesktopUserConfig { + // Utilisé AVANT SpringApplication.run() : Logback s'initialise avec sa config + // par défaut (console), puis Spring reprend la main — même destination que + // l'ancien System.out, mais horodaté et uniforme avec le reste des logs. + private static final Logger log = LoggerFactory.getLogger(DesktopUserConfig.class); + + /** Clé partagée fichier utilisateur / propriété système / Spring. */ + private static final String SERVER_PORT_PROPERTY = "server.port"; + private DesktopUserConfig() {} private static final String DEFAULT_TEMPLATE = """ @@ -69,10 +80,10 @@ public final class DesktopUserConfig { try { Files.createDirectories(file.getParent()); Files.writeString(file, DEFAULT_TEMPLATE); - System.out.println("[Desktop] Config utilisateur creee : " + file); + log.info("[Desktop] Config utilisateur creee : {}", file); } catch (IOException e) { - System.err.println("[Desktop] Impossible de creer " + file + " : " + e.getMessage() - + " — defauts utilises (port 8080, admin/admin)."); + log.warn("[Desktop] Impossible de creer {} : {} — defauts utilises (port 8080, admin/admin).", + file, e.getMessage()); } } @@ -87,13 +98,13 @@ public final class DesktopUserConfig { int wanted = configuredPort(); int chosen = isPortFree(wanted) ? wanted : findFreePort(wanted); if (chosen != wanted) { - System.out.println("[Desktop] Port " + wanted + " occupe — repli sur le port libre " + chosen + "."); + log.info("[Desktop] Port {} occupe — repli sur le port libre {}.", wanted, chosen); } - System.setProperty("server.port", String.valueOf(chosen)); + System.setProperty(SERVER_PORT_PROPERTY, String.valueOf(chosen)); try { Files.writeString(portFile(), String.valueOf(chosen)); } catch (IOException e) { - System.err.println("[Desktop] Ecriture du port impossible (" + e.getMessage() + ")."); + log.warn("[Desktop] Ecriture du port impossible ({}).", e.getMessage()); } return chosen; } @@ -104,7 +115,7 @@ public final class DesktopUserConfig { * → port configuré → 8080. */ public static int runningPort() { - String sys = System.getProperty("server.port"); + String sys = System.getProperty(SERVER_PORT_PROPERTY); if (sys != null && !sys.isBlank()) { try { return Integer.parseInt(sys.trim()); } catch (NumberFormatException ignored) { /* fallthrough */ } } @@ -124,7 +135,7 @@ public final class DesktopUserConfig { Properties props = new Properties(); try (InputStream in = Files.newInputStream(file)) { props.load(in); - String v = props.getProperty("server.port"); + String v = props.getProperty(SERVER_PORT_PROPERTY); if (v != null && !v.isBlank()) { return Integer.parseInt(v.trim()); } diff --git a/core/src/main/java/com/loremind/infrastructure/licensing/FileDockerConfigWriter.java b/core/src/main/java/com/loremind/infrastructure/licensing/FileDockerConfigWriter.java index 3d2fa08..a94d94e 100644 --- a/core/src/main/java/com/loremind/infrastructure/licensing/FileDockerConfigWriter.java +++ b/core/src/main/java/com/loremind/infrastructure/licensing/FileDockerConfigWriter.java @@ -37,6 +37,9 @@ public class FileDockerConfigWriter implements DockerConfigWriter { private static final Logger log = LoggerFactory.getLogger(FileDockerConfigWriter.class); + /** Cle racine du config.json Docker portant les registres authentifies. */ + private static final String AUTHS_KEY = "auths"; + private final Path configPath; private final ObjectMapper mapper = new ObjectMapper(); @@ -62,9 +65,9 @@ public class FileDockerConfigWriter implements DockerConfigWriter { root = mapper.createObjectNode(); } - ObjectNode auths = root.has("auths") && root.get("auths").isObject() - ? (ObjectNode) root.get("auths") - : root.putObject("auths"); + ObjectNode auths = root.has(AUTHS_KEY) && root.get(AUTHS_KEY).isObject() + ? (ObjectNode) root.get(AUTHS_KEY) + : root.putObject(AUTHS_KEY); String b64 = Base64.getEncoder().encodeToString( (credentials.username() + ":" + credentials.password()).getBytes(StandardCharsets.UTF_8)); diff --git a/core/src/main/java/com/loremind/infrastructure/licensing/NimbusJwtVerifier.java b/core/src/main/java/com/loremind/infrastructure/licensing/NimbusJwtVerifier.java index d478145..564e4f4 100644 --- a/core/src/main/java/com/loremind/infrastructure/licensing/NimbusJwtVerifier.java +++ b/core/src/main/java/com/loremind/infrastructure/licensing/NimbusJwtVerifier.java @@ -8,7 +8,7 @@ import com.nimbusds.jose.crypto.Ed25519Verifier; import com.nimbusds.jose.jwk.OctetKeyPair; import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.SignedJWT; -import org.bouncycastle.asn1.ASN1Sequence; +import org.bouncycastle.asn1.ASN1Primitive; import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -21,8 +21,8 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.text.ParseException; +import java.time.Instant; import java.util.Base64; -import java.util.Date; /** * Verifie les JWT EdDSA/Ed25519 emis par le relais Patreon. @@ -88,25 +88,48 @@ public class NimbusJwtVerifier implements JwtVerifier { @Override public LicenseClaims verify(String rawJwt) throws JwtVerificationException { + ensureConfigured(); + ensureNotBlank(rawJwt); + + SignedJWT signed = parseJwt(rawJwt); + ensureExpectedAlgorithm(signed); + verifySignature(signed); + + JWTClaimsSet claims = parseClaims(signed); + ensureExpectedIssuer(claims); + ensureExpectedAudience(claims); + + return toLicenseClaims(claims); + } + + private void ensureConfigured() throws JwtVerificationException { if (publicKey == null) { throw new JwtVerificationException("JWT verifier not configured"); } + } + + private static void ensureNotBlank(String rawJwt) throws JwtVerificationException { if (rawJwt == null || rawJwt.isBlank()) { throw new JwtVerificationException("JWT is empty"); } + } - SignedJWT signed; + private static SignedJWT parseJwt(String rawJwt) throws JwtVerificationException { try { - signed = SignedJWT.parse(rawJwt); + return SignedJWT.parse(rawJwt); } catch (ParseException e) { throw new JwtVerificationException("JWT parse error: " + e.getMessage(), e); } + } + private static void ensureExpectedAlgorithm(SignedJWT signed) throws JwtVerificationException { JWSAlgorithm alg = signed.getHeader().getAlgorithm(); if (!JWSAlgorithm.EdDSA.equals(alg)) { throw new JwtVerificationException("Unexpected JWT algorithm: " + alg); } + } + private void verifySignature(SignedJWT signed) throws JwtVerificationException { try { JWSVerifier verifier = new Ed25519Verifier(publicKey); if (!signed.verify(verifier)) { @@ -115,31 +138,43 @@ public class NimbusJwtVerifier implements JwtVerifier { } catch (Exception e) { throw new JwtVerificationException("JWT signature verification failed: " + e.getMessage(), e); } + } - JWTClaimsSet claims; + private static JWTClaimsSet parseClaims(SignedJWT signed) throws JwtVerificationException { try { - claims = signed.getJWTClaimsSet(); + return signed.getJWTClaimsSet(); } catch (ParseException e) { throw new JwtVerificationException("JWT claims parse error", e); } + } + private void ensureExpectedIssuer(JWTClaimsSet claims) throws JwtVerificationException { if (!expectedIssuer.equals(claims.getIssuer())) { throw new JwtVerificationException("JWT issuer mismatch: " + claims.getIssuer()); } + } + + private void ensureExpectedAudience(JWTClaimsSet claims) throws JwtVerificationException { if (claims.getAudience() == null || !claims.getAudience().contains(expectedAudience)) { throw new JwtVerificationException("JWT audience mismatch"); } + } - Date exp = claims.getExpirationTime(); - Date iat = claims.getIssueTime(); + /** + * Construit les LicenseClaims depuis les claims validés (issuer/audience/signature). + * Note : on ne refuse pas un JWT expiré ici. C'est au LicenseService de decider ce + * qu'il fait d'un JWT expire (grace period, refresh, etc.). La verification de + * signature reste valide tant que la cle existe. + */ + private static LicenseClaims toLicenseClaims(JWTClaimsSet claims) throws JwtVerificationException { + // Nimbus n'expose exp/iat qu'en java.util.Date : converti en Instant SANS variable + // Date intermediaire (aucune reference au type legacy dans cette classe). String sub = claims.getSubject(); - if (exp == null || iat == null || sub == null) { + if (claims.getExpirationTime() == null || claims.getIssueTime() == null || sub == null) { throw new JwtVerificationException("JWT missing required claims"); } - - // Note : on ne refuse pas un JWT expire ici. C'est au LicenseService - // de decider ce qu'il fait d'un JWT expire (grace period, refresh, etc.). - // La verification de signature reste valide tant que la cle existe. + Instant exp = claims.getExpirationTime().toInstant(); + Instant iat = claims.getIssueTime().toInstant(); String tierId; String instanceId; @@ -153,13 +188,7 @@ public class NimbusJwtVerifier implements JwtVerifier { throw new JwtVerificationException("JWT missing tier_id or instance_id"); } - return new LicenseClaims( - sub, - tierId, - instanceId, - iat.toInstant(), - exp.toInstant() - ); + return new LicenseClaims(sub, tierId, instanceId, iat, exp); } /** @@ -174,7 +203,7 @@ public class NimbusJwtVerifier implements JwtVerifier { .replace("-----END PUBLIC KEY-----", "") .replaceAll("\\s+", ""); byte[] der = Base64.getDecoder().decode(base64); - SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(ASN1Sequence.fromByteArray(der)); + SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(ASN1Primitive.fromByteArray(der)); byte[] keyBytes = spki.getPublicKeyData().getOctets(); String x = Base64.getUrlEncoder().withoutPadding().encodeToString(keyBytes); return new OctetKeyPair.Builder(com.nimbusds.jose.jwk.Curve.Ed25519, com.nimbusds.jose.util.Base64URL.from(x)) diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/CharacterNpcMarkdownBackfill.java b/core/src/main/java/com/loremind/infrastructure/persistence/CharacterNpcMarkdownBackfill.java index 497483f..f55a9a3 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/CharacterNpcMarkdownBackfill.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/CharacterNpcMarkdownBackfill.java @@ -31,6 +31,24 @@ public class CharacterNpcMarkdownBackfill { private static final Logger log = LoggerFactory.getLogger(CharacterNpcMarkdownBackfill.class); + // SQL en CONSTANTES de compilation (pas de concaténation avec un paramètre) : + // les noms de table sont figés dans les littéraux, aucune donnée dynamique + // n'entre dans la requête hors placeholders '?'. + // Sélection : fiches avec markdown non vide ET field_values vide ou absent. + // field_values peut etre NULL (legacy avant refonte) ou "{}" (refonte appliquee mais sans data). + private static final String BACKFILL_WHERE = + " WHERE markdown_content IS NOT NULL " + + " AND markdown_content <> '' " + + " AND (field_values IS NULL OR field_values = '' OR field_values = '{}')"; + private static final String SELECT_CHARACTERS = + "SELECT id, markdown_content FROM characters" + BACKFILL_WHERE; + private static final String SELECT_NPCS = + "SELECT id, markdown_content FROM npcs" + BACKFILL_WHERE; + private static final String UPDATE_CHARACTERS = + "UPDATE characters SET field_values = ? WHERE id = ?"; + private static final String UPDATE_NPCS = + "UPDATE npcs SET field_values = ? WHERE id = ?"; + private final JdbcTemplate jdbc; private final ObjectMapper mapper = new ObjectMapper(); @@ -44,8 +62,8 @@ public class CharacterNpcMarkdownBackfill { log.debug("Backfill skip : colonne markdown_content absente (deja migre ou install propre)."); return; } - int chars = backfillTable("characters"); - int npcs = backfillTable("npcs"); + int chars = backfillTable("characters", SELECT_CHARACTERS, UPDATE_CHARACTERS); + int npcs = backfillTable("npcs", SELECT_NPCS, UPDATE_NPCS); if (chars + npcs > 0) { log.info("Backfill markdown -> field_values : {} character(s), {} npc(s) migre(s).", chars, npcs); } @@ -65,14 +83,7 @@ public class CharacterNpcMarkdownBackfill { } } - private int backfillTable(String table) { - // Selection : fiches avec markdown non vide ET field_values vide ou absent. - // field_values peut etre NULL (legacy avant refonte) ou "{}" (refonte appliquee mais sans data). - String selectSql = "SELECT id, markdown_content FROM " + table - + " WHERE markdown_content IS NOT NULL " - + " AND markdown_content <> '' " - + " AND (field_values IS NULL OR field_values = '' OR field_values = '{}')"; - + private int backfillTable(String label, String selectSql, String updateSql) { var rows = jdbc.queryForList(selectSql); int migrated = 0; for (var row : rows) { @@ -82,10 +93,10 @@ public class CharacterNpcMarkdownBackfill { try { json = mapper.writeValueAsString(Map.of("Notes", markdown)); } catch (Exception e) { - log.error("Backfill {} id={} : echec serialisation JSON, ignore. {}", table, id, e.getMessage()); + log.error("Backfill {} id={} : echec serialisation JSON, ignore. {}", label, id, e.getMessage()); continue; } - jdbc.update("UPDATE " + table + " SET field_values = ? WHERE id = ?", json, id); + jdbc.update(updateSql, json, id); migrated++; } return migrated; diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/GameSystemSeeder.java b/core/src/main/java/com/loremind/infrastructure/persistence/GameSystemSeeder.java index 0d9a047..23f9fd0 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/GameSystemSeeder.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/GameSystemSeeder.java @@ -35,6 +35,10 @@ public class GameSystemSeeder { private static final Logger log = LoggerFactory.getLogger(GameSystemSeeder.class); + private static final String AUTHOR_SEED = "LoreMind seed"; + private static final String FIELD_HISTOIRE = "Histoire"; + private static final String FIELD_GALERIE = "Galerie"; + private final GameSystemRepository gameSystemRepository; public GameSystemSeeder(GameSystemRepository gameSystemRepository) { @@ -81,7 +85,7 @@ public class GameSystemSeeder { GameSystem.builder() .name("Nimble (extrait)") .description("Système léger et narratif, résolution rapide des combats.") - .author("LoreMind seed") + .author(AUTHOR_SEED) .isPublic(false) .rulesMarkdown(NIMBLE_RULES) .characterTemplate(nimbleCharacterTemplate()) @@ -90,7 +94,7 @@ public class GameSystemSeeder { GameSystem.builder() .name("D&D 5e SRD (extrait)") .description("Extrait libre des bases du System Reference Document 5.1.") - .author("LoreMind seed") + .author(AUTHOR_SEED) .isPublic(false) .rulesMarkdown(DND_SRD_RULES) .characterTemplate(dndCharacterTemplate()) @@ -99,7 +103,7 @@ public class GameSystemSeeder { GameSystem.builder() .name("Homebrew Exemple") .description("Template minimaliste à dupliquer pour créer votre propre système.") - .author("LoreMind seed") + .author(AUTHOR_SEED) .isPublic(false) .rulesMarkdown(HOMEBREW_EXAMPLE) .characterTemplate(genericCharacterTemplate()) @@ -113,10 +117,10 @@ public class GameSystemSeeder { /** Template generique PJ — utilise pour Homebrew, backfill, et fallback. */ private static List genericCharacterTemplate() { return List.of( - TemplateField.text("Histoire"), + TemplateField.text(FIELD_HISTOIRE), TemplateField.text("Personnalite"), TemplateField.text("Apparence"), - TemplateField.image("Galerie", ImageLayout.GALLERY), + TemplateField.image(FIELD_GALERIE, ImageLayout.GALLERY), TemplateField.text("Notes") ); } @@ -137,9 +141,9 @@ public class GameSystemSeeder { TemplateField.number("Blessures graves max"), TemplateField.text("Capacites de classe"), TemplateField.text("Equipement"), - TemplateField.text("Histoire"), + TemplateField.text(FIELD_HISTOIRE), TemplateField.text("Objectifs personnels"), - TemplateField.image("Galerie", ImageLayout.GALLERY) + TemplateField.image(FIELD_GALERIE, ImageLayout.GALLERY) ); } @@ -157,8 +161,8 @@ public class GameSystemSeeder { TemplateField.text("Competences"), TemplateField.text("Equipement"), TemplateField.text("Sorts"), - TemplateField.text("Histoire"), - TemplateField.image("Galerie", ImageLayout.GALLERY) + TemplateField.text(FIELD_HISTOIRE), + TemplateField.image(FIELD_GALERIE, ImageLayout.GALLERY) ); } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/converter/MapJsonConverter.java b/core/src/main/java/com/loremind/infrastructure/persistence/converter/MapJsonConverter.java index a3c47fe..47c22f7 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/converter/MapJsonConverter.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/converter/MapJsonConverter.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; +import java.util.HashMap; import java.util.Map; /** @@ -32,8 +33,11 @@ public class MapJsonConverter implements AttributeConverter, @Override @SuppressWarnings("unchecked") public Map convertToEntityAttribute(String dbData) { - if (dbData == null) { - return null; + // Map vide (mutable) plutôt que null : les consommateurs itèrent/écrivent + // sans avoir à se défendre contre le null (même contrat que les autres + // converters de ce package). + if (dbData == null || dbData.isBlank()) { + return new HashMap<>(); } try { return objectMapper.readValue(dbData, Map.class); diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/converter/TemplateFieldListJsonConverter.java b/core/src/main/java/com/loremind/infrastructure/persistence/converter/TemplateFieldListJsonConverter.java index 2316e8a..95dd37f 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/converter/TemplateFieldListJsonConverter.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/converter/TemplateFieldListJsonConverter.java @@ -60,71 +60,8 @@ public class TemplateFieldListJsonConverter } List result = new ArrayList<>(); for (JsonNode item : root) { - if (item.isTextual()) { - // Format legacy : chaine simple, on suppose TEXT par defaut. - // L'id stable est retro-rempli avec le nom (les valeurs de Page - // sont deja rangees par nom -> id == name, aucune migration). - String name = item.asText(); - TemplateField legacy = TemplateField.text(name); - legacy.setId(name); - result.add(legacy); - } else if (item.isObject()) { - // Nouveau format : {id?, name, type, layout?, labels?, foundryPath?, pos?} - String name = item.path("name").asText(null); - String typeStr = item.path("type").asText("TEXT"); - FieldType type; - try { - type = FieldType.valueOf(typeStr); - } catch (IllegalArgumentException ex) { - // Type inconnu (ajoute par une version future) : fallback TEXT. - type = FieldType.TEXT; - } - ImageLayout layout = null; - if (type == FieldType.IMAGE) { - String layoutStr = item.path("layout").asText(null); - if (layoutStr != null && !layoutStr.isBlank()) { - try { - layout = ImageLayout.valueOf(layoutStr); - } catch (IllegalArgumentException ex) { - // Layout inconnu : on laisse null → rendu GALLERY par defaut cote UI. - layout = null; - } - } - } - List labels = null; - if (type == FieldType.KEY_VALUE_LIST || type == FieldType.TABLE) { - JsonNode labelsNode = item.path("labels"); - if (labelsNode.isArray()) { - labels = new ArrayList<>(); - for (JsonNode label : labelsNode) { - if (label.isTextual()) labels.add(label.asText()); - } - } - } - // foundryPath : lu via hasNonNull pour eviter le piege NullNode - // (asText() renverrait la chaine "null"). Historiquement omis a la - // relecture -> il etait perdu au save+reload : corrige ici. - String foundryPath = item.hasNonNull("foundryPath") - ? item.get("foundryPath").asText() : null; - // id : explicite si present, sinon retro-rempli avec le nom. - String id = item.hasNonNull("id") ? item.get("id").asText() : null; - if (id == null || id.isBlank()) { - id = name; - } - BlockPosition pos = readPos(item.path("pos")); - if (name != null && !name.isBlank()) { - result.add(TemplateField.builder() - .id(id) - .name(name) - .type(type) - .layout(layout) - .labels(labels) - .foundryPath(foundryPath) - .pos(pos) - .build()); - } - } - // Autres types de noeuds (nombre, booleen...) : ignores silencieusement. + TemplateField field = parseField(item); + if (field != null) result.add(field); } return result; } catch (Exception e) { @@ -133,6 +70,101 @@ public class TemplateFieldListJsonConverter } } + /** Un element du tableau JSON -> TemplateField, ou null si non convertible (ignore). */ + private static TemplateField parseField(JsonNode item) { + if (item.isTextual()) { + return parseLegacyField(item.asText()); + } + if (item.isObject()) { + return parseObjectField(item); + } + // Autres types de noeuds (nombre, booleen...) : ignores silencieusement. + return null; + } + + /** + * Format legacy : chaine simple, on suppose TEXT par defaut. L'id stable est + * retro-rempli avec le nom (les valeurs de Page sont deja rangees par nom -> + * id == name, aucune migration). + */ + private static TemplateField parseLegacyField(String name) { + TemplateField legacy = TemplateField.text(name); + legacy.setId(name); + return legacy; + } + + /** Nouveau format : {id?, name, type, layout?, labels?, foundryPath?, pos?}. Null si nom vide. */ + private static TemplateField parseObjectField(JsonNode item) { + String name = item.path("name").asText(null); + if (name == null || name.isBlank()) { + return null; + } + FieldType type = parseType(item.path("type").asText("TEXT")); + // foundryPath : lu via hasNonNull pour eviter le piege NullNode + // (asText() renverrait la chaine "null"). Historiquement omis a la + // relecture -> il etait perdu au save+reload : corrige ici. + String foundryPath = item.hasNonNull("foundryPath") + ? item.get("foundryPath").asText() : null; + // id : explicite si present, sinon retro-rempli avec le nom. + String id = item.hasNonNull("id") ? item.get("id").asText() : null; + if (id == null || id.isBlank()) { + id = name; + } + return TemplateField.builder() + .id(id) + .name(name) + .type(type) + .layout(parseLayout(item, type)) + .labels(parseLabels(item, type)) + .foundryPath(foundryPath) + .pos(readPos(item.path("pos"))) + .build(); + } + + /** Type du champ ; fallback TEXT si inconnu (ajoute par une version future). */ + private static FieldType parseType(String typeStr) { + try { + return FieldType.valueOf(typeStr); + } catch (IllegalArgumentException ex) { + return FieldType.TEXT; + } + } + + /** Layout d'un champ IMAGE ; null si absent/inconnu (-> rendu GALLERY par defaut cote UI). */ + private static ImageLayout parseLayout(JsonNode item, FieldType type) { + if (type != FieldType.IMAGE) { + return null; + } + String layoutStr = item.path("layout").asText(null); + if (layoutStr == null || layoutStr.isBlank()) { + return null; + } + try { + return ImageLayout.valueOf(layoutStr); + } catch (IllegalArgumentException ex) { + return null; + } + } + + /** Libelles d'un champ KEY_VALUE_LIST/TABLE ; null pour les autres types. */ + // S1168 : null est ici SEMANTIQUE ("champ sans libelles"), distinct d'une liste vide — + // le JSON persiste ecrit "labels":null (compat ascendante) et le front distingue null/[]. + @SuppressWarnings("java:S1168") + private static List parseLabels(JsonNode item, FieldType type) { + if (type != FieldType.KEY_VALUE_LIST && type != FieldType.TABLE) { + return null; + } + JsonNode labelsNode = item.path("labels"); + if (!labelsNode.isArray()) { + return null; + } + List labels = new ArrayList<>(); + for (JsonNode label : labelsNode) { + if (label.isTextual()) labels.add(label.asText()); + } + return labels; + } + /** * Lit le placement {@code pos: {x, y, w, h}} d'un bloc. Renvoie null si le * noeud n'est pas un objet ou si toutes les coordonnees sont absentes diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/ArcJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ArcJpaEntity.java index b24cf2f..4fe61ce 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/ArcJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ArcJpaEntity.java @@ -9,6 +9,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.ArrayList; import java.util.List; @@ -94,12 +95,12 @@ public class ArcJpaEntity { @PrePersist protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); + createdAt = LocalDateTime.now(ZoneId.systemDefault()); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } @PreUpdate protected void onUpdate() { - updatedAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/CampaignJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/CampaignJpaEntity.java index a85a739..ae74dfe 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/CampaignJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/CampaignJpaEntity.java @@ -6,6 +6,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.time.ZoneId; /** * Entité JPA pour la persistance des Campaigns en base de données PostgreSQL. @@ -64,12 +65,12 @@ public class CampaignJpaEntity { @PrePersist protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); + createdAt = LocalDateTime.now(ZoneId.systemDefault()); + updatedAt = createdAt; } @PreUpdate protected void onUpdate() { - updatedAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/ChapterJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ChapterJpaEntity.java index c9278d0..b3cf2dc 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/ChapterJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ChapterJpaEntity.java @@ -7,6 +7,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.ArrayList; import java.util.List; @@ -68,12 +69,12 @@ public class ChapterJpaEntity { @PrePersist protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); + createdAt = LocalDateTime.now(ZoneId.systemDefault()); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } @PreUpdate protected void onUpdate() { - updatedAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/CharacterJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/CharacterJpaEntity.java index 60efb7b..d11d6c2 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/CharacterJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/CharacterJpaEntity.java @@ -10,6 +10,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -81,8 +82,8 @@ public class CharacterJpaEntity { @PrePersist protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); + createdAt = LocalDateTime.now(ZoneId.systemDefault()); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); if (values == null) values = new HashMap<>(); if (imageValues == null) imageValues = new HashMap<>(); if (keyValueValues == null) keyValueValues = new HashMap<>(); @@ -90,6 +91,6 @@ public class CharacterJpaEntity { @PreUpdate protected void onUpdate() { - updatedAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/ClockJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ClockJpaEntity.java index 4744174..9d16719 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/ClockJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ClockJpaEntity.java @@ -8,6 +8,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.time.ZoneId; /** * Entité JPA pour la persistance des Horloges (Clocks) en PostgreSQL. @@ -64,13 +65,13 @@ public class ClockJpaEntity { @PrePersist protected void onCreate() { - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault()); createdAt = now; updatedAt = now; } @PreUpdate protected void onUpdate() { - updatedAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/ConversationJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ConversationJpaEntity.java index ed2e19d..64ad6cb 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/ConversationJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ConversationJpaEntity.java @@ -19,6 +19,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.ArrayList; import java.util.List; @@ -77,13 +78,13 @@ public class ConversationJpaEntity { @PrePersist protected void onCreate() { - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault()); createdAt = now; updatedAt = now; } @PreUpdate protected void onUpdate() { - updatedAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/ConversationMessageJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ConversationMessageJpaEntity.java index bbf82f6..99f7143 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/ConversationMessageJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ConversationMessageJpaEntity.java @@ -16,6 +16,7 @@ import lombok.NoArgsConstructor; import lombok.ToString; import java.time.LocalDateTime; +import java.time.ZoneId; /** * Persistance d'un message appartenant a une {@link ConversationJpaEntity}. @@ -54,6 +55,6 @@ public class ConversationMessageJpaEntity { @PrePersist protected void onCreate() { - createdAt = LocalDateTime.now(); + createdAt = LocalDateTime.now(ZoneId.systemDefault()); } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/EnemyJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/EnemyJpaEntity.java index bb22bd0..cb9ac18 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/EnemyJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/EnemyJpaEntity.java @@ -10,6 +10,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -83,8 +84,8 @@ public class EnemyJpaEntity { @PrePersist protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); + createdAt = LocalDateTime.now(ZoneId.systemDefault()); + updatedAt = createdAt; if (values == null) values = new HashMap<>(); if (imageValues == null) imageValues = new HashMap<>(); if (keyValueValues == null) keyValueValues = new HashMap<>(); @@ -92,6 +93,6 @@ public class EnemyJpaEntity { @PreUpdate protected void onUpdate() { - updatedAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/FrontJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/FrontJpaEntity.java index f72d847..8c45afd 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/FrontJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/FrontJpaEntity.java @@ -7,6 +7,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.time.ZoneId; /** * Entité JPA pour la persistance des Fronts en PostgreSQL. @@ -44,13 +45,13 @@ public class FrontJpaEntity { @PrePersist protected void onCreate() { - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault()); createdAt = now; updatedAt = now; } @PreUpdate protected void onUpdate() { - updatedAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/GameSystemJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/GameSystemJpaEntity.java index a2f185f..216dc1c 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/GameSystemJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/GameSystemJpaEntity.java @@ -9,6 +9,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.ArrayList; import java.util.List; @@ -69,8 +70,8 @@ public class GameSystemJpaEntity { @PrePersist protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); + createdAt = LocalDateTime.now(ZoneId.systemDefault()); + updatedAt = createdAt; if (characterTemplate == null) characterTemplate = new ArrayList<>(); if (npcTemplate == null) npcTemplate = new ArrayList<>(); if (enemyTemplate == null) enemyTemplate = new ArrayList<>(); @@ -78,6 +79,6 @@ public class GameSystemJpaEntity { @PreUpdate protected void onUpdate() { - updatedAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/ImageJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ImageJpaEntity.java index 23a25dc..9808637 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/ImageJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ImageJpaEntity.java @@ -7,6 +7,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.time.ZoneId; /** * Entite JPA pour les metadonnees d'images en PostgreSQL. @@ -43,7 +44,7 @@ public class ImageJpaEntity { @PrePersist protected void onCreate() { if (uploadedAt == null) { - uploadedAt = LocalDateTime.now(); + uploadedAt = LocalDateTime.now(ZoneId.systemDefault()); } } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/ItemCatalogJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ItemCatalogJpaEntity.java index b4ded93..eb155d3 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/ItemCatalogJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ItemCatalogJpaEntity.java @@ -7,6 +7,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.ArrayList; import java.util.List; @@ -60,12 +61,12 @@ public class ItemCatalogJpaEntity { @PrePersist protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); + createdAt = LocalDateTime.now(ZoneId.systemDefault()); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } @PreUpdate protected void onUpdate() { - updatedAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/LoreJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/LoreJpaEntity.java index 33827f1..5f7277a 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/LoreJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/LoreJpaEntity.java @@ -6,6 +6,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.time.ZoneId; /** * Entité JPA pour la persistance des Lores en base de données PostgreSQL. @@ -43,12 +44,12 @@ public class LoreJpaEntity { @PrePersist protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); + createdAt = LocalDateTime.now(ZoneId.systemDefault()); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } @PreUpdate protected void onUpdate() { - updatedAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/LoreNodeJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/LoreNodeJpaEntity.java index 9a6f224..2aa3d85 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/LoreNodeJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/LoreNodeJpaEntity.java @@ -6,6 +6,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.time.ZoneId; /** * Entité JPA pour la persistance des LoreNodes en base de données PostgreSQL. @@ -48,12 +49,12 @@ public class LoreNodeJpaEntity { @PrePersist protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); + createdAt = LocalDateTime.now(ZoneId.systemDefault()); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } @PreUpdate protected void onUpdate() { - updatedAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/NotebookJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/NotebookJpaEntity.java index 41009d4..75d9a86 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/NotebookJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/NotebookJpaEntity.java @@ -7,6 +7,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.time.ZoneId; @Entity @Table(name = "notebooks", indexes = { @@ -36,12 +37,12 @@ public class NotebookJpaEntity { @PrePersist protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); + createdAt = LocalDateTime.now(ZoneId.systemDefault()); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } @PreUpdate protected void onUpdate() { - updatedAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/NotebookMessageJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/NotebookMessageJpaEntity.java index 7c25d0c..c484d5f 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/NotebookMessageJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/NotebookMessageJpaEntity.java @@ -7,6 +7,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.time.ZoneId; @Entity @Table(name = "notebook_messages", indexes = { @@ -44,6 +45,6 @@ public class NotebookMessageJpaEntity { @PrePersist protected void onCreate() { - if (createdAt == null) createdAt = LocalDateTime.now(); + if (createdAt == null) createdAt = LocalDateTime.now(ZoneId.systemDefault()); } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/NotebookSourceJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/NotebookSourceJpaEntity.java index f207fd3..2a0ca60 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/NotebookSourceJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/NotebookSourceJpaEntity.java @@ -7,6 +7,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.time.ZoneId; @Entity @Table(name = "notebook_sources", indexes = { @@ -42,6 +43,6 @@ public class NotebookSourceJpaEntity { @PrePersist protected void onCreate() { - if (createdAt == null) createdAt = LocalDateTime.now(); + if (createdAt == null) createdAt = LocalDateTime.now(ZoneId.systemDefault()); } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/NpcJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/NpcJpaEntity.java index 3bac2f2..6a7a688 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/NpcJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/NpcJpaEntity.java @@ -11,6 +11,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -74,8 +75,8 @@ public class NpcJpaEntity { @PrePersist protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); + createdAt = LocalDateTime.now(ZoneId.systemDefault()); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); if (values == null) values = new HashMap<>(); if (imageValues == null) imageValues = new HashMap<>(); if (keyValueValues == null) keyValueValues = new HashMap<>(); @@ -83,6 +84,6 @@ public class NpcJpaEntity { @PreUpdate protected void onUpdate() { - updatedAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/PageJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/PageJpaEntity.java index 07784f1..0970eac 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/PageJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/PageJpaEntity.java @@ -14,6 +14,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.List; import java.util.Map; @@ -94,12 +95,12 @@ public class PageJpaEntity { @PrePersist protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); + createdAt = LocalDateTime.now(ZoneId.systemDefault()); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } @PreUpdate protected void onUpdate() { - updatedAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/PlaythroughJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/PlaythroughJpaEntity.java index 580a68e..13c6c2e 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/PlaythroughJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/PlaythroughJpaEntity.java @@ -7,6 +7,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.time.ZoneId; /** * Entité JPA pour la table {@code playthroughs} — instances jouées d'une Campagne. @@ -43,12 +44,12 @@ public class PlaythroughJpaEntity { @PrePersist protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); + createdAt = LocalDateTime.now(ZoneId.systemDefault()); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } @PreUpdate protected void onUpdate() { - updatedAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/QuestJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/QuestJpaEntity.java index c99ddfb..47c8ae9 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/QuestJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/QuestJpaEntity.java @@ -11,6 +11,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.ArrayList; import java.util.List; @@ -90,12 +91,12 @@ public class QuestJpaEntity { @PrePersist protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); + createdAt = LocalDateTime.now(ZoneId.systemDefault()); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } @PreUpdate protected void onUpdate() { - updatedAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/RandomTableJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/RandomTableJpaEntity.java index 357f560..d8170a7 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/RandomTableJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/RandomTableJpaEntity.java @@ -7,6 +7,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.ArrayList; import java.util.List; @@ -62,12 +63,12 @@ public class RandomTableJpaEntity { @PrePersist protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); + createdAt = LocalDateTime.now(ZoneId.systemDefault()); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } @PreUpdate protected void onUpdate() { - updatedAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/SceneJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/SceneJpaEntity.java index 8874158..eebc556 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/SceneJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/SceneJpaEntity.java @@ -14,6 +14,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.ArrayList; import java.util.List; @@ -140,12 +141,12 @@ public class SceneJpaEntity { @PrePersist protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); + createdAt = LocalDateTime.now(ZoneId.systemDefault()); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } @PreUpdate protected void onUpdate() { - updatedAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/SessionEntryJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/SessionEntryJpaEntity.java index fcdbf1e..f749358 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/SessionEntryJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/SessionEntryJpaEntity.java @@ -8,6 +8,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.time.ZoneId; /** * Entité JPA pour la persistance des entrées de journal de session. @@ -48,13 +49,13 @@ public class SessionEntryJpaEntity { @PrePersist protected void onCreate() { - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault()); createdAt = now; updatedAt = now; } @PreUpdate protected void onUpdate() { - updatedAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/SessionJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/SessionJpaEntity.java index e851a10..14c713a 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/SessionJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/SessionJpaEntity.java @@ -7,6 +7,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.time.ZoneId; /** * Entité JPA pour la persistance des Sessions en PostgreSQL. @@ -61,13 +62,13 @@ public class SessionJpaEntity { @PrePersist protected void onCreate() { - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault()); createdAt = now; updatedAt = now; } @PreUpdate protected void onUpdate() { - updatedAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/StoredFileJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/StoredFileJpaEntity.java index d9431d9..03a843c 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/StoredFileJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/StoredFileJpaEntity.java @@ -7,6 +7,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.time.ZoneId; /** * Entite JPA pour les metadonnees de fichiers generiques en PostgreSQL. @@ -44,7 +45,7 @@ public class StoredFileJpaEntity { @PrePersist protected void onCreate() { if (uploadedAt == null) { - uploadedAt = LocalDateTime.now(); + uploadedAt = LocalDateTime.now(ZoneId.systemDefault()); } } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/TemplateJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/TemplateJpaEntity.java index 64cb09b..a74cd98 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/TemplateJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/TemplateJpaEntity.java @@ -9,6 +9,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.List; /** @@ -54,12 +55,12 @@ public class TemplateJpaEntity { @PrePersist protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); + createdAt = LocalDateTime.now(ZoneId.systemDefault()); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } @PreUpdate protected void onUpdate() { - updatedAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(ZoneId.systemDefault()); } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/migration/PlaythroughMigrationRunner.java b/core/src/main/java/com/loremind/infrastructure/persistence/migration/PlaythroughMigrationRunner.java index 220c91c..027c057 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/migration/PlaythroughMigrationRunner.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/migration/PlaythroughMigrationRunner.java @@ -10,6 +10,7 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.List; /** @@ -35,7 +36,11 @@ public class PlaythroughMigrationRunner { private static final Logger LOG = LoggerFactory.getLogger(PlaythroughMigrationRunner.class); private static final String DEFAULT_PLAYTHROUGH_NAME = "Partie principale"; + private static final String COLUMN_CAMPAIGN_ID = "campaign_id"; + // S6809 : auto-invocation assumee — "corriger" (auto-injection/@Lazy) changerait la semantique + // transactionnelle reelle de cette migration one-shot idempotente. + @SuppressWarnings("java:S6809") @Bean public ApplicationRunner runPlaythroughMigration(JdbcTemplate jdbc) { return args -> migrate(jdbc); @@ -73,7 +78,7 @@ public class PlaythroughMigrationRunner { ); if (campaignsWithoutPlaythrough.isEmpty()) return 0; - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault()); for (Long campaignId : campaignsWithoutPlaythrough) { jdbc.update( "INSERT INTO playthroughs (campaign_id, name, description, created_at, updated_at) " + @@ -85,7 +90,7 @@ public class PlaythroughMigrationRunner { } private int migrateSessions(JdbcTemplate jdbc) { - if (!columnExists(jdbc, "sessions", "campaign_id")) return 0; + if (!columnExists(jdbc, "sessions", COLUMN_CAMPAIGN_ID)) return 0; return jdbc.update( "UPDATE sessions s " + "SET playthrough_id = (SELECT p.id FROM playthroughs p WHERE p.campaign_id = CAST(s.campaign_id AS BIGINT) LIMIT 1) " + @@ -94,7 +99,7 @@ public class PlaythroughMigrationRunner { } private int migrateCharacters(JdbcTemplate jdbc) { - if (!columnExists(jdbc, "characters", "campaign_id")) return 0; + if (!columnExists(jdbc, "characters", COLUMN_CAMPAIGN_ID)) return 0; return jdbc.update( "UPDATE characters c " + "SET playthrough_id = (SELECT p.id FROM playthroughs p WHERE p.campaign_id = c.campaign_id LIMIT 1) " + @@ -145,11 +150,13 @@ public class PlaythroughMigrationRunner { * Idempotent : PostgreSQL ne bronche pas si la colonne est déjà NULLABLE. */ private void relaxLegacyNotNullConstraints(JdbcTemplate jdbc) { - relaxNotNull(jdbc, "sessions", "campaign_id"); - relaxNotNull(jdbc, "characters", "campaign_id"); + relaxNotNull(jdbc, "sessions", COLUMN_CAMPAIGN_ID); + relaxNotNull(jdbc, "characters", COLUMN_CAMPAIGN_ID); relaxNotNull(jdbc, "chapters", "progression_status"); } + // S2077 : DDL non parametrable ; table/colonne proviennent exclusivement d'appels internes hardcodes. + @SuppressWarnings("java:S2077") private void relaxNotNull(JdbcTemplate jdbc, String table, String column) { if (!columnExists(jdbc, table, column)) return; try { diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresArcRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresArcRepository.java index 376016b..66a1a75 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresArcRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresArcRepository.java @@ -9,7 +9,6 @@ import org.springframework.stereotype.Repository; import java.util.ArrayList; import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; /** * Adaptateur d'infrastructure qui implémente le Port ArcRepository. @@ -42,14 +41,14 @@ public class PostgresArcRepository implements ArcRepository { Long longCampaignId = Long.parseLong(campaignId); return jpaRepository.findByCampaignId(longCampaignId).stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } @Override public List findAll() { return jpaRepository.findAll().stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } @Override diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepository.java index 028d3b6..97d520f 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepository.java @@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; /** * Adaptateur d'infrastructure qui implémente le Port CampaignRepository. @@ -43,7 +42,7 @@ public class PostgresCampaignRepository implements CampaignRepository { public List findAll() { return jpaRepository.findAll().stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } @Override @@ -62,7 +61,7 @@ public class PostgresCampaignRepository implements CampaignRepository { public List searchByName(String query) { return jpaRepository.findByNameContainingIgnoreCase(query).stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } private Campaign toDomainEntity(CampaignJpaEntity jpaEntity) { diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresChapterRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresChapterRepository.java index 78e8394..dc854a9 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresChapterRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresChapterRepository.java @@ -9,7 +9,6 @@ import org.springframework.stereotype.Repository; import java.util.ArrayList; import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; /** * Adaptateur d'infrastructure qui implémente le Port ChapterRepository. @@ -42,14 +41,14 @@ public class PostgresChapterRepository implements ChapterRepository { Long longArcId = Long.parseLong(arcId); return jpaRepository.findByArcId(longArcId).stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } @Override public List findAll() { return jpaRepository.findAll().stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } @Override diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCharacterRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCharacterRepository.java index a2a63bb..9575ca1 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCharacterRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCharacterRepository.java @@ -9,7 +9,6 @@ import org.springframework.stereotype.Repository; import java.util.HashMap; import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; @Repository public class PostgresCharacterRepository implements CharacterRepository { @@ -36,7 +35,7 @@ public class PostgresCharacterRepository implements CharacterRepository { public List findByPlaythroughId(String playthroughId) { return jpaRepository.findByPlaythroughIdOrderByOrderAsc(Long.parseLong(playthroughId)).stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } @Override @@ -53,7 +52,7 @@ public class PostgresCharacterRepository implements CharacterRepository { public List searchByName(String query) { return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } private Character toDomainEntity(CharacterJpaEntity e) { diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresClockRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresClockRepository.java index dbbbb9c..4d7ecfc 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresClockRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresClockRepository.java @@ -9,7 +9,6 @@ import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; /** * Adaptateur d'infrastructure : implémente le port {@link ClockRepository} via JPA/Postgres. @@ -37,7 +36,7 @@ public class PostgresClockRepository implements ClockRepository { public List findByPlaythroughId(String playthroughId) { return jpaRepository.findByPlaythroughIdOrderByOrderAsc(Long.parseLong(playthroughId)).stream() .map(this::toDomain) - .collect(Collectors.toList()); + .toList(); } @Override diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresConversationRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresConversationRepository.java index db565ec..b6c4aa3 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresConversationRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresConversationRepository.java @@ -7,13 +7,13 @@ import com.loremind.infrastructure.persistence.entity.ConversationJpaEntity; import com.loremind.infrastructure.persistence.entity.ConversationMessageJpaEntity; import com.loremind.infrastructure.persistence.jpa.ConversationJpaRepository; import jakarta.persistence.EntityNotFoundException; +import org.hibernate.Hibernate; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; import java.util.Collections; import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; /** * Adaptateur Postgres pour ConversationRepository. @@ -46,7 +46,7 @@ public class PostgresConversationRepository implements ConversationRepository { return jpa.findById(Long.parseLong(id)) .map(e -> { // Force l'initialisation LAZY avant de sortir de la transaction. - e.getMessages().size(); + Hibernate.initialize(e.getMessages()); return toDomain(e, true); }); } @@ -66,7 +66,7 @@ public class PostgresConversationRepository implements ConversationRepository { } else { return Collections.emptyList(); } - return rows.stream().map(e -> toDomain(e, false)).collect(Collectors.toList()); + return rows.stream().map(e -> toDomain(e, false)).toList(); } @Override @@ -88,7 +88,7 @@ public class PostgresConversationRepository implements ConversationRepository { .build(); conv.getMessages().add(msg); // Force updatedAt via @PreUpdate en modifiant la conv (touch). - conv.setUpdatedAt(java.time.LocalDateTime.now()); + conv.setUpdatedAt(java.time.LocalDateTime.now(java.time.ZoneId.systemDefault())); ConversationJpaEntity saved = jpa.save(conv); ConversationMessageJpaEntity persisted = saved.getMessages().get(saved.getMessages().size() - 1); @@ -122,7 +122,7 @@ public class PostgresConversationRepository implements ConversationRepository { private Conversation toDomain(ConversationJpaEntity e, boolean withMessages) { List msgs = withMessages - ? e.getMessages().stream().map(this::toDomainMessage).collect(Collectors.toList()) + ? e.getMessages().stream().map(this::toDomainMessage).toList() : new java.util.ArrayList<>(); return Conversation.builder() .id(e.getId().toString()) diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresEnemyRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresEnemyRepository.java index 9b5aef5..ccf67d0 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresEnemyRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresEnemyRepository.java @@ -9,7 +9,6 @@ import org.springframework.stereotype.Repository; import java.util.HashMap; import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; @Repository public class PostgresEnemyRepository implements EnemyRepository { @@ -34,7 +33,7 @@ public class PostgresEnemyRepository implements EnemyRepository { public List findByCampaignId(String campaignId) { return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } @Override @@ -46,7 +45,7 @@ public class PostgresEnemyRepository implements EnemyRepository { public List searchByName(String query) { return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } private Enemy toDomainEntity(EnemyJpaEntity e) { diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresFrontRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresFrontRepository.java index f1168f1..2757aac 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresFrontRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresFrontRepository.java @@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; /** * Adaptateur d'infrastructure : implémente le port {@link FrontRepository} via JPA/Postgres. @@ -36,7 +35,7 @@ public class PostgresFrontRepository implements FrontRepository { public List findByPlaythroughId(String playthroughId) { return jpaRepository.findByPlaythroughIdOrderByOrderAsc(Long.parseLong(playthroughId)).stream() .map(this::toDomain) - .collect(Collectors.toList()); + .toList(); } @Override diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresGameSystemRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresGameSystemRepository.java index d72b407..4d3676d 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresGameSystemRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresGameSystemRepository.java @@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; @Repository public class PostgresGameSystemRepository implements GameSystemRepository { @@ -35,7 +34,7 @@ public class PostgresGameSystemRepository implements GameSystemRepository { public List findAll() { return jpaRepository.findAll().stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } @Override @@ -52,7 +51,7 @@ public class PostgresGameSystemRepository implements GameSystemRepository { public List searchByName(String query) { return jpaRepository.findByNameContainingIgnoreCase(query).stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } private GameSystem toDomainEntity(GameSystemJpaEntity e) { diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresItemCatalogRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresItemCatalogRepository.java index d7a6f39..0c6198e 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresItemCatalogRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresItemCatalogRepository.java @@ -64,7 +64,7 @@ public class PostgresItemCatalogRepository implements ItemCatalogRepository { public List findByCampaignId(String campaignId) { return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } @Override @@ -82,7 +82,7 @@ public class PostgresItemCatalogRepository implements ItemCatalogRepository { public List searchByName(String query) { return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } private ItemCatalog toDomainEntity(ItemCatalogJpaEntity e) { diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresLoreNodeRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresLoreNodeRepository.java index 98854b2..7c15546 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresLoreNodeRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresLoreNodeRepository.java @@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; /** * Adaptateur d'infrastructure qui implémente le Port LoreNodeRepository. @@ -41,7 +40,7 @@ public class PostgresLoreNodeRepository implements LoreNodeRepository { Long longLoreId = Long.parseLong(loreId); return jpaRepository.findByLoreId(longLoreId).stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } @Override @@ -49,19 +48,19 @@ public class PostgresLoreNodeRepository implements LoreNodeRepository { if (parentId == null || parentId.isEmpty()) { return jpaRepository.findByParentId(null).stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } Long longParentId = Long.parseLong(parentId); return jpaRepository.findByParentId(longParentId).stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } @Override public List findAll() { return jpaRepository.findAll().stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } @Override @@ -85,7 +84,7 @@ public class PostgresLoreNodeRepository implements LoreNodeRepository { public List searchByName(String query) { return jpaRepository.findByNameContainingIgnoreCase(query).stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } private LoreNode toDomainEntity(LoreNodeJpaEntity jpaEntity) { diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresLoreRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresLoreRepository.java index 02b9e9e..97454a7 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresLoreRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresLoreRepository.java @@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; /** * Adaptateur d'infrastructure qui implémente le Port LoreRepository. @@ -42,7 +41,7 @@ public class PostgresLoreRepository implements LoreRepository { public List findAll() { return jpaRepository.findAll().stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } @Override @@ -61,7 +60,7 @@ public class PostgresLoreRepository implements LoreRepository { public List searchByName(String query) { return jpaRepository.findByNameContainingIgnoreCase(query).stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } // Méthodes de conversion diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNotebookRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNotebookRepository.java index 55a9a59..708d529 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNotebookRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNotebookRepository.java @@ -15,7 +15,6 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; @Repository public class PostgresNotebookRepository implements NotebookRepository { @@ -53,7 +52,7 @@ public class PostgresNotebookRepository implements NotebookRepository { @Override public List findByCampaignId(String campaignId) { return notebookJpa.findByCampaignIdOrderByUpdatedAtDesc(Long.parseLong(campaignId)).stream() - .map(this::toNotebook).collect(Collectors.toList()); + .map(this::toNotebook).toList(); } @Override @@ -93,7 +92,7 @@ public class PostgresNotebookRepository implements NotebookRepository { @Override public List findSourcesByNotebookId(String notebookId) { return sourceJpa.findByNotebookIdOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream() - .map(this::toSource).collect(Collectors.toList()); + .map(this::toSource).toList(); } @Override @@ -116,19 +115,20 @@ public class PostgresNotebookRepository implements NotebookRepository { @Override public List findMessagesByNotebookId(String notebookId) { return messageJpa.findByNotebookIdAndArchivedAtIsNullOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream() - .map(this::toMessage).collect(Collectors.toList()); + .map(this::toMessage).toList(); } @Override @Transactional public void archiveMessagesByNotebookId(String notebookId) { - messageJpa.archiveActiveMessages(Long.parseLong(notebookId), java.time.LocalDateTime.now()); + messageJpa.archiveActiveMessages(Long.parseLong(notebookId), + java.time.LocalDateTime.now(java.time.ZoneId.systemDefault())); } @Override public List findArchivedMessagesByNotebookId(String notebookId) { return messageJpa.findByNotebookIdAndArchivedAtIsNotNullOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream() - .map(this::toMessage).collect(Collectors.toList()); + .map(this::toMessage).toList(); } // --- Mapping --- diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNpcRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNpcRepository.java index ad9b147..8999704 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNpcRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNpcRepository.java @@ -10,7 +10,6 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; @Repository public class PostgresNpcRepository implements NpcRepository { @@ -37,7 +36,7 @@ public class PostgresNpcRepository implements NpcRepository { public List findByCampaignId(String campaignId) { return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } @Override @@ -54,7 +53,7 @@ public class PostgresNpcRepository implements NpcRepository { public List searchByName(String query) { return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } private Npc toDomainEntity(NpcJpaEntity e) { diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresPageRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresPageRepository.java index e7ca5fd..39c66f4 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresPageRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresPageRepository.java @@ -10,7 +10,6 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; /** * Adaptateur d'infrastructure qui implémente le Port PageRepository. @@ -42,7 +41,7 @@ public class PostgresPageRepository implements PageRepository { public List findByLoreId(String loreId) { return jpaRepository.findByLoreId(Long.parseLong(loreId)).stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } @Override @@ -50,14 +49,14 @@ public class PostgresPageRepository implements PageRepository { Long longNodeId = Long.parseLong(nodeId); return jpaRepository.findByNodeId(longNodeId).stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } @Override public List findAll() { return jpaRepository.findAll().stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } @Override @@ -81,7 +80,7 @@ public class PostgresPageRepository implements PageRepository { public List searchByTitle(String query) { return jpaRepository.findByTitleContainingIgnoreCase(query).stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } private Page toDomainEntity(PageJpaEntity e) { diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresPlaythroughRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresPlaythroughRepository.java index eb67d98..1fc57a2 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresPlaythroughRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresPlaythroughRepository.java @@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; @Repository public class PostgresPlaythroughRepository implements PlaythroughRepository { @@ -34,12 +33,12 @@ public class PostgresPlaythroughRepository implements PlaythroughRepository { public List findByCampaignId(String campaignId) { return jpa.findByCampaignId(Long.parseLong(campaignId)).stream() .map(this::toDomain) - .collect(Collectors.toList()); + .toList(); } @Override public List findAll() { - return jpa.findAll().stream().map(this::toDomain).collect(Collectors.toList()); + return jpa.findAll().stream().map(this::toDomain).toList(); } @Override diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresQuestProgressionRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresQuestProgressionRepository.java index dcc78d7..657824c 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresQuestProgressionRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresQuestProgressionRepository.java @@ -10,7 +10,6 @@ import org.springframework.stereotype.Repository; import java.util.HashSet; import java.util.List; import java.util.Set; -import java.util.stream.Collectors; @Repository public class PostgresQuestProgressionRepository implements QuestProgressionRepository { @@ -25,7 +24,7 @@ public class PostgresQuestProgressionRepository implements QuestProgressionRepos public List findByPlaythroughId(String playthroughId) { return jpa.findByPlaythroughId(Long.parseLong(playthroughId)).stream() .map(this::toDomain) - .collect(Collectors.toList()); + .toList(); } @Override diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresQuestRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresQuestRepository.java index 0ec32d1..5edf40b 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresQuestRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresQuestRepository.java @@ -9,7 +9,6 @@ import org.springframework.stereotype.Repository; import java.util.ArrayList; import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; /** * Adaptateur d'infrastructure qui implémente le Port QuestRepository. @@ -40,7 +39,7 @@ public class PostgresQuestRepository implements QuestRepository { public List findByCampaignId(String campaignId) { return jpaRepository.findByCampaignId(Long.parseLong(campaignId)).stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } @Override @@ -48,14 +47,14 @@ public class PostgresQuestRepository implements QuestRepository { if (arcId == null || arcId.isBlank()) return List.of(); // pas d'arc → aucune quête rattachée return jpaRepository.findByArcId(Long.parseLong(arcId)).stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } @Override public List findAll() { return jpaRepository.findAll().stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } @Override diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresRandomTableRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresRandomTableRepository.java index 8d472ef..05ce66e 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresRandomTableRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresRandomTableRepository.java @@ -68,7 +68,7 @@ public class PostgresRandomTableRepository implements RandomTableRepository { public List findByCampaignId(String campaignId) { return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } @Override @@ -86,7 +86,7 @@ public class PostgresRandomTableRepository implements RandomTableRepository { public List searchByName(String query) { return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } private RandomTable toDomainEntity(RandomTableJpaEntity e) { diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepository.java index 6847bd6..9addfee 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepository.java @@ -10,7 +10,6 @@ import org.springframework.stereotype.Repository; import java.util.ArrayList; import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; /** * Adaptateur d'infrastructure qui implémente le Port SceneRepository. @@ -43,14 +42,14 @@ public class PostgresSceneRepository implements SceneRepository { Long longChapterId = Long.parseLong(chapterId); return jpaRepository.findByChapterId(longChapterId).stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } @Override public List findAll() { return jpaRepository.findAll().stream() .map(this::toDomainEntity) - .collect(Collectors.toList()); + .toList(); } @Override diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSessionEntryRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSessionEntryRepository.java index 70566bc..809e5a2 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSessionEntryRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSessionEntryRepository.java @@ -9,7 +9,6 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; /** * Adaptateur d'infrastructure : implémente le Port SessionEntryRepository. @@ -38,7 +37,7 @@ public class PostgresSessionEntryRepository implements SessionEntryRepository { public List findBySessionId(String sessionId) { return jpaRepository.findBySessionIdOrderByOccurredAtAsc(sessionId).stream() .map(this::toDomain) - .collect(Collectors.toList()); + .toList(); } @Override diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSessionRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSessionRepository.java index a2bdfeb..ea1e3a1 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSessionRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSessionRepository.java @@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; /** * Adaptateur d'infrastructure : implémente le port SessionRepository en utilisant @@ -38,14 +37,14 @@ public class PostgresSessionRepository implements SessionRepository { public List findAll() { return jpaRepository.findAll().stream() .map(this::toDomain) - .collect(Collectors.toList()); + .toList(); } @Override public List findByPlaythroughId(String playthroughId) { return jpaRepository.findByPlaythroughIdOrderByStartedAtDesc(Long.parseLong(playthroughId)).stream() .map(this::toDomain) - .collect(Collectors.toList()); + .toList(); } @Override diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresTemplateRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresTemplateRepository.java index e0261d1..f26d631 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresTemplateRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresTemplateRepository.java @@ -9,7 +9,6 @@ import org.springframework.stereotype.Repository; import java.util.ArrayList; import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; /** * Adaptateur d'infrastructure qui implémente le Port TemplateRepository. @@ -40,14 +39,14 @@ public class PostgresTemplateRepository implements TemplateRepository { public List