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.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<StoryStepEntity> 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<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. */
private StoryEntity findOwnedOrFail(final Long storyId, final Long userId) {
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.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();
}
/**

View File

@ -48,10 +48,10 @@ public class StoryStepRepository implements PanacheRepository<StoryStepEntity> {
* @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;
}
}

View File

@ -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

View File

@ -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:

View File

@ -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<string | null>(null);
const [editing, setEditing] = useState<Scenario | null>(null);
const [creating, setCreating] = useState(false);
const [starting, setStarting] = useState<number | null>(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 (
<section>
<div className="page-header">
@ -56,7 +73,13 @@ export function ScenariosPage() {
<div className="card-actions">
<button className="btn-sm" onClick={() => { setEditing(s); setCreating(false); }}>Edit</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>
))}

View File

@ -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<Story[]>([]);
const [error, setError] = useState<string | null>(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() {
</div>
{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">
{stories.map((s) => (
<div key={s.id} className="card story-card">
@ -74,7 +48,9 @@ export function StoriesPage() {
</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>
</section>
);

View File

@ -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. */