Add initial project structure with Maven, Quarkus, and React setup
- Created .gitignore to exclude build artifacts and IDE files. - Added pom.xml for Maven project configuration, including dependencies for Quarkus, OpenAPI, and testing. - Introduced project documentation outlining development workflow, architecture, and coding standards. - Implemented core business logic with services for authentication, scenario management, story lifecycle, and user management. - Established security context for JWT-based authentication. - Generated API specifications and client code for frontend integration. - Set up initial database entities and repositories for user, scenario, and story management.
This commit is contained in:
commit
7302cc52a7
69
.cursor/rules/story-teller-project.mdc
Normal file
69
.cursor/rules/story-teller-project.mdc
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
description: StoryTeller project context, architecture, and coding standards (Java/Quarkus, Maven, frontend)
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# StoryTeller – Project Instructions
|
||||||
|
|
||||||
|
## Project context
|
||||||
|
|
||||||
|
- **Project**: StoryTeller
|
||||||
|
- **Java**: 21
|
||||||
|
- **Build**: Maven
|
||||||
|
- **Framework**: Quarkus (latest stable)
|
||||||
|
- **GroupId**: de.neitzel | **ArtifactId**: storyteller
|
||||||
|
- **Base package**: `de.neitzel.storyteller`
|
||||||
|
- **Sub-packages**: `business`, `common`, `data`, `fascade`
|
||||||
|
- Startup code lives in the base package.
|
||||||
|
- **DB migrations**: Liquibase; scripts in `src/main/resources/db/migration`.
|
||||||
|
|
||||||
|
## Architecture and package rules
|
||||||
|
|
||||||
|
- **Startup/bootstrap**: `de.neitzel.storyteller`
|
||||||
|
- **Business logic**: `de.neitzel.storyteller.business`
|
||||||
|
- **Shared utilities, cross-cutting types**: `de.neitzel.storyteller.common`
|
||||||
|
- **Persistence / data access**: `de.neitzel.storyteller.data`
|
||||||
|
- **External-facing facades (REST, API)**: `de.neitzel.storyteller.fascade`
|
||||||
|
- Keep clear package boundaries; avoid circular dependencies.
|
||||||
|
|
||||||
|
## Coding standards (Java)
|
||||||
|
|
||||||
|
- Use Lombok to reduce boilerplate where it helps.
|
||||||
|
- Prefer immutability: `final` fields, constructor injection.
|
||||||
|
- Use Quarkus idioms; avoid heavyweight frameworks.
|
||||||
|
- Keep methods small and focused; one responsibility per class.
|
||||||
|
- Use Java 21 features when they improve clarity.
|
||||||
|
- Add concise comments only for non-obvious logic.
|
||||||
|
- All classes, fields, and methods (including private) must have JavaDoc describing purpose and usage.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Add or update unit tests for new or changed logic.
|
||||||
|
- Use JUnit 5 and Mockito; Arrange–Act–Assert; keep tests deterministic.
|
||||||
|
- Mock external dependencies; add integration tests only when requested.
|
||||||
|
|
||||||
|
## Maven and dependencies
|
||||||
|
|
||||||
|
- Target Java 21 in the Maven toolchain.
|
||||||
|
- Use the latest stable Quarkus platform BOM.
|
||||||
|
- Keep dependencies minimal; no new libraries without need.
|
||||||
|
- Keep versions up to date (especially for security).
|
||||||
|
- Put plugin and dependency versions in the `properties` section of `pom.xml`.
|
||||||
|
- Use `quarkus-maven-plugin` for build/run; `maven-surefire-plugin` for tests (JUnit 5); `maven-compiler-plugin` with Java 21.
|
||||||
|
|
||||||
|
## Frontend (React / Vite)
|
||||||
|
|
||||||
|
- Frontend lives under `src/main/web` (React, TypeScript, Vite, MUI).
|
||||||
|
- OpenAPI TypeScript client is generated into `src/main/web/src/api/generated/` by Maven (`mvn generate-sources`); do not edit by hand.
|
||||||
|
- Production build output: `target/web-dist`; copied to `META-INF/resources` by `maven-resources-plugin` for the Quarkus SPA.
|
||||||
|
|
||||||
|
## Do and don’t
|
||||||
|
|
||||||
|
- **Do**: keep code in the correct package; add tests for business logic.
|
||||||
|
- **Don’t**: put startup code outside the base package; add unnecessary abstraction layers.
|
||||||
|
|
||||||
|
## Output expectations
|
||||||
|
|
||||||
|
- Provide complete, compilable code with package declarations and imports.
|
||||||
|
- Prefer Quarkus-friendly APIs and annotations.
|
||||||
|
- When adding or changing logic, update or add unit tests.
|
||||||
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
/target/
|
||||||
|
/src/main/web/node_modules/
|
||||||
|
/src/main/web/node/
|
||||||
|
/.idea/
|
||||||
|
/.DS_Store
|
||||||
|
# Maven / Java build output
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Frontend (src/main/web) temporary and build artifacts
|
||||||
|
src/main/web/node_modules/
|
||||||
|
src/main/web/dist/
|
||||||
|
src/main/web/.vite/
|
||||||
|
src/main/web/.cache/
|
||||||
|
src/main/web/.eslintcache
|
||||||
|
src/main/web/coverage/
|
||||||
|
src/main/web/npm-debug.log*
|
||||||
|
src/main/web/yarn-debug.log*
|
||||||
|
src/main/web/yarn-error.log*
|
||||||
|
src/main/web/pnpm-debug.log*
|
||||||
|
|
||||||
|
# IntelliJ IDEA
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
out/
|
||||||
|
|
||||||
|
# Other common IDE/editor files
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Generated API client (regenerated by mvn generate-sources)
|
||||||
|
src/main/web/src/api/generated/
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
109
docs/development-workflow.md
Normal file
109
docs/development-workflow.md
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
## StoryTeller Development Workflow
|
||||||
|
|
||||||
|
### Stack and Layout
|
||||||
|
|
||||||
|
- **Backend**: Quarkus 3.31.2 (Java 21, Maven).
|
||||||
|
- **Frontend**: React 18 + TypeScript + Vite in `src/main/web`.
|
||||||
|
- **Database**: PostgreSQL with Liquibase migrations.
|
||||||
|
- **Auth**: SmallRye JWT with RSA key signing; BCrypt password hashing.
|
||||||
|
- **API Contract**: OpenAPI 3.0.3 at `src/main/resources/openapi/story-teller-api.yaml`.
|
||||||
|
- **Generated Code**:
|
||||||
|
- Java interfaces/models: `target/generated-sources/openapi/java/src/gen/java/`
|
||||||
|
- TypeScript client: `src/main/web/src/api/generated/`
|
||||||
|
|
||||||
|
### Local Setup
|
||||||
|
|
||||||
|
1. **Prerequisites**: JDK 21, Maven 3.9+, PostgreSQL, Node.js 22 (managed by frontend-maven-plugin).
|
||||||
|
2. **Database**: Create a PostgreSQL database:
|
||||||
|
```sql
|
||||||
|
CREATE DATABASE storyteller;
|
||||||
|
CREATE USER storyteller WITH PASSWORD 'storyteller';
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE storyteller TO storyteller;
|
||||||
|
```
|
||||||
|
3. **Build everything**: `mvn package` (generates API code, compiles Java, builds frontend, runs tests).
|
||||||
|
4. **Run in dev mode**: `mvn quarkus:dev` (hot reload, frontend proxy at http://localhost:5173).
|
||||||
|
5. **Frontend dev**: `cd src/main/web && npm run dev` (standalone Vite dev server, proxies `/api` to Quarkus).
|
||||||
|
|
||||||
|
### Default Admin Account
|
||||||
|
|
||||||
|
After first startup, Liquibase seeds an admin user:
|
||||||
|
- **Username**: `admin`
|
||||||
|
- **Password**: `admin`
|
||||||
|
|
||||||
|
Change this immediately in non-development environments.
|
||||||
|
|
||||||
|
### OpenAPI-First Rules
|
||||||
|
|
||||||
|
- The OpenAPI YAML is the **single source of truth** for backend and frontend API contracts.
|
||||||
|
- **Never edit generated files** in `target/generated-sources/` or `src/main/web/src/api/generated/`.
|
||||||
|
- To change the API:
|
||||||
|
1. Edit `src/main/resources/openapi/story-teller-api.yaml`.
|
||||||
|
2. Run `mvn generate-sources` to regenerate Java and TypeScript.
|
||||||
|
3. Update service/resource implementations to match new interfaces.
|
||||||
|
|
||||||
|
### Common Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---|---|
|
||||||
|
| `mvn generate-sources` | Regenerate Java+TS from OpenAPI |
|
||||||
|
| `mvn compile` | Compile Java backend |
|
||||||
|
| `mvn test` | Run unit tests |
|
||||||
|
| `mvn package` | Full build: Java + frontend + tests |
|
||||||
|
| `mvn quarkus:dev` | Dev mode with hot reload |
|
||||||
|
| `cd src/main/web && npm run dev` | Frontend dev server |
|
||||||
|
|
||||||
|
### Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
de.neitzel.storyteller
|
||||||
|
├── business/ # Business logic services
|
||||||
|
├── common/
|
||||||
|
│ └── security/ # JWT, password hashing, auth context
|
||||||
|
├── data/
|
||||||
|
│ ├── entity/ # JPA entities
|
||||||
|
│ └── repository/ # Panache repositories
|
||||||
|
└── fascade/
|
||||||
|
├── api/ # Generated OpenAPI interfaces (DO NOT EDIT)
|
||||||
|
├── model/ # Generated OpenAPI models (DO NOT EDIT)
|
||||||
|
└── rest/ # REST resource implementations
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
|
||||||
|
1. Client sends `POST /api/auth/login` with username/password.
|
||||||
|
2. Server verifies credentials, returns signed JWT with user claims.
|
||||||
|
3. Client stores JWT in localStorage and sends it as `Authorization: Bearer <token>` on all subsequent requests.
|
||||||
|
4. Quarkus SmallRye JWT validates the token on protected endpoints.
|
||||||
|
5. `AuthContext` (request-scoped CDI bean) extracts user identity from the JWT.
|
||||||
|
|
||||||
|
### Authorization
|
||||||
|
|
||||||
|
- **Public**: `/api/auth/*`
|
||||||
|
- **Admin-only**: `/api/users/*` (user management)
|
||||||
|
- **Authenticated users**: `/api/scenarios/*`, `/api/stories/*` (with per-user ownership checks)
|
||||||
|
|
||||||
|
### Story Generation & Compaction
|
||||||
|
|
||||||
|
The AI step generation flow includes automatic context compaction:
|
||||||
|
|
||||||
|
1. Client sends a direction prompt.
|
||||||
|
2. Server estimates context size (scene + characters + unmerged steps + direction).
|
||||||
|
3. If it exceeds the limit (~12K chars), the oldest half of unmerged steps are compacted into the scene description.
|
||||||
|
4. Steps are marked `addedToScene = true` after compaction.
|
||||||
|
5. The AI generates the next step from the (possibly compacted) context.
|
||||||
|
6. The response includes a `compacted` flag so the frontend can prompt the user to review the updated scene.
|
||||||
|
|
||||||
|
**Note**: The current AI adapter is a stub that generates placeholder text. Replace the `generateAiContinuation` and `compactScene` methods in `StoryService` with actual LLM integration.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Unit tests use JUnit 5 + Mockito in `src/test/java/`.
|
||||||
|
- Tests cover auth, user service, scenario service, and story service.
|
||||||
|
- Run with `mvn test`.
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
- **JWT errors**: Ensure `privateKey.pem` and `publicKey.pem` exist in `src/main/resources/`.
|
||||||
|
- **Generated code issues**: Run `mvn clean generate-sources` to rebuild from scratch.
|
||||||
|
- **TypeScript errors**: Check that `src/main/web/node_modules` exists; run `npm install` in `src/main/web`.
|
||||||
|
- **DB connection**: Verify PostgreSQL is running and the `storyteller` database/user exist.
|
||||||
364
docs/story-teller-specification.md
Normal file
364
docs/story-teller-specification.md
Normal file
@ -0,0 +1,364 @@
|
|||||||
|
## Story Teller – Application Specification
|
||||||
|
|
||||||
|
### 1. Overview
|
||||||
|
|
||||||
|
**Story Teller** is a web application where an AI collaboratively builds a story step by step with the user.
|
||||||
|
|
||||||
|
The user starts from a predefined scenario (starting scene and characters), gives directions about what should happen next, optionally edits the scene and characters, and the AI produces the next part of the story. The system manages scenes, story steps, and automatic context compaction when AI prompts become too large.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. User Roles & Permissions
|
||||||
|
|
||||||
|
#### 2.1 Roles
|
||||||
|
|
||||||
|
- **Regular user**
|
||||||
|
- Authenticate via simple login.
|
||||||
|
- Manage own content:
|
||||||
|
- Create, edit, delete **scenarios** (starting points).
|
||||||
|
- Create, edit, delete **starting characters** associated with their scenarios.
|
||||||
|
- Start new **stories** from scenarios they own.
|
||||||
|
- Load existing stories they own and continue them.
|
||||||
|
- Edit and merge steps, scenes, and characters within their own stories.
|
||||||
|
- Delete their own stories.
|
||||||
|
|
||||||
|
- **Admin user**
|
||||||
|
- All capabilities of regular users.
|
||||||
|
- Additional capabilities:
|
||||||
|
- Create new users.
|
||||||
|
- View list of all users.
|
||||||
|
- Edit user information (e.g., username, admin flag, active flag, password reset).
|
||||||
|
- Delete users (with defined behavior for their content).
|
||||||
|
|
||||||
|
> Anonymous users are not supported; all access is via login.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Core Domain Model
|
||||||
|
|
||||||
|
#### 3.1 User
|
||||||
|
|
||||||
|
- **Fields**
|
||||||
|
- `id`
|
||||||
|
- `username` (unique)
|
||||||
|
- `passwordHash`
|
||||||
|
- `isAdmin` (boolean)
|
||||||
|
- `active` (boolean)
|
||||||
|
- `createdAt`
|
||||||
|
- `updatedAt`
|
||||||
|
|
||||||
|
#### 3.2 Scenario (Starting Point)
|
||||||
|
|
||||||
|
- **Fields**
|
||||||
|
- `id`
|
||||||
|
- `ownerUserId`
|
||||||
|
- `title`
|
||||||
|
- `description` (overall scenario description)
|
||||||
|
- `startingSceneDescription` (initial scene narrative text)
|
||||||
|
- `startingCharacters` (list of `ScenarioCharacter`)
|
||||||
|
- `createdAt`
|
||||||
|
- `updatedAt`
|
||||||
|
|
||||||
|
- **ScenarioCharacter**
|
||||||
|
- `id`
|
||||||
|
- `scenarioId`
|
||||||
|
- `name`
|
||||||
|
- `role` (e.g., “protagonist”, “villain”, “guide”)
|
||||||
|
- `description`
|
||||||
|
|
||||||
|
#### 3.3 Story
|
||||||
|
|
||||||
|
- **Fields**
|
||||||
|
- `id`
|
||||||
|
- `ownerUserId`
|
||||||
|
- `scenarioId` (reference to starting scenario)
|
||||||
|
- `title`
|
||||||
|
- `currentSceneDescription` (full narrative of the current scene)
|
||||||
|
- `currentSceneCharacters` (list of `StoryCharacter`)
|
||||||
|
- `status` (e.g., `ACTIVE`, `ARCHIVED`, `DELETED`)
|
||||||
|
- `createdAt`
|
||||||
|
- `updatedAt`
|
||||||
|
|
||||||
|
- **StoryCharacter**
|
||||||
|
- `id`
|
||||||
|
- `storyId`
|
||||||
|
- `name`
|
||||||
|
- `role`
|
||||||
|
- `description`
|
||||||
|
- (Optional) `origin` (e.g., `FROM_SCENARIO`, `ADDED_IN_STORY`)
|
||||||
|
|
||||||
|
#### 3.4 StoryStep
|
||||||
|
|
||||||
|
- Represents a continuation segment in the story.
|
||||||
|
|
||||||
|
- **Fields**
|
||||||
|
- `id`
|
||||||
|
- `storyId`
|
||||||
|
- `stepNumber` (monotonic integer starting from 1)
|
||||||
|
- `content` (text for this step: AI-written and/or user-edited)
|
||||||
|
- `addedToScene` (boolean; `true` if this step has been merged/compacted into the current scene)
|
||||||
|
- `userDirection` (the user’s “what should happen next” text used for this step)
|
||||||
|
- `createdAt`
|
||||||
|
- (Optional) `aiPromptMetadata` (model name, parameters, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Functional Requirements
|
||||||
|
|
||||||
|
#### 4.1 Authentication & User Management
|
||||||
|
|
||||||
|
- **Login**
|
||||||
|
- User provides username and password.
|
||||||
|
- On success, user session is established (e.g., cookie or token-based).
|
||||||
|
- On failure, an error message is displayed.
|
||||||
|
|
||||||
|
- **Admin user management**
|
||||||
|
- **List users**
|
||||||
|
- Paginated list with username, admin flag, active flag, and creation time.
|
||||||
|
- **Create user**
|
||||||
|
- Set username, initial password, and admin flag.
|
||||||
|
- **Edit user**
|
||||||
|
- Change password (reset).
|
||||||
|
- Toggle `isAdmin`.
|
||||||
|
- Toggle `active`.
|
||||||
|
- **Delete user**
|
||||||
|
- Remove user with defined behavior for their content (e.g., soft delete or cascade; to be decided at implementation).
|
||||||
|
|
||||||
|
#### 4.2 Scenario & Starting Characters Management
|
||||||
|
|
||||||
|
- **Create scenario**
|
||||||
|
- User defines:
|
||||||
|
- Title.
|
||||||
|
- Overall description (optional).
|
||||||
|
- Starting scene description.
|
||||||
|
- Initial set of starting characters (name, role, description).
|
||||||
|
|
||||||
|
- **Edit scenario**
|
||||||
|
- Update title, descriptions, and characters:
|
||||||
|
- Add, edit, remove `ScenarioCharacter`s.
|
||||||
|
|
||||||
|
- **Delete scenario**
|
||||||
|
- User can delete their own scenarios.
|
||||||
|
- Behavior if stories exist for this scenario (e.g., forbid deletion or only mark the scenario as inactive) is defined at implementation.
|
||||||
|
|
||||||
|
- **List scenarios**
|
||||||
|
- Users see only scenarios they own.
|
||||||
|
- Admins can see all scenarios.
|
||||||
|
|
||||||
|
#### 4.3 Story Lifecycle
|
||||||
|
|
||||||
|
- **Start new story from a scenario**
|
||||||
|
- User selects a scenario.
|
||||||
|
- System initializes a `Story`:
|
||||||
|
- `currentSceneDescription` from `startingSceneDescription`.
|
||||||
|
- `currentSceneCharacters` from `startingCharacters`.
|
||||||
|
- No `StoryStep`s initially.
|
||||||
|
- User can optionally set a custom story title.
|
||||||
|
|
||||||
|
- **Load existing story**
|
||||||
|
- User sees a list of their stories (title, originating scenario, last updated).
|
||||||
|
- Selecting a story shows:
|
||||||
|
- Current scene.
|
||||||
|
- Current scene characters.
|
||||||
|
- Story step list with `stepNumber` and `addedToScene` flag.
|
||||||
|
- Input area for “what should happen next”.
|
||||||
|
|
||||||
|
- **Delete story**
|
||||||
|
- Users can delete their own stories.
|
||||||
|
- Implementation can choose soft delete (recommended) or hard delete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Story Interaction & AI Behavior
|
||||||
|
|
||||||
|
#### 5.1 User Actions Per Step
|
||||||
|
|
||||||
|
On an active story, the user can:
|
||||||
|
|
||||||
|
- **Enter a description of what should happen next**
|
||||||
|
- Free text input for the direction of the next step.
|
||||||
|
|
||||||
|
- **Modify the current scene**
|
||||||
|
- Direct text editing of `currentSceneDescription`.
|
||||||
|
- Or AI-assisted edit:
|
||||||
|
- User describes desired changes.
|
||||||
|
- AI proposes modified scene text.
|
||||||
|
- User can accept, reject, or further edit the proposal.
|
||||||
|
|
||||||
|
- **Modify current characters**
|
||||||
|
- Directly:
|
||||||
|
- Change name, role, or description of existing characters.
|
||||||
|
- Remove characters.
|
||||||
|
- Add new characters.
|
||||||
|
- Or AI-assisted:
|
||||||
|
- User describes desired character changes (e.g., “make the mentor more mysterious”).
|
||||||
|
- AI proposes updates to character fields.
|
||||||
|
- User confirms or edits these suggestions.
|
||||||
|
|
||||||
|
#### 5.2 Request Payload to AI (Normal Case)
|
||||||
|
|
||||||
|
When generating the next story step without compaction:
|
||||||
|
|
||||||
|
- **Request to AI must include:**
|
||||||
|
- **Current scene**
|
||||||
|
- `currentSceneDescription`.
|
||||||
|
- `currentSceneCharacters` (list of name, role, description).
|
||||||
|
- **User direction**
|
||||||
|
- The user’s text describing what should happen next for this step.
|
||||||
|
- **Unmerged steps**
|
||||||
|
- All `StoryStep` entries where `addedToScene == false`.
|
||||||
|
- Steps are sent in chronological order as additional context after the scene.
|
||||||
|
|
||||||
|
- **AI output**
|
||||||
|
- Narrative text for the next part of the story (step content).
|
||||||
|
|
||||||
|
- **System actions**
|
||||||
|
- Create a new `StoryStep` with:
|
||||||
|
- `stepNumber = max(existing stepNumber) + 1`.
|
||||||
|
- `content = AI response` (subsequently editable by user).
|
||||||
|
- `addedToScene = false`.
|
||||||
|
- `userDirection` stored for this step.
|
||||||
|
|
||||||
|
#### 5.3 Context Compaction Flow (When Prompt Too Large)
|
||||||
|
|
||||||
|
If the combined size of:
|
||||||
|
- Current scene description,
|
||||||
|
- Current characters,
|
||||||
|
- All unmerged steps,
|
||||||
|
- And the new user direction
|
||||||
|
|
||||||
|
exceeds the AI model’s context limits, the system performs a **compaction phase** before requesting a new step.
|
||||||
|
|
||||||
|
**Compaction phase (first AI call):**
|
||||||
|
|
||||||
|
- Send to the AI:
|
||||||
|
- The current scene (description and characters).
|
||||||
|
- A subset of the unmerged `StoryStep`s, typically the oldest half of them.
|
||||||
|
- Ask the AI to:
|
||||||
|
- Merge and compact this information into a new, coherent scene description.
|
||||||
|
- Optionally adjust character descriptions to reflect changes implied by the merged steps.
|
||||||
|
|
||||||
|
**Update of domain state:**
|
||||||
|
|
||||||
|
- Replace `currentSceneDescription` with the compacted scene from the AI.
|
||||||
|
- Update `currentSceneCharacters` if the AI returned updated characters.
|
||||||
|
- Mark the merged `StoryStep`s as:
|
||||||
|
- `addedToScene = true`.
|
||||||
|
|
||||||
|
**Repeated compaction (if needed):**
|
||||||
|
|
||||||
|
- If context is still too large, repeat the compaction step with the remaining unmerged steps until:
|
||||||
|
- The full story history is representable by:
|
||||||
|
- One current scene,
|
||||||
|
- Current characters,
|
||||||
|
- A small remaining set of unmerged steps.
|
||||||
|
|
||||||
|
**User review after compaction:**
|
||||||
|
|
||||||
|
- Show the **new current scene and characters** to the user.
|
||||||
|
- Allow:
|
||||||
|
- Direct editing of the compacted scene and characters.
|
||||||
|
- AI-assisted modification (prompt–suggest–accept/edit loop).
|
||||||
|
- Once the user is satisfied, they can:
|
||||||
|
- Enter new “what should happen next” text.
|
||||||
|
- Trigger the normal “generate next step” AI call, which uses the compacted scene plus remaining unmerged steps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Frontend UX Requirements
|
||||||
|
|
||||||
|
#### 6.1 Login & Admin Screens
|
||||||
|
|
||||||
|
- **Login page**
|
||||||
|
- Username and password fields.
|
||||||
|
- Button to log in.
|
||||||
|
- Display of error messages for invalid credentials.
|
||||||
|
|
||||||
|
- **Admin user management**
|
||||||
|
- User list table showing:
|
||||||
|
- Username.
|
||||||
|
- Admin flag.
|
||||||
|
- Active flag.
|
||||||
|
- Actions:
|
||||||
|
- Create user (form).
|
||||||
|
- Edit user (change password, toggle admin/active).
|
||||||
|
- Delete user.
|
||||||
|
|
||||||
|
#### 6.2 Scenario Management UI
|
||||||
|
|
||||||
|
- **Scenario list**
|
||||||
|
- Displays scenarios owned by the logged-in user.
|
||||||
|
- Actions:
|
||||||
|
- Create scenario.
|
||||||
|
- Edit scenario.
|
||||||
|
- Delete scenario.
|
||||||
|
|
||||||
|
- **Scenario editor**
|
||||||
|
- Fields:
|
||||||
|
- Title.
|
||||||
|
- Scenario description.
|
||||||
|
- Starting scene description.
|
||||||
|
- Character list:
|
||||||
|
- For each character: name, role, description.
|
||||||
|
- Add / edit / delete characters.
|
||||||
|
|
||||||
|
#### 6.3 Story Workspace UI
|
||||||
|
|
||||||
|
For a selected story, the workspace should provide:
|
||||||
|
|
||||||
|
- **Current scene panel**
|
||||||
|
- Read-only by default with buttons:
|
||||||
|
- “Edit Scene” (direct text edit).
|
||||||
|
- “AI Edit Scene” (prompt-based suggestion).
|
||||||
|
|
||||||
|
- **Characters panel**
|
||||||
|
- List of characters with name, role, and description.
|
||||||
|
- Actions:
|
||||||
|
- Add character.
|
||||||
|
- Edit character.
|
||||||
|
- Remove character.
|
||||||
|
- “AI Suggest Character Changes”.
|
||||||
|
|
||||||
|
- **Steps panel**
|
||||||
|
- List of steps with:
|
||||||
|
- Step number.
|
||||||
|
- Short preview of `content`.
|
||||||
|
- Badge or indicator for `addedToScene` vs not.
|
||||||
|
- Clicking a step expands to show full `content`.
|
||||||
|
|
||||||
|
- **Next step input**
|
||||||
|
- Text area: “What should happen next?”
|
||||||
|
- Button: “Generate Next Step”.
|
||||||
|
|
||||||
|
- **Compaction UX**
|
||||||
|
- When compaction is triggered:
|
||||||
|
- Show progress indication.
|
||||||
|
- After completion, present the updated current scene and characters.
|
||||||
|
- Provide:
|
||||||
|
- “Accept Scene” (continue).
|
||||||
|
- “Edit Scene” (direct or AI-assisted).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Non-Functional Requirements (High-Level)
|
||||||
|
|
||||||
|
- **Persistence**
|
||||||
|
- Use a relational database to store `User`, `Scenario`, `ScenarioCharacter`, `Story`, `StoryCharacter`, and `StoryStep`.
|
||||||
|
|
||||||
|
- **AI integration**
|
||||||
|
- Backend component to:
|
||||||
|
- Build AI prompts from current scene, characters, user direction, and steps.
|
||||||
|
- Check approximate prompt size against model limits.
|
||||||
|
- Trigger compaction workflow when limits are exceeded.
|
||||||
|
|
||||||
|
- **Security**
|
||||||
|
- Authorization checks ensure:
|
||||||
|
- Users only access and modify their own scenarios and stories.
|
||||||
|
- Admin endpoints/actions are restricted to admin users.
|
||||||
|
|
||||||
|
- **Auditability**
|
||||||
|
- Store:
|
||||||
|
- `userDirection` and AI-generated `content` per `StoryStep`.
|
||||||
|
- Optional metadata about AI calls for debugging and analysis.
|
||||||
|
|
||||||
|
This specification describes the intended behavior and structure of the Story Teller application across users, scenarios, stories, AI integration, and frontend UX.
|
||||||
250
pom.xml
Normal file
250
pom.xml
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<groupId>de.neitzel</groupId>
|
||||||
|
<artifactId>storyteller</artifactId>
|
||||||
|
<version>0.1.0-SNAPSHOT</version>
|
||||||
|
<name>StoryTeller</name>
|
||||||
|
<description>OpenAPI-first StoryTeller application with Quarkus and React.</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
<maven.compiler.release>21</maven.compiler.release>
|
||||||
|
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
|
||||||
|
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
|
||||||
|
<quarkus.platform.version>3.31.2</quarkus.platform.version>
|
||||||
|
<quarkus.plugin.version>${quarkus.platform.version}</quarkus.plugin.version>
|
||||||
|
<openapi.generator.version>7.14.0</openapi.generator.version>
|
||||||
|
<build.helper.plugin.version>3.6.0</build.helper.plugin.version>
|
||||||
|
<frontend.maven.plugin.version>1.15.1</frontend.maven.plugin.version>
|
||||||
|
<maven.resources.plugin.version>3.3.1</maven.resources.plugin.version>
|
||||||
|
<maven.surefire.plugin.version>3.5.3</maven.surefire.plugin.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>${quarkus.platform.group-id}</groupId>
|
||||||
|
<artifactId>${quarkus.platform.artifact-id}</artifactId>
|
||||||
|
<version>${quarkus.platform.version}</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-arc</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-rest-jackson</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-security</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-smallrye-jwt</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-smallrye-jwt-build</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-hibernate-validator</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-hibernate-orm-panache</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-jdbc-postgresql</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-liquibase</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.swagger</groupId>
|
||||||
|
<artifactId>swagger-annotations</artifactId>
|
||||||
|
<version>1.6.15</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mindrot</groupId>
|
||||||
|
<artifactId>jbcrypt</artifactId>
|
||||||
|
<version>0.4</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-junit</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mockito</groupId>
|
||||||
|
<artifactId>mockito-core</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-maven-plugin</artifactId>
|
||||||
|
<version>${quarkus.plugin.version}</version>
|
||||||
|
<extensions>true</extensions>
|
||||||
|
</plugin>
|
||||||
|
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.openapitools</groupId>
|
||||||
|
<artifactId>openapi-generator-maven-plugin</artifactId>
|
||||||
|
<version>${openapi.generator.version}</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>generate-java-api</id>
|
||||||
|
<phase>generate-sources</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>generate</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<inputSpec>${project.basedir}/src/main/resources/openapi/story-teller-api.yaml</inputSpec>
|
||||||
|
<generatorName>jaxrs-spec</generatorName>
|
||||||
|
<output>${project.build.directory}/generated-sources/openapi/java</output>
|
||||||
|
<apiPackage>de.neitzel.storyteller.fascade.api</apiPackage>
|
||||||
|
<modelPackage>de.neitzel.storyteller.fascade.model</modelPackage>
|
||||||
|
<generateApis>true</generateApis>
|
||||||
|
<generateModels>true</generateModels>
|
||||||
|
<generateSupportingFiles>false</generateSupportingFiles>
|
||||||
|
<configOptions>
|
||||||
|
<interfaceOnly>true</interfaceOnly>
|
||||||
|
<useJakartaEe>true</useJakartaEe>
|
||||||
|
<useTags>true</useTags>
|
||||||
|
<dateLibrary>java8</dateLibrary>
|
||||||
|
<sourceFolder>src/gen/java</sourceFolder>
|
||||||
|
</configOptions>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<id>generate-typescript-client</id>
|
||||||
|
<phase>generate-sources</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>generate</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<inputSpec>${project.basedir}/src/main/resources/openapi/story-teller-api.yaml</inputSpec>
|
||||||
|
<generatorName>typescript-fetch</generatorName>
|
||||||
|
<output>${project.basedir}/src/main/web/src/api/generated</output>
|
||||||
|
<generateApis>true</generateApis>
|
||||||
|
<generateModels>true</generateModels>
|
||||||
|
<generateSupportingFiles>true</generateSupportingFiles>
|
||||||
|
<configOptions>
|
||||||
|
<supportsES6>true</supportsES6>
|
||||||
|
<npmName>@storyteller/api-client</npmName>
|
||||||
|
<modelPropertyNaming>original</modelPropertyNaming>
|
||||||
|
</configOptions>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.codehaus.mojo</groupId>
|
||||||
|
<artifactId>build-helper-maven-plugin</artifactId>
|
||||||
|
<version>${build.helper.plugin.version}</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>add-generated-java-sources</id>
|
||||||
|
<phase>generate-sources</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>add-source</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<sources>
|
||||||
|
<source>${project.build.directory}/generated-sources/openapi/java/src/gen/java</source>
|
||||||
|
</sources>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
|
||||||
|
<plugin>
|
||||||
|
<groupId>com.github.eirslett</groupId>
|
||||||
|
<artifactId>frontend-maven-plugin</artifactId>
|
||||||
|
<version>${frontend.maven.plugin.version}</version>
|
||||||
|
<configuration>
|
||||||
|
<workingDirectory>src/main/web</workingDirectory>
|
||||||
|
</configuration>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>install-node-and-npm</id>
|
||||||
|
<goals>
|
||||||
|
<goal>install-node-and-npm</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<nodeVersion>v22.14.0</nodeVersion>
|
||||||
|
<npmVersion>10.9.2</npmVersion>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<id>npm-install</id>
|
||||||
|
<phase>generate-resources</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>npm</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<arguments>install</arguments>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<id>npm-build</id>
|
||||||
|
<phase>prepare-package</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>npm</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<arguments>run build</arguments>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-resources-plugin</artifactId>
|
||||||
|
<version>${maven.resources.plugin.version}</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>copy-web-dist-to-quarkus-static</id>
|
||||||
|
<phase>prepare-package</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>copy-resources</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<outputDirectory>${project.build.outputDirectory}/META-INF/resources</outputDirectory>
|
||||||
|
<resources>
|
||||||
|
<resource>
|
||||||
|
<directory>${project.build.directory}/web-dist</directory>
|
||||||
|
<filtering>false</filtering>
|
||||||
|
</resource>
|
||||||
|
</resources>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<version>${maven.surefire.plugin.version}</version>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
|
||||||
|
</project>
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
package de.neitzel.storyteller.business;
|
||||||
|
|
||||||
|
import de.neitzel.storyteller.common.security.JwtService;
|
||||||
|
import de.neitzel.storyteller.common.security.PasswordHasher;
|
||||||
|
import de.neitzel.storyteller.data.entity.UserEntity;
|
||||||
|
import de.neitzel.storyteller.data.repository.UserRepository;
|
||||||
|
import de.neitzel.storyteller.fascade.model.LoginRequest;
|
||||||
|
import de.neitzel.storyteller.fascade.model.LoginResponse;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.ws.rs.WebApplicationException;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles user authentication: credential verification and JWT issuance.
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class AuthService {
|
||||||
|
|
||||||
|
/** User repository for credential lookup. */
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
/** Password hashing utility. */
|
||||||
|
private final PasswordHasher passwordHasher;
|
||||||
|
|
||||||
|
/** JWT token builder. */
|
||||||
|
private final JwtService jwtService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs the service with required dependencies.
|
||||||
|
*
|
||||||
|
* @param userRepository user persistence
|
||||||
|
* @param passwordHasher BCrypt hasher
|
||||||
|
* @param jwtService token builder
|
||||||
|
*/
|
||||||
|
public AuthService(final UserRepository userRepository,
|
||||||
|
final PasswordHasher passwordHasher,
|
||||||
|
final JwtService jwtService) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.passwordHasher = passwordHasher;
|
||||||
|
this.jwtService = jwtService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticates the user and returns a signed JWT.
|
||||||
|
*
|
||||||
|
* @param request login payload with username and password
|
||||||
|
* @return login response containing the JWT and user info
|
||||||
|
* @throws WebApplicationException 401 if credentials are invalid
|
||||||
|
*/
|
||||||
|
public LoginResponse login(final LoginRequest request) {
|
||||||
|
final UserEntity user = userRepository.findByUsername(request.getUsername())
|
||||||
|
.orElseThrow(() -> new WebApplicationException("Invalid credentials", Response.Status.UNAUTHORIZED));
|
||||||
|
|
||||||
|
if (!user.isActive()) {
|
||||||
|
throw new WebApplicationException("Account is disabled", Response.Status.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!passwordHasher.verify(request.getPassword(), user.getPasswordHash())) {
|
||||||
|
throw new WebApplicationException("Invalid credentials", Response.Status.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String token = jwtService.generateToken(user.getId(), user.getUsername(), user.isAdmin());
|
||||||
|
return new LoginResponse()
|
||||||
|
.token(token)
|
||||||
|
.userId(user.getId())
|
||||||
|
.username(user.getUsername())
|
||||||
|
.isAdmin(user.isAdmin());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,183 @@
|
|||||||
|
package de.neitzel.storyteller.business;
|
||||||
|
|
||||||
|
import de.neitzel.storyteller.data.entity.ScenarioCharacterEntity;
|
||||||
|
import de.neitzel.storyteller.data.entity.ScenarioEntity;
|
||||||
|
import de.neitzel.storyteller.data.repository.ScenarioCharacterRepository;
|
||||||
|
import de.neitzel.storyteller.data.repository.ScenarioRepository;
|
||||||
|
import de.neitzel.storyteller.fascade.model.CharacterDto;
|
||||||
|
import de.neitzel.storyteller.fascade.model.CreateScenarioRequest;
|
||||||
|
import de.neitzel.storyteller.fascade.model.Scenario;
|
||||||
|
import de.neitzel.storyteller.fascade.model.UpdateScenarioRequest;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import jakarta.ws.rs.WebApplicationException;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages scenario CRUD operations with per-user ownership enforcement.
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class ScenarioService {
|
||||||
|
|
||||||
|
/** Scenario persistence. */
|
||||||
|
private final ScenarioRepository scenarioRepository;
|
||||||
|
|
||||||
|
/** Character persistence. */
|
||||||
|
private final ScenarioCharacterRepository characterRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs the service with required repositories.
|
||||||
|
*
|
||||||
|
* @param scenarioRepository scenario persistence
|
||||||
|
* @param characterRepository character persistence
|
||||||
|
*/
|
||||||
|
public ScenarioService(final ScenarioRepository scenarioRepository,
|
||||||
|
final ScenarioCharacterRepository characterRepository) {
|
||||||
|
this.scenarioRepository = scenarioRepository;
|
||||||
|
this.characterRepository = characterRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists scenarios visible to the calling user. Admins see all; regular users see only their own.
|
||||||
|
*
|
||||||
|
* @param userId calling user's id
|
||||||
|
* @param isAdmin whether the caller is an admin
|
||||||
|
* @return list of scenario DTOs
|
||||||
|
*/
|
||||||
|
public List<Scenario> listScenarios(final Long userId, final boolean isAdmin) {
|
||||||
|
final List<ScenarioEntity> entities = isAdmin
|
||||||
|
? scenarioRepository.listAll()
|
||||||
|
: scenarioRepository.findByOwner(userId);
|
||||||
|
return entities.stream().map(this::toDto).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a single scenario by id with ownership check.
|
||||||
|
*
|
||||||
|
* @param scenarioId scenario id
|
||||||
|
* @param userId calling user's id
|
||||||
|
* @param isAdmin whether the caller is an admin
|
||||||
|
* @return scenario DTO
|
||||||
|
*/
|
||||||
|
public Scenario getScenario(final Long scenarioId, final Long userId, final boolean isAdmin) {
|
||||||
|
final ScenarioEntity entity = findOwnedOrFail(scenarioId, userId, isAdmin);
|
||||||
|
return toDto(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new scenario owned by the calling user.
|
||||||
|
*
|
||||||
|
* @param request payload
|
||||||
|
* @param userId calling user's id
|
||||||
|
* @return created scenario DTO
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public Scenario createScenario(final CreateScenarioRequest request, final Long userId) {
|
||||||
|
final ScenarioEntity entity = new ScenarioEntity();
|
||||||
|
entity.setOwnerUserId(userId);
|
||||||
|
entity.setTitle(request.getTitle());
|
||||||
|
entity.setDescription(request.getDescription());
|
||||||
|
entity.setStartingSceneDescription(request.getStartingSceneDescription());
|
||||||
|
entity.setCreatedAt(LocalDateTime.now());
|
||||||
|
entity.setUpdatedAt(LocalDateTime.now());
|
||||||
|
scenarioRepository.persist(entity);
|
||||||
|
|
||||||
|
if (request.getStartingCharacters() != null) {
|
||||||
|
for (final CharacterDto dto : request.getStartingCharacters()) {
|
||||||
|
persistCharacter(entity.getId(), dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scenarioRepository.getEntityManager().flush();
|
||||||
|
scenarioRepository.getEntityManager().refresh(entity);
|
||||||
|
return toDto(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an existing scenario with ownership check.
|
||||||
|
*
|
||||||
|
* @param scenarioId scenario id
|
||||||
|
* @param request update payload
|
||||||
|
* @param userId calling user's id
|
||||||
|
* @param isAdmin whether the caller is an admin
|
||||||
|
* @return updated scenario DTO
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public Scenario updateScenario(final Long scenarioId, final UpdateScenarioRequest request,
|
||||||
|
final Long userId, final boolean isAdmin) {
|
||||||
|
final ScenarioEntity entity = findOwnedOrFail(scenarioId, userId, isAdmin);
|
||||||
|
if (request.getTitle() != null) {
|
||||||
|
entity.setTitle(request.getTitle());
|
||||||
|
}
|
||||||
|
if (request.getDescription() != null) {
|
||||||
|
entity.setDescription(request.getDescription());
|
||||||
|
}
|
||||||
|
if (request.getStartingSceneDescription() != null) {
|
||||||
|
entity.setStartingSceneDescription(request.getStartingSceneDescription());
|
||||||
|
}
|
||||||
|
entity.setUpdatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
if (request.getStartingCharacters() != null) {
|
||||||
|
characterRepository.deleteByScenario(scenarioId);
|
||||||
|
scenarioRepository.getEntityManager().flush();
|
||||||
|
for (final CharacterDto dto : request.getStartingCharacters()) {
|
||||||
|
persistCharacter(scenarioId, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scenarioRepository.getEntityManager().flush();
|
||||||
|
scenarioRepository.getEntityManager().refresh(entity);
|
||||||
|
return toDto(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a scenario with ownership check.
|
||||||
|
*
|
||||||
|
* @param scenarioId scenario id
|
||||||
|
* @param userId calling user's id
|
||||||
|
* @param isAdmin whether the caller is an admin
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void deleteScenario(final Long scenarioId, final Long userId, final boolean isAdmin) {
|
||||||
|
final ScenarioEntity entity = findOwnedOrFail(scenarioId, userId, isAdmin);
|
||||||
|
characterRepository.deleteByScenario(scenarioId);
|
||||||
|
scenarioRepository.delete(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a scenario, throwing 404 if absent or 403 if ownership fails.
|
||||||
|
*/
|
||||||
|
private ScenarioEntity findOwnedOrFail(final Long scenarioId, final Long userId, final boolean isAdmin) {
|
||||||
|
final ScenarioEntity entity = scenarioRepository.findById(scenarioId);
|
||||||
|
if (entity == null) {
|
||||||
|
throw new WebApplicationException("Scenario not found", Response.Status.NOT_FOUND);
|
||||||
|
}
|
||||||
|
if (!isAdmin && !entity.getOwnerUserId().equals(userId)) {
|
||||||
|
throw new WebApplicationException("Access denied", Response.Status.FORBIDDEN);
|
||||||
|
}
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persists a single character row for a scenario. */
|
||||||
|
private void persistCharacter(final Long scenarioId, final CharacterDto dto) {
|
||||||
|
final ScenarioCharacterEntity ch = new ScenarioCharacterEntity();
|
||||||
|
ch.setScenarioId(scenarioId);
|
||||||
|
ch.setName(dto.getName());
|
||||||
|
ch.setRole(dto.getRole());
|
||||||
|
ch.setDescription(dto.getDescription());
|
||||||
|
characterRepository.persist(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps a ScenarioEntity + its characters to the API Scenario DTO. */
|
||||||
|
private Scenario toDto(final ScenarioEntity entity) {
|
||||||
|
final List<CharacterDto> chars = entity.getStartingCharacters().stream()
|
||||||
|
.map(ch -> new CharacterDto().name(ch.getName()).role(ch.getRole()).description(ch.getDescription()))
|
||||||
|
.toList();
|
||||||
|
return new Scenario()
|
||||||
|
.id(entity.getId())
|
||||||
|
.title(entity.getTitle())
|
||||||
|
.description(entity.getDescription())
|
||||||
|
.startingSceneDescription(entity.getStartingSceneDescription())
|
||||||
|
.startingCharacters(chars);
|
||||||
|
}
|
||||||
|
}
|
||||||
373
src/main/java/de/neitzel/storyteller/business/StoryService.java
Normal file
373
src/main/java/de/neitzel/storyteller/business/StoryService.java
Normal file
@ -0,0 +1,373 @@
|
|||||||
|
package de.neitzel.storyteller.business;
|
||||||
|
|
||||||
|
import de.neitzel.storyteller.data.entity.ScenarioEntity;
|
||||||
|
import de.neitzel.storyteller.data.entity.StoryCharacterEntity;
|
||||||
|
import de.neitzel.storyteller.data.entity.StoryEntity;
|
||||||
|
import de.neitzel.storyteller.data.entity.StoryStepEntity;
|
||||||
|
import de.neitzel.storyteller.data.repository.ScenarioRepository;
|
||||||
|
import de.neitzel.storyteller.data.repository.StoryCharacterRepository;
|
||||||
|
import de.neitzel.storyteller.data.repository.StoryRepository;
|
||||||
|
import de.neitzel.storyteller.data.repository.StoryStepRepository;
|
||||||
|
import de.neitzel.storyteller.fascade.model.CharacterDto;
|
||||||
|
import de.neitzel.storyteller.fascade.model.GenerateStepRequest;
|
||||||
|
import de.neitzel.storyteller.fascade.model.GenerateStepResponse;
|
||||||
|
import de.neitzel.storyteller.fascade.model.StartStoryRequest;
|
||||||
|
import de.neitzel.storyteller.fascade.model.Story;
|
||||||
|
import de.neitzel.storyteller.fascade.model.StoryStep;
|
||||||
|
import de.neitzel.storyteller.fascade.model.UpdateStepRequest;
|
||||||
|
import de.neitzel.storyteller.fascade.model.UpdateStoryRequest;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the full story lifecycle: creation, scene/character editing,
|
||||||
|
* step generation with AI, and context compaction.
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class StoryService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approximate character budget before compaction kicks in.
|
||||||
|
* In a real system this would be configurable and model-specific.
|
||||||
|
*/
|
||||||
|
private static final int CONTEXT_CHAR_LIMIT = 12_000;
|
||||||
|
|
||||||
|
/** Story persistence. */
|
||||||
|
private final StoryRepository storyRepository;
|
||||||
|
/** Story character persistence. */
|
||||||
|
private final StoryCharacterRepository characterRepository;
|
||||||
|
/** Story step persistence. */
|
||||||
|
private final StoryStepRepository stepRepository;
|
||||||
|
/** Scenario persistence (for initial data on story start). */
|
||||||
|
private final ScenarioRepository scenarioRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs the service with required repositories.
|
||||||
|
*
|
||||||
|
* @param storyRepository story persistence
|
||||||
|
* @param characterRepository character persistence
|
||||||
|
* @param stepRepository step persistence
|
||||||
|
* @param scenarioRepository scenario persistence
|
||||||
|
*/
|
||||||
|
public StoryService(final StoryRepository storyRepository,
|
||||||
|
final StoryCharacterRepository characterRepository,
|
||||||
|
final StoryStepRepository stepRepository,
|
||||||
|
final ScenarioRepository scenarioRepository) {
|
||||||
|
this.storyRepository = storyRepository;
|
||||||
|
this.characterRepository = characterRepository;
|
||||||
|
this.stepRepository = stepRepository;
|
||||||
|
this.scenarioRepository = scenarioRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists non-deleted stories for the calling user.
|
||||||
|
*
|
||||||
|
* @param userId calling user's id
|
||||||
|
* @return story DTOs
|
||||||
|
*/
|
||||||
|
public List<Story> listStories(final Long userId) {
|
||||||
|
return storyRepository.findByOwner(userId).stream()
|
||||||
|
.map(this::toDto)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a single story by id with ownership check.
|
||||||
|
*
|
||||||
|
* @param storyId story id
|
||||||
|
* @param userId calling user's id
|
||||||
|
* @return story DTO
|
||||||
|
*/
|
||||||
|
public Story getStory(final Long storyId, final Long userId) {
|
||||||
|
return toDto(findOwnedOrFail(storyId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a new story from a scenario, copying its scene and characters.
|
||||||
|
*
|
||||||
|
* @param request payload with scenario id and title
|
||||||
|
* @param userId calling user's id
|
||||||
|
* @return created story DTO
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public Story startStory(final StartStoryRequest request, final Long userId) {
|
||||||
|
final ScenarioEntity scenario = scenarioRepository.findById(request.getScenarioId());
|
||||||
|
if (scenario == null) {
|
||||||
|
throw new WebApplicationException("Scenario not found", Response.Status.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
final StoryEntity entity = new StoryEntity();
|
||||||
|
entity.setOwnerUserId(userId);
|
||||||
|
entity.setScenarioId(scenario.getId());
|
||||||
|
entity.setTitle(request.getTitle());
|
||||||
|
entity.setCurrentSceneDescription(scenario.getStartingSceneDescription());
|
||||||
|
entity.setStatus("ACTIVE");
|
||||||
|
entity.setCreatedAt(LocalDateTime.now());
|
||||||
|
entity.setUpdatedAt(LocalDateTime.now());
|
||||||
|
storyRepository.persist(entity);
|
||||||
|
|
||||||
|
scenario.getStartingCharacters().forEach(sc -> {
|
||||||
|
final StoryCharacterEntity ch = new StoryCharacterEntity();
|
||||||
|
ch.setStoryId(entity.getId());
|
||||||
|
ch.setName(sc.getName());
|
||||||
|
ch.setRole(sc.getRole());
|
||||||
|
ch.setDescription(sc.getDescription());
|
||||||
|
characterRepository.persist(ch);
|
||||||
|
});
|
||||||
|
|
||||||
|
storyRepository.getEntityManager().flush();
|
||||||
|
storyRepository.getEntityManager().refresh(entity);
|
||||||
|
return toDto(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a story's title, scene description, and/or characters.
|
||||||
|
*
|
||||||
|
* @param storyId story id
|
||||||
|
* @param request update payload
|
||||||
|
* @param userId calling user's id
|
||||||
|
* @return updated story DTO
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public Story updateStory(final Long storyId, final UpdateStoryRequest request, final Long userId) {
|
||||||
|
final StoryEntity entity = findOwnedOrFail(storyId, userId);
|
||||||
|
if (request.getTitle() != null) {
|
||||||
|
entity.setTitle(request.getTitle());
|
||||||
|
}
|
||||||
|
if (request.getCurrentSceneDescription() != null) {
|
||||||
|
entity.setCurrentSceneDescription(request.getCurrentSceneDescription());
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
storyRepository.getEntityManager().flush();
|
||||||
|
storyRepository.getEntityManager().refresh(entity);
|
||||||
|
return toDto(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-deletes a story.
|
||||||
|
*
|
||||||
|
* @param storyId story id
|
||||||
|
* @param userId calling user's id
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void deleteStory(final Long storyId, final Long userId) {
|
||||||
|
final StoryEntity entity = findOwnedOrFail(storyId, userId);
|
||||||
|
entity.setStatus("DELETED");
|
||||||
|
entity.setUpdatedAt(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists all steps for a story, ordered by step number.
|
||||||
|
*
|
||||||
|
* @param storyId story id
|
||||||
|
* @param userId calling user's id
|
||||||
|
* @return step DTOs
|
||||||
|
*/
|
||||||
|
public List<StoryStep> listStorySteps(final Long storyId, final Long userId) {
|
||||||
|
findOwnedOrFail(storyId, userId);
|
||||||
|
return stepRepository.findByStoryOrdered(storyId).stream()
|
||||||
|
.map(this::toStepDto)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a story step's content (user editing).
|
||||||
|
*
|
||||||
|
* @param storyId story id
|
||||||
|
* @param stepId step id
|
||||||
|
* @param request update payload
|
||||||
|
* @param userId calling user's id
|
||||||
|
* @return updated step DTO
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public StoryStep updateStoryStep(final Long storyId, final Long stepId,
|
||||||
|
final UpdateStepRequest request, final Long userId) {
|
||||||
|
findOwnedOrFail(storyId, userId);
|
||||||
|
final StoryStepEntity step = stepRepository.findById(stepId);
|
||||||
|
if (step == null || !step.getStoryId().equals(storyId)) {
|
||||||
|
throw new WebApplicationException("Step not found", Response.Status.NOT_FOUND);
|
||||||
|
}
|
||||||
|
if (request.getContent() != null) {
|
||||||
|
step.setContent(request.getContent());
|
||||||
|
}
|
||||||
|
return toStepDto(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the next story step. Performs context compaction when the combined
|
||||||
|
* prompt size exceeds {@link #CONTEXT_CHAR_LIMIT}, then generates the continuation.
|
||||||
|
*
|
||||||
|
* @param storyId story id
|
||||||
|
* @param request generation request with user direction
|
||||||
|
* @param userId calling user's id
|
||||||
|
* @return generation response containing the new step and compaction flag
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public GenerateStepResponse generateStoryStep(final Long storyId,
|
||||||
|
final GenerateStepRequest request,
|
||||||
|
final Long userId) {
|
||||||
|
final StoryEntity story = findOwnedOrFail(storyId, userId);
|
||||||
|
|
||||||
|
if (request.getCurrentSceneDescription() != null) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
storyRepository.getEntityManager().flush();
|
||||||
|
|
||||||
|
List<StoryStepEntity> unmerged = stepRepository.findUnmergedByStory(storyId);
|
||||||
|
boolean compacted = false;
|
||||||
|
|
||||||
|
while (estimateContextSize(story, unmerged, request.getDirection()) > CONTEXT_CHAR_LIMIT
|
||||||
|
&& unmerged.size() > 1) {
|
||||||
|
compacted = true;
|
||||||
|
final int half = Math.max(1, unmerged.size() / 2);
|
||||||
|
final List<StoryStepEntity> toMerge = unmerged.subList(0, half);
|
||||||
|
final String compactedScene = compactScene(story.getCurrentSceneDescription(), toMerge);
|
||||||
|
story.setCurrentSceneDescription(compactedScene);
|
||||||
|
toMerge.forEach(s -> s.setAddedToScene(true));
|
||||||
|
storyRepository.getEntityManager().flush();
|
||||||
|
unmerged = stepRepository.findUnmergedByStory(storyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
story.setUpdatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
final int nextNumber = stepRepository.maxStepNumber(storyId) + 1;
|
||||||
|
final String content = generateAiContinuation(story, unmerged, request.getDirection());
|
||||||
|
final StoryStepEntity step = new StoryStepEntity();
|
||||||
|
step.setStoryId(storyId);
|
||||||
|
step.setStepNumber(nextNumber);
|
||||||
|
step.setContent(content);
|
||||||
|
step.setUserDirection(request.getDirection());
|
||||||
|
step.setAddedToScene(false);
|
||||||
|
step.setCreatedAt(LocalDateTime.now());
|
||||||
|
stepRepository.persist(step);
|
||||||
|
|
||||||
|
storyRepository.getEntityManager().refresh(story);
|
||||||
|
|
||||||
|
final GenerateStepResponse response = new GenerateStepResponse()
|
||||||
|
.step(toStepDto(step))
|
||||||
|
.compacted(compacted);
|
||||||
|
if (compacted) {
|
||||||
|
response.setUpdatedScene(story.getCurrentSceneDescription());
|
||||||
|
response.setUpdatedCharacters(story.getCurrentSceneCharacters().stream()
|
||||||
|
.map(ch -> new CharacterDto().name(ch.getName()).role(ch.getRole()).description(ch.getDescription()))
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rough character count estimator for context window sizing.
|
||||||
|
* In production this would use tokenization.
|
||||||
|
*/
|
||||||
|
private int estimateContextSize(final StoryEntity story,
|
||||||
|
final List<StoryStepEntity> unmerged,
|
||||||
|
final String direction) {
|
||||||
|
int size = story.getCurrentSceneDescription().length();
|
||||||
|
for (final StoryCharacterEntity ch : story.getCurrentSceneCharacters()) {
|
||||||
|
size += ch.getName().length() + ch.getRole().length()
|
||||||
|
+ (ch.getDescription() != null ? ch.getDescription().length() : 0);
|
||||||
|
}
|
||||||
|
for (final StoryStepEntity s : unmerged) {
|
||||||
|
size += s.getContent().length();
|
||||||
|
}
|
||||||
|
size += direction != null ? direction.length() : 0;
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI stub: compacts a scene description by summarising the merged steps into the scene.
|
||||||
|
* In production this would call an LLM.
|
||||||
|
*/
|
||||||
|
private String compactScene(final String currentScene, final List<StoryStepEntity> stepsToMerge) {
|
||||||
|
final StringBuilder sb = new StringBuilder(currentScene);
|
||||||
|
sb.append("\n\n[Compacted from ").append(stepsToMerge.size()).append(" earlier steps:]\n");
|
||||||
|
for (final StoryStepEntity s : stepsToMerge) {
|
||||||
|
sb.append("- ").append(truncate(s.getContent(), 200)).append("\n");
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI stub: generates the next story segment from the current context.
|
||||||
|
* In production this would call an LLM.
|
||||||
|
*/
|
||||||
|
private String generateAiContinuation(final StoryEntity story,
|
||||||
|
final List<StoryStepEntity> unmerged,
|
||||||
|
final String direction) {
|
||||||
|
return "[AI continuation] Direction: " + direction
|
||||||
|
+ "\nScene: " + truncate(story.getCurrentSceneDescription(), 120)
|
||||||
|
+ "\nUnmerged steps: " + unmerged.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Truncates text for display in stubs. */
|
||||||
|
private String truncate(final String text, final int max) {
|
||||||
|
if (text == null) return "";
|
||||||
|
return text.length() > max ? text.substring(0, max) + "..." : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
if (entity == null || "DELETED".equals(entity.getStatus())) {
|
||||||
|
throw new WebApplicationException("Story not found", Response.Status.NOT_FOUND);
|
||||||
|
}
|
||||||
|
if (!entity.getOwnerUserId().equals(userId)) {
|
||||||
|
throw new WebApplicationException("Access denied", Response.Status.FORBIDDEN);
|
||||||
|
}
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps a StoryEntity to the API Story DTO. */
|
||||||
|
private Story toDto(final StoryEntity entity) {
|
||||||
|
final List<CharacterDto> chars = entity.getCurrentSceneCharacters().stream()
|
||||||
|
.map(ch -> new CharacterDto().name(ch.getName()).role(ch.getRole()).description(ch.getDescription()))
|
||||||
|
.toList();
|
||||||
|
return new Story()
|
||||||
|
.id(entity.getId())
|
||||||
|
.title(entity.getTitle())
|
||||||
|
.scenarioId(entity.getScenarioId())
|
||||||
|
.currentSceneDescription(entity.getCurrentSceneDescription())
|
||||||
|
.currentSceneCharacters(chars)
|
||||||
|
.status(Story.StatusEnum.fromValue(entity.getStatus()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps a StoryStepEntity to the API StoryStep DTO. */
|
||||||
|
private StoryStep toStepDto(final StoryStepEntity entity) {
|
||||||
|
return new StoryStep()
|
||||||
|
.id(entity.getId())
|
||||||
|
.stepNumber(entity.getStepNumber())
|
||||||
|
.content(entity.getContent())
|
||||||
|
.userDirection(entity.getUserDirection())
|
||||||
|
.addedToScene(entity.isAddedToScene());
|
||||||
|
}
|
||||||
|
}
|
||||||
130
src/main/java/de/neitzel/storyteller/business/UserService.java
Normal file
130
src/main/java/de/neitzel/storyteller/business/UserService.java
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
package de.neitzel.storyteller.business;
|
||||||
|
|
||||||
|
import de.neitzel.storyteller.common.security.PasswordHasher;
|
||||||
|
import de.neitzel.storyteller.data.entity.UserEntity;
|
||||||
|
import de.neitzel.storyteller.data.repository.UserRepository;
|
||||||
|
import de.neitzel.storyteller.fascade.model.CreateUserRequest;
|
||||||
|
import de.neitzel.storyteller.fascade.model.UpdateUserRequest;
|
||||||
|
import de.neitzel.storyteller.fascade.model.User;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import jakarta.ws.rs.WebApplicationException;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-only user management: create, list, update, and delete users.
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class UserService {
|
||||||
|
|
||||||
|
/** User persistence. */
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
/** Password hashing utility. */
|
||||||
|
private final PasswordHasher passwordHasher;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs the service with required dependencies.
|
||||||
|
*
|
||||||
|
* @param userRepository user persistence
|
||||||
|
* @param passwordHasher BCrypt hasher
|
||||||
|
*/
|
||||||
|
public UserService(final UserRepository userRepository,
|
||||||
|
final PasswordHasher passwordHasher) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.passwordHasher = passwordHasher;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists all users (admin-only).
|
||||||
|
*
|
||||||
|
* @return all users mapped to API DTOs
|
||||||
|
*/
|
||||||
|
public List<User> listUsers() {
|
||||||
|
return userRepository.listAll().stream()
|
||||||
|
.map(this::toDto)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new user (admin-only).
|
||||||
|
*
|
||||||
|
* @param request creation payload
|
||||||
|
* @return the newly created user DTO
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public User createUser(final CreateUserRequest request) {
|
||||||
|
if (userRepository.findByUsername(request.getUsername()).isPresent()) {
|
||||||
|
throw new WebApplicationException("Username already taken", Response.Status.CONFLICT);
|
||||||
|
}
|
||||||
|
final UserEntity entity = new UserEntity();
|
||||||
|
entity.setUsername(request.getUsername());
|
||||||
|
entity.setPasswordHash(passwordHasher.hash(request.getPassword()));
|
||||||
|
entity.setAdmin(Boolean.TRUE.equals(request.getIsAdmin()));
|
||||||
|
entity.setActive(true);
|
||||||
|
entity.setCreatedAt(LocalDateTime.now());
|
||||||
|
entity.setUpdatedAt(LocalDateTime.now());
|
||||||
|
userRepository.persist(entity);
|
||||||
|
return toDto(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an existing user (admin-only).
|
||||||
|
*
|
||||||
|
* @param userId the user to update
|
||||||
|
* @param request fields to change
|
||||||
|
* @return updated user DTO
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public User updateUser(final Long userId, final UpdateUserRequest request) {
|
||||||
|
final UserEntity entity = userRepository.findById(userId);
|
||||||
|
if (entity == null) {
|
||||||
|
throw new WebApplicationException("User not found", Response.Status.NOT_FOUND);
|
||||||
|
}
|
||||||
|
if (request.getUsername() != null) {
|
||||||
|
entity.setUsername(request.getUsername());
|
||||||
|
}
|
||||||
|
if (request.getPassword() != null) {
|
||||||
|
entity.setPasswordHash(passwordHasher.hash(request.getPassword()));
|
||||||
|
}
|
||||||
|
if (request.getIsAdmin() != null) {
|
||||||
|
entity.setAdmin(request.getIsAdmin());
|
||||||
|
}
|
||||||
|
if (request.getActive() != null) {
|
||||||
|
entity.setActive(request.getActive());
|
||||||
|
}
|
||||||
|
entity.setUpdatedAt(LocalDateTime.now());
|
||||||
|
return toDto(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a user (admin-only, soft delete by deactivation).
|
||||||
|
*
|
||||||
|
* @param userId user to delete
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void deleteUser(final Long userId) {
|
||||||
|
final UserEntity entity = userRepository.findById(userId);
|
||||||
|
if (entity == null) {
|
||||||
|
throw new WebApplicationException("User not found", Response.Status.NOT_FOUND);
|
||||||
|
}
|
||||||
|
entity.setActive(false);
|
||||||
|
entity.setUpdatedAt(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a persistence entity to the API DTO.
|
||||||
|
*
|
||||||
|
* @param entity JPA entity
|
||||||
|
* @return API model
|
||||||
|
*/
|
||||||
|
private User toDto(final UserEntity entity) {
|
||||||
|
return new User()
|
||||||
|
.id(entity.getId())
|
||||||
|
.username(entity.getUsername())
|
||||||
|
.isAdmin(entity.isAdmin())
|
||||||
|
.active(entity.isActive());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
package de.neitzel.storyteller.common.security;
|
||||||
|
|
||||||
|
import jakarta.enterprise.context.RequestScoped;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import org.eclipse.microprofile.jwt.JsonWebToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request-scoped helper that extracts identity information from the current JWT.
|
||||||
|
*/
|
||||||
|
@RequestScoped
|
||||||
|
public class AuthContext {
|
||||||
|
|
||||||
|
/** The injected JWT token for the current request. */
|
||||||
|
@Inject
|
||||||
|
JsonWebToken jwt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the authenticated user's database id.
|
||||||
|
*
|
||||||
|
* @return user id from the JWT {@code userId} claim
|
||||||
|
*/
|
||||||
|
public Long getUserId() {
|
||||||
|
return jwt.getClaim(JwtService.CLAIM_USER_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the authenticated user's login name (JWT subject).
|
||||||
|
*
|
||||||
|
* @return username
|
||||||
|
*/
|
||||||
|
public String getUsername() {
|
||||||
|
return jwt.getSubject();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the current user has the admin role.
|
||||||
|
*
|
||||||
|
* @return true if admin
|
||||||
|
*/
|
||||||
|
public boolean isAdmin() {
|
||||||
|
return jwt.getGroups().contains("admin");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
package de.neitzel.storyteller.common.security;
|
||||||
|
|
||||||
|
import io.smallrye.jwt.build.Jwt;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds signed JWT tokens for authenticated users.
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class JwtService {
|
||||||
|
|
||||||
|
/** Claim name for the user's database id. */
|
||||||
|
public static final String CLAIM_USER_ID = "userId";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a signed JWT for the given user.
|
||||||
|
*
|
||||||
|
* @param userId database id
|
||||||
|
* @param username login name used as the token subject
|
||||||
|
* @param isAdmin whether the user has admin role
|
||||||
|
* @return signed JWT string
|
||||||
|
*/
|
||||||
|
public String generateToken(final Long userId, final String username, final boolean isAdmin) {
|
||||||
|
final Set<String> groups = isAdmin ? Set.of("admin", "user") : Set.of("user");
|
||||||
|
return Jwt.issuer("storyteller")
|
||||||
|
.subject(username)
|
||||||
|
.groups(groups)
|
||||||
|
.claim(CLAIM_USER_ID, userId)
|
||||||
|
.sign();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
package de.neitzel.storyteller.common.security;
|
||||||
|
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import org.mindrot.jbcrypt.BCrypt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides password hashing and verification using BCrypt.
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class PasswordHasher {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hashes a plain-text password with BCrypt.
|
||||||
|
*
|
||||||
|
* @param plainText the raw password
|
||||||
|
* @return BCrypt hash string
|
||||||
|
*/
|
||||||
|
public String hash(final String plainText) {
|
||||||
|
return BCrypt.hashpw(plainText, BCrypt.gensalt());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a plain-text password against a stored BCrypt hash.
|
||||||
|
*
|
||||||
|
* @param plainText the raw password
|
||||||
|
* @param storedHash the stored BCrypt hash
|
||||||
|
* @return true if the password matches
|
||||||
|
*/
|
||||||
|
public boolean verify(final String plainText, final String storedHash) {
|
||||||
|
return BCrypt.checkpw(plainText, storedHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
package de.neitzel.storyteller.data.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JPA entity representing a character within a scenario's starting set.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "scenario_character")
|
||||||
|
public class ScenarioCharacterEntity {
|
||||||
|
|
||||||
|
/** Primary key. */
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** Parent scenario id. */
|
||||||
|
@Column(name = "scenario_id", nullable = false)
|
||||||
|
private Long scenarioId;
|
||||||
|
|
||||||
|
/** Character name. */
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/** Character role. */
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String role;
|
||||||
|
|
||||||
|
/** Character description. */
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/** @return primary key */
|
||||||
|
public Long getId() { return id; }
|
||||||
|
/** @param id primary key */
|
||||||
|
public void setId(final Long id) { this.id = id; }
|
||||||
|
|
||||||
|
/** @return parent scenario id */
|
||||||
|
public Long getScenarioId() { return scenarioId; }
|
||||||
|
/** @param scenarioId parent scenario id */
|
||||||
|
public void setScenarioId(final Long scenarioId) { this.scenarioId = scenarioId; }
|
||||||
|
|
||||||
|
/** @return name */
|
||||||
|
public String getName() { return name; }
|
||||||
|
/** @param name character name */
|
||||||
|
public void setName(final String name) { this.name = name; }
|
||||||
|
|
||||||
|
/** @return role */
|
||||||
|
public String getRole() { return role; }
|
||||||
|
/** @param role character role */
|
||||||
|
public void setRole(final String role) { this.role = role; }
|
||||||
|
|
||||||
|
/** @return description */
|
||||||
|
public String getDescription() { return description; }
|
||||||
|
/** @param description character description */
|
||||||
|
public void setDescription(final String description) { this.description = description; }
|
||||||
|
}
|
||||||
@ -0,0 +1,95 @@
|
|||||||
|
package de.neitzel.storyteller.data.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.CascadeType;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.OneToMany;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JPA entity representing a scenario (starting point for stories).
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "scenario")
|
||||||
|
public class ScenarioEntity {
|
||||||
|
|
||||||
|
/** Primary key. */
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** Owning user id. */
|
||||||
|
@Column(name = "owner_user_id", nullable = false)
|
||||||
|
private Long ownerUserId;
|
||||||
|
|
||||||
|
/** Scenario title. */
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
/** Optional overall description. */
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/** Initial scene narrative text. */
|
||||||
|
@Column(name = "starting_scene_description", nullable = false, columnDefinition = "TEXT")
|
||||||
|
private String startingSceneDescription;
|
||||||
|
|
||||||
|
/** Starting characters for this scenario. */
|
||||||
|
@OneToMany(mappedBy = "scenarioId", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
|
||||||
|
private List<ScenarioCharacterEntity> startingCharacters = new ArrayList<>();
|
||||||
|
|
||||||
|
/** Row creation timestamp. */
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
/** Row last-update timestamp. */
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
/** @return primary key */
|
||||||
|
public Long getId() { return id; }
|
||||||
|
/** @param id primary key */
|
||||||
|
public void setId(final Long id) { this.id = id; }
|
||||||
|
|
||||||
|
/** @return owning user id */
|
||||||
|
public Long getOwnerUserId() { return ownerUserId; }
|
||||||
|
/** @param ownerUserId owning user id */
|
||||||
|
public void setOwnerUserId(final Long ownerUserId) { this.ownerUserId = ownerUserId; }
|
||||||
|
|
||||||
|
/** @return title */
|
||||||
|
public String getTitle() { return title; }
|
||||||
|
/** @param title scenario title */
|
||||||
|
public void setTitle(final String title) { this.title = title; }
|
||||||
|
|
||||||
|
/** @return description */
|
||||||
|
public String getDescription() { return description; }
|
||||||
|
/** @param description overall description */
|
||||||
|
public void setDescription(final String description) { this.description = description; }
|
||||||
|
|
||||||
|
/** @return starting scene narrative */
|
||||||
|
public String getStartingSceneDescription() { return startingSceneDescription; }
|
||||||
|
/** @param startingSceneDescription starting scene narrative */
|
||||||
|
public void setStartingSceneDescription(final String startingSceneDescription) { this.startingSceneDescription = startingSceneDescription; }
|
||||||
|
|
||||||
|
/** @return starting characters */
|
||||||
|
public List<ScenarioCharacterEntity> getStartingCharacters() { return startingCharacters; }
|
||||||
|
/** @param startingCharacters starting characters */
|
||||||
|
public void setStartingCharacters(final List<ScenarioCharacterEntity> startingCharacters) { this.startingCharacters = startingCharacters; }
|
||||||
|
|
||||||
|
/** @return creation time */
|
||||||
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
|
/** @param createdAt creation time */
|
||||||
|
public void setCreatedAt(final LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
|
||||||
|
/** @return last update time */
|
||||||
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
/** @param updatedAt last update time */
|
||||||
|
public void setUpdatedAt(final LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
package de.neitzel.storyteller.data.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JPA entity representing a character within a story's current scene.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "story_character")
|
||||||
|
public class StoryCharacterEntity {
|
||||||
|
|
||||||
|
/** Primary key. */
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** Parent story id. */
|
||||||
|
@Column(name = "story_id", nullable = false)
|
||||||
|
private Long storyId;
|
||||||
|
|
||||||
|
/** Character name. */
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/** Character role (e.g. protagonist, villain). */
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String role;
|
||||||
|
|
||||||
|
/** Character description. */
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/** @return primary key */
|
||||||
|
public Long getId() { return id; }
|
||||||
|
/** @param id primary key */
|
||||||
|
public void setId(final Long id) { this.id = id; }
|
||||||
|
|
||||||
|
/** @return parent story id */
|
||||||
|
public Long getStoryId() { return storyId; }
|
||||||
|
/** @param storyId parent story id */
|
||||||
|
public void setStoryId(final Long storyId) { this.storyId = storyId; }
|
||||||
|
|
||||||
|
/** @return name */
|
||||||
|
public String getName() { return name; }
|
||||||
|
/** @param name character name */
|
||||||
|
public void setName(final String name) { this.name = name; }
|
||||||
|
|
||||||
|
/** @return role */
|
||||||
|
public String getRole() { return role; }
|
||||||
|
/** @param role character role */
|
||||||
|
public void setRole(final String role) { this.role = role; }
|
||||||
|
|
||||||
|
/** @return description */
|
||||||
|
public String getDescription() { return description; }
|
||||||
|
/** @param description character description */
|
||||||
|
public void setDescription(final String description) { this.description = description; }
|
||||||
|
}
|
||||||
@ -0,0 +1,104 @@
|
|||||||
|
package de.neitzel.storyteller.data.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.CascadeType;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.OneToMany;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JPA entity representing a story created from a scenario.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "story")
|
||||||
|
public class StoryEntity {
|
||||||
|
|
||||||
|
/** Primary key. */
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** Owning user id. */
|
||||||
|
@Column(name = "owner_user_id", nullable = false)
|
||||||
|
private Long ownerUserId;
|
||||||
|
|
||||||
|
/** Originating scenario id. */
|
||||||
|
@Column(name = "scenario_id", nullable = false)
|
||||||
|
private Long scenarioId;
|
||||||
|
|
||||||
|
/** Story title. */
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
/** Current compacted scene narrative. */
|
||||||
|
@Column(name = "current_scene_description", nullable = false, columnDefinition = "TEXT")
|
||||||
|
private String currentSceneDescription;
|
||||||
|
|
||||||
|
/** Lifecycle status: ACTIVE, ARCHIVED, DELETED. */
|
||||||
|
@Column(nullable = false, length = 32)
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
/** Current scene characters. */
|
||||||
|
@OneToMany(mappedBy = "storyId", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
|
||||||
|
private List<StoryCharacterEntity> currentSceneCharacters = new ArrayList<>();
|
||||||
|
|
||||||
|
/** Row creation timestamp. */
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
/** Row last-update timestamp. */
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
/** @return primary key */
|
||||||
|
public Long getId() { return id; }
|
||||||
|
/** @param id primary key */
|
||||||
|
public void setId(final Long id) { this.id = id; }
|
||||||
|
|
||||||
|
/** @return owning user id */
|
||||||
|
public Long getOwnerUserId() { return ownerUserId; }
|
||||||
|
/** @param ownerUserId owning user id */
|
||||||
|
public void setOwnerUserId(final Long ownerUserId) { this.ownerUserId = ownerUserId; }
|
||||||
|
|
||||||
|
/** @return originating scenario id */
|
||||||
|
public Long getScenarioId() { return scenarioId; }
|
||||||
|
/** @param scenarioId scenario id */
|
||||||
|
public void setScenarioId(final Long scenarioId) { this.scenarioId = scenarioId; }
|
||||||
|
|
||||||
|
/** @return title */
|
||||||
|
public String getTitle() { return title; }
|
||||||
|
/** @param title story title */
|
||||||
|
public void setTitle(final String title) { this.title = title; }
|
||||||
|
|
||||||
|
/** @return current scene narrative */
|
||||||
|
public String getCurrentSceneDescription() { return currentSceneDescription; }
|
||||||
|
/** @param currentSceneDescription scene narrative */
|
||||||
|
public void setCurrentSceneDescription(final String currentSceneDescription) { this.currentSceneDescription = currentSceneDescription; }
|
||||||
|
|
||||||
|
/** @return lifecycle status */
|
||||||
|
public String getStatus() { return status; }
|
||||||
|
/** @param status lifecycle status */
|
||||||
|
public void setStatus(final String status) { this.status = status; }
|
||||||
|
|
||||||
|
/** @return scene characters */
|
||||||
|
public List<StoryCharacterEntity> getCurrentSceneCharacters() { return currentSceneCharacters; }
|
||||||
|
/** @param currentSceneCharacters scene characters */
|
||||||
|
public void setCurrentSceneCharacters(final List<StoryCharacterEntity> currentSceneCharacters) { this.currentSceneCharacters = currentSceneCharacters; }
|
||||||
|
|
||||||
|
/** @return creation time */
|
||||||
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
|
/** @param createdAt creation time */
|
||||||
|
public void setCreatedAt(final LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
|
||||||
|
/** @return last update time */
|
||||||
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
/** @param updatedAt last update time */
|
||||||
|
public void setUpdatedAt(final LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||||
|
}
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
package de.neitzel.storyteller.data.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JPA entity representing a single step in a story's progression.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "story_step")
|
||||||
|
public class StoryStepEntity {
|
||||||
|
|
||||||
|
/** Primary key. */
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** Parent story id. */
|
||||||
|
@Column(name = "story_id", nullable = false)
|
||||||
|
private Long storyId;
|
||||||
|
|
||||||
|
/** Monotonically increasing step number within a story. */
|
||||||
|
@Column(name = "step_number", nullable = false)
|
||||||
|
private int stepNumber;
|
||||||
|
|
||||||
|
/** Narrative text for this step. */
|
||||||
|
@Column(nullable = false, columnDefinition = "TEXT")
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
/** The user's direction prompt that produced this step. */
|
||||||
|
@Column(name = "user_direction", columnDefinition = "TEXT")
|
||||||
|
private String userDirection;
|
||||||
|
|
||||||
|
/** Whether this step has been compacted into the current scene. */
|
||||||
|
@Column(name = "added_to_scene", nullable = false)
|
||||||
|
private boolean addedToScene;
|
||||||
|
|
||||||
|
/** Row creation timestamp. */
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
/** @return primary key */
|
||||||
|
public Long getId() { return id; }
|
||||||
|
/** @param id primary key */
|
||||||
|
public void setId(final Long id) { this.id = id; }
|
||||||
|
|
||||||
|
/** @return parent story id */
|
||||||
|
public Long getStoryId() { return storyId; }
|
||||||
|
/** @param storyId parent story id */
|
||||||
|
public void setStoryId(final Long storyId) { this.storyId = storyId; }
|
||||||
|
|
||||||
|
/** @return step number */
|
||||||
|
public int getStepNumber() { return stepNumber; }
|
||||||
|
/** @param stepNumber step number */
|
||||||
|
public void setStepNumber(final int stepNumber) { this.stepNumber = stepNumber; }
|
||||||
|
|
||||||
|
/** @return narrative content */
|
||||||
|
public String getContent() { return content; }
|
||||||
|
/** @param content narrative content */
|
||||||
|
public void setContent(final String content) { this.content = content; }
|
||||||
|
|
||||||
|
/** @return user's direction prompt */
|
||||||
|
public String getUserDirection() { return userDirection; }
|
||||||
|
/** @param userDirection user's direction prompt */
|
||||||
|
public void setUserDirection(final String userDirection) { this.userDirection = userDirection; }
|
||||||
|
|
||||||
|
/** @return true if compacted into scene */
|
||||||
|
public boolean isAddedToScene() { return addedToScene; }
|
||||||
|
/** @param addedToScene compacted flag */
|
||||||
|
public void setAddedToScene(final boolean addedToScene) { this.addedToScene = addedToScene; }
|
||||||
|
|
||||||
|
/** @return creation time */
|
||||||
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
|
/** @param createdAt creation time */
|
||||||
|
public void setCreatedAt(final LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
}
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
package de.neitzel.storyteller.data.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JPA entity representing an application user.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "app_user")
|
||||||
|
public class UserEntity {
|
||||||
|
|
||||||
|
/** Primary key. */
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** Unique login name. */
|
||||||
|
@Column(nullable = false, unique = true, length = 128)
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
/** BCrypt password hash. */
|
||||||
|
@Column(name = "password_hash", nullable = false)
|
||||||
|
private String passwordHash;
|
||||||
|
|
||||||
|
/** Whether the user has admin privileges. */
|
||||||
|
@Column(name = "is_admin", nullable = false)
|
||||||
|
private boolean admin;
|
||||||
|
|
||||||
|
/** Whether the account is active. */
|
||||||
|
@Column(nullable = false)
|
||||||
|
private boolean active;
|
||||||
|
|
||||||
|
/** Row creation timestamp. */
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
/** Row last-update timestamp. */
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
/** @return primary key */
|
||||||
|
public Long getId() { return id; }
|
||||||
|
/** @param id primary key */
|
||||||
|
public void setId(final Long id) { this.id = id; }
|
||||||
|
|
||||||
|
/** @return login name */
|
||||||
|
public String getUsername() { return username; }
|
||||||
|
/** @param username login name */
|
||||||
|
public void setUsername(final String username) { this.username = username; }
|
||||||
|
|
||||||
|
/** @return BCrypt hash */
|
||||||
|
public String getPasswordHash() { return passwordHash; }
|
||||||
|
/** @param passwordHash BCrypt hash */
|
||||||
|
public void setPasswordHash(final String passwordHash) { this.passwordHash = passwordHash; }
|
||||||
|
|
||||||
|
/** @return true if admin */
|
||||||
|
public boolean isAdmin() { return admin; }
|
||||||
|
/** @param admin admin flag */
|
||||||
|
public void setAdmin(final boolean admin) { this.admin = admin; }
|
||||||
|
|
||||||
|
/** @return true if active */
|
||||||
|
public boolean isActive() { return active; }
|
||||||
|
/** @param active active flag */
|
||||||
|
public void setActive(final boolean active) { this.active = active; }
|
||||||
|
|
||||||
|
/** @return creation time */
|
||||||
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
|
/** @param createdAt creation time */
|
||||||
|
public void setCreatedAt(final LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
|
||||||
|
/** @return last update time */
|
||||||
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
/** @param updatedAt last update time */
|
||||||
|
public void setUpdatedAt(final LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
package de.neitzel.storyteller.data.repository;
|
||||||
|
|
||||||
|
import de.neitzel.storyteller.data.entity.ScenarioCharacterEntity;
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheRepository;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panache repository for {@link ScenarioCharacterEntity} persistence operations.
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class ScenarioCharacterRepository implements PanacheRepository<ScenarioCharacterEntity> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all characters belonging to a scenario.
|
||||||
|
*
|
||||||
|
* @param scenarioId the scenario id
|
||||||
|
* @return list of characters
|
||||||
|
*/
|
||||||
|
public List<ScenarioCharacterEntity> findByScenario(final Long scenarioId) {
|
||||||
|
return list("scenarioId", scenarioId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes all characters for a scenario.
|
||||||
|
*
|
||||||
|
* @param scenarioId the scenario id
|
||||||
|
* @return number of deleted rows
|
||||||
|
*/
|
||||||
|
public long deleteByScenario(final Long scenarioId) {
|
||||||
|
return delete("scenarioId", scenarioId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package de.neitzel.storyteller.data.repository;
|
||||||
|
|
||||||
|
import de.neitzel.storyteller.data.entity.ScenarioEntity;
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheRepository;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panache repository for {@link ScenarioEntity} persistence operations.
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class ScenarioRepository implements PanacheRepository<ScenarioEntity> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all scenarios owned by a specific user.
|
||||||
|
*
|
||||||
|
* @param ownerUserId the owning user's id
|
||||||
|
* @return list of scenarios
|
||||||
|
*/
|
||||||
|
public List<ScenarioEntity> findByOwner(final Long ownerUserId) {
|
||||||
|
return list("ownerUserId", ownerUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
package de.neitzel.storyteller.data.repository;
|
||||||
|
|
||||||
|
import de.neitzel.storyteller.data.entity.StoryCharacterEntity;
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheRepository;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panache repository for {@link StoryCharacterEntity} persistence operations.
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class StoryCharacterRepository implements PanacheRepository<StoryCharacterEntity> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all characters belonging to a story.
|
||||||
|
*
|
||||||
|
* @param storyId the story id
|
||||||
|
* @return list of characters
|
||||||
|
*/
|
||||||
|
public List<StoryCharacterEntity> findByStory(final Long storyId) {
|
||||||
|
return list("storyId", storyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes all characters for a story.
|
||||||
|
*
|
||||||
|
* @param storyId the story id
|
||||||
|
* @return number of deleted rows
|
||||||
|
*/
|
||||||
|
public long deleteByStory(final Long storyId) {
|
||||||
|
return delete("storyId", storyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package de.neitzel.storyteller.data.repository;
|
||||||
|
|
||||||
|
import de.neitzel.storyteller.data.entity.StoryEntity;
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheRepository;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panache repository for {@link StoryEntity} persistence operations.
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class StoryRepository implements PanacheRepository<StoryEntity> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all non-deleted stories owned by a specific user.
|
||||||
|
*
|
||||||
|
* @param ownerUserId the owning user's id
|
||||||
|
* @return list of stories
|
||||||
|
*/
|
||||||
|
public List<StoryEntity> findByOwner(final Long ownerUserId) {
|
||||||
|
return list("ownerUserId = ?1 and status != 'DELETED'", ownerUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
package de.neitzel.storyteller.data.repository;
|
||||||
|
|
||||||
|
import de.neitzel.storyteller.data.entity.StoryStepEntity;
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheRepository;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panache repository for {@link StoryStepEntity} persistence operations.
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class StoryStepRepository implements PanacheRepository<StoryStepEntity> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all steps for a story ordered by step number ascending.
|
||||||
|
*
|
||||||
|
* @param storyId the story id
|
||||||
|
* @return ordered list of steps
|
||||||
|
*/
|
||||||
|
public List<StoryStepEntity> findByStoryOrdered(final Long storyId) {
|
||||||
|
return list("storyId = ?1 order by stepNumber asc", storyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns only the unmerged steps for a story ordered by step number ascending.
|
||||||
|
*
|
||||||
|
* @param storyId the story id
|
||||||
|
* @return ordered list of unmerged steps
|
||||||
|
*/
|
||||||
|
public List<StoryStepEntity> findUnmergedByStory(final Long storyId) {
|
||||||
|
return list("storyId = ?1 and addedToScene = false order by stepNumber asc", storyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes all steps for a story.
|
||||||
|
*
|
||||||
|
* @param storyId the story id
|
||||||
|
* @return number of deleted rows
|
||||||
|
*/
|
||||||
|
public long deleteByStory(final Long storyId) {
|
||||||
|
return delete("storyId", storyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the maximum step number for a story, or 0 if no steps exist.
|
||||||
|
*
|
||||||
|
* @param storyId the story id
|
||||||
|
* @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)
|
||||||
|
.setParameter("sid", storyId)
|
||||||
|
.getSingleResult();
|
||||||
|
return result.intValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package de.neitzel.storyteller.data.repository;
|
||||||
|
|
||||||
|
import de.neitzel.storyteller.data.entity.UserEntity;
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheRepository;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panache repository for {@link UserEntity} persistence operations.
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class UserRepository implements PanacheRepository<UserEntity> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a user by unique username.
|
||||||
|
*
|
||||||
|
* @param username the login name
|
||||||
|
* @return optional containing the user if found
|
||||||
|
*/
|
||||||
|
public Optional<UserEntity> findByUsername(final String username) {
|
||||||
|
return find("username", username).firstResultOptional();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
package de.neitzel.storyteller.fascade.rest;
|
||||||
|
|
||||||
|
import de.neitzel.storyteller.business.AuthService;
|
||||||
|
import de.neitzel.storyteller.fascade.api.AuthApi;
|
||||||
|
import de.neitzel.storyteller.fascade.model.LoginRequest;
|
||||||
|
import de.neitzel.storyteller.fascade.model.LoginResponse;
|
||||||
|
import jakarta.annotation.security.PermitAll;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST facade for authentication. This endpoint is publicly accessible.
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
@PermitAll
|
||||||
|
public class AuthResource implements AuthApi {
|
||||||
|
|
||||||
|
/** Authentication service delegate. */
|
||||||
|
private final AuthService authService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs the resource with service dependency.
|
||||||
|
*
|
||||||
|
* @param authService auth service
|
||||||
|
*/
|
||||||
|
public AuthResource(final AuthService authService) {
|
||||||
|
this.authService = authService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public LoginResponse login(final LoginRequest loginRequest) {
|
||||||
|
return authService.login(loginRequest);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
package de.neitzel.storyteller.fascade.rest;
|
||||||
|
|
||||||
|
import de.neitzel.storyteller.business.ScenarioService;
|
||||||
|
import de.neitzel.storyteller.common.security.AuthContext;
|
||||||
|
import de.neitzel.storyteller.fascade.api.ScenariosApi;
|
||||||
|
import de.neitzel.storyteller.fascade.model.CreateScenarioRequest;
|
||||||
|
import de.neitzel.storyteller.fascade.model.Scenario;
|
||||||
|
import de.neitzel.storyteller.fascade.model.UpdateScenarioRequest;
|
||||||
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST facade for scenario management. Available to all authenticated users.
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
@RolesAllowed({"user", "admin"})
|
||||||
|
public class ScenariosResource implements ScenariosApi {
|
||||||
|
|
||||||
|
/** Scenario service delegate. */
|
||||||
|
private final ScenarioService scenarioService;
|
||||||
|
|
||||||
|
/** Request-scoped auth context. */
|
||||||
|
private final AuthContext authContext;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs the resource with dependencies.
|
||||||
|
*
|
||||||
|
* @param scenarioService scenario service
|
||||||
|
* @param authContext auth context
|
||||||
|
*/
|
||||||
|
public ScenariosResource(final ScenarioService scenarioService,
|
||||||
|
final AuthContext authContext) {
|
||||||
|
this.scenarioService = scenarioService;
|
||||||
|
this.authContext = authContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public Scenario createScenario(final CreateScenarioRequest createScenarioRequest) {
|
||||||
|
return scenarioService.createScenario(createScenarioRequest, authContext.getUserId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public void deleteScenario(final Long scenarioId) {
|
||||||
|
scenarioService.deleteScenario(scenarioId, authContext.getUserId(), authContext.isAdmin());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public Scenario getScenario(final Long scenarioId) {
|
||||||
|
return scenarioService.getScenario(scenarioId, authContext.getUserId(), authContext.isAdmin());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public List<Scenario> listScenarios() {
|
||||||
|
return scenarioService.listScenarios(authContext.getUserId(), authContext.isAdmin());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public Scenario updateScenario(final Long scenarioId, final UpdateScenarioRequest updateScenarioRequest) {
|
||||||
|
return scenarioService.updateScenario(scenarioId, updateScenarioRequest,
|
||||||
|
authContext.getUserId(), authContext.isAdmin());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,91 @@
|
|||||||
|
package de.neitzel.storyteller.fascade.rest;
|
||||||
|
|
||||||
|
import de.neitzel.storyteller.business.StoryService;
|
||||||
|
import de.neitzel.storyteller.common.security.AuthContext;
|
||||||
|
import de.neitzel.storyteller.fascade.api.StoriesApi;
|
||||||
|
import de.neitzel.storyteller.fascade.model.GenerateStepRequest;
|
||||||
|
import de.neitzel.storyteller.fascade.model.GenerateStepResponse;
|
||||||
|
import de.neitzel.storyteller.fascade.model.StartStoryRequest;
|
||||||
|
import de.neitzel.storyteller.fascade.model.Story;
|
||||||
|
import de.neitzel.storyteller.fascade.model.StoryStep;
|
||||||
|
import de.neitzel.storyteller.fascade.model.UpdateStepRequest;
|
||||||
|
import de.neitzel.storyteller.fascade.model.UpdateStoryRequest;
|
||||||
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST facade for story and story-step operations. Available to all authenticated users.
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
@RolesAllowed({"user", "admin"})
|
||||||
|
public class StoriesResource implements StoriesApi {
|
||||||
|
|
||||||
|
/** Story service delegate. */
|
||||||
|
private final StoryService storyService;
|
||||||
|
|
||||||
|
/** Request-scoped auth context. */
|
||||||
|
private final AuthContext authContext;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs the resource with dependencies.
|
||||||
|
*
|
||||||
|
* @param storyService story service
|
||||||
|
* @param authContext auth context
|
||||||
|
*/
|
||||||
|
public StoriesResource(final StoryService storyService,
|
||||||
|
final AuthContext authContext) {
|
||||||
|
this.storyService = storyService;
|
||||||
|
this.authContext = authContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public void deleteStory(final Long storyId) {
|
||||||
|
storyService.deleteStory(storyId, authContext.getUserId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public GenerateStepResponse generateStoryStep(final Long storyId,
|
||||||
|
final GenerateStepRequest generateStepRequest) {
|
||||||
|
return storyService.generateStoryStep(storyId, generateStepRequest, authContext.getUserId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public Story getStory(final Long storyId) {
|
||||||
|
return storyService.getStory(storyId, authContext.getUserId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public List<Story> listStories() {
|
||||||
|
return storyService.listStories(authContext.getUserId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public List<StoryStep> listStorySteps(final Long storyId) {
|
||||||
|
return storyService.listStorySteps(storyId, authContext.getUserId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public Story startStory(final StartStoryRequest startStoryRequest) {
|
||||||
|
return storyService.startStory(startStoryRequest, authContext.getUserId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public Story updateStory(final Long storyId, final UpdateStoryRequest updateStoryRequest) {
|
||||||
|
return storyService.updateStory(storyId, updateStoryRequest, authContext.getUserId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public StoryStep updateStoryStep(final Long storyId, final Long stepId,
|
||||||
|
final UpdateStepRequest updateStepRequest) {
|
||||||
|
return storyService.updateStoryStep(storyId, stepId, updateStepRequest, authContext.getUserId());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
package de.neitzel.storyteller.fascade.rest;
|
||||||
|
|
||||||
|
import de.neitzel.storyteller.business.UserService;
|
||||||
|
import de.neitzel.storyteller.fascade.api.UsersApi;
|
||||||
|
import de.neitzel.storyteller.fascade.model.CreateUserRequest;
|
||||||
|
import de.neitzel.storyteller.fascade.model.UpdateUserRequest;
|
||||||
|
import de.neitzel.storyteller.fascade.model.User;
|
||||||
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST facade for admin-only user management endpoints.
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
@RolesAllowed("admin")
|
||||||
|
public class UsersResource implements UsersApi {
|
||||||
|
|
||||||
|
/** User service delegate. */
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs the resource with service dependency.
|
||||||
|
*
|
||||||
|
* @param userService user service
|
||||||
|
*/
|
||||||
|
public UsersResource(final UserService userService) {
|
||||||
|
this.userService = userService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public User createUser(final CreateUserRequest createUserRequest) {
|
||||||
|
return userService.createUser(createUserRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public void deleteUser(final Long userId) {
|
||||||
|
userService.deleteUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public List<User> listUsers() {
|
||||||
|
return userService.listUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public User updateUser(final Long userId, final UpdateUserRequest updateUserRequest) {
|
||||||
|
return userService.updateUser(userId, updateUserRequest);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/main/resources/application.properties
Normal file
25
src/main/resources/application.properties
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
quarkus.http.port=8080
|
||||||
|
quarkus.http.cors=true
|
||||||
|
|
||||||
|
quarkus.liquibase.migrate-at-start=true
|
||||||
|
quarkus.liquibase.change-log=db/migration/db.changelog-master.yaml
|
||||||
|
|
||||||
|
quarkus.datasource.db-kind=postgresql
|
||||||
|
quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/storyteller
|
||||||
|
quarkus.datasource.username=storyteller
|
||||||
|
quarkus.datasource.password=storyteller
|
||||||
|
|
||||||
|
quarkus.hibernate-orm.database.generation=none
|
||||||
|
|
||||||
|
mp.jwt.verify.publickey.location=publicKey.pem
|
||||||
|
mp.jwt.verify.issuer=storyteller
|
||||||
|
smallrye.jwt.sign.key.location=privateKey.pem
|
||||||
|
smallrye.jwt.new-token.lifespan=86400
|
||||||
|
smallrye.jwt.new-token.issuer=storyteller
|
||||||
|
|
||||||
|
quarkus.http.auth.permission.public.paths=/api/auth/*
|
||||||
|
quarkus.http.auth.permission.public.policy=permit
|
||||||
|
quarkus.http.auth.permission.protected.paths=/api/*
|
||||||
|
quarkus.http.auth.permission.protected.policy=authenticated
|
||||||
|
quarkus.http.auth.permission.static.paths=/*
|
||||||
|
quarkus.http.auth.permission.static.policy=permit
|
||||||
281
src/main/resources/db/migration/db.changelog-master.yaml
Normal file
281
src/main/resources/db/migration/db.changelog-master.yaml
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
databaseChangeLog:
|
||||||
|
- changeSet:
|
||||||
|
id: 001-create-users
|
||||||
|
author: storyteller
|
||||||
|
changes:
|
||||||
|
- createTable:
|
||||||
|
tableName: app_user
|
||||||
|
columns:
|
||||||
|
- column:
|
||||||
|
name: id
|
||||||
|
type: BIGINT
|
||||||
|
autoIncrement: true
|
||||||
|
constraints:
|
||||||
|
primaryKey: true
|
||||||
|
nullable: false
|
||||||
|
- column:
|
||||||
|
name: username
|
||||||
|
type: VARCHAR(128)
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
unique: true
|
||||||
|
- column:
|
||||||
|
name: password_hash
|
||||||
|
type: VARCHAR(255)
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
- column:
|
||||||
|
name: is_admin
|
||||||
|
type: BOOLEAN
|
||||||
|
defaultValueBoolean: false
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
- column:
|
||||||
|
name: active
|
||||||
|
type: BOOLEAN
|
||||||
|
defaultValueBoolean: true
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
- column:
|
||||||
|
name: created_at
|
||||||
|
type: TIMESTAMP
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
- column:
|
||||||
|
name: updated_at
|
||||||
|
type: TIMESTAMP
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
|
||||||
|
- changeSet:
|
||||||
|
id: 002-create-scenarios
|
||||||
|
author: storyteller
|
||||||
|
changes:
|
||||||
|
- createTable:
|
||||||
|
tableName: scenario
|
||||||
|
columns:
|
||||||
|
- column:
|
||||||
|
name: id
|
||||||
|
type: BIGINT
|
||||||
|
autoIncrement: true
|
||||||
|
constraints:
|
||||||
|
primaryKey: true
|
||||||
|
nullable: false
|
||||||
|
- column:
|
||||||
|
name: owner_user_id
|
||||||
|
type: BIGINT
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
foreignKeyName: fk_scenario_owner
|
||||||
|
references: app_user(id)
|
||||||
|
- column:
|
||||||
|
name: title
|
||||||
|
type: VARCHAR(255)
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
- column:
|
||||||
|
name: description
|
||||||
|
type: TEXT
|
||||||
|
- column:
|
||||||
|
name: starting_scene_description
|
||||||
|
type: TEXT
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
- column:
|
||||||
|
name: created_at
|
||||||
|
type: TIMESTAMP
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
- column:
|
||||||
|
name: updated_at
|
||||||
|
type: TIMESTAMP
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
- createTable:
|
||||||
|
tableName: scenario_character
|
||||||
|
columns:
|
||||||
|
- column:
|
||||||
|
name: id
|
||||||
|
type: BIGINT
|
||||||
|
autoIncrement: true
|
||||||
|
constraints:
|
||||||
|
primaryKey: true
|
||||||
|
nullable: false
|
||||||
|
- column:
|
||||||
|
name: scenario_id
|
||||||
|
type: BIGINT
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
foreignKeyName: fk_scenario_character_scenario
|
||||||
|
references: scenario(id)
|
||||||
|
- column:
|
||||||
|
name: name
|
||||||
|
type: VARCHAR(255)
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
- column:
|
||||||
|
name: role
|
||||||
|
type: VARCHAR(255)
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
- column:
|
||||||
|
name: description
|
||||||
|
type: TEXT
|
||||||
|
|
||||||
|
- changeSet:
|
||||||
|
id: 003-create-stories
|
||||||
|
author: storyteller
|
||||||
|
changes:
|
||||||
|
- createTable:
|
||||||
|
tableName: story
|
||||||
|
columns:
|
||||||
|
- column:
|
||||||
|
name: id
|
||||||
|
type: BIGINT
|
||||||
|
autoIncrement: true
|
||||||
|
constraints:
|
||||||
|
primaryKey: true
|
||||||
|
nullable: false
|
||||||
|
- column:
|
||||||
|
name: owner_user_id
|
||||||
|
type: BIGINT
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
foreignKeyName: fk_story_owner
|
||||||
|
references: app_user(id)
|
||||||
|
- column:
|
||||||
|
name: scenario_id
|
||||||
|
type: BIGINT
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
foreignKeyName: fk_story_scenario
|
||||||
|
references: scenario(id)
|
||||||
|
- column:
|
||||||
|
name: title
|
||||||
|
type: VARCHAR(255)
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
- column:
|
||||||
|
name: current_scene_description
|
||||||
|
type: TEXT
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
- column:
|
||||||
|
name: status
|
||||||
|
type: VARCHAR(32)
|
||||||
|
defaultValue: ACTIVE
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
- column:
|
||||||
|
name: created_at
|
||||||
|
type: TIMESTAMP
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
- column:
|
||||||
|
name: updated_at
|
||||||
|
type: TIMESTAMP
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
- createTable:
|
||||||
|
tableName: story_character
|
||||||
|
columns:
|
||||||
|
- column:
|
||||||
|
name: id
|
||||||
|
type: BIGINT
|
||||||
|
autoIncrement: true
|
||||||
|
constraints:
|
||||||
|
primaryKey: true
|
||||||
|
nullable: false
|
||||||
|
- column:
|
||||||
|
name: story_id
|
||||||
|
type: BIGINT
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
foreignKeyName: fk_story_character_story
|
||||||
|
references: story(id)
|
||||||
|
- column:
|
||||||
|
name: name
|
||||||
|
type: VARCHAR(255)
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
- column:
|
||||||
|
name: role
|
||||||
|
type: VARCHAR(255)
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
- column:
|
||||||
|
name: description
|
||||||
|
type: TEXT
|
||||||
|
|
||||||
|
- changeSet:
|
||||||
|
id: 004-create-story-steps
|
||||||
|
author: storyteller
|
||||||
|
changes:
|
||||||
|
- createTable:
|
||||||
|
tableName: story_step
|
||||||
|
columns:
|
||||||
|
- column:
|
||||||
|
name: id
|
||||||
|
type: BIGINT
|
||||||
|
autoIncrement: true
|
||||||
|
constraints:
|
||||||
|
primaryKey: true
|
||||||
|
nullable: false
|
||||||
|
- column:
|
||||||
|
name: story_id
|
||||||
|
type: BIGINT
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
foreignKeyName: fk_story_step_story
|
||||||
|
references: story(id)
|
||||||
|
- column:
|
||||||
|
name: step_number
|
||||||
|
type: INTEGER
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
- column:
|
||||||
|
name: content
|
||||||
|
type: TEXT
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
- column:
|
||||||
|
name: user_direction
|
||||||
|
type: TEXT
|
||||||
|
- column:
|
||||||
|
name: added_to_scene
|
||||||
|
type: BOOLEAN
|
||||||
|
defaultValueBoolean: false
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
- column:
|
||||||
|
name: created_at
|
||||||
|
type: TIMESTAMP
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
|
||||||
|
- changeSet:
|
||||||
|
id: 005-seed-admin
|
||||||
|
author: storyteller
|
||||||
|
comment: Seed initial admin user (password is admin, BCrypt hash)
|
||||||
|
changes:
|
||||||
|
- insert:
|
||||||
|
tableName: app_user
|
||||||
|
columns:
|
||||||
|
- column:
|
||||||
|
name: username
|
||||||
|
value: admin
|
||||||
|
- column:
|
||||||
|
name: password_hash
|
||||||
|
value: $2a$10$e7j3j8GSHRMfwA8rg7j6DeanhTxEC8qtlcOhq7DNM/f6/Rli2arFe
|
||||||
|
- column:
|
||||||
|
name: is_admin
|
||||||
|
valueBoolean: true
|
||||||
|
- column:
|
||||||
|
name: active
|
||||||
|
valueBoolean: true
|
||||||
|
- column:
|
||||||
|
name: created_at
|
||||||
|
valueComputed: CURRENT_TIMESTAMP
|
||||||
|
- column:
|
||||||
|
name: updated_at
|
||||||
|
valueComputed: CURRENT_TIMESTAMP
|
||||||
506
src/main/resources/openapi/story-teller-api.yaml
Normal file
506
src/main/resources/openapi/story-teller-api.yaml
Normal file
@ -0,0 +1,506 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: StoryTeller API
|
||||||
|
version: 1.0.0
|
||||||
|
description: OpenAPI-first API for StoryTeller.
|
||||||
|
servers:
|
||||||
|
- url: /api
|
||||||
|
tags:
|
||||||
|
- name: Auth
|
||||||
|
- name: Users
|
||||||
|
- name: Scenarios
|
||||||
|
- name: Stories
|
||||||
|
paths:
|
||||||
|
/auth/login:
|
||||||
|
post:
|
||||||
|
tags: [Auth]
|
||||||
|
operationId: login
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/LoginRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Login successful.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/LoginResponse'
|
||||||
|
/users:
|
||||||
|
get:
|
||||||
|
tags: [Users]
|
||||||
|
operationId: listUsers
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: List users.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/User'
|
||||||
|
post:
|
||||||
|
tags: [Users]
|
||||||
|
operationId: createUser
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CreateUserRequest'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: User created.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/User'
|
||||||
|
/users/{userId}:
|
||||||
|
put:
|
||||||
|
tags: [Users]
|
||||||
|
operationId: updateUser
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/UserId'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/UpdateUserRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: User updated.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/User'
|
||||||
|
delete:
|
||||||
|
tags: [Users]
|
||||||
|
operationId: deleteUser
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/UserId'
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: User deleted.
|
||||||
|
/scenarios:
|
||||||
|
get:
|
||||||
|
tags: [Scenarios]
|
||||||
|
operationId: listScenarios
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: List scenarios for the authenticated user.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Scenario'
|
||||||
|
post:
|
||||||
|
tags: [Scenarios]
|
||||||
|
operationId: createScenario
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CreateScenarioRequest'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Scenario created.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Scenario'
|
||||||
|
/scenarios/{scenarioId}:
|
||||||
|
get:
|
||||||
|
tags: [Scenarios]
|
||||||
|
operationId: getScenario
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/ScenarioId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Get scenario.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Scenario'
|
||||||
|
put:
|
||||||
|
tags: [Scenarios]
|
||||||
|
operationId: updateScenario
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/ScenarioId'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/UpdateScenarioRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Scenario updated.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Scenario'
|
||||||
|
delete:
|
||||||
|
tags: [Scenarios]
|
||||||
|
operationId: deleteScenario
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/ScenarioId'
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Scenario deleted.
|
||||||
|
/stories:
|
||||||
|
get:
|
||||||
|
tags: [Stories]
|
||||||
|
operationId: listStories
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: List stories for the authenticated user.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Story'
|
||||||
|
post:
|
||||||
|
tags: [Stories]
|
||||||
|
operationId: startStory
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/StartStoryRequest'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Story created.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Story'
|
||||||
|
/stories/{storyId}:
|
||||||
|
get:
|
||||||
|
tags: [Stories]
|
||||||
|
operationId: getStory
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/StoryId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Get story.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Story'
|
||||||
|
put:
|
||||||
|
tags: [Stories]
|
||||||
|
operationId: updateStory
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/StoryId'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/UpdateStoryRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Story updated.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Story'
|
||||||
|
delete:
|
||||||
|
tags: [Stories]
|
||||||
|
operationId: deleteStory
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/StoryId'
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Story deleted.
|
||||||
|
/stories/{storyId}/steps:
|
||||||
|
get:
|
||||||
|
tags: [Stories]
|
||||||
|
operationId: listStorySteps
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/StoryId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: List story steps.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/StoryStep'
|
||||||
|
/stories/{storyId}/steps/{stepId}:
|
||||||
|
put:
|
||||||
|
tags: [Stories]
|
||||||
|
operationId: updateStoryStep
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/StoryId'
|
||||||
|
- $ref: '#/components/parameters/StepId'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/UpdateStepRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Step updated.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/StoryStep'
|
||||||
|
/stories/{storyId}/generate-step:
|
||||||
|
post:
|
||||||
|
tags: [Stories]
|
||||||
|
operationId: generateStoryStep
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/StoryId'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GenerateStepRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Next step generated.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GenerateStepResponse'
|
||||||
|
components:
|
||||||
|
parameters:
|
||||||
|
UserId:
|
||||||
|
in: path
|
||||||
|
name: userId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
ScenarioId:
|
||||||
|
in: path
|
||||||
|
name: scenarioId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
StoryId:
|
||||||
|
in: path
|
||||||
|
name: storyId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
StepId:
|
||||||
|
in: path
|
||||||
|
name: stepId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
schemas:
|
||||||
|
LoginRequest:
|
||||||
|
type: object
|
||||||
|
required: [username, password]
|
||||||
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
LoginResponse:
|
||||||
|
type: object
|
||||||
|
required: [token, userId, username, isAdmin]
|
||||||
|
properties:
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
|
userId:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
isAdmin:
|
||||||
|
type: boolean
|
||||||
|
User:
|
||||||
|
type: object
|
||||||
|
required: [id, username, isAdmin, active]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
isAdmin:
|
||||||
|
type: boolean
|
||||||
|
active:
|
||||||
|
type: boolean
|
||||||
|
CreateUserRequest:
|
||||||
|
type: object
|
||||||
|
required: [username, password, isAdmin]
|
||||||
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
isAdmin:
|
||||||
|
type: boolean
|
||||||
|
UpdateUserRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
isAdmin:
|
||||||
|
type: boolean
|
||||||
|
active:
|
||||||
|
type: boolean
|
||||||
|
CharacterDto:
|
||||||
|
type: object
|
||||||
|
required: [name, role, description]
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
Scenario:
|
||||||
|
type: object
|
||||||
|
required: [id, title, startingSceneDescription, startingCharacters]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
startingSceneDescription:
|
||||||
|
type: string
|
||||||
|
startingCharacters:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/CharacterDto'
|
||||||
|
CreateScenarioRequest:
|
||||||
|
type: object
|
||||||
|
required: [title, startingSceneDescription, startingCharacters]
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
startingSceneDescription:
|
||||||
|
type: string
|
||||||
|
startingCharacters:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/CharacterDto'
|
||||||
|
UpdateScenarioRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
startingSceneDescription:
|
||||||
|
type: string
|
||||||
|
startingCharacters:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/CharacterDto'
|
||||||
|
Story:
|
||||||
|
type: object
|
||||||
|
required: [id, title, scenarioId, currentSceneDescription, currentSceneCharacters, status]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
scenarioId:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
currentSceneDescription:
|
||||||
|
type: string
|
||||||
|
currentSceneCharacters:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/CharacterDto'
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [ACTIVE, ARCHIVED, DELETED]
|
||||||
|
StartStoryRequest:
|
||||||
|
type: object
|
||||||
|
required: [scenarioId, title]
|
||||||
|
properties:
|
||||||
|
scenarioId:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
UpdateStoryRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
currentSceneDescription:
|
||||||
|
type: string
|
||||||
|
currentSceneCharacters:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/CharacterDto'
|
||||||
|
StoryStep:
|
||||||
|
type: object
|
||||||
|
required: [id, stepNumber, content, addedToScene]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
stepNumber:
|
||||||
|
type: integer
|
||||||
|
content:
|
||||||
|
type: string
|
||||||
|
userDirection:
|
||||||
|
type: string
|
||||||
|
addedToScene:
|
||||||
|
type: boolean
|
||||||
|
UpdateStepRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
content:
|
||||||
|
type: string
|
||||||
|
GenerateStepRequest:
|
||||||
|
type: object
|
||||||
|
required: [direction]
|
||||||
|
properties:
|
||||||
|
direction:
|
||||||
|
type: string
|
||||||
|
currentSceneDescription:
|
||||||
|
type: string
|
||||||
|
currentCharacters:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/CharacterDto'
|
||||||
|
GenerateStepResponse:
|
||||||
|
type: object
|
||||||
|
required: [step, compacted]
|
||||||
|
properties:
|
||||||
|
step:
|
||||||
|
$ref: '#/components/schemas/StoryStep'
|
||||||
|
compacted:
|
||||||
|
type: boolean
|
||||||
|
description: True if context compaction was performed before generation.
|
||||||
|
updatedScene:
|
||||||
|
type: string
|
||||||
|
description: New scene description after compaction, if compacted is true.
|
||||||
|
updatedCharacters:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/CharacterDto'
|
||||||
|
description: Updated characters after compaction, if compacted is true.
|
||||||
28
src/main/resources/privateKey.pem
Normal file
28
src/main/resources/privateKey.pem
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC6YIKoRr2JZ6wd
|
||||||
|
xMD1t8l0xzjDftktJDU9Z52jicWjeAQMsZEzzeZ8tBhM+XVhsHKI00nPuU9VjQCM
|
||||||
|
IpNbg7o9z1gxALmgvv0erj/QAST0T3/5+Miyy3+FKm/srqN4C5r0Xy5Dx+3TNnvR
|
||||||
|
VXwePfp/QHKsi2FfirAOx1s9FBSPckhWN0QNMkeghP9Oy/ScXY1zzVneesBlKFB5
|
||||||
|
hKS7vhxqzucP3df6Bp3y7xw8Ozi94uS8A5Mmj9O1RbAykhmE5GGUFiCiPXECfM43
|
||||||
|
O+0Ilygm6mlve5v/Xno1kEY+4h6B25ojmFs3w1uGQh9xZteZr+TmFvaM2031tZVr
|
||||||
|
OQ3j8v7/AgMBAAECggEAAbByPrg1a5Nm+fYYkaGSz94mY484xLH5UY8o3JyZD/w8
|
||||||
|
fSj22fNZzXOMbNYt/C6vOxI/LwTM9UeL47lEgKXXAExqzjbld9GDVc3agjYgcZ5u
|
||||||
|
2IMqvoQdqcVSsmB61tG0G9bpAnBDdZCe2qzdrSB+rryUzX+N6GHHarrf1tPhw9MP
|
||||||
|
YM36Per0/q6D10RS11X4Zubt/DbZ44+pNQdTWzxtQ3zMv/gUegSEVm3kOo+1GK/B
|
||||||
|
l7WdYshTS81zFbt3hza22tkbqJ6Qdhvk2NLNZSFNBJtBqcrgp5BYV4BUqc+qk/mu
|
||||||
|
+eWc6yy99nFDcFiut/52RBijLXmUb23P8VXV5aFlUQKBgQDptsEsNVTUZp1dMKgx
|
||||||
|
+8cwoYrHSkVxus0Z8E+eO+UNT5dzv2dT8yPM+Widt9A2rXAbwGdRY+JGPX4oawRx
|
||||||
|
j/MSX7suHLRY1LcBjodnKcvJa1uJ14RmSBQnR9Leb55JOrB/f9HlyXsRg7r/0BGT
|
||||||
|
TJZzXC5jZS0po9zGhZw3M+ZW9QKBgQDMJjQiO1W67IpRMCt4pWjKxnsUPacKZLtr
|
||||||
|
rgqfr1lGTugMaRoYPZ9uTeXjK6oT/teDZRw3Puxw9+OXpbMJ7RSesVq6nLZP6mMf
|
||||||
|
9ZHzXhBDMKpW8/YnvAf+hgQDww45a0H3ewJxpLtM8nv4JgWCEfpNGJpzNt5zL0MZ
|
||||||
|
ZGpQdAR9owKBgHM/MGCZZ9xZQY4bmUUUj/PWJPYDFN1xLQR2cPxpMpjuv5NPNie6
|
||||||
|
hNPlxQXJB35+5gJ5TTlgVMsoNZa+tvE897L+y/GALBqFwjydSP3BKYGIVBpT0TES
|
||||||
|
qAV6sGwJhHc93pzwrdNvGCXZ3JOayZ/mK2Z1dVaEJNIcwJiQeCjsidAlAoGBAKIa
|
||||||
|
R9VSth1KS/5xuGMBPPeeqQaGeggXunajaQ4pR0M7zqhkIHNVIy6MLlm3R0K/XdOY
|
||||||
|
ytHXZhnBzHeS/FqKZZApFfkODPniDLnI3g6YB2PC1c9bwn8EoHhrY+60yKuxTl07
|
||||||
|
0NH6UzujY+rEDiWdLtpfCe0oiXR+99HG2WBu703vAoGBAMRS7OTt4eao+hEHiEB1
|
||||||
|
I/nykaM5IAnX5+S3PEB4l/wpAhBm5NV3wp3GnLaUtxXgpo0Fn+Eq6hwpUl8E5Ema
|
||||||
|
v1KwJZ8zqq+O8qzKgEvC9HiG7XJ6Y50kXaGxK2BUC0BBh6cZQghGm35RycZgptKq
|
||||||
|
nmdKX8jcOu8cfZgjs1DwruEi
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
9
src/main/resources/publicKey.pem
Normal file
9
src/main/resources/publicKey.pem
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAumCCqEa9iWesHcTA9bfJ
|
||||||
|
dMc4w37ZLSQ1PWedo4nFo3gEDLGRM83mfLQYTPl1YbByiNNJz7lPVY0AjCKTW4O6
|
||||||
|
Pc9YMQC5oL79Hq4/0AEk9E9/+fjIsst/hSpv7K6jeAua9F8uQ8ft0zZ70VV8Hj36
|
||||||
|
f0ByrIthX4qwDsdbPRQUj3JIVjdEDTJHoIT/Tsv0nF2Nc81Z3nrAZShQeYSku74c
|
||||||
|
as7nD93X+gad8u8cPDs4veLkvAOTJo/TtUWwMpIZhORhlBYgoj1xAnzONzvtCJco
|
||||||
|
Juppb3ub/156NZBGPuIegduaI5hbN8NbhkIfcWbXma/k5hb2jNtN9bWVazkN4/L+
|
||||||
|
/wIDAQAB
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
12
src/main/web/index.html
Normal file
12
src/main/web/index.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>StoryTeller</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1906
src/main/web/package-lock.json
generated
Normal file
1906
src/main/web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
src/main/web/package.json
Normal file
24
src/main/web/package.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "storyteller-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.30.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.15.21",
|
||||||
|
"@types/react": "^18.3.21",
|
||||||
|
"@types/react-dom": "^18.3.7",
|
||||||
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"vite": "^6.3.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/main/web/src/App.tsx
Normal file
51
src/main/web/src/App.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { Link, Route, Routes } from "react-router-dom";
|
||||||
|
import { useAuth } from "./context/AuthContext";
|
||||||
|
import { ProtectedRoute } from "./components/ProtectedRoute";
|
||||||
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
|
import { HomePage } from "./pages/HomePage";
|
||||||
|
import { ScenariosPage } from "./pages/ScenariosPage";
|
||||||
|
import { StoriesPage } from "./pages/StoriesPage";
|
||||||
|
import { StoryWorkspacePage } from "./pages/StoryWorkspacePage";
|
||||||
|
import { AdminUsersPage } from "./pages/AdminUsersPage";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root application shell with navigation and route definitions.
|
||||||
|
*/
|
||||||
|
export default function App() {
|
||||||
|
const { auth, logout } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="layout">
|
||||||
|
<header className="header">
|
||||||
|
<Link to="/" className="brand">StoryTeller</Link>
|
||||||
|
{auth && (
|
||||||
|
<nav>
|
||||||
|
<Link to="/scenarios">Scenarios</Link>
|
||||||
|
<Link to="/stories">Stories</Link>
|
||||||
|
{auth.isAdmin && <Link to="/admin/users">Users</Link>}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
<div className="header-right">
|
||||||
|
{auth ? (
|
||||||
|
<>
|
||||||
|
<span className="username">{auth.username}</span>
|
||||||
|
<button className="btn-sm btn-logout" onClick={logout}>Logout</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Link to="/login">Sign In</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="content">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/" element={<ProtectedRoute><HomePage /></ProtectedRoute>} />
|
||||||
|
<Route path="/scenarios" element={<ProtectedRoute><ScenariosPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/stories" element={<ProtectedRoute><StoriesPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/stories/:storyId" element={<ProtectedRoute><StoryWorkspacePage /></ProtectedRoute>} />
|
||||||
|
<Route path="/admin/users" element={<ProtectedRoute adminOnly><AdminUsersPage /></ProtectedRoute>} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
src/main/web/src/api/client.ts
Normal file
39
src/main/web/src/api/client.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import {
|
||||||
|
AuthApi,
|
||||||
|
Configuration,
|
||||||
|
ScenariosApi,
|
||||||
|
StoriesApi,
|
||||||
|
UsersApi,
|
||||||
|
} from "./generated/src";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "storyteller_auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the JWT from localStorage and returns auth headers if available.
|
||||||
|
*/
|
||||||
|
function getAuthHeaders(): Record<string, string> {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
const auth = JSON.parse(stored);
|
||||||
|
if (auth?.token) {
|
||||||
|
return { Authorization: `Bearer ${auth.token}` };
|
||||||
|
}
|
||||||
|
} catch { /* ignore malformed storage */ }
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns fresh API client instances with current auth token.
|
||||||
|
* Call this inside components/effects to ensure the latest token is used.
|
||||||
|
*/
|
||||||
|
export function getApiClient() {
|
||||||
|
const config = new Configuration({ basePath: "/api", headers: getAuthHeaders() });
|
||||||
|
return {
|
||||||
|
auth: new AuthApi(new Configuration({ basePath: "/api" })),
|
||||||
|
users: new UsersApi(config),
|
||||||
|
scenarios: new ScenariosApi(config),
|
||||||
|
stories: new StoriesApi(config),
|
||||||
|
};
|
||||||
|
}
|
||||||
19
src/main/web/src/components/ProtectedRoute.tsx
Normal file
19
src/main/web/src/components/ProtectedRoute.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
adminOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route guard that redirects unauthenticated users to the login page.
|
||||||
|
* If adminOnly is true, also redirects non-admin users.
|
||||||
|
*/
|
||||||
|
export function ProtectedRoute({ children, adminOnly = false }: Props) {
|
||||||
|
const { auth } = useAuth();
|
||||||
|
if (!auth) return <Navigate to="/login" replace />;
|
||||||
|
if (adminOnly && !auth.isAdmin) return <Navigate to="/" replace />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
57
src/main/web/src/context/AuthContext.tsx
Normal file
57
src/main/web/src/context/AuthContext.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
token: string;
|
||||||
|
userId: number;
|
||||||
|
username: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
auth: AuthState | null;
|
||||||
|
login: (state: AuthState) => void;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = "storyteller_auth";
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue>({
|
||||||
|
auth: null,
|
||||||
|
login: () => {},
|
||||||
|
logout: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides authentication state to the component tree.
|
||||||
|
* Persists auth state in localStorage across page reloads.
|
||||||
|
*/
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [auth, setAuth] = useState<AuthState | null>(() => {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
try { return JSON.parse(stored); } catch { return null; }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (auth) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(auth));
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
}, [auth]);
|
||||||
|
|
||||||
|
const login = useCallback((state: AuthState) => setAuth(state), []);
|
||||||
|
const logout = useCallback(() => setAuth(null), []);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ auth, login, logout }), [auth, login, logout]);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hook to access auth state and actions. */
|
||||||
|
export function useAuth() {
|
||||||
|
return useContext(AuthContext);
|
||||||
|
}
|
||||||
16
src/main/web/src/main.tsx
Normal file
16
src/main/web/src/main.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import { AuthProvider } from "./context/AuthContext";
|
||||||
|
import App from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
126
src/main/web/src/pages/AdminUsersPage.tsx
Normal file
126
src/main/web/src/pages/AdminUsersPage.tsx
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { User, CreateUserRequest } from "../api/generated/src";
|
||||||
|
import { getApiClient } from "../api/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin user management page: list, create, edit, and delete users.
|
||||||
|
*/
|
||||||
|
export function AdminUsersPage() {
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const api = getApiClient();
|
||||||
|
setUsers(await api.users.listUsers());
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to load users.");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const handleCreate = async (data: CreateUserRequest) => {
|
||||||
|
const api = getApiClient();
|
||||||
|
await api.users.createUser({ createUserRequest: data });
|
||||||
|
setShowCreate(false);
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleActive = async (user: User) => {
|
||||||
|
const api = getApiClient();
|
||||||
|
await api.users.updateUser({ userId: user.id, updateUserRequest: { active: !user.active } });
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleAdmin = async (user: User) => {
|
||||||
|
const api = getApiClient();
|
||||||
|
await api.users.updateUser({ userId: user.id, updateUserRequest: { isAdmin: !user.isAdmin } });
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (userId: number) => {
|
||||||
|
if (!confirm("Deactivate this user?")) return;
|
||||||
|
const api = getApiClient();
|
||||||
|
await api.users.deleteUser({ userId });
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div className="page-header">
|
||||||
|
<h2>User Management</h2>
|
||||||
|
<button onClick={() => setShowCreate(!showCreate)}>
|
||||||
|
{showCreate ? "Cancel" : "+ Create User"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
|
{showCreate && <CreateUserForm onSubmit={handleCreate} />}
|
||||||
|
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Admin</th>
|
||||||
|
<th>Active</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((u) => (
|
||||||
|
<tr key={u.id}>
|
||||||
|
<td>{u.username}</td>
|
||||||
|
<td>
|
||||||
|
<button className="btn-sm" onClick={() => handleToggleAdmin(u)}>
|
||||||
|
{u.isAdmin ? "Yes" : "No"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button className="btn-sm" onClick={() => handleToggleActive(u)}>
|
||||||
|
{u.active ? "Active" : "Inactive"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button className="btn-sm btn-danger" onClick={() => handleDelete(u.id)}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateUserForm({ onSubmit }: { onSubmit: (data: CreateUserRequest) => void }) {
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="card form-card inline-form"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit({ username, password, isAdmin });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Username
|
||||||
|
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||||
|
</label>
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input type="checkbox" checked={isAdmin} onChange={(e) => setIsAdmin(e.target.checked)} />
|
||||||
|
Admin
|
||||||
|
</label>
|
||||||
|
<button type="submit">Create</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/main/web/src/pages/HomePage.tsx
Normal file
23
src/main/web/src/pages/HomePage.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Landing page showing quick-start actions based on the user's role.
|
||||||
|
*/
|
||||||
|
export function HomePage() {
|
||||||
|
const { auth } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="home">
|
||||||
|
<h2>Welcome, {auth?.username ?? "Storyteller"}!</h2>
|
||||||
|
<p className="muted">
|
||||||
|
StoryTeller helps you collaborate with AI to build stories step by step.
|
||||||
|
</p>
|
||||||
|
<div className="home-actions">
|
||||||
|
<Link to="/scenarios" className="btn">My Scenarios</Link>
|
||||||
|
<Link to="/stories" className="btn">My Stories</Link>
|
||||||
|
{auth?.isAdmin && <Link to="/admin/users" className="btn btn-outline">Manage Users</Link>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/main/web/src/pages/LoginPage.tsx
Normal file
50
src/main/web/src/pages/LoginPage.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import { getApiClient } from "../api/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login page with username/password form.
|
||||||
|
*/
|
||||||
|
export function LoginPage() {
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const api = getApiClient();
|
||||||
|
const resp = await api.auth.login({ loginRequest: { username, password } });
|
||||||
|
login({ token: resp.token, userId: resp.userId, username: resp.username, isAdmin: resp.isAdmin });
|
||||||
|
navigate("/");
|
||||||
|
} catch {
|
||||||
|
setError("Invalid username or password.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-center">
|
||||||
|
<form className="card form-card" onSubmit={handleSubmit}>
|
||||||
|
<h2>Sign In</h2>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
<label>
|
||||||
|
Username
|
||||||
|
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} required autoFocus />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={loading}>{loading ? "Signing in..." : "Sign In"}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
src/main/web/src/pages/ScenariosPage.tsx
Normal file
144
src/main/web/src/pages/ScenariosPage.tsx
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import type { Scenario, CharacterDto } from "../api/generated/src";
|
||||||
|
import { getApiClient } from "../api/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scenario list page with create, edit, and delete actions.
|
||||||
|
*/
|
||||||
|
export function ScenariosPage() {
|
||||||
|
const [scenarios, setScenarios] = useState<Scenario[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [editing, setEditing] = useState<Scenario | null>(null);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const api = getApiClient();
|
||||||
|
setScenarios(await api.scenarios.listScenarios());
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to load scenarios.");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
if (!confirm("Delete this scenario?")) return;
|
||||||
|
const api = getApiClient();
|
||||||
|
await api.scenarios.deleteScenario({ scenarioId: id });
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div className="page-header">
|
||||||
|
<h2>Scenarios</h2>
|
||||||
|
<button onClick={() => { setCreating(true); setEditing(null); }}>+ New Scenario</button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
|
{(creating || editing) && (
|
||||||
|
<ScenarioEditor
|
||||||
|
scenario={editing}
|
||||||
|
onSave={() => { setCreating(false); setEditing(null); load(); }}
|
||||||
|
onCancel={() => { setCreating(false); setEditing(null); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card-grid">
|
||||||
|
{scenarios.map((s) => (
|
||||||
|
<div key={s.id} className="card scenario-card">
|
||||||
|
<h3>{s.title}</h3>
|
||||||
|
{s.description && <p className="muted">{s.description}</p>}
|
||||||
|
<p><strong>Scene:</strong> {truncate(s.startingSceneDescription, 150)}</p>
|
||||||
|
<p className="muted">{s.startingCharacters.length} character(s)</p>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{scenarios.length === 0 && !creating && <p className="muted">No scenarios yet. Create one to get started.</p>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(text: string, max: number) {
|
||||||
|
return text.length > max ? text.substring(0, max) + "..." : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorProps {
|
||||||
|
scenario: Scenario | null;
|
||||||
|
onSave: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScenarioEditor({ scenario, onSave, onCancel }: EditorProps) {
|
||||||
|
const [title, setTitle] = useState(scenario?.title ?? "");
|
||||||
|
const [description, setDescription] = useState(scenario?.description ?? "");
|
||||||
|
const [sceneDesc, setSceneDesc] = useState(scenario?.startingSceneDescription ?? "");
|
||||||
|
const [chars, setChars] = useState<CharacterDto[]>(scenario?.startingCharacters ?? []);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const api = getApiClient();
|
||||||
|
if (scenario) {
|
||||||
|
await api.scenarios.updateScenario({
|
||||||
|
scenarioId: scenario.id,
|
||||||
|
updateScenarioRequest: { title, description, startingSceneDescription: sceneDesc, startingCharacters: chars },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await api.scenarios.createScenario({
|
||||||
|
createScenarioRequest: { title, description, startingSceneDescription: sceneDesc, startingCharacters: chars },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onSave();
|
||||||
|
};
|
||||||
|
|
||||||
|
const addChar = () => setChars([...chars, { name: "", role: "", description: "" }]);
|
||||||
|
const removeChar = (i: number) => setChars(chars.filter((_, idx) => idx !== i));
|
||||||
|
const updateChar = (i: number, field: keyof CharacterDto, val: string) => {
|
||||||
|
const next = [...chars];
|
||||||
|
next[i] = { ...next[i], [field]: val };
|
||||||
|
setChars(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="card form-card scenario-editor" onSubmit={handleSubmit}>
|
||||||
|
<h3>{scenario ? "Edit Scenario" : "New Scenario"}</h3>
|
||||||
|
<label>
|
||||||
|
Title
|
||||||
|
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Description
|
||||||
|
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Starting Scene Description
|
||||||
|
<textarea value={sceneDesc} onChange={(e) => setSceneDesc(e.target.value)} rows={4} required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Starting Characters</legend>
|
||||||
|
{chars.map((ch, i) => (
|
||||||
|
<div key={i} className="char-row">
|
||||||
|
<input placeholder="Name" value={ch.name} onChange={(e) => updateChar(i, "name", e.target.value)} required />
|
||||||
|
<input placeholder="Role" value={ch.role} onChange={(e) => updateChar(i, "role", e.target.value)} required />
|
||||||
|
<input placeholder="Description" value={ch.description} onChange={(e) => updateChar(i, "description", e.target.value)} />
|
||||||
|
<button type="button" className="btn-sm btn-danger" onClick={() => removeChar(i)}>X</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className="btn-sm" onClick={addChar}>+ Character</button>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="submit">{scenario ? "Save" : "Create"}</button>
|
||||||
|
<button type="button" onClick={onCancel}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
src/main/web/src/pages/StoriesPage.tsx
Normal file
85
src/main/web/src/pages/StoriesPage.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Link, useSearchParams } 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.
|
||||||
|
*/
|
||||||
|
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 {
|
||||||
|
const api = getApiClient();
|
||||||
|
setStories(await api.stories.listStories());
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to load stories.");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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();
|
||||||
|
await api.stories.deleteStory({ storyId: id });
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div className="page-header">
|
||||||
|
<h2>Stories</h2>
|
||||||
|
</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">
|
||||||
|
<h3>{s.title}</h3>
|
||||||
|
<span className={`badge badge-${s.status.toLowerCase()}`}>{s.status}</span>
|
||||||
|
<p className="muted">{truncate(s.currentSceneDescription, 120)}</p>
|
||||||
|
<p className="muted">{s.currentSceneCharacters.length} character(s)</p>
|
||||||
|
<div className="card-actions">
|
||||||
|
<Link className="btn-sm btn-link" to={`/stories/${s.id}`}>Open</Link>
|
||||||
|
<button className="btn-sm btn-danger" onClick={() => handleDelete(s.id)}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{stories.length === 0 && <p className="muted">No stories yet. Start one from a scenario.</p>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(text: string, max: number) {
|
||||||
|
return text.length > max ? text.substring(0, max) + "..." : text;
|
||||||
|
}
|
||||||
275
src/main/web/src/pages/StoryWorkspacePage.tsx
Normal file
275
src/main/web/src/pages/StoryWorkspacePage.tsx
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import type {
|
||||||
|
Story,
|
||||||
|
StoryStep,
|
||||||
|
CharacterDto,
|
||||||
|
GenerateStepResponse,
|
||||||
|
} from "../api/generated/src";
|
||||||
|
import { getApiClient } from "../api/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full story workspace with scene panel, characters panel, steps panel,
|
||||||
|
* next-step generation input, and compaction review flow.
|
||||||
|
*/
|
||||||
|
export function StoryWorkspacePage() {
|
||||||
|
const { storyId } = useParams<{ storyId: string }>();
|
||||||
|
const id = Number(storyId);
|
||||||
|
|
||||||
|
const [story, setStory] = useState<Story | null>(null);
|
||||||
|
const [steps, setSteps] = useState<StoryStep[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [direction, setDirection] = useState("");
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [compactionResult, setCompactionResult] = useState<GenerateStepResponse | null>(null);
|
||||||
|
|
||||||
|
// Scene editing
|
||||||
|
const [editingScene, setEditingScene] = useState(false);
|
||||||
|
const [sceneText, setSceneText] = useState("");
|
||||||
|
|
||||||
|
// Character editing
|
||||||
|
const [editingChars, setEditingChars] = useState(false);
|
||||||
|
const [chars, setChars] = useState<CharacterDto[]>([]);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const api = getApiClient();
|
||||||
|
const [s, st] = await Promise.all([
|
||||||
|
api.stories.getStory({ storyId: id }),
|
||||||
|
api.stories.listStorySteps({ storyId: id }),
|
||||||
|
]);
|
||||||
|
setStory(s);
|
||||||
|
setSteps(st);
|
||||||
|
setSceneText(s.currentSceneDescription);
|
||||||
|
setChars(s.currentSceneCharacters);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to load story.");
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const handleSaveScene = async () => {
|
||||||
|
const api = getApiClient();
|
||||||
|
const updated = await api.stories.updateStory({
|
||||||
|
storyId: id,
|
||||||
|
updateStoryRequest: { currentSceneDescription: sceneText },
|
||||||
|
});
|
||||||
|
setStory(updated);
|
||||||
|
setEditingScene(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveChars = async () => {
|
||||||
|
const api = getApiClient();
|
||||||
|
const updated = await api.stories.updateStory({
|
||||||
|
storyId: id,
|
||||||
|
updateStoryRequest: { currentSceneCharacters: chars },
|
||||||
|
});
|
||||||
|
setStory(updated);
|
||||||
|
setChars(updated.currentSceneCharacters);
|
||||||
|
setEditingChars(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addChar = () => setChars([...chars, { name: "", role: "", description: "" }]);
|
||||||
|
const removeChar = (i: number) => setChars(chars.filter((_, idx) => idx !== i));
|
||||||
|
const updateChar = (i: number, field: keyof CharacterDto, val: string) => {
|
||||||
|
const next = [...chars];
|
||||||
|
next[i] = { ...next[i], [field]: val };
|
||||||
|
setChars(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!direction.trim()) return;
|
||||||
|
setGenerating(true);
|
||||||
|
setError(null);
|
||||||
|
setCompactionResult(null);
|
||||||
|
try {
|
||||||
|
const api = getApiClient();
|
||||||
|
const resp = await api.stories.generateStoryStep({
|
||||||
|
storyId: id,
|
||||||
|
generateStepRequest: { direction },
|
||||||
|
});
|
||||||
|
if (resp.compacted) {
|
||||||
|
setCompactionResult(resp);
|
||||||
|
}
|
||||||
|
setDirection("");
|
||||||
|
load();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : "Generation failed.");
|
||||||
|
} finally {
|
||||||
|
setGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAcceptCompaction = () => {
|
||||||
|
setCompactionResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditStep = async (stepId: number, newContent: string) => {
|
||||||
|
const api = getApiClient();
|
||||||
|
await api.stories.updateStoryStep({
|
||||||
|
storyId: id,
|
||||||
|
stepId,
|
||||||
|
updateStepRequest: { content: newContent },
|
||||||
|
});
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!story) {
|
||||||
|
return <section>{error ? <p className="error">{error}</p> : <p>Loading...</p>}</section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="workspace">
|
||||||
|
<h2 className="workspace-title">{story.title}</h2>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
|
{/* Compaction review overlay */}
|
||||||
|
{compactionResult && (
|
||||||
|
<div className="compaction-review card">
|
||||||
|
<h3>Context Compaction Performed</h3>
|
||||||
|
<p>The scene has been updated by merging earlier steps.</p>
|
||||||
|
{compactionResult.updatedScene && (
|
||||||
|
<>
|
||||||
|
<h4>Updated Scene</h4>
|
||||||
|
<pre className="scene-preview">{compactionResult.updatedScene}</pre>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="form-actions">
|
||||||
|
<button onClick={handleAcceptCompaction}>Accept & Continue</button>
|
||||||
|
<button onClick={() => { setEditingScene(true); setCompactionResult(null); }}>
|
||||||
|
Edit Scene First
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="workspace-grid">
|
||||||
|
{/* Scene panel */}
|
||||||
|
<div className="panel scene-panel card">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h3>Current Scene</h3>
|
||||||
|
{!editingScene && (
|
||||||
|
<button className="btn-sm" onClick={() => { setSceneText(story.currentSceneDescription); setEditingScene(true); }}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{editingScene ? (
|
||||||
|
<div>
|
||||||
|
<textarea
|
||||||
|
className="scene-editor"
|
||||||
|
value={sceneText}
|
||||||
|
onChange={(e) => setSceneText(e.target.value)}
|
||||||
|
rows={8}
|
||||||
|
/>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button onClick={handleSaveScene}>Save</button>
|
||||||
|
<button onClick={() => setEditingScene(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<pre className="scene-preview">{story.currentSceneDescription}</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Characters panel */}
|
||||||
|
<div className="panel chars-panel card">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h3>Characters</h3>
|
||||||
|
{!editingChars && (
|
||||||
|
<button className="btn-sm" onClick={() => { setChars(story.currentSceneCharacters); setEditingChars(true); }}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{editingChars ? (
|
||||||
|
<div>
|
||||||
|
{chars.map((ch, i) => (
|
||||||
|
<div key={i} className="char-row">
|
||||||
|
<input placeholder="Name" value={ch.name} onChange={(e) => updateChar(i, "name", e.target.value)} />
|
||||||
|
<input placeholder="Role" value={ch.role} onChange={(e) => updateChar(i, "role", e.target.value)} />
|
||||||
|
<input placeholder="Description" value={ch.description} onChange={(e) => updateChar(i, "description", e.target.value)} />
|
||||||
|
<button type="button" className="btn-sm btn-danger" onClick={() => removeChar(i)}>X</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className="btn-sm" onClick={addChar}>+ Character</button>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button onClick={handleSaveChars}>Save</button>
|
||||||
|
<button onClick={() => setEditingChars(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="char-list">
|
||||||
|
{story.currentSceneCharacters.map((ch, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<strong>{ch.name}</strong> <span className="badge">{ch.role}</span>
|
||||||
|
{ch.description && <span className="muted"> — {ch.description}</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{story.currentSceneCharacters.length === 0 && <li className="muted">No characters</li>}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Steps panel */}
|
||||||
|
<div className="panel steps-panel card">
|
||||||
|
<h3>Story Steps</h3>
|
||||||
|
<div className="steps-list">
|
||||||
|
{steps.map((step) => (
|
||||||
|
<StepItem key={step.id} step={step} onEdit={handleEditStep} />
|
||||||
|
))}
|
||||||
|
{steps.length === 0 && <p className="muted">No steps yet. Generate the first one below.</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Next step input */}
|
||||||
|
<form className="card next-step-form" onSubmit={handleGenerate}>
|
||||||
|
<h3>What Should Happen Next?</h3>
|
||||||
|
<textarea
|
||||||
|
placeholder="Describe what should happen next in the story..."
|
||||||
|
value={direction}
|
||||||
|
onChange={(e) => setDirection(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={generating}>
|
||||||
|
{generating ? "Generating..." : "Generate Next Step"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepItem({ step, onEdit }: { step: StoryStep; onEdit: (id: number, content: string) => void }) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [content, setContent] = useState(step.content);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`step-item ${step.addedToScene ? "merged" : "unmerged"}`}>
|
||||||
|
<div className="step-header">
|
||||||
|
<span className="step-number">#{step.stepNumber}</span>
|
||||||
|
<span className={`badge ${step.addedToScene ? "badge-merged" : "badge-unmerged"}`}>
|
||||||
|
{step.addedToScene ? "Merged" : "Active"}
|
||||||
|
</span>
|
||||||
|
{step.userDirection && <span className="muted step-direction">"{step.userDirection}"</span>}
|
||||||
|
{!editing && (
|
||||||
|
<button className="btn-sm" onClick={() => { setContent(step.content); setEditing(true); }}>Edit</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{editing ? (
|
||||||
|
<div>
|
||||||
|
<textarea value={content} onChange={(e) => setContent(e.target.value)} rows={4} className="step-editor" />
|
||||||
|
<div className="form-actions">
|
||||||
|
<button onClick={() => { onEdit(step.id, content); setEditing(false); }}>Save</button>
|
||||||
|
<button onClick={() => setEditing(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="step-content">{step.content}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
162
src/main/web/src/styles.css
Normal file
162
src/main/web/src/styles.css
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f4f5f7;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--primary: #2563eb;
|
||||||
|
--primary-hover: #1d4ed8;
|
||||||
|
--danger: #dc2626;
|
||||||
|
--danger-hover: #b91c1c;
|
||||||
|
--text: #1f2937;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--border: #e5e7eb;
|
||||||
|
--radius: 8px;
|
||||||
|
--shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.layout { min-height: 100vh; display: flex; flex-direction: column; }
|
||||||
|
.header {
|
||||||
|
display: flex; align-items: center; gap: 1.5rem;
|
||||||
|
padding: .75rem 1.5rem;
|
||||||
|
background: #111827; color: #fff;
|
||||||
|
}
|
||||||
|
.brand { font-weight: 700; font-size: 1.2rem; color: #fff; text-decoration: none; }
|
||||||
|
.header nav { display: flex; gap: 1rem; }
|
||||||
|
.header nav a, .header a { color: #d1d5db; text-decoration: none; font-size: .9rem; }
|
||||||
|
.header nav a:hover, .header a:hover { color: #fff; }
|
||||||
|
.header-right { margin-left: auto; display: flex; align-items: center; gap: .75rem; }
|
||||||
|
.username { color: #9ca3af; font-size: .85rem; }
|
||||||
|
.content { flex: 1; padding: 1.5rem 2rem; max-width: 1200px; width: 100%; margin: 0 auto; }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
button, .btn {
|
||||||
|
display: inline-block; padding: .5rem 1rem; border: none; border-radius: var(--radius);
|
||||||
|
background: var(--primary); color: #fff; font-size: .9rem; cursor: pointer;
|
||||||
|
text-decoration: none; text-align: center; transition: background .15s;
|
||||||
|
}
|
||||||
|
button:hover, .btn:hover { background: var(--primary-hover); }
|
||||||
|
button:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
.btn-outline { background: transparent; border: 1px solid var(--primary); color: var(--primary); }
|
||||||
|
.btn-outline:hover { background: var(--primary); color: #fff; }
|
||||||
|
.btn-sm { padding: .25rem .6rem; font-size: .8rem; }
|
||||||
|
.btn-danger { background: var(--danger); }
|
||||||
|
.btn-danger:hover { background: var(--danger-hover); }
|
||||||
|
.btn-link { background: transparent; color: var(--primary); text-decoration: underline; }
|
||||||
|
.btn-link:hover { color: var(--primary-hover); }
|
||||||
|
.btn-logout { background: transparent; border: 1px solid #6b7280; color: #d1d5db; }
|
||||||
|
.btn-logout:hover { background: #374151; }
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 1.25rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; margin-top: 1rem; }
|
||||||
|
.card-actions { display: flex; gap: .5rem; margin-top: .75rem; }
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.form-card { max-width: 480px; margin-bottom: 1.5rem; }
|
||||||
|
.form-card label { display: block; margin-bottom: .75rem; font-size: .9rem; font-weight: 500; }
|
||||||
|
.form-card input[type="text"],
|
||||||
|
.form-card input[type="password"],
|
||||||
|
.form-card textarea,
|
||||||
|
.scene-editor, .step-editor {
|
||||||
|
width: 100%; padding: .5rem .75rem; border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); font-size: .9rem; margin-top: .25rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
textarea { resize: vertical; }
|
||||||
|
.form-card button[type="submit"] { margin-top: .5rem; }
|
||||||
|
.form-actions { display: flex; gap: .5rem; margin-top: .75rem; }
|
||||||
|
.inline-form { max-width: 600px; }
|
||||||
|
.checkbox-label { display: flex !important; align-items: center; gap: .5rem; flex-direction: row; }
|
||||||
|
.checkbox-label input { width: auto; margin: 0; }
|
||||||
|
fieldset { border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; margin-bottom: .75rem; }
|
||||||
|
legend { font-weight: 600; font-size: .9rem; }
|
||||||
|
|
||||||
|
.char-row {
|
||||||
|
display: flex; gap: .5rem; margin-bottom: .5rem; align-items: center;
|
||||||
|
}
|
||||||
|
.char-row input { flex: 1; padding: .35rem .5rem; border: 1px solid var(--border); border-radius: 4px; font-size: .85rem; }
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.data-table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
|
||||||
|
.data-table th, .data-table td { text-align: left; padding: .5rem .75rem; border-bottom: 1px solid var(--border); }
|
||||||
|
.data-table th { font-size: .8rem; text-transform: uppercase; color: var(--muted); }
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge {
|
||||||
|
display: inline-block; padding: .15rem .5rem; border-radius: 12px;
|
||||||
|
font-size: .75rem; font-weight: 600; background: #e5e7eb; color: #374151;
|
||||||
|
}
|
||||||
|
.badge-active { background: #dcfce7; color: #166534; }
|
||||||
|
.badge-archived { background: #fef9c3; color: #854d0e; }
|
||||||
|
.badge-deleted { background: #fee2e2; color: #991b1b; }
|
||||||
|
.badge-merged { background: #dbeafe; color: #1e40af; }
|
||||||
|
.badge-unmerged { background: #fef3c7; color: #92400e; }
|
||||||
|
|
||||||
|
/* Page header */
|
||||||
|
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
/* Utilities */
|
||||||
|
.muted { color: var(--muted); font-size: .9rem; }
|
||||||
|
.error { color: var(--danger); margin-bottom: .75rem; font-weight: 500; }
|
||||||
|
|
||||||
|
/* Login */
|
||||||
|
.page-center {
|
||||||
|
display: flex; justify-content: center; align-items: center;
|
||||||
|
min-height: calc(100vh - 56px);
|
||||||
|
}
|
||||||
|
.page-center .form-card { width: 100%; max-width: 400px; }
|
||||||
|
.page-center h2 { margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
/* Home */
|
||||||
|
.home { text-align: center; padding-top: 3rem; }
|
||||||
|
.home h2 { font-size: 1.8rem; margin-bottom: .5rem; }
|
||||||
|
.home-actions { display: flex; justify-content: center; gap: 1rem; margin-top: 2rem; flex-wrap: wrap; }
|
||||||
|
.home-actions .btn { min-width: 160px; padding: .75rem 1.5rem; }
|
||||||
|
|
||||||
|
/* Workspace */
|
||||||
|
.workspace-title { margin-bottom: 1rem; }
|
||||||
|
.workspace-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem; }
|
||||||
|
@media (max-width: 768px) { .workspace-grid { grid-template-columns: 1fr; } }
|
||||||
|
.panel { margin-bottom: 0; }
|
||||||
|
.panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .75rem; }
|
||||||
|
.panel-header h3 { margin: 0; }
|
||||||
|
.scene-preview { white-space: pre-wrap; font-size: .9rem; line-height: 1.5; background: var(--bg); padding: .75rem; border-radius: 4px; max-height: 300px; overflow-y: auto; font-family: inherit; }
|
||||||
|
.char-list { list-style: none; }
|
||||||
|
.char-list li { padding: .35rem 0; border-bottom: 1px solid var(--border); }
|
||||||
|
.char-list li:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
/* Steps */
|
||||||
|
.steps-list { display: flex; flex-direction: column; gap: .75rem; }
|
||||||
|
.step-item { padding: .75rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); }
|
||||||
|
.step-item.merged { opacity: .7; }
|
||||||
|
.step-header { display: flex; align-items: center; gap: .5rem; margin-bottom: .4rem; flex-wrap: wrap; }
|
||||||
|
.step-number { font-weight: 700; font-size: .9rem; }
|
||||||
|
.step-direction { font-style: italic; font-size: .8rem; }
|
||||||
|
.step-content { font-size: .9rem; white-space: pre-wrap; }
|
||||||
|
|
||||||
|
/* Next step form */
|
||||||
|
.next-step-form { margin-top: 1rem; }
|
||||||
|
.next-step-form h3 { margin-bottom: .5rem; }
|
||||||
|
.next-step-form textarea { width: 100%; margin-bottom: .5rem; }
|
||||||
|
|
||||||
|
/* Compaction review */
|
||||||
|
.compaction-review {
|
||||||
|
border: 2px solid var(--primary); margin-bottom: 1rem; background: #eff6ff;
|
||||||
|
}
|
||||||
|
.compaction-review h3 { color: var(--primary); margin-bottom: .5rem; }
|
||||||
|
|
||||||
|
/* Scenario editor */
|
||||||
|
.scenario-editor { max-width: 700px; margin-bottom: 1.5rem; }
|
||||||
|
.scenario-card h3 { margin-bottom: .35rem; }
|
||||||
17
src/main/web/tsconfig.app.json
Normal file
17
src/main/web/tsconfig.app.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": false,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
1
src/main/web/tsconfig.app.tsbuildinfo
Normal file
1
src/main/web/tsconfig.app.tsbuildinfo
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/api/generated/src/index.ts","./src/api/generated/src/runtime.ts","./src/api/generated/src/apis/authapi.ts","./src/api/generated/src/apis/scenariosapi.ts","./src/api/generated/src/apis/storiesapi.ts","./src/api/generated/src/apis/usersapi.ts","./src/api/generated/src/apis/index.ts","./src/api/generated/src/models/character.ts","./src/api/generated/src/models/characterdto.ts","./src/api/generated/src/models/createscenariorequest.ts","./src/api/generated/src/models/createuserrequest.ts","./src/api/generated/src/models/generatesteprequest.ts","./src/api/generated/src/models/generatestepresponse.ts","./src/api/generated/src/models/loginrequest.ts","./src/api/generated/src/models/loginresponse.ts","./src/api/generated/src/models/scenario.ts","./src/api/generated/src/models/startstoryrequest.ts","./src/api/generated/src/models/story.ts","./src/api/generated/src/models/storystep.ts","./src/api/generated/src/models/updatescenariorequest.ts","./src/api/generated/src/models/updatesteprequest.ts","./src/api/generated/src/models/updatestoryrequest.ts","./src/api/generated/src/models/updateuserrequest.ts","./src/api/generated/src/models/user.ts","./src/api/generated/src/models/index.ts","./src/components/protectedroute.tsx","./src/context/authcontext.tsx","./src/pages/adminuserspage.tsx","./src/pages/homepage.tsx","./src/pages/loginpage.tsx","./src/pages/scenariospage.tsx","./src/pages/storiespage.tsx","./src/pages/storyworkspacepage.tsx"],"version":"5.9.3"}
|
||||||
7
src/main/web/tsconfig.json
Normal file
7
src/main/web/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
12
src/main/web/tsconfig.node.json
Normal file
12
src/main/web/tsconfig.node.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
1
src/main/web/tsconfig.node.tsbuildinfo
Normal file
1
src/main/web/tsconfig.node.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
2
src/main/web/vite.config.d.ts
vendored
Normal file
2
src/main/web/vite.config.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
declare const _default: import("vite").UserConfig;
|
||||||
|
export default _default;
|
||||||
15
src/main/web/vite.config.js
Normal file
15
src/main/web/vite.config.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
outDir: "../../../target/web-dist",
|
||||||
|
emptyOutDir: true
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:8080"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
16
src/main/web/vite.config.ts
Normal file
16
src/main/web/vite.config.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
outDir: "../../../target/web-dist",
|
||||||
|
emptyOutDir: true
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:8080"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -0,0 +1,97 @@
|
|||||||
|
package de.neitzel.storyteller.business;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
import de.neitzel.storyteller.common.security.JwtService;
|
||||||
|
import de.neitzel.storyteller.common.security.PasswordHasher;
|
||||||
|
import de.neitzel.storyteller.data.entity.UserEntity;
|
||||||
|
import de.neitzel.storyteller.data.repository.UserRepository;
|
||||||
|
import de.neitzel.storyteller.fascade.model.LoginRequest;
|
||||||
|
import de.neitzel.storyteller.fascade.model.LoginResponse;
|
||||||
|
import jakarta.ws.rs.WebApplicationException;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for {@link AuthService}.
|
||||||
|
*/
|
||||||
|
class AuthServiceTest {
|
||||||
|
|
||||||
|
private UserRepository userRepository;
|
||||||
|
private PasswordHasher passwordHasher;
|
||||||
|
private JwtService jwtService;
|
||||||
|
private AuthService authService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
userRepository = Mockito.mock(UserRepository.class);
|
||||||
|
passwordHasher = Mockito.mock(PasswordHasher.class);
|
||||||
|
jwtService = Mockito.mock(JwtService.class);
|
||||||
|
authService = new AuthService(userRepository, passwordHasher, jwtService);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Successful login returns JWT and user info. */
|
||||||
|
@Test
|
||||||
|
void loginSuccessReturnsTokenAndUserInfo() {
|
||||||
|
final UserEntity user = new UserEntity();
|
||||||
|
user.setId(1L);
|
||||||
|
user.setUsername("alice");
|
||||||
|
user.setPasswordHash("hashed");
|
||||||
|
user.setAdmin(false);
|
||||||
|
user.setActive(true);
|
||||||
|
|
||||||
|
when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user));
|
||||||
|
when(passwordHasher.verify("secret", "hashed")).thenReturn(true);
|
||||||
|
when(jwtService.generateToken(1L, "alice", false)).thenReturn("jwt-token");
|
||||||
|
|
||||||
|
final LoginResponse response = authService.login(new LoginRequest().username("alice").password("secret"));
|
||||||
|
|
||||||
|
assertEquals("jwt-token", response.getToken());
|
||||||
|
assertEquals(1L, response.getUserId());
|
||||||
|
assertEquals("alice", response.getUsername());
|
||||||
|
assertFalse(response.getIsAdmin());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Login with wrong password throws 401. */
|
||||||
|
@Test
|
||||||
|
void loginWrongPasswordThrows401() {
|
||||||
|
final UserEntity user = new UserEntity();
|
||||||
|
user.setId(1L);
|
||||||
|
user.setUsername("alice");
|
||||||
|
user.setPasswordHash("hashed");
|
||||||
|
user.setActive(true);
|
||||||
|
|
||||||
|
when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user));
|
||||||
|
when(passwordHasher.verify("wrong", "hashed")).thenReturn(false);
|
||||||
|
|
||||||
|
assertThrows(WebApplicationException.class,
|
||||||
|
() -> authService.login(new LoginRequest().username("alice").password("wrong")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Login with unknown username throws 401. */
|
||||||
|
@Test
|
||||||
|
void loginUnknownUserThrows401() {
|
||||||
|
when(userRepository.findByUsername("nobody")).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThrows(WebApplicationException.class,
|
||||||
|
() -> authService.login(new LoginRequest().username("nobody").password("x")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Login with inactive account throws 401. */
|
||||||
|
@Test
|
||||||
|
void loginInactiveUserThrows401() {
|
||||||
|
final UserEntity user = new UserEntity();
|
||||||
|
user.setId(1L);
|
||||||
|
user.setUsername("alice");
|
||||||
|
user.setPasswordHash("hashed");
|
||||||
|
user.setActive(false);
|
||||||
|
|
||||||
|
when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user));
|
||||||
|
|
||||||
|
assertThrows(WebApplicationException.class,
|
||||||
|
() -> authService.login(new LoginRequest().username("alice").password("secret")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
package de.neitzel.storyteller.business;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
import de.neitzel.storyteller.data.entity.ScenarioEntity;
|
||||||
|
import de.neitzel.storyteller.data.repository.ScenarioCharacterRepository;
|
||||||
|
import de.neitzel.storyteller.data.repository.ScenarioRepository;
|
||||||
|
import de.neitzel.storyteller.fascade.model.Scenario;
|
||||||
|
import jakarta.ws.rs.WebApplicationException;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for {@link ScenarioService}.
|
||||||
|
*/
|
||||||
|
class ScenarioServiceTest {
|
||||||
|
|
||||||
|
private ScenarioRepository scenarioRepository;
|
||||||
|
private ScenarioCharacterRepository characterRepository;
|
||||||
|
private ScenarioService scenarioService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
scenarioRepository = Mockito.mock(ScenarioRepository.class);
|
||||||
|
characterRepository = Mockito.mock(ScenarioCharacterRepository.class);
|
||||||
|
scenarioService = new ScenarioService(scenarioRepository, characterRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Regular user sees only own scenarios. */
|
||||||
|
@Test
|
||||||
|
void listScenariosForRegularUserFiltersbyOwner() {
|
||||||
|
final ScenarioEntity entity = new ScenarioEntity();
|
||||||
|
entity.setId(1L);
|
||||||
|
entity.setOwnerUserId(10L);
|
||||||
|
entity.setTitle("My Scenario");
|
||||||
|
entity.setStartingSceneDescription("A dark cave");
|
||||||
|
entity.setStartingCharacters(Collections.emptyList());
|
||||||
|
when(scenarioRepository.findByOwner(10L)).thenReturn(List.of(entity));
|
||||||
|
|
||||||
|
final List<Scenario> result = scenarioService.listScenarios(10L, false);
|
||||||
|
|
||||||
|
assertEquals(1, result.size());
|
||||||
|
assertEquals("My Scenario", result.get(0).getTitle());
|
||||||
|
verify(scenarioRepository, never()).listAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin sees all scenarios. */
|
||||||
|
@Test
|
||||||
|
void listScenariosForAdminReturnsAll() {
|
||||||
|
when(scenarioRepository.listAll()).thenReturn(Collections.emptyList());
|
||||||
|
|
||||||
|
scenarioService.listScenarios(1L, true);
|
||||||
|
|
||||||
|
verify(scenarioRepository).listAll();
|
||||||
|
verify(scenarioRepository, never()).findByOwner(anyLong());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accessing another user's scenario throws 403. */
|
||||||
|
@Test
|
||||||
|
void getScenarioOtherOwnerThrowsForbidden() {
|
||||||
|
final ScenarioEntity entity = new ScenarioEntity();
|
||||||
|
entity.setId(1L);
|
||||||
|
entity.setOwnerUserId(20L);
|
||||||
|
when(scenarioRepository.findById(1L)).thenReturn(entity);
|
||||||
|
|
||||||
|
assertThrows(WebApplicationException.class,
|
||||||
|
() -> scenarioService.getScenario(1L, 10L, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accessing a nonexistent scenario throws 404. */
|
||||||
|
@Test
|
||||||
|
void getScenarioNotFoundThrows404() {
|
||||||
|
when(scenarioRepository.findById(99L)).thenReturn(null);
|
||||||
|
|
||||||
|
assertThrows(WebApplicationException.class,
|
||||||
|
() -> scenarioService.getScenario(99L, 10L, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,154 @@
|
|||||||
|
package de.neitzel.storyteller.business;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
import de.neitzel.storyteller.data.entity.ScenarioCharacterEntity;
|
||||||
|
import de.neitzel.storyteller.data.entity.ScenarioEntity;
|
||||||
|
import de.neitzel.storyteller.data.entity.StoryCharacterEntity;
|
||||||
|
import de.neitzel.storyteller.data.entity.StoryEntity;
|
||||||
|
import de.neitzel.storyteller.data.entity.StoryStepEntity;
|
||||||
|
import de.neitzel.storyteller.data.repository.ScenarioRepository;
|
||||||
|
import de.neitzel.storyteller.data.repository.StoryCharacterRepository;
|
||||||
|
import de.neitzel.storyteller.data.repository.StoryRepository;
|
||||||
|
import de.neitzel.storyteller.data.repository.StoryStepRepository;
|
||||||
|
import de.neitzel.storyteller.fascade.model.GenerateStepRequest;
|
||||||
|
import de.neitzel.storyteller.fascade.model.GenerateStepResponse;
|
||||||
|
import de.neitzel.storyteller.fascade.model.StartStoryRequest;
|
||||||
|
import de.neitzel.storyteller.fascade.model.Story;
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import jakarta.ws.rs.WebApplicationException;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for {@link StoryService}.
|
||||||
|
*/
|
||||||
|
class StoryServiceTest {
|
||||||
|
|
||||||
|
private StoryRepository storyRepository;
|
||||||
|
private StoryCharacterRepository characterRepository;
|
||||||
|
private StoryStepRepository stepRepository;
|
||||||
|
private ScenarioRepository scenarioRepository;
|
||||||
|
private EntityManager entityManager;
|
||||||
|
private StoryService storyService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
storyRepository = Mockito.mock(StoryRepository.class);
|
||||||
|
characterRepository = Mockito.mock(StoryCharacterRepository.class);
|
||||||
|
stepRepository = Mockito.mock(StoryStepRepository.class);
|
||||||
|
scenarioRepository = Mockito.mock(ScenarioRepository.class);
|
||||||
|
entityManager = Mockito.mock(EntityManager.class);
|
||||||
|
storyService = new StoryService(storyRepository, characterRepository, stepRepository, scenarioRepository);
|
||||||
|
|
||||||
|
when(storyRepository.getEntityManager()).thenReturn(entityManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starting a story copies the scenario's scene and characters. */
|
||||||
|
@Test
|
||||||
|
void startStoryCopiesScenarioScene() {
|
||||||
|
final ScenarioEntity scenario = new ScenarioEntity();
|
||||||
|
scenario.setId(1L);
|
||||||
|
scenario.setStartingSceneDescription("A dark forest");
|
||||||
|
final ScenarioCharacterEntity ch = new ScenarioCharacterEntity();
|
||||||
|
ch.setName("Elara");
|
||||||
|
ch.setRole("protagonist");
|
||||||
|
ch.setDescription("A young mage");
|
||||||
|
scenario.setStartingCharacters(List.of(ch));
|
||||||
|
|
||||||
|
when(scenarioRepository.findById(1L)).thenReturn(scenario);
|
||||||
|
doAnswer(inv -> {
|
||||||
|
StoryEntity e = inv.getArgument(0);
|
||||||
|
e.setId(10L);
|
||||||
|
e.setCurrentSceneCharacters(Collections.emptyList());
|
||||||
|
return null;
|
||||||
|
}).when(storyRepository).persist(any(StoryEntity.class));
|
||||||
|
|
||||||
|
final Story result = storyService.startStory(new StartStoryRequest().scenarioId(1L).title("My Story"), 5L);
|
||||||
|
|
||||||
|
assertEquals("A dark forest", result.getCurrentSceneDescription());
|
||||||
|
assertEquals("My Story", result.getTitle());
|
||||||
|
verify(characterRepository).persist(any(StoryCharacterEntity.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starting a story with non-existent scenario throws 404. */
|
||||||
|
@Test
|
||||||
|
void startStoryNonExistentScenarioThrows404() {
|
||||||
|
when(scenarioRepository.findById(99L)).thenReturn(null);
|
||||||
|
|
||||||
|
assertThrows(WebApplicationException.class,
|
||||||
|
() -> storyService.startStory(new StartStoryRequest().scenarioId(99L).title("X"), 1L));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accessing another user's story throws 403. */
|
||||||
|
@Test
|
||||||
|
void getStoryWrongOwnerThrowsForbidden() {
|
||||||
|
final StoryEntity entity = new StoryEntity();
|
||||||
|
entity.setId(1L);
|
||||||
|
entity.setOwnerUserId(20L);
|
||||||
|
entity.setStatus("ACTIVE");
|
||||||
|
entity.setCurrentSceneCharacters(Collections.emptyList());
|
||||||
|
when(storyRepository.findById(1L)).thenReturn(entity);
|
||||||
|
|
||||||
|
assertThrows(WebApplicationException.class,
|
||||||
|
() -> storyService.getStory(1L, 10L));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generating a step without compaction sets compacted=false. */
|
||||||
|
@Test
|
||||||
|
void generateStepWithoutCompaction() {
|
||||||
|
final StoryEntity story = new StoryEntity();
|
||||||
|
story.setId(1L);
|
||||||
|
story.setOwnerUserId(5L);
|
||||||
|
story.setStatus("ACTIVE");
|
||||||
|
story.setCurrentSceneDescription("Short scene");
|
||||||
|
story.setCurrentSceneCharacters(Collections.emptyList());
|
||||||
|
|
||||||
|
when(storyRepository.findById(1L)).thenReturn(story);
|
||||||
|
when(stepRepository.findUnmergedByStory(1L)).thenReturn(Collections.emptyList());
|
||||||
|
when(stepRepository.maxStepNumber(1L)).thenReturn(0);
|
||||||
|
|
||||||
|
final GenerateStepResponse resp = storyService.generateStoryStep(
|
||||||
|
1L,
|
||||||
|
new GenerateStepRequest().direction("Add a dragon"),
|
||||||
|
5L
|
||||||
|
);
|
||||||
|
|
||||||
|
assertFalse(resp.getCompacted());
|
||||||
|
assertNotNull(resp.getStep());
|
||||||
|
assertEquals(1, resp.getStep().getStepNumber());
|
||||||
|
verify(stepRepository).persist(any(StoryStepEntity.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Listing stories for a user delegates to repository. */
|
||||||
|
@Test
|
||||||
|
void listStoriesDelegatesToRepository() {
|
||||||
|
when(storyRepository.findByOwner(5L)).thenReturn(Collections.emptyList());
|
||||||
|
|
||||||
|
final List<Story> result = storyService.listStories(5L);
|
||||||
|
|
||||||
|
assertTrue(result.isEmpty());
|
||||||
|
verify(storyRepository).findByOwner(5L);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deleting a story soft-deletes by setting status to DELETED. */
|
||||||
|
@Test
|
||||||
|
void deleteStorySoftDeletes() {
|
||||||
|
final StoryEntity entity = new StoryEntity();
|
||||||
|
entity.setId(1L);
|
||||||
|
entity.setOwnerUserId(5L);
|
||||||
|
entity.setStatus("ACTIVE");
|
||||||
|
entity.setCurrentSceneCharacters(Collections.emptyList());
|
||||||
|
when(storyRepository.findById(1L)).thenReturn(entity);
|
||||||
|
|
||||||
|
storyService.deleteStory(1L, 5L);
|
||||||
|
|
||||||
|
assertEquals("DELETED", entity.getStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
package de.neitzel.storyteller.business;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
import de.neitzel.storyteller.common.security.PasswordHasher;
|
||||||
|
import de.neitzel.storyteller.data.entity.UserEntity;
|
||||||
|
import de.neitzel.storyteller.data.repository.UserRepository;
|
||||||
|
import de.neitzel.storyteller.fascade.model.CreateUserRequest;
|
||||||
|
import de.neitzel.storyteller.fascade.model.UpdateUserRequest;
|
||||||
|
import de.neitzel.storyteller.fascade.model.User;
|
||||||
|
import jakarta.ws.rs.WebApplicationException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for {@link UserService}.
|
||||||
|
*/
|
||||||
|
class UserServiceTest {
|
||||||
|
|
||||||
|
private UserRepository userRepository;
|
||||||
|
private PasswordHasher passwordHasher;
|
||||||
|
private UserService userService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
userRepository = Mockito.mock(UserRepository.class);
|
||||||
|
passwordHasher = Mockito.mock(PasswordHasher.class);
|
||||||
|
userService = new UserService(userRepository, passwordHasher);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Listing users maps entities to DTOs correctly. */
|
||||||
|
@Test
|
||||||
|
void listUsersReturnsMappedDtos() {
|
||||||
|
final UserEntity entity = new UserEntity();
|
||||||
|
entity.setId(1L);
|
||||||
|
entity.setUsername("alice");
|
||||||
|
entity.setAdmin(true);
|
||||||
|
entity.setActive(true);
|
||||||
|
when(userRepository.listAll()).thenReturn(List.of(entity));
|
||||||
|
|
||||||
|
final List<User> result = userService.listUsers();
|
||||||
|
|
||||||
|
assertEquals(1, result.size());
|
||||||
|
assertEquals("alice", result.get(0).getUsername());
|
||||||
|
assertTrue(result.get(0).getIsAdmin());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creating a user with duplicate username throws conflict. */
|
||||||
|
@Test
|
||||||
|
void createUserDuplicateUsernameThrowsConflict() {
|
||||||
|
when(userRepository.findByUsername("alice")).thenReturn(Optional.of(new UserEntity()));
|
||||||
|
|
||||||
|
assertThrows(WebApplicationException.class,
|
||||||
|
() -> userService.createUser(new CreateUserRequest().username("alice").password("x").isAdmin(false)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creating a user hashes the password and persists. */
|
||||||
|
@Test
|
||||||
|
void createUserHashesPasswordAndPersists() {
|
||||||
|
when(userRepository.findByUsername("bob")).thenReturn(Optional.empty());
|
||||||
|
when(passwordHasher.hash("secret")).thenReturn("hashed");
|
||||||
|
doAnswer(inv -> {
|
||||||
|
UserEntity e = inv.getArgument(0);
|
||||||
|
e.setId(42L);
|
||||||
|
return null;
|
||||||
|
}).when(userRepository).persist(any(UserEntity.class));
|
||||||
|
|
||||||
|
final User result = userService.createUser(new CreateUserRequest().username("bob").password("secret").isAdmin(false));
|
||||||
|
|
||||||
|
assertEquals("bob", result.getUsername());
|
||||||
|
assertEquals(42L, result.getId());
|
||||||
|
verify(passwordHasher).hash("secret");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Updating a non-existent user throws not-found. */
|
||||||
|
@Test
|
||||||
|
void updateUserNotFoundThrows404() {
|
||||||
|
when(userRepository.findById(99L)).thenReturn(null);
|
||||||
|
|
||||||
|
assertThrows(WebApplicationException.class,
|
||||||
|
() -> userService.updateUser(99L, new UpdateUserRequest().username("new")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
package de.neitzel.storyteller.common.security;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for {@link PasswordHasher}.
|
||||||
|
*/
|
||||||
|
class PasswordHasherTest {
|
||||||
|
|
||||||
|
private final PasswordHasher hasher = new PasswordHasher();
|
||||||
|
|
||||||
|
/** Hash and verify round-trip succeeds. */
|
||||||
|
@Test
|
||||||
|
void hashAndVerifyRoundTrip() {
|
||||||
|
final String hash = hasher.hash("mypassword");
|
||||||
|
assertNotNull(hash);
|
||||||
|
assertTrue(hasher.verify("mypassword", hash));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verification fails for wrong password. */
|
||||||
|
@Test
|
||||||
|
void verifyFailsForWrongPassword() {
|
||||||
|
final String hash = hasher.hash("correctPassword");
|
||||||
|
assertFalse(hasher.verify("wrongPassword", hash));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Each hash is unique (salted). */
|
||||||
|
@Test
|
||||||
|
void hashesAreUnique() {
|
||||||
|
final String hash1 = hasher.hash("same");
|
||||||
|
final String hash2 = hasher.hash("same");
|
||||||
|
assertNotEquals(hash1, hash2);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user