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:
parent
ee6e1c7cb1
commit
fa9d2d3136
@ -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);
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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. */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user