From fa9d2d3136b17a8421d5b7fae2627155bd822195 Mon Sep 17 00:00:00 2001 From: Konrad Neitzel Date: Wed, 25 Feb 2026 06:13:14 +0100 Subject: [PATCH] Enhance StoryService and UI for story creation - Updated StoryService to auto-generate the opening story step from the scenario description when starting a new story. - Modified title handling to default to the scenario title if no title is provided. - Refactored character management to improve consistency in character replacement. - Updated ScenariosPage to handle story starting with a button instead of a link, providing better user feedback during the process. - Cleaned up StoriesPage by removing unused form for starting stories and improving user navigation prompts. - Adjusted OpenAPI specifications to reflect changes in request payloads. --- .../storyteller/business/StoryService.java | 73 +++++++++++-------- .../common/security/AuthContext.java | 9 ++- .../data/repository/StoryStepRepository.java | 6 +- src/main/resources/application.properties | 1 + .../resources/openapi/story-teller-api.yaml | 3 +- src/main/web/src/pages/ScenariosPage.tsx | 27 ++++++- src/main/web/src/pages/StoriesPage.tsx | 34 ++------- .../business/StoryServiceTest.java | 30 +++++++- 8 files changed, 112 insertions(+), 71 deletions(-) diff --git a/src/main/java/de/neitzel/storyteller/business/StoryService.java b/src/main/java/de/neitzel/storyteller/business/StoryService.java index 16a382c..8ad0bf3 100644 --- a/src/main/java/de/neitzel/storyteller/business/StoryService.java +++ b/src/main/java/de/neitzel/storyteller/business/StoryService.java @@ -21,7 +21,6 @@ import jakarta.transaction.Transactional; import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.Response; import java.time.LocalDateTime; -import java.util.ArrayList; import java.util.List; /** @@ -88,9 +87,11 @@ public class StoryService { } /** - * Starts a new story from a scenario, copying its scene and characters. + * Starts a new story from a scenario, copying its scene and characters, + * then auto-generates the opening story step from the scenario description. + * If no title is provided the scenario title is used. * - * @param request payload with scenario id and title + * @param request payload with scenario id and optional title * @param userId calling user's id * @return created story DTO */ @@ -101,10 +102,14 @@ public class StoryService { throw new WebApplicationException("Scenario not found", Response.Status.NOT_FOUND); } + final String title = request.getTitle() != null && !request.getTitle().isBlank() + ? request.getTitle() + : scenario.getTitle(); + final StoryEntity entity = new StoryEntity(); entity.setOwnerUserId(userId); entity.setScenarioId(scenario.getId()); - entity.setTitle(request.getTitle()); + entity.setTitle(title); entity.setCurrentSceneDescription(scenario.getStartingSceneDescription()); entity.setStatus("ACTIVE"); entity.setCreatedAt(LocalDateTime.now()); @@ -117,11 +122,19 @@ public class StoryService { ch.setName(sc.getName()); ch.setRole(sc.getRole()); ch.setDescription(sc.getDescription()); + entity.getCurrentSceneCharacters().add(ch); characterRepository.persist(ch); }); - storyRepository.getEntityManager().flush(); - storyRepository.getEntityManager().refresh(entity); + final String opening = generateAiContinuation(entity, List.of(), null); + final StoryStepEntity step = new StoryStepEntity(); + step.setStoryId(entity.getId()); + step.setStepNumber(1); + step.setContent(opening); + step.setAddedToScene(false); + step.setCreatedAt(LocalDateTime.now()); + stepRepository.persist(step); + return toDto(entity); } @@ -145,19 +158,8 @@ public class StoryService { entity.setUpdatedAt(LocalDateTime.now()); if (request.getCurrentSceneCharacters() != null) { - characterRepository.deleteByStory(storyId); - storyRepository.getEntityManager().flush(); - for (final CharacterDto dto : request.getCurrentSceneCharacters()) { - final StoryCharacterEntity ch = new StoryCharacterEntity(); - ch.setStoryId(storyId); - ch.setName(dto.getName()); - ch.setRole(dto.getRole()); - ch.setDescription(dto.getDescription()); - characterRepository.persist(ch); - } + replaceCharacters(entity, request.getCurrentSceneCharacters()); } - storyRepository.getEntityManager().flush(); - storyRepository.getEntityManager().refresh(entity); return toDto(entity); } @@ -230,18 +232,8 @@ public class StoryService { story.setCurrentSceneDescription(request.getCurrentSceneDescription()); } if (request.getCurrentCharacters() != null) { - characterRepository.deleteByStory(storyId); - storyRepository.getEntityManager().flush(); - for (final CharacterDto dto : request.getCurrentCharacters()) { - final StoryCharacterEntity ch = new StoryCharacterEntity(); - ch.setStoryId(storyId); - ch.setName(dto.getName()); - ch.setRole(dto.getRole()); - ch.setDescription(dto.getDescription()); - characterRepository.persist(ch); - } + replaceCharacters(story, request.getCurrentCharacters()); } - storyRepository.getEntityManager().flush(); List unmerged = stepRepository.findUnmergedByStory(storyId); boolean compacted = false; @@ -271,8 +263,6 @@ public class StoryService { step.setCreatedAt(LocalDateTime.now()); stepRepository.persist(step); - storyRepository.getEntityManager().refresh(story); - final GenerateStepResponse response = new GenerateStepResponse() .step(toStepDto(step)) .compacted(compacted); @@ -335,6 +325,27 @@ public class StoryService { return text.length() > max ? text.substring(0, max) + "..." : text; } + /** + * Replaces all characters on a story by clearing the managed collection + * (triggering orphan removal) and adding new entities from the DTOs. + * Works through the entity's collection so Hibernate's persistence context + * stays consistent — avoids stale references that break on refresh/cascade. + */ + private void replaceCharacters(final StoryEntity story, final List dtos) { + story.getCurrentSceneCharacters().clear(); + characterRepository.deleteByStory(story.getId()); + storyRepository.getEntityManager().flush(); + for (final CharacterDto dto : dtos) { + final StoryCharacterEntity ch = new StoryCharacterEntity(); + ch.setStoryId(story.getId()); + ch.setName(dto.getName()); + ch.setRole(dto.getRole()); + ch.setDescription(dto.getDescription()); + story.getCurrentSceneCharacters().add(ch); + characterRepository.persist(ch); + } + } + /** Finds a story ensuring ownership, throwing 404/403 as needed. */ private StoryEntity findOwnedOrFail(final Long storyId, final Long userId) { final StoryEntity entity = storyRepository.findById(storyId); diff --git a/src/main/java/de/neitzel/storyteller/common/security/AuthContext.java b/src/main/java/de/neitzel/storyteller/common/security/AuthContext.java index 4d66963..89cbfb4 100644 --- a/src/main/java/de/neitzel/storyteller/common/security/AuthContext.java +++ b/src/main/java/de/neitzel/storyteller/common/security/AuthContext.java @@ -2,6 +2,7 @@ package de.neitzel.storyteller.common.security; import jakarta.enterprise.context.RequestScoped; import jakarta.inject.Inject; +import jakarta.json.JsonNumber; import org.eclipse.microprofile.jwt.JsonWebToken; /** @@ -16,11 +17,17 @@ public class AuthContext { /** * Returns the authenticated user's database id. + * The claim arrives as a {@link JsonNumber} from the JWT parser, + * so it must be converted explicitly. * * @return user id from the JWT {@code userId} claim */ public Long getUserId() { - return jwt.getClaim(JwtService.CLAIM_USER_ID); + final Object raw = jwt.getClaim(JwtService.CLAIM_USER_ID); + if (raw instanceof JsonNumber jsonNumber) { + return jsonNumber.longValue(); + } + return ((Number) raw).longValue(); } /** diff --git a/src/main/java/de/neitzel/storyteller/data/repository/StoryStepRepository.java b/src/main/java/de/neitzel/storyteller/data/repository/StoryStepRepository.java index 870d6ae..857d91e 100644 --- a/src/main/java/de/neitzel/storyteller/data/repository/StoryStepRepository.java +++ b/src/main/java/de/neitzel/storyteller/data/repository/StoryStepRepository.java @@ -48,10 +48,10 @@ public class StoryStepRepository implements PanacheRepository { * @return max step number */ public int maxStepNumber(final Long storyId) { - Long result = getEntityManager() - .createQuery("select coalesce(max(s.stepNumber),0) from StoryStepEntity s where s.storyId = :sid", Long.class) + Integer result = getEntityManager() + .createQuery("select coalesce(max(s.stepNumber),0) from StoryStepEntity s where s.storyId = :sid", Integer.class) .setParameter("sid", storyId) .getSingleResult(); - return result.intValue(); + return result; } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 529d9b9..7f6f3f7 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,5 +1,6 @@ quarkus.http.port=8080 quarkus.http.cors.enabled=true +quarkus.rest.path=/api quarkus.liquibase.migrate-at-start=true quarkus.liquibase.change-log=db/migration/db.changelog-master.yaml diff --git a/src/main/resources/openapi/story-teller-api.yaml b/src/main/resources/openapi/story-teller-api.yaml index 1fe732a..c29a178 100644 --- a/src/main/resources/openapi/story-teller-api.yaml +++ b/src/main/resources/openapi/story-teller-api.yaml @@ -437,13 +437,14 @@ components: enum: [ACTIVE, ARCHIVED, DELETED] StartStoryRequest: type: object - required: [scenarioId, title] + required: [scenarioId] properties: scenarioId: type: integer format: int64 title: type: string + description: Optional override; defaults to the scenario title. UpdateStoryRequest: type: object properties: diff --git a/src/main/web/src/pages/ScenariosPage.tsx b/src/main/web/src/pages/ScenariosPage.tsx index c82f11c..a55a0b2 100644 --- a/src/main/web/src/pages/ScenariosPage.tsx +++ b/src/main/web/src/pages/ScenariosPage.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from "react"; -import { Link } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import type { Scenario, CharacterDto } from "../api/generated/src"; import { getApiClient } from "../api/client"; @@ -11,6 +11,8 @@ export function ScenariosPage() { const [error, setError] = useState(null); const [editing, setEditing] = useState(null); const [creating, setCreating] = useState(false); + const [starting, setStarting] = useState(null); + const navigate = useNavigate(); const load = useCallback(async () => { try { @@ -30,6 +32,21 @@ export function ScenariosPage() { load(); }; + const handleStartStory = async (scenarioId: number) => { + setStarting(scenarioId); + setError(null); + try { + const api = getApiClient(); + const story = await api.stories.startStory({ + startStoryRequest: { scenarioId }, + }); + navigate(`/stories/${story.id}`); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to start story."); + setStarting(null); + } + }; + return (
@@ -56,7 +73,13 @@ export function ScenariosPage() {
- Start Story +
))} diff --git a/src/main/web/src/pages/StoriesPage.tsx b/src/main/web/src/pages/StoriesPage.tsx index bae3b66..2ce4aa9 100644 --- a/src/main/web/src/pages/StoriesPage.tsx +++ b/src/main/web/src/pages/StoriesPage.tsx @@ -1,18 +1,14 @@ import { useCallback, useEffect, useState } from "react"; -import { Link, useSearchParams } from "react-router-dom"; +import { Link } from "react-router-dom"; import type { Story } from "../api/generated/src"; import { getApiClient } from "../api/client"; /** - * Stories list page with ability to start a new story from a scenario. + * Stories list page showing all stories for the authenticated user. */ export function StoriesPage() { const [stories, setStories] = useState([]); const [error, setError] = useState(null); - const [params] = useSearchParams(); - const [title, setTitle] = useState(""); - - const fromScenario = params.get("from"); const load = useCallback(async () => { try { @@ -25,17 +21,6 @@ export function StoriesPage() { useEffect(() => { load(); }, [load]); - const handleStart = async (e: React.FormEvent) => { - e.preventDefault(); - if (!fromScenario) return; - const api = getApiClient(); - await api.stories.startStory({ - startStoryRequest: { scenarioId: Number(fromScenario), title }, - }); - setTitle(""); - load(); - }; - const handleDelete = async (id: number) => { if (!confirm("Delete this story?")) return; const api = getApiClient(); @@ -50,17 +35,6 @@ export function StoriesPage() { {error &&

{error}

} - {fromScenario && ( -
-

Start New Story from Scenario #{fromScenario}

- - -
- )} -
{stories.map((s) => (
@@ -74,7 +48,9 @@ export function StoriesPage() {
))} - {stories.length === 0 &&

No stories yet. Start one from a scenario.

} + {stories.length === 0 && ( +

No stories yet. Start one from a scenario.

+ )}
); diff --git a/src/test/java/de/neitzel/storyteller/business/StoryServiceTest.java b/src/test/java/de/neitzel/storyteller/business/StoryServiceTest.java index ee43b67..61a1a17 100644 --- a/src/test/java/de/neitzel/storyteller/business/StoryServiceTest.java +++ b/src/test/java/de/neitzel/storyteller/business/StoryServiceTest.java @@ -50,11 +50,12 @@ class StoryServiceTest { when(storyRepository.getEntityManager()).thenReturn(entityManager); } - /** Starting a story copies the scenario's scene and characters. */ + /** Starting a story copies the scenario's scene and characters and generates an opening step. */ @Test - void startStoryCopiesScenarioScene() { + void startStoryCopiesScenarioAndGeneratesOpening() { final ScenarioEntity scenario = new ScenarioEntity(); scenario.setId(1L); + scenario.setTitle("Dark Forest Adventure"); scenario.setStartingSceneDescription("A dark forest"); final ScenarioCharacterEntity ch = new ScenarioCharacterEntity(); ch.setName("Elara"); @@ -66,7 +67,6 @@ class StoryServiceTest { doAnswer(inv -> { StoryEntity e = inv.getArgument(0); e.setId(10L); - e.setCurrentSceneCharacters(Collections.emptyList()); return null; }).when(storyRepository).persist(any(StoryEntity.class)); @@ -75,6 +75,28 @@ class StoryServiceTest { assertEquals("A dark forest", result.getCurrentSceneDescription()); assertEquals("My Story", result.getTitle()); verify(characterRepository).persist(any(StoryCharacterEntity.class)); + verify(stepRepository).persist(any(StoryStepEntity.class)); + } + + /** Title defaults to the scenario title when not provided. */ + @Test + void startStoryDefaultsToScenarioTitle() { + final ScenarioEntity scenario = new ScenarioEntity(); + scenario.setId(1L); + scenario.setTitle("Dark Forest Adventure"); + scenario.setStartingSceneDescription("A dark forest"); + scenario.setStartingCharacters(Collections.emptyList()); + + when(scenarioRepository.findById(1L)).thenReturn(scenario); + doAnswer(inv -> { + StoryEntity e = inv.getArgument(0); + e.setId(11L); + return null; + }).when(storyRepository).persist(any(StoryEntity.class)); + + final Story result = storyService.startStory(new StartStoryRequest().scenarioId(1L), 5L); + + assertEquals("Dark Forest Adventure", result.getTitle()); } /** Starting a story with non-existent scenario throws 404. */ @@ -83,7 +105,7 @@ class StoryServiceTest { when(scenarioRepository.findById(99L)).thenReturn(null); assertThrows(WebApplicationException.class, - () -> storyService.startStory(new StartStoryRequest().scenarioId(99L).title("X"), 1L)); + () -> storyService.startStory(new StartStoryRequest().scenarioId(99L), 1L)); } /** Accessing another user's story throws 403. */