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.
This commit is contained in:
Konrad Neitzel 2026-02-25 06:13:14 +01:00
parent ee6e1c7cb1
commit fa9d2d3136
8 changed files with 112 additions and 71 deletions

View File

@ -21,7 +21,6 @@ import jakarta.transaction.Transactional;
import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List; 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 * @param userId calling user's id
* @return created story DTO * @return created story DTO
*/ */
@ -101,10 +102,14 @@ public class StoryService {
throw new WebApplicationException("Scenario not found", Response.Status.NOT_FOUND); 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(); final StoryEntity entity = new StoryEntity();
entity.setOwnerUserId(userId); entity.setOwnerUserId(userId);
entity.setScenarioId(scenario.getId()); entity.setScenarioId(scenario.getId());
entity.setTitle(request.getTitle()); entity.setTitle(title);
entity.setCurrentSceneDescription(scenario.getStartingSceneDescription()); entity.setCurrentSceneDescription(scenario.getStartingSceneDescription());
entity.setStatus("ACTIVE"); entity.setStatus("ACTIVE");
entity.setCreatedAt(LocalDateTime.now()); entity.setCreatedAt(LocalDateTime.now());
@ -117,11 +122,19 @@ public class StoryService {
ch.setName(sc.getName()); ch.setName(sc.getName());
ch.setRole(sc.getRole()); ch.setRole(sc.getRole());
ch.setDescription(sc.getDescription()); ch.setDescription(sc.getDescription());
entity.getCurrentSceneCharacters().add(ch);
characterRepository.persist(ch); characterRepository.persist(ch);
}); });
storyRepository.getEntityManager().flush(); final String opening = generateAiContinuation(entity, List.of(), null);
storyRepository.getEntityManager().refresh(entity); 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); return toDto(entity);
} }
@ -145,19 +158,8 @@ public class StoryService {
entity.setUpdatedAt(LocalDateTime.now()); entity.setUpdatedAt(LocalDateTime.now());
if (request.getCurrentSceneCharacters() != null) { if (request.getCurrentSceneCharacters() != null) {
characterRepository.deleteByStory(storyId); replaceCharacters(entity, request.getCurrentSceneCharacters());
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);
} }
}
storyRepository.getEntityManager().flush();
storyRepository.getEntityManager().refresh(entity);
return toDto(entity); return toDto(entity);
} }
@ -230,18 +232,8 @@ public class StoryService {
story.setCurrentSceneDescription(request.getCurrentSceneDescription()); story.setCurrentSceneDescription(request.getCurrentSceneDescription());
} }
if (request.getCurrentCharacters() != null) { if (request.getCurrentCharacters() != null) {
characterRepository.deleteByStory(storyId); replaceCharacters(story, request.getCurrentCharacters());
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);
} }
}
storyRepository.getEntityManager().flush();
List<StoryStepEntity> unmerged = stepRepository.findUnmergedByStory(storyId); List<StoryStepEntity> unmerged = stepRepository.findUnmergedByStory(storyId);
boolean compacted = false; boolean compacted = false;
@ -271,8 +263,6 @@ public class StoryService {
step.setCreatedAt(LocalDateTime.now()); step.setCreatedAt(LocalDateTime.now());
stepRepository.persist(step); stepRepository.persist(step);
storyRepository.getEntityManager().refresh(story);
final GenerateStepResponse response = new GenerateStepResponse() final GenerateStepResponse response = new GenerateStepResponse()
.step(toStepDto(step)) .step(toStepDto(step))
.compacted(compacted); .compacted(compacted);
@ -335,6 +325,27 @@ public class StoryService {
return text.length() > max ? text.substring(0, max) + "..." : text; 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<CharacterDto> 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. */ /** Finds a story ensuring ownership, throwing 404/403 as needed. */
private StoryEntity findOwnedOrFail(final Long storyId, final Long userId) { private StoryEntity findOwnedOrFail(final Long storyId, final Long userId) {
final StoryEntity entity = storyRepository.findById(storyId); final StoryEntity entity = storyRepository.findById(storyId);

View File

@ -2,6 +2,7 @@ package de.neitzel.storyteller.common.security;
import jakarta.enterprise.context.RequestScoped; import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.json.JsonNumber;
import org.eclipse.microprofile.jwt.JsonWebToken; import org.eclipse.microprofile.jwt.JsonWebToken;
/** /**
@ -16,11 +17,17 @@ public class AuthContext {
/** /**
* Returns the authenticated user's database id. * 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 * @return user id from the JWT {@code userId} claim
*/ */
public Long getUserId() { 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();
} }
/** /**

View File

@ -48,10 +48,10 @@ public class StoryStepRepository implements PanacheRepository<StoryStepEntity> {
* @return max step number * @return max step number
*/ */
public int maxStepNumber(final Long storyId) { public int maxStepNumber(final Long storyId) {
Long result = getEntityManager() Integer result = getEntityManager()
.createQuery("select coalesce(max(s.stepNumber),0) from StoryStepEntity s where s.storyId = :sid", Long.class) .createQuery("select coalesce(max(s.stepNumber),0) from StoryStepEntity s where s.storyId = :sid", Integer.class)
.setParameter("sid", storyId) .setParameter("sid", storyId)
.getSingleResult(); .getSingleResult();
return result.intValue(); return result;
} }
} }

View File

@ -1,5 +1,6 @@
quarkus.http.port=8080 quarkus.http.port=8080
quarkus.http.cors.enabled=true quarkus.http.cors.enabled=true
quarkus.rest.path=/api
quarkus.liquibase.migrate-at-start=true quarkus.liquibase.migrate-at-start=true
quarkus.liquibase.change-log=db/migration/db.changelog-master.yaml quarkus.liquibase.change-log=db/migration/db.changelog-master.yaml

View File

@ -437,13 +437,14 @@ components:
enum: [ACTIVE, ARCHIVED, DELETED] enum: [ACTIVE, ARCHIVED, DELETED]
StartStoryRequest: StartStoryRequest:
type: object type: object
required: [scenarioId, title] required: [scenarioId]
properties: properties:
scenarioId: scenarioId:
type: integer type: integer
format: int64 format: int64
title: title:
type: string type: string
description: Optional override; defaults to the scenario title.
UpdateStoryRequest: UpdateStoryRequest:
type: object type: object
properties: properties:

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react"; 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 type { Scenario, CharacterDto } from "../api/generated/src";
import { getApiClient } from "../api/client"; import { getApiClient } from "../api/client";
@ -11,6 +11,8 @@ export function ScenariosPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState<Scenario | null>(null); const [editing, setEditing] = useState<Scenario | null>(null);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [starting, setStarting] = useState<number | null>(null);
const navigate = useNavigate();
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
@ -30,6 +32,21 @@ export function ScenariosPage() {
load(); 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 ( return (
<section> <section>
<div className="page-header"> <div className="page-header">
@ -56,7 +73,13 @@ export function ScenariosPage() {
<div className="card-actions"> <div className="card-actions">
<button className="btn-sm" onClick={() => { setEditing(s); setCreating(false); }}>Edit</button> <button className="btn-sm" onClick={() => { setEditing(s); setCreating(false); }}>Edit</button>
<button className="btn-sm btn-danger" onClick={() => handleDelete(s.id)}>Delete</button> <button className="btn-sm btn-danger" onClick={() => handleDelete(s.id)}>Delete</button>
<Link className="btn-sm btn-link" to={`/stories?from=${s.id}`}>Start Story</Link> <button
className="btn-sm btn-link"
disabled={starting === s.id}
onClick={() => handleStartStory(s.id)}
>
{starting === s.id ? "Starting..." : "Start Story"}
</button>
</div> </div>
</div> </div>
))} ))}

View File

@ -1,18 +1,14 @@
import { useCallback, useEffect, useState } from "react"; 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 type { Story } from "../api/generated/src";
import { getApiClient } from "../api/client"; 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() { export function StoriesPage() {
const [stories, setStories] = useState<Story[]>([]); const [stories, setStories] = useState<Story[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [params] = useSearchParams();
const [title, setTitle] = useState("");
const fromScenario = params.get("from");
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
@ -25,17 +21,6 @@ export function StoriesPage() {
useEffect(() => { load(); }, [load]); 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) => { const handleDelete = async (id: number) => {
if (!confirm("Delete this story?")) return; if (!confirm("Delete this story?")) return;
const api = getApiClient(); const api = getApiClient();
@ -50,17 +35,6 @@ export function StoriesPage() {
</div> </div>
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
{fromScenario && (
<form className="card form-card inline-form" onSubmit={handleStart}>
<h3>Start New Story from Scenario #{fromScenario}</h3>
<label>
Story Title
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} required />
</label>
<button type="submit">Start Story</button>
</form>
)}
<div className="card-grid"> <div className="card-grid">
{stories.map((s) => ( {stories.map((s) => (
<div key={s.id} className="card story-card"> <div key={s.id} className="card story-card">
@ -74,7 +48,9 @@ export function StoriesPage() {
</div> </div>
</div> </div>
))} ))}
{stories.length === 0 && <p className="muted">No stories yet. Start one from a scenario.</p>} {stories.length === 0 && (
<p className="muted">No stories yet. <Link to="/scenarios">Start one from a scenario.</Link></p>
)}
</div> </div>
</section> </section>
); );

View File

@ -50,11 +50,12 @@ class StoryServiceTest {
when(storyRepository.getEntityManager()).thenReturn(entityManager); 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 @Test
void startStoryCopiesScenarioScene() { void startStoryCopiesScenarioAndGeneratesOpening() {
final ScenarioEntity scenario = new ScenarioEntity(); final ScenarioEntity scenario = new ScenarioEntity();
scenario.setId(1L); scenario.setId(1L);
scenario.setTitle("Dark Forest Adventure");
scenario.setStartingSceneDescription("A dark forest"); scenario.setStartingSceneDescription("A dark forest");
final ScenarioCharacterEntity ch = new ScenarioCharacterEntity(); final ScenarioCharacterEntity ch = new ScenarioCharacterEntity();
ch.setName("Elara"); ch.setName("Elara");
@ -66,7 +67,6 @@ class StoryServiceTest {
doAnswer(inv -> { doAnswer(inv -> {
StoryEntity e = inv.getArgument(0); StoryEntity e = inv.getArgument(0);
e.setId(10L); e.setId(10L);
e.setCurrentSceneCharacters(Collections.emptyList());
return null; return null;
}).when(storyRepository).persist(any(StoryEntity.class)); }).when(storyRepository).persist(any(StoryEntity.class));
@ -75,6 +75,28 @@ class StoryServiceTest {
assertEquals("A dark forest", result.getCurrentSceneDescription()); assertEquals("A dark forest", result.getCurrentSceneDescription());
assertEquals("My Story", result.getTitle()); assertEquals("My Story", result.getTitle());
verify(characterRepository).persist(any(StoryCharacterEntity.class)); 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. */ /** Starting a story with non-existent scenario throws 404. */
@ -83,7 +105,7 @@ class StoryServiceTest {
when(scenarioRepository.findById(99L)).thenReturn(null); when(scenarioRepository.findById(99L)).thenReturn(null);
assertThrows(WebApplicationException.class, 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. */ /** Accessing another user's story throws 403. */