Compare commits

...
10 Commits
Author SHA1 Message Date
James Wampler 44f722019b Mirror mic-check's Gitea/GitHub CI-CD pipeline for novelly
CI / deploy (push) Successful in 10s
CI / build-and-push (push) Successful in 58s
Dual-engine workflow (.github/workflows/ci.yml, read by both GitHub Actions
and Gitea Actions): build, test, coverage badge on every push; on Gitea main
pushes only, build+push API/web images to the Gitea registry and redeploy
the persistent LAN stack via the shared [self-hosted, qa] runner. Replaces
the ad hoc docker-compose.deploy.yml manual workflow with
deploy/qa/docker-compose.qa.yml, pulled by CI — data volume preserved
across deploys (no -v on down), unlike mic-check's throwaway QA stack.
2026-08-18 18:25:01 -07:00
James Wampler 56e64c6f06 Update README for locations, Novel rename, and scene removal
Docs had drifted from the code across several prior renames; refreshes the
data model, API surface table, and tool counts to match, and documents the
new Locations feature and chapter character summary alongside the actual
current commit.
2026-08-18 11:40:39 -07:00
James Wampler c620ddd626 Replace chapter setting with multi-select locations; add chapter character summary
Chapters now carry many Locations (new Tags-style entity with cross-referencing)
instead of a single free-text Setting field, with a Locations tab on the novel
for browsing them and seeing every chapter set at each one. Also surfaces the
distinct characters appearing in a chapter's beats, linked, under the beat/word
count on the outline tab.
2026-08-18 11:36:13 -07:00
James Wampler 4313c8f206 Rename Project concept to Novel across the stack
Renames the domain concept from Project to Novel throughout the backend
(entities, DTOs, services, endpoints, ProjectAccessService/Permission,
ProjectId foreign keys), MCP server (tool names and routes), and the
React/Vite frontend (types, hooks, routes, components). Adds a new EF
Core migration (RenameProjectToNovel) using RenameTable/RenameColumn to
preserve existing data instead of dropping/recreating tables. Updates
CLAUDE.md's structure section to reference Novels/ instead of Projects/.
2026-08-17 23:03:09 -07:00
James Wampler 0ab4f568b5 Drop braces on single-line conditionals in BeatService 2026-08-17 22:54:09 -07:00
James Wampler bc85a36e18 Dashboard: show 10 chapters titled Chapters; bump chapter mtime on beat changes
Beat create/update/delete/reorder/assign/move now touch the parent
chapter's UpdatedAt so the dashboard's recency sort reflects beat edits,
not just chapter-level prose/tag changes.
2026-08-17 22:53:05 -07:00
James Wampler 17facba3b9 Group character beats into arc-stage sections; reciprocal relationship types
Arc stages now group the beats that establish or pay off that stage of a
character's arc (many-to-many via ArcStageBeats), and carry a Result field
(renamed from Description) describing what the stage results in for the
character. Assigning a beat to a stage moves it out of any other stage of
the same character. New endpoint POST /api/arc-stages/{id}/beats, MCP tool
set_arc_stage_beats, and frontend grouping UI in CharacterArc/CharacterBeats.

Also records a relationship's reciprocal type so both characters' dossiers
show the correct direction (e.g. "sister" / "brother") instead of mirroring
the same label.
2026-08-17 22:45:07 -07:00
James Wampler eb1efcf9f8 Stop tracking .mcp.json; it carries a real API key
.mcp.json needs NOVELLY_API_KEY to match the API's Auth:ServiceApiKey
user secret, so it can't be a checked-in file — gitignore it and keep
.mcp.json.example (with the key blanked out) as the template. README
walks through copying the example and setting the matching user secret.
2026-08-17 18:39:56 -07:00
James Wampler c879d9bfce Add publish-mcp.sh so the standalone MCP binary stops going stale
Aspire doesn't run or manage src/Novelly.Mcp — it's a separate stdio
process the MCP client spawns from a published binary that nothing
rebuilds automatically. It had drifted 12 days out of date and was
silently missing the service-API-key auth header, causing confusing
401s. Script wraps the existing dotnet publish command via the shared
ensure_dotnet helper; README points at it instead of the raw command.

Also registers a UserSecretsId on Novelly.Api so Auth:ServiceApiKey
can be set locally without landing in appsettings.
2026-08-17 18:38:48 -07:00
James Wampler cb66ef7343 Add dashboard tag cloud and spectrum tag color picker
- Dashboard's Characters column gains a tag cloud below it, sized by
  usage count, linking into the tags page.
- Tags page color editor replaced with 16 preset swatches across the
  spectrum plus a "Custom..." link to the native hex picker.
- Fix: tag color/name updates weren't invalidating the tag-references
  query, so the reference panel showed a stale color after editing.
- Tag cloud/tag list selection now round-trips through a ?tag= query
  param so clicking a cloud tag selects it on the tags page.
2026-08-17 17:52:43 -07:00
134 changed files with 8213 additions and 1872 deletions
+3
View File
@@ -104,6 +104,9 @@ dotnet_style_qualification_for_property = false:suggestion
dotnet_style_qualification_for_method = false:warning dotnet_style_qualification_for_method = false:warning
dotnet_style_qualification_for_event = false:warning dotnet_style_qualification_for_event = false:warning
dotnet_diagnostic.CA1873.severity = silent
[*.cs] [*.cs]
csharp_using_directive_placement = outside_namespace:silent csharp_using_directive_placement = outside_namespace:silent
csharp_prefer_simple_using_statement = true:suggestion csharp_prefer_simple_using_statement = true:suggestion
-1
View File
@@ -1 +0,0 @@
ANTHROPIC_API_KEY=
+75
View File
@@ -0,0 +1,75 @@
# CI/CD pipeline for Novelly. Read by both Gitea Actions and GitHub Actions (both look
# under .github/workflows/). Every non-checkout step just invokes a bash script under
# scripts/ci/, so the entire pipeline is reproducible by running the same scripts
# locally — no marketplace build/test/push actions.
#
# Gitea (origin) is the internal remote and runs the full pipeline: build, test,
# coverage badge, docker push, deploy, health check. GitHub is the public mirror and
# only needs to prove the code builds and tests pass — it has no registry secrets and
# no [self-hosted, qa] runner, so the docker push/deploy job is skipped there via the
# `github.server_url` check below (identical on both engines: https://github.com on
# GitHub, the Gitea instance URL on Gitea).
name: CI
on:
push:
paths-ignore: [badges/**]
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: write
env:
REGISTRY: ${{ secrets.REGISTRY }}
REGISTRY_OWNER: ${{ secrets.REGISTRY_OWNER }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
steps:
- uses: actions/checkout@v4
- name: Build
run: ./scripts/ci/build.sh
- name: Test
run: ./scripts/ci/test.sh
- name: Coverage report
run: ./scripts/ci/coverage.sh
- name: Publish coverage badge
env:
GITHUB_TOKEN: ${{ github.token }}
run: ./scripts/ci/publish-coverage-badge.sh
- name: Build Docker images
if: github.server_url != 'https://github.com' && github.ref_name == 'main'
run: ./scripts/ci/docker-build.sh
- name: Push Docker images
if: github.server_url != 'https://github.com' && github.ref_name == 'main'
run: ./scripts/ci/docker-push.sh
deploy:
needs: build-and-push
if: github.server_url != 'https://github.com' && github.ref_name == 'main'
runs-on: [self-hosted, qa]
env:
REGISTRY: ${{ secrets.REGISTRY }}
REGISTRY_OWNER: ${{ secrets.REGISTRY_OWNER }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
WEB_PORT: ${{ vars.WEB_PORT }}
steps:
# actions/checkout@v4 is a Node-based action; this runner has no node in PATH, so
# checkout plain git instead of via marketplace action.
- name: Checkout
run: |
git init -q .
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
git -c http.extraheader="AUTHORIZATION: bearer ${{ github.token }}" fetch --depth=1 origin "${{ github.sha }}"
git checkout -q FETCH_HEAD
- name: Deploy
run: ./scripts/ci/deploy.sh
+3
View File
@@ -176,6 +176,7 @@ _TeamCity*
coverage*.json coverage*.json
coverage*.xml coverage*.xml
coverage*.info coverage*.info
coverage/
# Visual Studio code coverage results # Visual Studio code coverage results
*.coverage *.coverage
@@ -436,9 +437,11 @@ dist/
*.db-shm *.db-shm
*.db-wal *.db-wal
mcp-server/ mcp-server/
.mcp.json
# Toolchains installed locally by scripts/ci/lib.sh # Toolchains installed locally by scripts/ci/lib.sh
.dotnet/ .dotnet/
.dotnet-tools/
.node/ .node/
.idea/ .idea/
-11
View File
@@ -1,11 +0,0 @@
{
"mcpServers": {
"novelly": {
"command": "/home/james/src/novelly/mcp-server/Novelly.Mcp",
"env": {
"NOVELLY_API_URL": "http://localhost:5080",
"DOTNET_ROOT": "/home/james/.dotnet"
}
}
}
}
+2 -1
View File
@@ -3,7 +3,8 @@
"novelly": { "novelly": {
"command": "./mcp-server/Novelly.Mcp", "command": "./mcp-server/Novelly.Mcp",
"env": { "env": {
"NOVELLY_API_URL": "http://localhost:5080" "NOVELLY_API_URL": "http://localhost:5080",
"NOVELLY_API_KEY": "<matches the API's Auth:ServiceApiKey user secret>"
} }
} }
} }
+1 -1
View File
@@ -9,7 +9,7 @@ Novelly: software plan + write novel. ASP.NET Core 10, C#, TypeScript, React, .N
## Structure ## Structure
- `src/Novelly.Api/` — whole back end, organised by feature. One folder per feature holds - `src/Novelly.Api/` — whole back end, organised by feature. One folder per feature holds
entity, DTOs, service, endpoints together: `Projects/`, `Characters/`, `Chapters/`, `Beats/`, entity, DTOs, service, endpoints together: `Novels/`, `Characters/`, `Chapters/`, `Beats/`,
`Scenes/`, `Tags/`, `Agent/`. `Common/` holds what crosses features; `Data/` holds `Scenes/`, `Tags/`, `Agent/`. `Common/` holds what crosses features; `Data/` holds
`DbContext` + EF migrations. `DbContext` + EF migrations.
- `src/Novelly.AppHost/` — .NET Aspire orchestration; run this to bring up API + web client - `src/Novelly.AppHost/` — .NET Aspire orchestration; run this to bring up API + web client
+56 -31
View File
@@ -1,5 +1,9 @@
# Novelly # Novelly
[![GitHub CI](https://github.com/wamplerj/novelly/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/wamplerj/novelly/actions/workflows/ci.yml)
[![Gitea CI](https://git.wampler.us/wamplerj/novelly/actions/workflows/ci.yml/badge.svg?branch=main)](https://git.wampler.us/wamplerj/novelly/actions?workflow=ci.yml)
![Coverage](badges/coverage.svg)
Software for planning and writing a novel. You outline the book, keep character Software for planning and writing a novel. You outline the book, keep character
dossiers, break chapters into scenes, and draft prose — with a Claude-powered agent dossiers, break chapters into scenes, and draft prose — with a Claude-powered agent
embedded in the app that can read and edit the same data you can, and an MCP server that embedded in the app that can read and edit the same data you can, and an MCP server that
@@ -21,7 +25,7 @@ same edit.
| `Novelly.Web` | React 19, TypeScript, Vite, TanStack Query, Tailwind v4 | | `Novelly.Web` | React 19, TypeScript, Vite, TanStack Query, Tailwind v4 |
The back end is one project organised by feature, not by layer. Each feature folder — The back end is one project organised by feature, not by layer. Each feature folder —
`Projects/`, `Characters/`, `Chapters/`, `Beats/`, `Scenes/`, `Tags/`, `Agent/` — holds its `Novels/`, `Characters/`, `Chapters/`, `Beats/`, `Tags/`, `Locations/`, `Agent/` — holds its
entity, DTOs, service and endpoints together, so adding a capability means touching one entity, DTOs, service and endpoints together, so adding a capability means touching one
folder rather than four. `Common/` holds what genuinely crosses features and `Data/` holds folder rather than four. `Common/` holds what genuinely crosses features and `Data/` holds
the `DbContext` and migrations. the `DbContext` and migrations.
@@ -65,7 +69,7 @@ and everything else keeps working.
### Tests ### Tests
```bash ```bash
dotnet test # 73 tests dotnet test # 174 tests
cd src/Novelly.Web && npm run build # typecheck + bundle cd src/Novelly.Web && npm run build # typecheck + bundle
``` ```
@@ -98,11 +102,11 @@ out of `appsettings.json` and use user-secrets or the environment.
## The data model ## The data model
``` ```
Project ──┬── Character ──┬── CharacterRelationship Novel ──┬── Character ──┬── CharacterRelationship
│ └── CharacterArcStage (the arc: flat, ordered) │ └── CharacterArcStage (the arc: flat, ordered)
├── Chapter ──── Beat (the outline: flat, ordered) ├── Chapter ──── Beat (the outline: flat, ordered)
│ └── Scene (the prose)
├── Tag (applied to characters, chapters and beats) ├── Tag (applied to characters, chapters and beats)
├── Location (applied to chapters)
├── OpenQuestion (attached to a chapter and/or a character) ├── OpenQuestion (attached to a chapter and/or a character)
└── AgentConversation ── AgentMessage └── AgentConversation ── AgentMessage
``` ```
@@ -113,18 +117,20 @@ Project ──┬── Character ──┬── CharacterRelationship
| Column | What goes in it | | Column | What goes in it |
|---|---| |---|---|
| Beat | A three-to-five word handle — "she burns the atlas", not a sentence | | Beat | A three-to-five word handle — "she burns the atlas", not a sentence |
| Character | Whose beat it is. Optional; not every beat belongs to one person | | Characters | Who's in the beat. Optional; not every beat belongs to anyone, and a beat can name more than one |
| What happened | The event itself | | What happened | The event itself |
| What's next | What it sets in motion — the hook into the following beat | | What's next | What it sets in motion — the hook into the following beat |
| Scene | Optional grouping: which scene will carry this beat's prose |
Beats are flat and ordered by `SortOrder` within their chapter. There is no nesting and Beats are flat and ordered by `SortOrder` within their chapter. There is no nesting and
no tree — reordering is one call that takes the beat ids in the order wanted. no tree — reordering is one call that takes the beat ids in the order wanted. The outline
tab also rolls up the distinct set of characters across a chapter's beats under the beat
and word counts, each linking to that character's page — a quick cast list without
opening every beat.
**Beats plan; scenes carry prose.** The two layers are deliberately separate: an outline **Beats plan; the chapter's `Prose` carries the draft.** A chapter has one prose field,
is for working out what happens, and a scene is where you write it. A beat's `SceneId` is written and redrafted in place; `WordCount` is recomputed from it whenever it changes.
the optional link between them, and it is nullable in both directions — deleting a scene There is no separate scene entity — the outline (beats) and the draft (prose) are the
ungroups its beats rather than deleting the plan. two views of the same chapter.
**Main characters carry the book; supporting characters hold it up.** A character's **Main characters carry the book; supporting characters hold it up.** A character's
`Importance` (`Main` or `Supporting`) is separate from their `Role` — role is the part they `Importance` (`Main` or `Supporting`) is separate from their `Role` — role is the part they
@@ -149,19 +155,26 @@ append the decision to the notes of whatever it was attached to — so a settled
up where you re-read it rather than in a list you have stopped looking at. Resolved up where you re-read it rather than in a list you have stopped looking at. Resolved
questions drop off the list unless you ask for them. questions drop off the list unless you ask for them.
**Tags cross-reference the book.** A tag is scoped to one project, unique by name **Tags cross-reference the book.** A tag is scoped to one novel, unique by name
(case-insensitively), and can be attached to any character, chapter or beat. Applying an (case-insensitively), and can be attached to any character, chapter or beat. Applying an
unknown tag by name creates it, so tagging is one action rather than two. `GET unknown tag by name creates it, so tagging is one action rather than two. `GET
/api/tags/{id}/references` returns everything carrying a tag, which is how you trace a /api/tags/{id}/references` returns everything carrying a tag, which is how you trace a
motif or a thread across all three kinds at once. motif or a thread across all three kinds at once.
**A chapter can have several locations.** Locations replaced the old single free-text
`Setting` field: a chapter now carries a multi-select list of locations (where and when
it takes place), each a novel-scoped, name-deduped entity — applying an unknown location
by name creates it, same as tags. A "Locations" tab on the novel lists every location
with its chapter count, and `GET /api/locations/{id}/references` lists every chapter set
there.
## The embedded agent ## The embedded agent
`NovelAgentService` runs the tool-use loop: it calls the Messages API, executes any tools `NovelAgentService` runs the tool-use loop: it calls the Messages API, executes any tools
Claude asks for, feeds every result back in a single user turn, and repeats until Claude Claude asks for, feeds every result back in a single user turn, and repeats until Claude
stops asking. It has 29 tools covering the brief, characters and their arcs, chapter outlines stops asking. It has 33 tools covering the brief, characters and their arcs, chapter
(beats), scenes, tags and open questions — all of them going through the same application outlines (beats), tags, locations and open questions — all of them going through the same
services the REST API uses. application services the REST API uses.
A few deliberate choices worth knowing about: A few deliberate choices worth knowing about:
@@ -179,30 +192,41 @@ A few deliberate choices worth knowing about:
## The MCP server ## The MCP server
A stdio MCP server exposing 38 tools over the same REST API. It holds no domain logic of A stdio MCP server exposing 45 tools over the same REST API. It holds no domain logic of
its own — it is a second front end, not a second implementation. its own — it is a second front end, not a second implementation.
Build it, then point your MCP client at the produced binary: Build it, then point your MCP client at the produced binary:
```bash ```bash
dotnet publish src/Novelly.Mcp -c Release -o ./mcp-server ./scripts/publish-mcp.sh
``` ```
`.mcp.json` (or Claude Desktop's config): Aspire does not run or manage this process — it's a separate stdio subprocess your MCP
client spawns directly, so nothing rebuilds it automatically. Re-run the script (and
reconnect your MCP client) after pulling changes that touch `src/Novelly.Mcp`, or it keeps
serving whatever was published last, including against a stale auth contract.
Copy `.mcp.json.example` to `.mcp.json` (gitignored, since it carries your API key) and
fill in the key:
```jsonc ```jsonc
{ {
"mcpServers": { "mcpServers": {
"novelly": { "novelly": {
"command": "/absolute/path/to/mcp-server/Novelly.Mcp", "command": "/absolute/path/to/mcp-server/Novelly.Mcp",
"env": { "NOVELLY_API_URL": "http://localhost:5080" } "env": {
"NOVELLY_API_URL": "http://localhost:5080",
"NOVELLY_API_KEY": "<matches the API's Auth:ServiceApiKey user secret>"
}
} }
} }
} }
``` ```
The API must be running. If it is not, the tools say so in a message the model can act on The API must be running, with `Auth:ServiceApiKey` set (e.g. via
rather than failing opaquely. `dotnet user-secrets set Auth:ServiceApiKey <key> -p src/Novelly.Api`) to the same value
as `NOVELLY_API_KEY` above. If the API is not running, or the key is missing or mismatched,
the tools say so in a message the model can act on rather than failing opaquely.
### Importing an existing outline ### Importing an existing outline
@@ -219,19 +243,20 @@ running and `.mcp.json` is set up.
| Resource | Routes | | Resource | Routes |
|---|---| |---|---|
| Projects | `GET\|POST /api/projects`, `GET\|PATCH\|DELETE /api/projects/{id}` | | Novels | `GET\|POST /api/novels`, `GET\|PATCH\|DELETE /api/novels/{id}` |
| Characters | `GET\|POST /api/projects/{id}/characters`, `GET\|PATCH\|DELETE /api/characters/{id}`, `POST /api/characters/{id}/relationships` | | Characters | `GET\|POST /api/novels/{id}/characters`, `GET\|PATCH\|DELETE /api/characters/{id}`, `POST /api/characters/{id}/relationships` |
| Chapters | `GET\|POST /api/projects/{id}/chapters`, `GET\|PATCH\|DELETE /api/chapters/{id}` | | Chapters | `GET\|POST /api/novels/{id}/chapters`, `GET\|PATCH\|DELETE /api/chapters/{id}` |
| Beats | `GET\|POST /api/chapters/{id}/beats`, `POST /api/chapters/{id}/beats/reorder`, `GET\|PATCH\|DELETE /api/beats/{id}` | | Beats | `GET\|POST /api/chapters/{id}/beats`, `POST /api/chapters/{id}/beats/reorder`, `GET\|PATCH\|DELETE /api/beats/{id}` |
| Scenes | `GET\|POST /api/chapters/{id}/scenes`, `GET\|PATCH\|DELETE /api/scenes/{id}` | | Tags | `GET\|POST /api/novels/{id}/tags`, `GET /api/tags/{id}/references`, `PATCH\|DELETE /api/tags/{id}` |
| Tags | `GET\|POST /api/projects/{id}/tags`, `GET /api/tags/{id}/references`, `PATCH\|DELETE /api/tags/{id}` | | Locations | `GET\|POST /api/novels/{id}/locations`, `GET /api/locations/{id}/references`, `PATCH\|DELETE /api/locations/{id}` |
| Arcs | `GET\|POST /api/characters/{id}/arc`, `POST /api/characters/{id}/arc/reorder`, `GET\|PATCH\|DELETE /api/arc-stages/{id}` | | Arcs | `GET\|POST /api/characters/{id}/arc`, `POST /api/characters/{id}/arc/reorder`, `GET\|PATCH\|DELETE /api/arc-stages/{id}` |
| Questions | `GET\|POST /api/projects/{id}/questions`, `GET\|PATCH\|DELETE /api/questions/{id}`, `POST /api/questions/{id}/resolve`, `POST /api/questions/{id}/reopen` | | Questions | `GET\|POST /api/novels/{id}/questions`, `GET\|PATCH\|DELETE /api/questions/{id}`, `POST /api/questions/{id}/resolve`, `POST /api/questions/{id}/reopen` |
| Agent | `GET /api/projects/{id}/agent/conversations`, `POST /api/projects/{id}/agent/messages`, `GET\|DELETE /api/conversations/{id}` | | Agent | `GET /api/novels/{id}/agent/conversations`, `POST /api/novels/{id}/agent/messages`, `GET\|DELETE /api/conversations/{id}` |
`PATCH` bodies are partial: an omitted field is left alone, an empty string clears it. A `PATCH` bodies are partial: an omitted field is left alone, an empty string clears it. A
`tags` array replaces that item's tags outright and creates any names the project has not `tags` array replaces that item's tags outright and creates any names the novel has not
seen; omitting it leaves tags untouched. seen; the same is true of a chapter's `locations` array. Omitting either leaves it
untouched.
Enums travel as names (`"Protagonist"`, `"Drafted"`), never ordinals. In development the Enums travel as names (`"Protagonist"`, `"Drafted"`), never ordinals. In development the
OpenAPI document is at `/openapi/v1.json`. OpenAPI document is at `/openapi/v1.json`.
+138
View File
@@ -0,0 +1,138 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="155" height="20">
<style type="text/css">
<![CDATA[
@keyframes fade1 {
0% { visibility: visible; opacity: 1; }
23% { visibility: visible; opacity: 1; }
25% { visibility: hidden; opacity: 0; }
48% { visibility: hidden; opacity: 0; }
50% { visibility: hidden; opacity: 0; }
73% { visibility: hidden; opacity: 0; }
75% { visibility: hidden; opacity: 0; }
98% { visibility: hidden; opacity: 0; }
100% { visibility: visible; opacity: 1; }
}
@keyframes fade2 {
0% { visibility: hidden; opacity: 0; }
23% { visibility: hidden; opacity: 0; }
25% { visibility: visible; opacity: 1; }
48% { visibility: visible; opacity: 1; }
50% { visibility: hidden; opacity: 0; }
73% { visibility: hidden; opacity: 0; }
75% { visibility: hidden; opacity: 0; }
98% { visibility: hidden; opacity: 0; }
100% { visibility: hidden; opacity: 0; }
}
@keyframes fade3 {
0% { visibility: hidden; opacity: 0; }
23% { visibility: hidden; opacity: 0; }
25% { visibility: hidden; opacity: 0; }
48% { visibility: hidden; opacity: 0; }
50% { visibility: visible; opacity: 1; }
73% { visibility: visible; opacity: 1; }
75% { visibility: hidden; opacity: 0; }
98% { visibility: hidden; opacity: 0; }
100% { visibility: hidden; opacity: 0; }
}
@keyframes fade4 {
0% { visibility: hidden; opacity: 0; }
23% { visibility: hidden; opacity: 0; }
25% { visibility: hidden; opacity: 0; }
48% { visibility: hidden; opacity: 0; }
50% { visibility: hidden; opacity: 0; }
73% { visibility: hidden; opacity: 0; }
75% { visibility: visible; opacity: 1; }
98% { visibility: visible; opacity: 1; }
100% { visibility: hidden; opacity: 0; }
}
.linecoverage {
animation-duration: 15s;
animation-name: fade1;
animation-iteration-count: infinite;
}
.branchcoverage {
animation-duration: 15s;
animation-name: fade2;
animation-iteration-count: infinite;
}
.methodcoverage {
animation-duration: 15s;
animation-name: fade3;
animation-iteration-count: infinite;
}
.fullmethodcoverage {
animation-duration: 15s;
animation-name: fade4;
animation-iteration-count: infinite;
}
]]>
</style>
<title>Code coverage</title>
<defs>
<linearGradient id="gradient" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<linearGradient id="c">
<stop offset="0" stop-color="#d40000"/>
<stop offset="1" stop-color="#ff2a2a"/>
</linearGradient>
<linearGradient id="a">
<stop offset="0" stop-color="#e0e0de"/>
<stop offset="1" stop-color="#fff"/>
</linearGradient>
<linearGradient id="b">
<stop offset="0" stop-color="#37c837"/>
<stop offset="1" stop-color="#217821"/>
</linearGradient>
<linearGradient xlink:href="#a" id="e" x1="106.44" x2="69.96" y1="-11.96" y2="-46.84" gradientTransform="matrix(-.8426 -.00045 -.00045 -.8426 -94.27 -75.82)" gradientUnits="userSpaceOnUse"/>
<linearGradient xlink:href="#b" id="f" x1="56.19" x2="77.97" y1="-23.45" y2="10.62" gradientTransform="matrix(.8426 .00045 .00045 .8426 94.27 75.82)" gradientUnits="userSpaceOnUse"/>
<linearGradient xlink:href="#c" id="g" x1="79.98" x2="132.9" y1="10.79" y2="10.79" gradientTransform="matrix(.8426 .00045 .00045 .8426 94.27 75.82)" gradientUnits="userSpaceOnUse"/>
<mask id="mask">
<rect width="155" height="20" rx="3" fill="#fff"/>
</mask>
<g id="icon" transform="matrix(.04486 0 0 .04481 -.48 -.63)">
<rect width="52.92" height="52.92" x="-109.72" y="-27.13" fill="url(#e)" transform="rotate(-135)"/>
<rect width="52.92" height="52.92" x="70.19" y="-39.18" fill="url(#f)" transform="rotate(45)"/>
<rect width="52.92" height="52.92" x="80.05" y="-15.74" fill="url(#g)" transform="rotate(45)"/>
</g>
</defs>
<g mask="url(#mask)">
<rect x="0" y="0" width="90" height="20" fill="#444"/>
<rect x="90" y="0" width="20" height="20" fill="#c00"/>
<rect x="110" y="0" width="45" height="20" fill="#00B600"/>
<rect x="0" y="0" width="155" height="20" fill="url(#gradient)"/>
</g>
<g>
<path class="" stroke="#fff" d="M94 6.5 h12 M94 10.5 h12 M94 14.5 h12"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Arial,Geneva,sans-serif" font-size="11">
<a xlink:href="https://github.com/danielpalme/ReportGenerator" target="_top">
<title>Generated by: ReportGenerator 5.5.11.0</title>
<use xlink:href="#icon" transform="translate(3,1) scale(3.5)"/>
</a>
<text x="53" y="15" fill="#010101" fill-opacity=".3">Coverage</text>
<text x="53" y="14" fill="#fff">Coverage</text>
<text class="" x="132.5" y="15" fill="#010101" fill-opacity=".3">65.7%</text><text class="" x="132.5" y="14">65.7%</text>
</g>
<g>
<rect class="" x="90" y="0" width="65" height="20" fill-opacity="0"><title>Line coverage</title></rect>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB

+43
View File
@@ -0,0 +1,43 @@
name: novelly
# Persistent LAN deployment, pulled and recreated by CI on every push to main.
# Unlike a throwaway QA stack, this one keeps its data volume across deploys — `down`
# is run without `-v` so the author's novel data survives a redeploy.
services:
api:
image: ${API_IMAGE}:latest
restart: unless-stopped
environment:
ConnectionStrings__Novel: "Data Source=/data/novel.db"
Cors__Origins__0: "http://localhost:${WEB_PORT:-6173}"
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
volumes:
- novelly-data:/data
networks:
- novelly
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/api/health || exit 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
web:
image: ${WEB_IMAGE}:latest
restart: unless-stopped
depends_on:
api:
condition: service_healthy
networks:
- novelly
ports:
- "${WEB_PORT:-6173}:80"
networks:
novelly:
name: novelly-net
volumes:
novelly-data:
name: novelly-data
-27
View File
@@ -1,27 +0,0 @@
services:
api:
build:
context: .
dockerfile: src/Novelly.Api/Dockerfile
restart: unless-stopped
environment:
ConnectionStrings__Novel: "Data Source=/data/novel.db"
Cors__Origins__0: "http://localhost:6173"
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
volumes:
- novelly-data:/data
ports:
- "6080:8080"
web:
build:
context: src/Novelly.Web
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
- api
ports:
- "6173:80"
volumes:
novelly-data:
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Compiles the API (Release) and builds the web SPA. Acts as the compile gate before
# tests/image builds run.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
cd "$CI_ROOT"
ensure_dotnet
log "Restoring and publishing Novelly.Api (Release)"
dotnet publish src/Novelly.Api/Novelly.Api.csproj -c Release
log "Installing web dependencies (npm ci)"
npm --prefix src/Novelly.Web ci
log "Building the web client (vite build)"
npm --prefix src/Novelly.Web run build
log "build.sh complete"
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Turns the Cobertura output from test.sh into a badge + summary via reportgenerator,
# prints it, appends a build-report summary when running under Actions, and refreshes
# the coverage badge committed at badges/coverage.svg. Readme embeds that badge via a
# relative path, which resolves on both GitHub and Gitea since the same repo content is
# pushed to both remotes.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
cd "$CI_ROOT"
ensure_dotnet
ensure_reportgenerator
REPORT_DIR="$CI_ROOT/coverage/report"
log "Generating coverage report with reportgenerator"
reportgenerator \
-reports:"coverage/dotnet/coverage.cobertura.xml" \
-targetdir:"$REPORT_DIR" \
-reporttypes:"Badges;MarkdownSummaryGithub;TextSummary"
cat "$REPORT_DIR/Summary.txt"
if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
cat "$REPORT_DIR/SummaryGithub.md" >> "$GITHUB_STEP_SUMMARY"
fi
mkdir -p "$CI_ROOT/badges"
cp "$REPORT_DIR/badge_linecoverage.svg" "$CI_ROOT/badges/coverage.svg"
log "coverage.sh complete"
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Deploys the freshly-pushed :latest images to the persistent LAN novelly stack and
# waits for the API to report healthy. Unlike a throwaway QA stack, `down` is run
# without `-v` — the novelly-data volume (the author's actual novel) must survive
# every redeploy. Recreating containers against an unchanged image is a no-op, so
# this is safe to re-run.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
cd "$CI_ROOT"
image_names
registry_login
export API_IMAGE WEB_IMAGE
export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}"
export WEB_PORT="${WEB_PORT:-6173}"
COMPOSE="docker compose -f deploy/qa/docker-compose.qa.yml"
log "Pulling latest :latest images"
$COMPOSE pull
log "Recreating the novelly stack (data volume preserved)"
$COMPOSE down
$COMPOSE up -d
log "Waiting for the API health check"
attempts=30
until $COMPOSE exec -T api curl -fsS http://localhost:8080/api/health > /dev/null 2>&1; do
attempts=$((attempts - 1))
if [[ "$attempts" -le 0 ]]; then
fail "novelly stack did not become healthy in time"
fi
sleep 2
done
log "novelly deployed and healthy at http://localhost:${WEB_PORT}"
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Builds the API and web images and tags them with both the current git sha and
# "latest" (the tag the deploy compose stack pulls). Both Dockerfiles are already
# self-contained multi-stage builds (used directly by docker-compose.deploy.yml today),
# so no separate CI-only Dockerfile variant is needed.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
cd "$CI_ROOT"
image_names
log "Building $API_IMAGE:$GIT_SHA / :latest"
docker build \
-f src/Novelly.Api/Dockerfile \
-t "$API_IMAGE:$GIT_SHA" \
-t "$API_IMAGE:latest" \
.
log "Building $WEB_IMAGE:$GIT_SHA / :latest"
docker build \
-f src/Novelly.Web/Dockerfile \
-t "$WEB_IMAGE:$GIT_SHA" \
-t "$WEB_IMAGE:latest" \
src/Novelly.Web
log "docker-build.sh complete"
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Pushes the images built by docker-build.sh (git-sha and latest tags) to the Gitea
# container registry.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
cd "$CI_ROOT"
image_names
registry_login
for tag in "$GIT_SHA" latest; do
log "Pushing $API_IMAGE:$tag"
docker push "$API_IMAGE:$tag"
log "Pushing $WEB_IMAGE:$tag"
docker push "$WEB_IMAGE:$tag"
done
log "docker-push.sh complete"
+46
View File
@@ -25,6 +25,10 @@ DOTNET_CHANNEL="${DOTNET_CHANNEL:-10.0}"
# one as a last resort, so a bare runner behaves the same as a dev machine. # one as a last resort, so a bare runner behaves the same as a dev machine.
ensure_dotnet() { ensure_dotnet() {
if command -v dotnet > /dev/null 2>&1; then if command -v dotnet > /dev/null 2>&1; then
# DOTNET_ROOT is unset by default even when dotnet is already on PATH (e.g. a
# per-user install at ~/.dotnet) — apphost binaries like reportgenerator's fail to
# find the runtime without it.
export DOTNET_ROOT="${DOTNET_ROOT:-$(dirname "$(command -v dotnet)")}"
return 0 return 0
fi fi
@@ -51,3 +55,45 @@ ensure_dotnet() {
ensure_node() { ensure_node() {
command -v npm > /dev/null 2>&1 || fail "npm not found on PATH; install Node.js to build the web client" command -v npm > /dev/null 2>&1 || fail "npm not found on PATH; install Node.js to build the web client"
} }
ensure_reportgenerator() {
if command -v reportgenerator > /dev/null 2>&1; then
return 0
fi
local tool_dir="$CI_ROOT/.dotnet-tools"
if [[ ! -x "$tool_dir/reportgenerator" ]]; then
log "reportgenerator not found on PATH; installing dotnet-reportgenerator-globaltool"
dotnet tool install dotnet-reportgenerator-globaltool --tool-path "$tool_dir"
fi
export PATH="$tool_dir:$PATH"
}
# Registry configuration. All values come from the environment (CI secrets or a
# developer's shell) — nothing is hardcoded, per project convention.
REGISTRY="${REGISTRY:-}"
REGISTRY_OWNER="${REGISTRY_OWNER:-}"
REGISTRY_USER="${REGISTRY_USER:-}"
REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
GIT_SHA="$(git -C "$CI_ROOT" rev-parse --short HEAD)"
require_registry_vars() {
[[ -n "$REGISTRY" ]] || fail "REGISTRY env var is required (e.g. git.wampler.us)"
[[ -n "$REGISTRY_OWNER" ]] || fail "REGISTRY_OWNER env var is required (e.g. your gitea org/user)"
}
# Populates API_IMAGE / WEB_IMAGE, e.g. git.wampler.us/wamplerj/novelly-api
image_names() {
require_registry_vars
API_IMAGE="$REGISTRY/$REGISTRY_OWNER/novelly-api"
WEB_IMAGE="$REGISTRY/$REGISTRY_OWNER/novelly-web"
}
registry_login() {
require_registry_vars
[[ -n "$REGISTRY_USER" ]] || fail "REGISTRY_USER env var is required to push images"
[[ -n "$REGISTRY_TOKEN" ]] || fail "REGISTRY_TOKEN env var is required to push images"
log "Logging in to $REGISTRY as $REGISTRY_USER"
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
}
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Commits the coverage badge refreshed by coverage.sh straight back to the
# branch that triggered this run, so readme.md's relative badges/coverage.svg
# link stays current. GITHUB_SERVER_URL/GITHUB_REPOSITORY/GITHUB_REF_NAME are
# default context env vars on both GitHub Actions and Gitea Actions (Gitea's
# engine is GitHub-Actions-compatible); GITHUB_TOKEN must be passed in
# explicitly from the workflow (${{ github.token }}) on both platforms.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
cd "$CI_ROOT"
[[ -n "${GITHUB_TOKEN:-}" ]] || fail "GITHUB_TOKEN env var is required to push the badge commit"
[[ -n "${GITHUB_SERVER_URL:-}" ]] || fail "GITHUB_SERVER_URL env var is required to push the badge commit"
[[ -n "${GITHUB_REPOSITORY:-}" ]] || fail "GITHUB_REPOSITORY env var is required to push the badge commit"
[[ -n "${GITHUB_REF_NAME:-}" ]] || fail "GITHUB_REF_NAME env var is required to push the badge commit"
if git diff --quiet -- badges/coverage.svg; then
log "badges/coverage.svg unchanged; nothing to publish"
exit 0
fi
git config user.name "novelly-ci"
git config user.email "ci@novelly.local"
git add badges/coverage.svg
git commit -m "chore: refresh coverage badge [skip ci]"
remote_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git -c http.extraheader="AUTHORIZATION: bearer ${GITHUB_TOKEN}" push "$remote_url" "HEAD:${GITHUB_REF_NAME}"
log "publish-coverage-badge.sh complete"
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Runs Novelly.Api.Tests with coverage collection. No web test suite exists yet
# (src/Novelly.Web/package.json has no "test" script) — nothing to run there.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
cd "$CI_ROOT"
ensure_dotnet
# Coverlet doesn't clear prior output — on a runner that reuses its workspace
# (self-hosted, unlike GitHub's ephemeral ones), stale coverage from past runs would
# otherwise get merged in by coverage.sh and silently skew the combined percentage.
rm -rf "$CI_ROOT/coverage/dotnet"
log "Running Novelly.Api.Tests"
dotnet test tests/Novelly.Api.Tests/Novelly.Api.Tests.csproj -c Release --logger trx \
/p:CollectCoverage=true /p:CoverletOutputFormat=cobertura \
/p:CoverletOutput="$CI_ROOT/coverage/dotnet/" \
/p:Exclude="[Novelly.ServiceDefaults]*" \
/p:ExcludeByFile="**/Data/Migrations/*.cs"
log "test.sh complete"
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Rebuilds the standalone Novelly.Mcp binary that Claude Code (or Claude Desktop) spawns
# per .mcp.json. Aspire does not run or manage this process, so nothing else rebuilds it —
# run this after pulling changes that touch src/Novelly.Mcp, or the MCP server silently
# keeps serving whatever was published last.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.." && source ./scripts/ci/lib.sh
cd "$CI_ROOT"
ensure_dotnet
log "Publishing Novelly.Mcp to ./mcp-server"
dotnet publish src/Novelly.Mcp -c Release -o ./mcp-server
log "Done. Reconnect the MCP server (e.g. /mcp in Claude Code) to pick up the new build."
+3 -3
View File
@@ -1,14 +1,14 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Projects; using Novelly.Api.Novels;
namespace Novelly.Api.Agent; namespace Novelly.Api.Agent;
public class AgentConversation public class AgentConversation
{ {
public Guid Id { get; init; } = Guid.NewGuid(); public Guid Id { get; init; } = Guid.NewGuid();
public Guid ProjectId { get; init; } public Guid NovelId { get; init; }
public Project? Project { get; init; } public Novel? Novel { get; init; }
public string Title { get; init; } = "New conversation"; public string Title { get; init; } = "New conversation";
+8 -8
View File
@@ -7,22 +7,22 @@ public static class AgentEndpoints
{ {
public static IEndpointRouteBuilder MapAgentEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapAgentEndpoints(this IEndpointRouteBuilder app)
{ {
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/agent").WithTags("Agent") var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/agent").WithTags("Agent")
.AddEndpointFilter<RequestLoggingEndpointFilter>() .AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>(); .AddEndpointFilter<ValidationEndpointFilter>();
projectScoped.MapGet("/conversations", async ( novelScoped.MapGet("/conversations", async (
Guid projectId, NovelAgentService agent, CancellationToken ct) => Guid novelId, NovelAgentService agent, CancellationToken ct) =>
Results.Ok(await agent.ListConversationsAsync(projectId, ct))) Results.Ok(await agent.ListConversationsAsync(novelId, ct)))
.WithSummary("List the project's agent conversations."); .WithSummary("List the novel's agent conversations.");
projectScoped.MapPost("/messages", async ( novelScoped.MapPost("/messages", async (
Guid projectId, Guid novelId,
SendAgentMessageRequest request, SendAgentMessageRequest request,
NovelAgentService agent, NovelAgentService agent,
CancellationToken ct) => CancellationToken ct) =>
{ {
var reply = await agent.SendMessageAsync(projectId, request, ct); var reply = await agent.SendMessageAsync(novelId, request, ct);
return reply is null return reply is null
? Results.NotFound() ? Results.NotFound()
: Results.Ok(new AgentTurnResponse(reply.ConversationId, reply.ToResponse())); : Results.Ok(new AgentTurnResponse(reply.ConversationId, reply.ToResponse()));
+3 -3
View File
@@ -4,9 +4,9 @@ using Novelly.Api.Common.Validation;
namespace Novelly.Api.Agent; namespace Novelly.Api.Agent;
public record ConversationSummaryResponse(Guid Id, Guid ProjectId, string Title, int MessageCount, DateTimeOffset UpdatedAt); public record ConversationSummaryResponse(Guid Id, Guid NovelId, string Title, int MessageCount, DateTimeOffset UpdatedAt);
public record ConversationResponse(Guid Id, Guid ProjectId, string Title, IReadOnlyList<AgentMessageResponse> Messages, DateTimeOffset UpdatedAt); public record ConversationResponse(Guid Id, Guid NovelId, string Title, IReadOnlyList<AgentMessageResponse> Messages, DateTimeOffset UpdatedAt);
public record AgentMessageResponse(Guid Id, AgentRole Role, string Content, IReadOnlyList<ToolCallResponse> ToolCalls, DateTimeOffset CreatedAt); public record AgentMessageResponse(Guid Id, AgentRole Role, string Content, IReadOnlyList<ToolCallResponse> ToolCalls, DateTimeOffset CreatedAt);
@@ -46,7 +46,7 @@ public static class AgentMapping
public static ConversationResponse ToResponse(this AgentConversation conversation) => new( public static ConversationResponse ToResponse(this AgentConversation conversation) => new(
conversation.Id, conversation.Id,
conversation.ProjectId, conversation.NovelId,
conversation.Title, conversation.Title,
[.. conversation.Messages.OrderBy(m => m.Sequence).Select(m => m.ToResponse())], [.. conversation.Messages.OrderBy(m => m.Sequence).Select(m => m.ToResponse())],
conversation.UpdatedAt); conversation.UpdatedAt);
+31 -31
View File
@@ -6,7 +6,7 @@ using Microsoft.Extensions.Options;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
using Novelly.Api.Projects; using Novelly.Api.Novels;
namespace Novelly.Api.Agent; namespace Novelly.Api.Agent;
@@ -26,14 +26,14 @@ public class NovelAgentService(
private readonly AgentOptions _options = options.Value; private readonly AgentOptions _options = options.Value;
public async Task<IReadOnlyList<ConversationSummaryResponse>> ListConversationsAsync( public async Task<IReadOnlyList<ConversationSummaryResponse>> ListConversationsAsync(
Guid projectId, CancellationToken ct = default) Guid novelId, CancellationToken ct = default)
{ {
logger.LogInformation("Listing agent conversations for project {ProjectId}", projectId); logger.LogInformation("Listing agent conversations for novel {NovelId}", novelId);
return await db.Conversations return await db.Conversations
.Where(c => c.ProjectId == projectId) .Where(c => c.NovelId == novelId)
.OrderByDescending(c => c.UpdatedAt) .OrderByDescending(c => c.UpdatedAt)
.Select(c => new ConversationSummaryResponse(c.Id, c.ProjectId, c.Title, c.Messages.Count, c.UpdatedAt)) .Select(c => new ConversationSummaryResponse(c.Id, c.NovelId, c.Title, c.Messages.Count, c.UpdatedAt))
.ToListAsync(ct); .ToListAsync(ct);
} }
@@ -63,39 +63,39 @@ public class NovelAgentService(
return true; return true;
} }
public async Task<AgentMessage?> SendMessageAsync(Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default) public async Task<AgentMessage?> SendMessageAsync(Guid novelId, SendAgentMessageRequest request, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(novelId, nameof(novelId));
Guard.Null(request, nameof(request)); Guard.Null(request, nameof(request));
sendMessageValidator.Validate(request).ThrowIfInvalid(logger); sendMessageValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation( logger.LogInformation(
"Sending agent message for project {ProjectId}, conversation {ConversationId}, message length {MessageLength}", "Sending agent message for novel {NovelId}, conversation {ConversationId}, message length {MessageLength}",
projectId, request.ConversationId, request.Message.Length); novelId, request.ConversationId, request.Message.Length);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct); var novel = await db.Novels.FirstOrDefaultAsync(p => p.Id == novelId, ct);
if (project is null) if (novel is null)
{ {
logger.LogWarning("Project {ProjectId} not found", projectId); logger.LogWarning("Novel {NovelId} not found", novelId);
return null; return null;
} }
var conversation = request.ConversationId is { } id var conversation = request.ConversationId is { } id
? await FindConversationAsync(id, ct) ? await FindConversationAsync(id, ct)
: StartConversation(projectId, request.Message); : StartConversation(novelId, request.Message);
if (conversation is null) return null; if (conversation is null) return null;
await AppendMessageAsync(conversation, AgentRole.User, request.Message, null, ct); await AppendMessageAsync(conversation, AgentRole.User, request.Message, null, ct);
var systemPrompt = BuildSystemPrompt(project); var systemPrompt = BuildSystemPrompt(novel);
var transcript = BuildTranscript(conversation); var transcript = BuildTranscript(conversation);
var toolCalls = new List<ToolCallResponse>(); var toolCalls = new List<ToolCallResponse>();
var text = new StringBuilder(); var text = new StringBuilder();
for (var iteration = 0; iteration < _options.MaxIterations; iteration++) for (var iteration = 0; iteration < _options.MaxIterations; iteration++)
{ {
logger.LogDebug("Agent iteration {Iteration} for project {ProjectId}", iteration, projectId); logger.LogDebug("Agent iteration {Iteration} for novel {NovelId}", iteration, novelId);
var response = await model.CompleteAsync(systemPrompt, transcript, toolset.Definitions, ct); var response = await model.CompleteAsync(systemPrompt, transcript, toolset.Definitions, ct);
@@ -113,9 +113,9 @@ public class NovelAgentService(
var results = new List<AgentContentBlock>(); var results = new List<AgentContentBlock>();
foreach (var call in requestedTools) foreach (var call in requestedTools)
{ {
var outcome = await toolset.ExecuteAsync(call.Name, projectId, call.Input, ct); var outcome = await toolset.ExecuteAsync(call.Name, novelId, call.Input, ct);
logger.Log(outcome.IsError ? LogLevel.Warning : LogLevel.Information, "Agent tool {Tool} on project {ProjectId} {Outcome}", call.Name, projectId, outcome.IsError ? "failed" : "succeeded"); logger.Log(outcome.IsError ? LogLevel.Warning : LogLevel.Information, "Agent tool {Tool} on novel {NovelId} {Outcome}", call.Name, novelId, outcome.IsError ? "failed" : "succeeded");
toolCalls.Add(new ToolCallResponse(call.Name, call.Input.ToString(), outcome.Content)); toolCalls.Add(new ToolCallResponse(call.Name, call.Input.ToString(), outcome.Content));
results.Add(new AgentToolResultBlock(call.Id, outcome.Content, outcome.IsError)); results.Add(new AgentToolResultBlock(call.Id, outcome.Content, outcome.IsError));
@@ -125,7 +125,7 @@ public class NovelAgentService(
if (iteration != _options.MaxIterations - 1) continue; if (iteration != _options.MaxIterations - 1) continue;
logger.LogWarning("Agent hit the {Max}-iteration ceiling on project {ProjectId}", _options.MaxIterations, projectId); logger.LogWarning("Agent hit the {Max}-iteration ceiling on novel {NovelId}", _options.MaxIterations, novelId);
text.AppendLine("_I reached my tool-call limit for this turn. Ask me to continue if there's more to do._"); text.AppendLine("_I reached my tool-call limit for this turn. Ask me to continue if there's more to do._");
} }
@@ -165,19 +165,19 @@ public class NovelAgentService(
return message; return message;
} }
private AgentConversation StartConversation(Guid projectId, string firstMessage) private AgentConversation StartConversation(Guid novelId, string firstMessage)
{ {
logger.LogDebug("Starting new agent conversation for project {ProjectId}", projectId); logger.LogDebug("Starting new agent conversation for novel {NovelId}", novelId);
var conversation = new AgentConversation var conversation = new AgentConversation
{ {
ProjectId = projectId, NovelId = novelId,
Title = Summarise(firstMessage) Title = Summarise(firstMessage)
}; };
db.Conversations.Add(conversation); db.Conversations.Add(conversation);
logger.LogDebug("Started agent conversation {ConversationId} for project {ProjectId}", conversation.Id, projectId); logger.LogDebug("Started agent conversation {ConversationId} for novel {NovelId}", conversation.Id, novelId);
return conversation; return conversation;
} }
@@ -209,25 +209,25 @@ public class NovelAgentService(
[new AgentTextBlock(m.Content)])) [new AgentTextBlock(m.Content)]))
]; ];
private static string BuildSystemPrompt(Project project) private static string BuildSystemPrompt(Novel novel)
{ {
var brief = new StringBuilder(); var brief = new StringBuilder();
brief.AppendLine($"Title: {project.Title}"); brief.AppendLine($"Title: {novel.Title}");
if (!string.IsNullOrWhiteSpace(project.Genre)) brief.AppendLine($"Genre: {project.Genre}"); if (!string.IsNullOrWhiteSpace(novel.Genre)) brief.AppendLine($"Genre: {novel.Genre}");
if (!string.IsNullOrWhiteSpace(project.Logline)) brief.AppendLine($"Logline: {project.Logline}"); if (!string.IsNullOrWhiteSpace(novel.Logline)) brief.AppendLine($"Logline: {novel.Logline}");
if (project.TargetWordCount is { } target) brief.AppendLine($"Target length: {target:N0} words"); if (novel.TargetWordCount is { } target) brief.AppendLine($"Target length: {target:N0} words");
return $""" return $"""
You are a developmental editor and writing partner embedded in the software the You are a developmental editor and writing partner embedded in the software the
writer is using to plan their novel. You have tools that read and write the writer is using to plan their novel. You have tools that read and write the
project's real data: the brief, character dossiers, the outline (beats) and each novel's real data: the brief, character dossiers, the outline (beats) and each
chapter's drafted prose. chapter's drafted prose.
The project you are working on: The novel you are working on:
{brief} {brief}
Working principles: Working principles:
- Read before you write. Call get_project_brief, get_outline, or list_characters - Read before you write. Call get_novel_brief, get_outline, or list_characters
to ground yourself rather than assuming what is already there. to ground yourself rather than assuming what is already there.
- The book is the writer's. Ask about the choices that define the story — what a - The book is the writer's. Ask about the choices that define the story — what a
character wants, what the ending costs them — instead of deciding for them. character wants, what the ending costs them — instead of deciding for them.
@@ -239,7 +239,7 @@ public class NovelAgentService(
genuinely in tension, what the outline is missing — over line-level polish, genuinely in tension, what the outline is missing — over line-level polish,
unless the writer asks for prose. unless the writer asks for prose.
- When drafting a chapter's prose, match the voice already established in the - When drafting a chapter's prose, match the voice already established in the
project. Write the chapter, then stop; do not append notes about your choices. novel. Write the chapter, then stop; do not append notes about your choices.
- Destructive operations (deleting outline nodes) need the writer's explicit - Destructive operations (deleting outline nodes) need the writer's explicit
go-ahead first. go-ahead first.
+60 -39
View File
@@ -3,7 +3,8 @@ using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Projects; using Novelly.Api.Locations;
using Novelly.Api.Novels;
using Novelly.Api.Questions; using Novelly.Api.Questions;
using Novelly.Api.Tags; using Novelly.Api.Tags;
@@ -23,12 +24,13 @@ public record AgentTool(
Func<Guid, JsonElement, CancellationToken, Task<object?>> Handler); Func<Guid, JsonElement, CancellationToken, Task<object?>> Handler);
public class NovelAgentToolset( public class NovelAgentToolset(
ProjectService projects, NovelService novels,
CharacterService characters, CharacterService characters,
CharacterArcService arcs, CharacterArcService arcs,
ChapterService chapters, ChapterService chapters,
BeatService beats, BeatService beats,
TagService tags, TagService tags,
LocationService locations,
OpenQuestionService questions, OpenQuestionService questions,
ILogger<NovelAgentToolset> logger) ILogger<NovelAgentToolset> logger)
{ {
@@ -45,7 +47,7 @@ public class NovelAgentToolset(
public IReadOnlyList<AgentToolDefinition> Definitions => public IReadOnlyList<AgentToolDefinition> Definitions =>
[.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))]; [.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))];
public async Task<AgentToolResult> ExecuteAsync(string name, Guid projectId, JsonElement input, CancellationToken ct = default) public async Task<AgentToolResult> ExecuteAsync(string name, Guid novelId, JsonElement input, CancellationToken ct = default)
{ {
if (!ByName.TryGetValue(name, out var tool)) if (!ByName.TryGetValue(name, out var tool))
{ {
@@ -53,29 +55,29 @@ public class NovelAgentToolset(
return new AgentToolResult($"No such tool: '{name}'.", true); return new AgentToolResult($"No such tool: '{name}'.", true);
} }
logger.LogDebug("Running tool {Tool} for project {ProjectId}", name, projectId); logger.LogDebug("Running tool {Tool} for novel {NovelId}", name, novelId);
try try
{ {
var result = await tool.Handler(projectId, input, ct); var result = await tool.Handler(novelId, input, ct);
if (result is ToolNotFound notFound) if (result is ToolNotFound notFound)
{ {
logger.LogWarning("Tool {Tool} for project {ProjectId} found no {Entity} {EntityId}", name, projectId, notFound.Entity, notFound.Id); logger.LogWarning("Tool {Tool} for novel {NovelId} found no {Entity} {EntityId}", name, novelId, notFound.Entity, notFound.Id);
return new AgentToolResult(notFound.Message, true); return new AgentToolResult(notFound.Message, true);
} }
logger.LogDebug("Tool {Tool} for project {ProjectId} succeeded", name, projectId); logger.LogDebug("Tool {Tool} for novel {NovelId} succeeded", name, novelId);
return new AgentToolResult(JsonSerializer.Serialize(result, SerializerOptions), false); return new AgentToolResult(JsonSerializer.Serialize(result, SerializerOptions), false);
} }
catch (ArgumentException ex) catch (ArgumentException ex)
{ {
logger.LogWarning(ex, "Tool {Tool} for project {ProjectId} failed: invalid argument", name, projectId); logger.LogWarning(ex, "Tool {Tool} for novel {NovelId} failed: invalid argument", name, novelId);
return new AgentToolResult(ex.Message, true); return new AgentToolResult(ex.Message, true);
} }
catch (InvalidOperationException ex) catch (InvalidOperationException ex)
{ {
logger.LogWarning(ex, "Tool {Tool} for project {ProjectId} failed: invalid operation", name, projectId); logger.LogWarning(ex, "Tool {Tool} for novel {NovelId} failed: invalid operation", name, novelId);
return new AgentToolResult(ex.Message, true); return new AgentToolResult(ex.Message, true);
} }
} }
@@ -95,15 +97,15 @@ public class NovelAgentToolset(
private IEnumerable<AgentTool> Build() private IEnumerable<AgentTool> Build()
{ {
yield return new AgentTool( yield return new AgentTool(
"get_project_brief", "get_novel_brief",
"Read the project's title, logline, synopsis, genre, notes and word-count target. " "Read the novel's title, logline, synopsis, genre, notes and word-count target. "
+ "Call this first in a conversation to ground yourself in what the book is.", + "Call this first in a conversation to ground yourself in what the book is.",
new JsonSchemaBuilder().Build(), new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => await OrNotFound(projects.GetAsync(projectId, ct), p => p.ToResponse(null), "Project", projectId)); async (novelId, _, ct) => await OrNotFound(novels.GetAsync(novelId, ct), p => p.ToResponse(null), "Novel", novelId));
yield return new AgentTool( yield return new AgentTool(
"update_project_brief", "update_novel_brief",
"Revise the project's top-level fields. Only the fields you supply change; " "Revise the novel's top-level fields. Only the fields you supply change; "
+ "pass an empty string to clear a field.", + "pass an empty string to clear a field.",
new JsonSchemaBuilder() new JsonSchemaBuilder()
.Str("title", "New title.") .Str("title", "New title.")
@@ -114,27 +116,27 @@ public class NovelAgentToolset(
.Str("notes", "Free-form notes on theme, tone, comparable titles.") .Str("notes", "Free-form notes on theme, tone, comparable titles.")
.Int("target_word_count", "Target manuscript length in words.") .Int("target_word_count", "Target manuscript length in words.")
.Build(), .Build(),
async (projectId, input, ct) => await OrNotFound(projects.UpdateAsync(projectId, new UpdateProjectRequest( async (novelId, input, ct) => await OrNotFound(novels.UpdateAsync(novelId, new UpdateNovelRequest(
JsonInput.String(input, "title"), JsonInput.String(input, "title"),
JsonInput.String(input, "author"), JsonInput.String(input, "author"),
JsonInput.String(input, "genre"), JsonInput.String(input, "genre"),
JsonInput.String(input, "logline"), JsonInput.String(input, "logline"),
JsonInput.String(input, "synopsis"), JsonInput.String(input, "synopsis"),
JsonInput.String(input, "notes"), JsonInput.String(input, "notes"),
JsonInput.Int(input, "target_word_count")), ct), p => p.ToResponse(null), "Project", projectId)); JsonInput.Int(input, "target_word_count")), ct), p => p.ToResponse(null), "Novel", novelId));
yield return new AgentTool( yield return new AgentTool(
"list_characters", "list_characters",
"List every character in the project with their full dossiers.", "List every character in the novel with their full dossiers.",
new JsonSchemaBuilder().Build(), new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => (await characters.ListAsync(projectId, ct)).Select(c => c.ToResponse())); async (novelId, _, ct) => (await characters.ListAsync(novelId, ct)).Select(c => c.ToResponse()));
yield return new AgentTool( yield return new AgentTool(
"create_character", "create_character",
"Add a character dossier. Name is the only requirement — leave fields blank when " "Add a character dossier. Name is the only requirement — leave fields blank when "
+ "the writer has not decided them yet rather than inventing detail.", + "the writer has not decided them yet rather than inventing detail.",
CharacterSchema(includeName: true, nameRequired: true).Build(), CharacterSchema(includeName: true, nameRequired: true).Build(),
async (projectId, input, ct) => await OrNotFound(characters.CreateAsync(projectId, new CreateCharacterRequest( async (novelId, input, ct) => await OrNotFound(characters.CreateAsync(novelId, new CreateCharacterRequest(
JsonInput.RequiredString(input, "name"), JsonInput.RequiredString(input, "name"),
JsonInput.Enum<CharacterRole>(input, "role") ?? CharacterRole.Supporting, JsonInput.Enum<CharacterRole>(input, "role") ?? CharacterRole.Supporting,
JsonInput.Enum<CharacterImportance>(input, "importance") ?? CharacterImportance.Supporting, JsonInput.Enum<CharacterImportance>(input, "importance") ?? CharacterImportance.Supporting,
@@ -152,7 +154,7 @@ public class NovelAgentToolset(
JsonInput.String(input, "voice"), JsonInput.String(input, "voice"),
JsonInput.String(input, "notes"), JsonInput.String(input, "notes"),
JsonInput.Strings(input, "tags"), JsonInput.Strings(input, "tags"),
JsonInput.Strings(input, "aliases")), ct), c => c.ToResponse(), "Project", projectId)); JsonInput.Strings(input, "aliases")), ct), c => c.ToResponse(), "Novel", novelId));
yield return new AgentTool( yield return new AgentTool(
"update_character", "update_character",
@@ -189,7 +191,7 @@ public class NovelAgentToolset(
yield return new AgentTool( yield return new AgentTool(
"link_character_identity", "link_character_identity",
"Record that a character is really another character — e.g. one introduced under one name " "Record that a character is really another character — e.g. one introduced under one name "
+ "who is later revealed to be a character already in the project under another name. Both " + "who is later revealed to be a character already in the novel under another name. Both "
+ "keep their own dossier and beats; the canonical identity is whichever character you link to.", + "keep their own dossier and beats; the canonical identity is whichever character you link to.",
new JsonSchemaBuilder() new JsonSchemaBuilder()
.Str("character_id", "Id of the character being revealed as someone else.", required: true) .Str("character_id", "Id of the character being revealed as someone else.", required: true)
@@ -348,10 +350,10 @@ public class NovelAgentToolset(
yield return new AgentTool( yield return new AgentTool(
"list_tags", "list_tags",
"List the project's tags with how many characters, chapters and beats carry each. " "List the novel's tags with how many characters, chapters and beats carry each. "
+ "Read this before inventing a new tag so you reuse the writer's vocabulary.", + "Read this before inventing a new tag so you reuse the writer's vocabulary.",
new JsonSchemaBuilder().Build(), new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => await tags.ListAsync(projectId, ct)); async (novelId, _, ct) => await tags.ListAsync(novelId, ct));
yield return new AgentTool( yield return new AgentTool(
"get_tag_references", "get_tag_references",
@@ -367,10 +369,29 @@ public class NovelAgentToolset(
}); });
yield return new AgentTool( yield return new AgentTool(
"list_chapters", "list_locations",
"List the project's chapters in manuscript order with beat and word counts.", "List the novel's locations with how many chapters are set there. "
+ "Read this before inventing a new location so you reuse the writer's vocabulary.",
new JsonSchemaBuilder().Build(), new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => (await chapters.ListAsync(projectId, ct)).Select(c => c.ToSummaryResponse())); async (novelId, _, ct) => await locations.ListAsync(novelId, ct));
yield return new AgentTool(
"get_location_references",
"Cross-reference a location: every chapter set there.",
new JsonSchemaBuilder()
.Str("location_id", "Id of the location to trace.", required: true)
.Build(),
async (_, input, ct) =>
{
var locationId = JsonInput.RequiredGuid(input, "location_id");
return await OrNotFound(locations.GetReferencesAsync(locationId, ct), l => l.ToReferencesResponse(), "Location", locationId);
});
yield return new AgentTool(
"list_chapters",
"List the novel's chapters in manuscript order with beat and word counts.",
new JsonSchemaBuilder().Build(),
async (novelId, _, ct) => (await chapters.ListAsync(novelId, ct)).Select(c => c.ToSummaryResponse()));
yield return new AgentTool( yield return new AgentTool(
"get_chapter", "get_chapter",
@@ -391,27 +412,27 @@ public class NovelAgentToolset(
.Str("title", "Chapter title.", required: true) .Str("title", "Chapter title.", required: true)
.Int("number", "Position in the manuscript, 1-based.") .Int("number", "Position in the manuscript, 1-based.")
.Str("summary", "What the chapter covers.") .Str("summary", "What the chapter covers.")
.Str("setting", "Where and when the chapter takes place.") .StringArray("locations", "Where and when the chapter takes place. Unknown locations are created.")
.Str("notes", "Anything else worth recording.") .Str("notes", "Anything else worth recording.")
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>()) .Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
.Int("target_word_count", "Target length in words.") .Int("target_word_count", "Target length in words.")
.Str("prose", "The chapter's drafted text, in markdown, if you are writing it now.") .Str("prose", "The chapter's drafted text, in markdown, if you are writing it now.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.") .StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
.Build(), .Build(),
async (projectId, input, ct) => await OrNotFound(chapters.CreateAsync(projectId, new CreateChapterRequest( async (novelId, input, ct) => await OrNotFound(chapters.CreateAsync(novelId, new CreateChapterRequest(
JsonInput.RequiredString(input, "title"), JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "number"), JsonInput.Int(input, "number"),
JsonInput.String(input, "summary"), JsonInput.String(input, "summary"),
JsonInput.String(input, "setting"), JsonInput.Strings(input, "locations"),
JsonInput.String(input, "notes"), JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned, JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
JsonInput.Int(input, "target_word_count"), JsonInput.Int(input, "target_word_count"),
JsonInput.String(input, "prose"), JsonInput.String(input, "prose"),
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Project", projectId)); JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Novel", novelId));
yield return new AgentTool( yield return new AgentTool(
"update_chapter", "update_chapter",
"Revise a chapter's title, number, summary, setting, notes, status or drafted " "Revise a chapter's title, number, summary, locations, notes, status or drafted "
+ "prose. Use 'prose' to write or replace the chapter's draft text in markdown; the " + "prose. Use 'prose' to write or replace the chapter's draft text in markdown; the "
+ "word count is recomputed automatically.", + "word count is recomputed automatically.",
new JsonSchemaBuilder() new JsonSchemaBuilder()
@@ -419,7 +440,7 @@ public class NovelAgentToolset(
.Str("title", "New title.") .Str("title", "New title.")
.Int("number", "Position in the manuscript.") .Int("number", "Position in the manuscript.")
.Str("summary", "What the chapter covers.") .Str("summary", "What the chapter covers.")
.Str("setting", "Where and when the chapter takes place.") .StringArray("locations", "Where and when the chapter takes place. Replaces the existing locations. Unknown locations are created.")
.Str("notes", "Anything else worth recording.") .Str("notes", "Anything else worth recording.")
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>()) .Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
.Int("target_word_count", "Target length in words.") .Int("target_word_count", "Target length in words.")
@@ -435,7 +456,7 @@ public class NovelAgentToolset(
JsonInput.String(input, "title"), JsonInput.String(input, "title"),
JsonInput.Int(input, "number"), JsonInput.Int(input, "number"),
JsonInput.String(input, "summary"), JsonInput.String(input, "summary"),
JsonInput.String(input, "setting"), JsonInput.Strings(input, "locations"),
JsonInput.String(input, "notes"), JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status"), JsonInput.Enum<DraftStatus>(input, "status"),
JsonInput.Int(input, "target_word_count"), JsonInput.Int(input, "target_word_count"),
@@ -456,7 +477,7 @@ public class NovelAgentToolset(
var characterId = JsonInput.RequiredGuid(input, "character_id"); var characterId = JsonInput.RequiredGuid(input, "character_id");
return await OrNotFound( return await OrNotFound(
beats.ListForCharacterAsync(characterId, ct), beats.ListForCharacterAsync(characterId, ct),
list => list.Select(b => b.ToCharacterBeatResponse()), list => list.Select(b => b.ToCharacterBeatResponse(characterId)),
"Character", "Character",
characterId); characterId);
}); });
@@ -548,8 +569,8 @@ public class NovelAgentToolset(
.Str("character_id", "Narrow to questions about one character.") .Str("character_id", "Narrow to questions about one character.")
.Bool("include_resolved", "Include questions already settled. Defaults to false.") .Bool("include_resolved", "Include questions already settled. Defaults to false.")
.Build(), .Build(),
async (projectId, input, ct) => (await questions.ListAsync( async (novelId, input, ct) => (await questions.ListAsync(
projectId, novelId,
JsonInput.Guid(input, "chapter_id"), JsonInput.Guid(input, "chapter_id"),
JsonInput.Guid(input, "character_id"), JsonInput.Guid(input, "character_id"),
JsonInput.Bool(input, "include_resolved") ?? false, JsonInput.Bool(input, "include_resolved") ?? false,
@@ -566,13 +587,13 @@ public class NovelAgentToolset(
.Str("chapter_id", "The chapter outline this is about, if any.") .Str("chapter_id", "The chapter outline this is about, if any.")
.Str("character_id", "The character this is about, if any.") .Str("character_id", "The character this is about, if any.")
.Build(), .Build(),
async (projectId, input, ct) => await OrNotFound(questions.CreateAsync( async (novelId, input, ct) => await OrNotFound(questions.CreateAsync(
projectId, novelId,
new CreateOpenQuestionRequest( new CreateOpenQuestionRequest(
JsonInput.RequiredString(input, "question"), JsonInput.RequiredString(input, "question"),
JsonInput.String(input, "detail"), JsonInput.String(input, "detail"),
JsonInput.Guid(input, "chapter_id"), JsonInput.Guid(input, "chapter_id"),
JsonInput.Guid(input, "character_id")), ct), q => q.ToResponse(), "Project", projectId)); JsonInput.Guid(input, "character_id")), ct), q => q.ToResponse(), "Novel", novelId));
yield return new AgentTool( yield return new AgentTool(
"resolve_open_question", "resolve_open_question",
+2
View File
@@ -19,6 +19,8 @@ public class Beat
public List<Character> Characters { get; set; } = []; public List<Character> Characters { get; set; } = [];
public List<CharacterArcStage> ArcStages { get; set; } = [];
public string? WhatHappened { get; set; } public string? WhatHappened { get; set; }
public string? WhatsNext { get; set; } public string? WhatsNext { get; set; }
+5 -3
View File
@@ -84,7 +84,8 @@ public record CharacterBeatResponse(
int SortOrder, int SortOrder,
string Title, string Title,
string? WhatHappened, string? WhatHappened,
string? WhatsNext); string? WhatsNext,
Guid? ArcStageId);
public record ReorderBeatsRequest(IReadOnlyList<Guid> BeatIds); public record ReorderBeatsRequest(IReadOnlyList<Guid> BeatIds);
@@ -150,7 +151,7 @@ public static class BeatMapping
[.. b.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())], [.. b.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
b.UpdatedAt); b.UpdatedAt);
public static CharacterBeatResponse ToCharacterBeatResponse(this Beat b) => new( public static CharacterBeatResponse ToCharacterBeatResponse(this Beat b, Guid characterId) => new(
b.Id, b.Id,
b.ChapterId, b.ChapterId,
b.Chapter?.Number ?? 0, b.Chapter?.Number ?? 0,
@@ -158,5 +159,6 @@ public static class BeatMapping
b.SortOrder, b.SortOrder,
b.Title, b.Title,
b.WhatHappened, b.WhatHappened,
b.WhatsNext); b.WhatsNext,
b.ArcStages.FirstOrDefault(s => s.CharacterId == characterId)?.Id);
} }
+1 -1
View File
@@ -46,7 +46,7 @@ public static class BeatEndpoints
app.MapGet("/api/characters/{characterId:guid}/beats", async ( app.MapGet("/api/characters/{characterId:guid}/beats", async (
Guid characterId, BeatService service, CancellationToken ct) => Guid characterId, BeatService service, CancellationToken ct) =>
(await service.ListForCharacterAsync(characterId, ct))?.Select(b => b.ToCharacterBeatResponse()).ToList().ToApiResult()) (await service.ListForCharacterAsync(characterId, ct))?.Select(b => b.ToCharacterBeatResponse(characterId)).ToList().ToApiResult())
.WithTags("Beats") .WithTags("Beats")
.WithSummary("Every beat this character appears in, in manuscript order."); .WithSummary("Every beat this character appears in, in manuscript order.");
+48 -33
View File
@@ -11,7 +11,7 @@ namespace Novelly.Api.Beats;
public class BeatService( public class BeatService(
INovelDbContext db, INovelDbContext db,
ProjectAccessService access, NovelAccessService access,
TagService tags, TagService tags,
ILogger<BeatService> logger, ILogger<BeatService> logger,
IModelValidator<CreateBeatRequest> createValidator, IModelValidator<CreateBeatRequest> createValidator,
@@ -26,7 +26,7 @@ public class BeatService(
logger.LogInformation("Listing beats for chapter {ChapterId}", chapterId); logger.LogInformation("Listing beats for chapter {ChapterId}", chapterId);
await RequireChapterAccessAsync(chapterId, ProjectPermission.Read, ct); await RequireChapterAccessAsync(chapterId, NovelPermission.Read, ct);
return await Query() return await Query()
.Where(b => b.ChapterId == chapterId) .Where(b => b.ChapterId == chapterId)
@@ -46,7 +46,7 @@ public class BeatService(
return null; return null;
} }
await RequireBeatAccessAsync(beat, ProjectPermission.Read, ct); await RequireBeatAccessAsync(beat, NovelPermission.Read, ct);
return beat; return beat;
} }
@@ -57,17 +57,18 @@ public class BeatService(
logger.LogInformation("Listing beats for character {CharacterId}", characterId); logger.LogInformation("Listing beats for character {CharacterId}", characterId);
var characterProjectId = await db.Characters.Where(c => c.Id == characterId).Select(c => (Guid?)c.ProjectId).FirstOrDefaultAsync(ct); var characterNovelId = await db.Characters.Where(c => c.Id == characterId).Select(c => (Guid?)c.NovelId).FirstOrDefaultAsync(ct);
if (characterProjectId is null) if (characterNovelId is null)
{ {
logger.LogWarning("Character {CharacterId} not found", characterId); logger.LogWarning("Character {CharacterId} not found", characterId);
return null; return null;
} }
await access.RequireAsync(characterProjectId.Value, ProjectPermission.Read, ct); await access.RequireAsync(characterNovelId.Value, NovelPermission.Read, ct);
var beats = await db.Beats var beats = await db.Beats
.Include(b => b.Chapter) .Include(b => b.Chapter)
.Include(b => b.ArcStages)
.Where(b => b.Characters.Any(c => c.Id == characterId)) .Where(b => b.Characters.Any(c => c.Id == characterId))
.ToListAsync(ct); .ToListAsync(ct);
@@ -94,7 +95,7 @@ public class BeatService(
return null; return null;
} }
await access.RequireAsync(chapter.ProjectId, ProjectPermission.CreateContent, ct); await access.RequireAsync(chapter.NovelId, NovelPermission.CreateContent, ct);
var beat = new Beat var beat = new Beat
{ {
@@ -107,14 +108,16 @@ public class BeatService(
if (request.CharacterIds is { } characterIds) if (request.CharacterIds is { } characterIds)
{ {
beat.Characters = await ResolveCharactersAsync(chapter.ProjectId, characterIds, ct); beat.Characters = await ResolveCharactersAsync(chapter.NovelId, characterIds, ct);
} }
if (request.Tags is { } names) if (request.Tags is { } names)
{ {
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct); beat.Tags = await tags.ResolveAsync(chapter.NovelId, names, ct);
} }
chapter.UpdatedAt = DateTimeOffset.UtcNow;
db.Beats.Add(beat); db.Beats.Add(beat);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -142,22 +145,23 @@ public class BeatService(
return null; return null;
} }
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(chapter.NovelId, NovelPermission.Write, ct);
beat.Title = Patch.Apply(beat.Title, request.Title) ?? beat.Title; beat.Title = Patch.Apply(beat.Title, request.Title) ?? beat.Title;
beat.SortOrder = request.SortOrder ?? beat.SortOrder; beat.SortOrder = request.SortOrder ?? beat.SortOrder;
beat.WhatHappened = Patch.Apply(beat.WhatHappened, request.WhatHappened); beat.WhatHappened = Patch.Apply(beat.WhatHappened, request.WhatHappened);
beat.WhatsNext = Patch.Apply(beat.WhatsNext, request.WhatsNext); beat.WhatsNext = Patch.Apply(beat.WhatsNext, request.WhatsNext);
beat.UpdatedAt = DateTimeOffset.UtcNow; beat.UpdatedAt = DateTimeOffset.UtcNow;
chapter.UpdatedAt = beat.UpdatedAt;
if (request.CharacterIds is { } characterIds) if (request.CharacterIds is { } characterIds)
{ {
beat.Characters = await ResolveCharactersAsync(chapter.ProjectId, characterIds, ct); beat.Characters = await ResolveCharactersAsync(chapter.NovelId, characterIds, ct);
} }
if (request.Tags is { } names) if (request.Tags is { } names)
{ {
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct); beat.Tags = await tags.ResolveAsync(chapter.NovelId, names, ct);
} }
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -176,7 +180,10 @@ public class BeatService(
return false; return false;
} }
await RequireBeatAccessAsync(beat, ProjectPermission.DeleteContent, ct); await RequireBeatAccessAsync(beat, NovelPermission.DeleteContent, ct);
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct);
if (chapter is not null) chapter.UpdatedAt = DateTimeOffset.UtcNow;
db.Beats.Remove(beat); db.Beats.Remove(beat);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -192,7 +199,7 @@ public class BeatService(
logger.LogInformation("Reordering {Count} beats for chapter {ChapterId}", request.BeatIds.Count, chapterId); logger.LogInformation("Reordering {Count} beats for chapter {ChapterId}", request.BeatIds.Count, chapterId);
await RequireChapterAccessAsync(chapterId, ProjectPermission.Write, ct); await RequireChapterAccessAsync(chapterId, NovelPermission.Write, ct);
var beats = await db.Beats.Where(b => b.ChapterId == chapterId).ToListAsync(ct); var beats = await db.Beats.Where(b => b.ChapterId == chapterId).ToListAsync(ct);
@@ -214,6 +221,9 @@ public class BeatService(
beat.SortOrder = order++; beat.SortOrder = order++;
} }
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct);
if (chapter is not null) chapter.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return await ListAsync(chapterId, ct); return await ListAsync(chapterId, ct);
} }
@@ -236,15 +246,15 @@ public class BeatService(
return null; return null;
} }
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(chapter.NovelId, NovelPermission.Write, ct);
var character = await db.Characters var character = await db.Characters
.FirstOrDefaultAsync(c => c.Id == request.CharacterId && c.ProjectId == chapter.ProjectId, ct); .FirstOrDefaultAsync(c => c.Id == request.CharacterId && c.NovelId == chapter.NovelId, ct);
if (character is null) if (character is null)
{ {
logger.LogWarning( logger.LogWarning(
"Rejected character assignment: character {CharacterId} not found in project {ProjectId}", "Rejected character assignment: character {CharacterId} not found in novel {NovelId}",
request.CharacterId, chapter.ProjectId); request.CharacterId, chapter.NovelId);
return null; return null;
} }
@@ -261,6 +271,7 @@ public class BeatService(
{ {
beat.Characters.Add(character); beat.Characters.Add(character);
beat.UpdatedAt = DateTimeOffset.UtcNow; beat.UpdatedAt = DateTimeOffset.UtcNow;
chapter.UpdatedAt = beat.UpdatedAt;
} }
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -286,16 +297,16 @@ public class BeatService(
} }
var targetChapter = await db.Chapters.FirstOrDefaultAsync( var targetChapter = await db.Chapters.FirstOrDefaultAsync(
c => c.Id == request.TargetChapterId && c.ProjectId == chapter.ProjectId, ct); c => c.Id == request.TargetChapterId && c.NovelId == chapter.NovelId, ct);
if (targetChapter is null) if (targetChapter is null)
{ {
logger.LogWarning( logger.LogWarning(
"Rejected beat move: target chapter {TargetChapterId} not found in project {ProjectId}", "Rejected beat move: target chapter {TargetChapterId} not found in novel {NovelId}",
request.TargetChapterId, chapter.ProjectId); request.TargetChapterId, chapter.NovelId);
return null; return null;
} }
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(chapter.NovelId, NovelPermission.Write, ct);
var beats = await Query().Where(b => b.ChapterId == chapterId && request.BeatIds.Contains(b.Id)).ToListAsync(ct); var beats = await Query().Where(b => b.ChapterId == chapterId && request.BeatIds.Contains(b.Id)).ToListAsync(ct);
var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList(); var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList();
@@ -311,21 +322,25 @@ public class BeatService(
} }
var nextSortOrder = await NextSortOrderAsync(request.TargetChapterId, ct); var nextSortOrder = await NextSortOrderAsync(request.TargetChapterId, ct);
var now = DateTimeOffset.UtcNow;
foreach (var beatId in request.BeatIds) foreach (var beatId in request.BeatIds)
{ {
var beat = beats.Single(b => b.Id == beatId); var beat = beats.Single(b => b.Id == beatId);
beat.ChapterId = request.TargetChapterId; beat.ChapterId = request.TargetChapterId;
beat.SortOrder = nextSortOrder++; beat.SortOrder = nextSortOrder++;
beat.UpdatedAt = DateTimeOffset.UtcNow; beat.UpdatedAt = now;
} }
chapter.UpdatedAt = now;
targetChapter.UpdatedAt = now;
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return beats; return beats;
} }
private async Task<List<Character>> ResolveCharactersAsync(Guid projectId, IReadOnlyList<Guid> characterIds, CancellationToken ct) private async Task<List<Character>> ResolveCharactersAsync(Guid novelId, IReadOnlyList<Guid> characterIds, CancellationToken ct)
{ {
logger.LogDebug("Resolving {Count} characters for project {ProjectId}", characterIds.Count, projectId); logger.LogDebug("Resolving {Count} characters for novel {NovelId}", characterIds.Count, novelId);
var distinct = characterIds.Distinct().ToList(); var distinct = characterIds.Distinct().ToList();
if (distinct.Count == 0) if (distinct.Count == 0)
@@ -334,17 +349,17 @@ public class BeatService(
} }
var found = await db.Characters var found = await db.Characters
.Where(c => c.ProjectId == projectId && distinct.Contains(c.Id)) .Where(c => c.NovelId == novelId && distinct.Contains(c.Id))
.ToListAsync(ct); .ToListAsync(ct);
if (found.Count != distinct.Count) if (found.Count != distinct.Count)
{ {
logger.LogWarning("Rejected beat reference: one or more characters do not belong to project {ProjectId}", projectId); logger.LogWarning("Rejected beat reference: one or more characters do not belong to novel {NovelId}", novelId);
throw new InvalidOperationException( throw new InvalidOperationException(
"A beat's characters must belong to the same project as its chapter."); "A beat's characters must belong to the same novel as its chapter.");
} }
logger.LogDebug("Resolved {Count} characters for project {ProjectId}", found.Count, projectId); logger.LogDebug("Resolved {Count} characters for novel {NovelId}", found.Count, novelId);
return found; return found;
} }
@@ -361,13 +376,13 @@ public class BeatService(
return next; return next;
} }
private async Task RequireChapterAccessAsync(Guid chapterId, ProjectPermission permission, CancellationToken ct) private async Task RequireChapterAccessAsync(Guid chapterId, NovelPermission permission, CancellationToken ct)
{ {
var projectId = await db.Chapters.Where(c => c.Id == chapterId).Select(c => c.ProjectId).FirstOrDefaultAsync(ct); var novelId = await db.Chapters.Where(c => c.Id == chapterId).Select(c => c.NovelId).FirstOrDefaultAsync(ct);
await access.RequireAsync(projectId, permission, ct); await access.RequireAsync(novelId, permission, ct);
} }
private Task RequireBeatAccessAsync(Beat beat, ProjectPermission permission, CancellationToken ct) => private Task RequireBeatAccessAsync(Beat beat, NovelPermission permission, CancellationToken ct) =>
RequireChapterAccessAsync(beat.ChapterId, permission, ct); RequireChapterAccessAsync(beat.ChapterId, permission, ct);
private IQueryable<Beat> Query() => private IQueryable<Beat> Query() =>
+6 -5
View File
@@ -2,7 +2,8 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Beats; using Novelly.Api.Beats;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Projects; using Novelly.Api.Locations;
using Novelly.Api.Novels;
using Novelly.Api.Tags; using Novelly.Api.Tags;
namespace Novelly.Api.Chapters; namespace Novelly.Api.Chapters;
@@ -10,8 +11,8 @@ namespace Novelly.Api.Chapters;
public class Chapter public class Chapter
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; } public Guid NovelId { get; set; }
public Project? Project { get; set; } public Novel? Novel { get; set; }
public int Number { get; set; } public int Number { get; set; }
@@ -19,7 +20,6 @@ public class Chapter
public string? Summary { get; set; } public string? Summary { get; set; }
public string? Setting { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public DraftStatus Status { get; set; } = DraftStatus.Planned; public DraftStatus Status { get; set; } = DraftStatus.Planned;
@@ -35,6 +35,7 @@ public class Chapter
public List<Beat> Beats { get; set; } = []; public List<Beat> Beats { get; set; } = [];
public List<Tag> Tags { get; set; } = []; public List<Tag> Tags { get; set; } = [];
public List<Location> Locations { get; set; } = [];
} }
public class ChapterEntityTypeConfiguration : IEntityTypeConfiguration<Chapter> public class ChapterEntityTypeConfiguration : IEntityTypeConfiguration<Chapter>
@@ -43,6 +44,6 @@ public class ChapterEntityTypeConfiguration : IEntityTypeConfiguration<Chapter>
{ {
entity.Property(c => c.Title).IsRequired().HasMaxLength(300); entity.Property(c => c.Title).IsRequired().HasMaxLength(300);
entity.Property(c => c.Status).HasConversion<string>().HasMaxLength(32); entity.Property(c => c.Status).HasConversion<string>().HasMaxLength(32);
entity.HasIndex(c => new { c.ProjectId, c.Number }); entity.HasIndex(c => new { c.NovelId, c.Number });
} }
} }
+18 -15
View File
@@ -1,17 +1,18 @@
using Novelly.Api.Beats; using Novelly.Api.Beats;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
using Novelly.Api.Locations;
using Novelly.Api.Tags; using Novelly.Api.Tags;
namespace Novelly.Api.Chapters; namespace Novelly.Api.Chapters;
public record ChapterSummaryResponse( public record ChapterSummaryResponse(
Guid Id, Guid Id,
Guid ProjectId, Guid NovelId,
int Number, int Number,
string Title, string Title,
string? Summary, string? Summary,
string? Setting, IReadOnlyList<LocationResponse> Locations,
DraftStatus Status, DraftStatus Status,
int? TargetWordCount, int? TargetWordCount,
int BeatCount, int BeatCount,
@@ -21,11 +22,11 @@ public record ChapterSummaryResponse(
public record ChapterResponse( public record ChapterResponse(
Guid Id, Guid Id,
Guid ProjectId, Guid NovelId,
int Number, int Number,
string Title, string Title,
string? Summary, string? Summary,
string? Setting, IReadOnlyList<LocationResponse> Locations,
string? Notes, string? Notes,
DraftStatus Status, DraftStatus Status,
int? TargetWordCount, int? TargetWordCount,
@@ -39,7 +40,7 @@ public record CreateChapterRequest(
string Title, string Title,
int? Number = null, int? Number = null,
string? Summary = null, string? Summary = null,
string? Setting = null, IReadOnlyList<string>? Locations = null,
string? Notes = null, string? Notes = null,
DraftStatus Status = DraftStatus.Planned, DraftStatus Status = DraftStatus.Planned,
int? TargetWordCount = null, int? TargetWordCount = null,
@@ -53,7 +54,7 @@ public class CreateChapterRequestValidator : IModelValidator<CreateChapterReques
var result = new ValidationResult(); var result = new ValidationResult();
result.AddRequiredTextErrors("Title", "Title", model.Title, 200); result.AddRequiredTextErrors("Title", "Title", model.Title, 200);
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result); ChapterValidation.OptionalFields(model.Number, model.Summary, model.Locations, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result);
return result; return result;
} }
@@ -63,7 +64,7 @@ public record UpdateChapterRequest(
string? Title = null, string? Title = null,
int? Number = null, int? Number = null,
string? Summary = null, string? Summary = null,
string? Setting = null, IReadOnlyList<string>? Locations = null,
string? Notes = null, string? Notes = null,
DraftStatus? Status = null, DraftStatus? Status = null,
int? TargetWordCount = null, int? TargetWordCount = null,
@@ -77,7 +78,7 @@ public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterReques
var result = new ValidationResult(); var result = new ValidationResult();
result.AddUnclearableTextErrors("Title", "Title", model.Title, "a chapter", 200); result.AddUnclearableTextErrors("Title", "Title", model.Title, "a chapter", 200);
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result); ChapterValidation.OptionalFields(model.Number, model.Summary, model.Locations, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result);
return result; return result;
} }
@@ -86,7 +87,7 @@ public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterReques
file static class ChapterValidation file static class ChapterValidation
{ {
public static void OptionalFields( public static void OptionalFields(
int? number, string? summary, string? setting, string? notes, int? targetWordCount, string? prose, int? number, string? summary, IReadOnlyList<string>? locations, string? notes, int? targetWordCount, string? prose,
IReadOnlyList<string>? tags, ValidationResult result) IReadOnlyList<string>? tags, ValidationResult result)
{ {
if (number is <= 0) if (number is <= 0)
@@ -95,8 +96,8 @@ file static class ChapterValidation
if (summary is { Length: > 20000 }) if (summary is { Length: > 20000 })
result.AddError("Summary", "'Summary' must be 20,000 characters or fewer."); result.AddError("Summary", "'Summary' must be 20,000 characters or fewer.");
if (setting is { Length: > 500 }) if (locations is not null && locations.Any(string.IsNullOrWhiteSpace))
result.AddError("Setting", "'Setting' must be 500 characters or fewer."); result.AddError("Locations", "'Locations' must not contain blank entries.");
if (notes is { Length: > 20000 }) if (notes is { Length: > 20000 })
result.AddError("Notes", "'Notes' must be 20,000 characters or fewer."); result.AddError("Notes", "'Notes' must be 20,000 characters or fewer.");
@@ -115,8 +116,9 @@ file static class ChapterValidation
public static class ChapterMapping public static class ChapterMapping
{ {
public static ChapterResponse ToResponse(this Chapter c) => new( public static ChapterResponse ToResponse(this Chapter c) => new(
c.Id, c.ProjectId, c.Number, c.Title, c.Summary, c.Id, c.NovelId, c.Number, c.Title, c.Summary,
c.Setting, c.Notes, [.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())],
c.Notes,
c.Status, c.TargetWordCount, c.Status, c.TargetWordCount,
[.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())], [.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())],
c.Prose, c.WordCount, c.Prose, c.WordCount,
@@ -124,8 +126,9 @@ public static class ChapterMapping
c.UpdatedAt); c.UpdatedAt);
public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new( public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new(
c.Id, c.ProjectId, c.Number, c.Title, c.Summary, c.Id, c.NovelId, c.Number, c.Title, c.Summary,
c.Setting, c.Status, c.TargetWordCount, [.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())],
c.Status, c.TargetWordCount,
c.Beats.Count, c.WordCount, c.Beats.Count, c.WordCount,
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())], [.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
c.UpdatedAt); c.UpdatedAt);
+7 -7
View File
@@ -7,18 +7,18 @@ public static class ChapterEndpoints
{ {
public static IEndpointRouteBuilder MapChapterEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapChapterEndpoints(this IEndpointRouteBuilder app)
{ {
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/chapters").WithTags("Chapters") var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/chapters").WithTags("Chapters")
.AddEndpointFilter<RequestLoggingEndpointFilter>() .AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>(); .AddEndpointFilter<ValidationEndpointFilter>();
projectScoped.MapGet("/", async (Guid projectId, ChapterService service, CancellationToken ct) => novelScoped.MapGet("/", async (Guid novelId, ChapterService service, CancellationToken ct) =>
Results.Ok((await service.ListAsync(projectId, ct)).Select(c => c.ToSummaryResponse()))) Results.Ok((await service.ListAsync(novelId, ct)).Select(c => c.ToSummaryResponse())))
.WithSummary("List a project's chapters in manuscript order."); .WithSummary("List a novel's chapters in manuscript order.");
projectScoped.MapPost("/", async ( novelScoped.MapPost("/", async (
Guid projectId, CreateChapterRequest request, ChapterService service, CancellationToken ct) => Guid novelId, CreateChapterRequest request, ChapterService service, CancellationToken ct) =>
{ {
var chapter = await service.CreateAsync(projectId, request, ct); var chapter = await service.CreateAsync(novelId, request, ct);
if (chapter is null) if (chapter is null)
{ {
return Results.NotFound(); return Results.NotFound();
+37 -25
View File
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
using Novelly.Api.Locations;
using Novelly.Api.Tags; using Novelly.Api.Tags;
using Novelly.Api.Users; using Novelly.Api.Users;
@@ -9,24 +10,26 @@ namespace Novelly.Api.Chapters;
public class ChapterService( public class ChapterService(
INovelDbContext db, INovelDbContext db,
ProjectAccessService access, NovelAccessService access,
TagService tags, TagService tags,
LocationService locations,
ILogger<ChapterService> logger, ILogger<ChapterService> logger,
IModelValidator<CreateChapterRequest> createValidator, IModelValidator<CreateChapterRequest> createValidator,
IModelValidator<UpdateChapterRequest> updateValidator) IModelValidator<UpdateChapterRequest> updateValidator)
{ {
public async Task<IReadOnlyList<Chapter>> ListAsync(Guid projectId, CancellationToken ct = default) public async Task<IReadOnlyList<Chapter>> ListAsync(Guid novelId, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(novelId, nameof(novelId));
logger.LogInformation("Listing chapters for project {ProjectId}", projectId); logger.LogInformation("Listing chapters for novel {NovelId}", novelId);
await access.RequireAsync(projectId, ProjectPermission.Read, ct); await access.RequireAsync(novelId, NovelPermission.Read, ct);
return await db.Chapters return await db.Chapters
.Include(c => c.Beats) .Include(c => c.Beats)
.Include(c => c.Tags) .Include(c => c.Tags)
.Where(c => c.ProjectId == projectId) .Include(c => c.Locations)
.Where(c => c.NovelId == novelId)
.OrderBy(c => c.Number) .OrderBy(c => c.Number)
.ToListAsync(ct); .ToListAsync(ct);
} }
@@ -43,33 +46,32 @@ public class ChapterService(
return null; return null;
} }
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Read, ct); await access.RequireAsync(chapter.NovelId, NovelPermission.Read, ct);
return chapter; return chapter;
} }
public async Task<Chapter?> CreateAsync(Guid projectId, CreateChapterRequest request, CancellationToken ct = default) public async Task<Chapter?> CreateAsync(Guid novelId, CreateChapterRequest request, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(novelId, nameof(novelId));
Guard.Null(request, nameof(request)); Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid(logger); createValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Creating chapter {Title} for project {ProjectId}", request.Title, projectId); logger.LogInformation("Creating chapter {Title} for novel {NovelId}", request.Title, novelId);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct)) if (!await db.Novels.AnyAsync(p => p.Id == novelId, ct))
{ {
logger.LogWarning("Rejected chapter creation: project {ProjectId} not found", projectId); logger.LogWarning("Rejected chapter creation: novel {NovelId} not found", novelId);
return null; return null;
} }
await access.RequireAsync(projectId, ProjectPermission.CreateContent, ct); await access.RequireAsync(novelId, NovelPermission.CreateContent, ct);
var chapter = new Chapter var chapter = new Chapter
{ {
ProjectId = projectId, NovelId = novelId,
Title = request.Title, Title = request.Title,
Number = request.Number ?? await NextChapterNumberAsync(projectId, ct), Number = request.Number ?? await NextChapterNumberAsync(novelId, ct),
Summary = request.Summary, Summary = request.Summary,
Setting = request.Setting,
Notes = request.Notes, Notes = request.Notes,
Status = request.Status, Status = request.Status,
TargetWordCount = request.TargetWordCount, TargetWordCount = request.TargetWordCount,
@@ -79,7 +81,12 @@ public class ChapterService(
if (request.Tags is { } names) if (request.Tags is { } names)
{ {
chapter.Tags = await tags.ResolveAsync(projectId, names, ct); chapter.Tags = await tags.ResolveAsync(novelId, names, ct);
}
if (request.Locations is { } locationNames)
{
chapter.Locations = await locations.ResolveAsync(novelId, locationNames, ct);
} }
db.Chapters.Add(chapter); db.Chapters.Add(chapter);
@@ -102,12 +109,11 @@ public class ChapterService(
return null; return null;
} }
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(chapter.NovelId, NovelPermission.Write, ct);
chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title; chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title;
chapter.Number = request.Number ?? chapter.Number; chapter.Number = request.Number ?? chapter.Number;
chapter.Summary = Patch.Apply(chapter.Summary, request.Summary); chapter.Summary = Patch.Apply(chapter.Summary, request.Summary);
chapter.Setting = Patch.Apply(chapter.Setting, request.Setting);
chapter.Notes = Patch.Apply(chapter.Notes, request.Notes); chapter.Notes = Patch.Apply(chapter.Notes, request.Notes);
chapter.Status = request.Status ?? chapter.Status; chapter.Status = request.Status ?? chapter.Status;
chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount; chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount;
@@ -122,7 +128,12 @@ public class ChapterService(
if (request.Tags is { } names) if (request.Tags is { } names)
{ {
chapter.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct); chapter.Tags = await tags.ResolveAsync(chapter.NovelId, names, ct);
}
if (request.Locations is { } locationNames)
{
chapter.Locations = await locations.ResolveAsync(chapter.NovelId, locationNames, ct);
} }
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -141,23 +152,23 @@ public class ChapterService(
return false; return false;
} }
await access.RequireAsync(chapter.ProjectId, ProjectPermission.DeleteContent, ct); await access.RequireAsync(chapter.NovelId, NovelPermission.DeleteContent, ct);
db.Chapters.Remove(chapter); db.Chapters.Remove(chapter);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return true; return true;
} }
private async Task<int> NextChapterNumberAsync(Guid projectId, CancellationToken ct) private async Task<int> NextChapterNumberAsync(Guid novelId, CancellationToken ct)
{ {
logger.LogDebug("Computing next chapter number for project {ProjectId}", projectId); logger.LogDebug("Computing next chapter number for novel {NovelId}", novelId);
var max = await db.Chapters var max = await db.Chapters
.Where(c => c.ProjectId == projectId) .Where(c => c.NovelId == novelId)
.MaxAsync(c => (int?)c.Number, ct); .MaxAsync(c => (int?)c.Number, ct);
var next = (max ?? 0) + 1; var next = (max ?? 0) + 1;
logger.LogDebug("Next chapter number for project {ProjectId} is {Number}", projectId, next); logger.LogDebug("Next chapter number for novel {NovelId} is {Number}", novelId, next);
return next; return next;
} }
@@ -169,6 +180,7 @@ public class ChapterService(
.Include(c => c.Beats).ThenInclude(b => b.Characters) .Include(c => c.Beats).ThenInclude(b => b.Characters)
.Include(c => c.Beats).ThenInclude(b => b.Tags) .Include(c => c.Beats).ThenInclude(b => b.Tags)
.Include(c => c.Tags) .Include(c => c.Tags)
.Include(c => c.Locations)
.FirstOrDefaultAsync(c => c.Id == id, ct); .FirstOrDefaultAsync(c => c.Id == id, ct);
if (chapter is null) if (chapter is null)
+4 -4
View File
@@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Beats; using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Projects; using Novelly.Api.Novels;
using Novelly.Api.Tags; using Novelly.Api.Tags;
namespace Novelly.Api.Characters; namespace Novelly.Api.Characters;
@@ -10,8 +10,8 @@ namespace Novelly.Api.Characters;
public class Character public class Character
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; } public Guid NovelId { get; set; }
public Project? Project { get; set; } public Novel? Novel { get; set; }
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public CharacterRole Role { get; set; } = CharacterRole.Supporting; public CharacterRole Role { get; set; } = CharacterRole.Supporting;
@@ -82,7 +82,7 @@ public class CharacterEntityTypeConfiguration : IEntityTypeConfiguration<Charact
entity.Property(c => c.Name).IsRequired().HasMaxLength(200); entity.Property(c => c.Name).IsRequired().HasMaxLength(200);
entity.Property(c => c.Role).HasConversion<string>().HasMaxLength(32); entity.Property(c => c.Role).HasConversion<string>().HasMaxLength(32);
entity.Property(c => c.Importance).HasConversion<string>().HasMaxLength(32); entity.Property(c => c.Importance).HasConversion<string>().HasMaxLength(32);
entity.HasIndex(c => c.ProjectId); entity.HasIndex(c => c.NovelId);
entity.HasIndex(c => c.SameCharacterAsId); entity.HasIndex(c => c.SameCharacterAsId);
entity.HasMany(c => c.Relationships).WithOne(r => r.Character!) entity.HasMany(c => c.Relationships).WithOne(r => r.Character!)
@@ -8,11 +8,12 @@ namespace Novelly.Api.Characters;
public class CharacterArcService( public class CharacterArcService(
INovelDbContext db, INovelDbContext db,
ProjectAccessService access, NovelAccessService access,
ILogger<CharacterArcService> logger, ILogger<CharacterArcService> logger,
IModelValidator<CreateArcStageRequest> createValidator, IModelValidator<CreateArcStageRequest> createValidator,
IModelValidator<UpdateArcStageRequest> updateValidator, IModelValidator<UpdateArcStageRequest> updateValidator,
IModelValidator<ReorderArcStagesRequest> reorderValidator) IModelValidator<ReorderArcStagesRequest> reorderValidator,
IModelValidator<SetArcStageBeatsRequest> setBeatsValidator)
{ {
public async Task<IReadOnlyList<CharacterArcStage>> ListAsync(Guid characterId, CancellationToken ct = default) public async Task<IReadOnlyList<CharacterArcStage>> ListAsync(Guid characterId, CancellationToken ct = default)
{ {
@@ -20,7 +21,7 @@ public class CharacterArcService(
logger.LogInformation("Listing arc stages for character {CharacterId}", characterId); logger.LogInformation("Listing arc stages for character {CharacterId}", characterId);
await RequireCharacterAccessAsync(characterId, ProjectPermission.Read, ct); await RequireCharacterAccessAsync(characterId, NovelPermission.Read, ct);
var stages = await Query() var stages = await Query()
.Where(s => s.CharacterId == characterId) .Where(s => s.CharacterId == characterId)
@@ -42,7 +43,7 @@ public class CharacterArcService(
return null; return null;
} }
await RequireCharacterAccessAsync(stage.CharacterId, ProjectPermission.Read, ct); await RequireCharacterAccessAsync(stage.CharacterId, NovelPermission.Read, ct);
return stage; return stage;
} }
@@ -62,15 +63,15 @@ public class CharacterArcService(
return null; return null;
} }
await access.RequireAsync(character.ProjectId, ProjectPermission.CreateContent, ct); await access.RequireAsync(character.NovelId, NovelPermission.CreateContent, ct);
await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct); await EnsureChapterIsInSameNovelAsync(character, request.ChapterId, ct);
var stage = new CharacterArcStage var stage = new CharacterArcStage
{ {
CharacterId = characterId, CharacterId = characterId,
Title = request.Title, Title = request.Title,
SortOrder = request.SortOrder ?? await NextSortOrderAsync(characterId, ct), SortOrder = request.SortOrder ?? await NextSortOrderAsync(characterId, ct),
Description = request.Description, Result = request.Result,
ChapterId = request.ChapterId ChapterId = request.ChapterId
}; };
@@ -102,12 +103,12 @@ public class CharacterArcService(
return null; return null;
} }
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct); await EnsureChapterIsInSameNovelAsync(character, request.ChapterId, ct);
stage.Title = Patch.Apply(stage.Title, request.Title) ?? stage.Title; stage.Title = Patch.Apply(stage.Title, request.Title) ?? stage.Title;
stage.SortOrder = request.SortOrder ?? stage.SortOrder; stage.SortOrder = request.SortOrder ?? stage.SortOrder;
stage.Description = Patch.Apply(stage.Description, request.Description); stage.Result = Patch.Apply(stage.Result, request.Result);
stage.ChapterId = request.ChapterId ?? stage.ChapterId; stage.ChapterId = request.ChapterId ?? stage.ChapterId;
stage.UpdatedAt = DateTimeOffset.UtcNow; stage.UpdatedAt = DateTimeOffset.UtcNow;
@@ -127,7 +128,7 @@ public class CharacterArcService(
return false; return false;
} }
await RequireCharacterAccessAsync(stage.CharacterId, ProjectPermission.DeleteContent, ct); await RequireCharacterAccessAsync(stage.CharacterId, NovelPermission.DeleteContent, ct);
db.CharacterArcStages.Remove(stage); db.CharacterArcStages.Remove(stage);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -143,7 +144,7 @@ public class CharacterArcService(
logger.LogInformation("Reordering {Count} arc stages for character {CharacterId}", request.StageIds.Count, characterId); logger.LogInformation("Reordering {Count} arc stages for character {CharacterId}", request.StageIds.Count, characterId);
await RequireCharacterAccessAsync(characterId, ProjectPermission.Write, ct); await RequireCharacterAccessAsync(characterId, NovelPermission.Write, ct);
var stages = await db.CharacterArcStages var stages = await db.CharacterArcStages
.Where(s => s.CharacterId == characterId) .Where(s => s.CharacterId == characterId)
@@ -171,7 +172,68 @@ public class CharacterArcService(
return await ListAsync(characterId, ct); return await ListAsync(characterId, ct);
} }
private async Task EnsureChapterIsInSameProjectAsync( public async Task<CharacterArcStage?> SetBeatsAsync(
Guid stageId, SetArcStageBeatsRequest request, CancellationToken ct = default)
{
Guard.Default(stageId, nameof(stageId));
Guard.Null(request, nameof(request));
setBeatsValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Setting {Count} beats for arc stage {ArcStageId}", request.BeatIds.Count, stageId);
var stage = await FindAsync(stageId, ct);
if (stage is null)
{
return null;
}
var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == stage.CharacterId, ct);
if (character is null)
{
logger.LogError("Arc stage {ArcStageId} references character {CharacterId} which does not exist", stageId, stage.CharacterId);
return null;
}
await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
var beats = await db.Beats
.Include(b => b.Characters)
.Include(b => b.ArcStages)
.Where(b => request.BeatIds.Contains(b.Id))
.ToListAsync(ct);
var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList();
if (missing.Count > 0)
{
logger.LogWarning("Rejected arc stage beat assignment: arc stage {ArcStageId} referenced missing beat {BeatId}", stageId, missing[0]);
return null;
}
var unrelated = beats.Where(b => b.Characters.All(c => c.Id != stage.CharacterId)).ToList();
if (unrelated.Count > 0)
{
logger.LogWarning(
"Rejected arc stage beat assignment: beat {BeatId} does not include character {CharacterId}",
unrelated[0].Id, stage.CharacterId);
throw new InvalidOperationException("A beat can only be grouped into an arc stage for a character who appears in it.");
}
foreach (var beat in beats)
{
foreach (var sibling in beat.ArcStages.Where(s => s.CharacterId == stage.CharacterId && s.Id != stageId).ToList())
{
beat.ArcStages.Remove(sibling);
}
}
stage.Beats = beats;
stage.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return (await FindAsync(stageId, ct))!;
}
private async Task EnsureChapterIsInSameNovelAsync(
Character character, Guid? chapterId, CancellationToken ct) Character character, Guid? chapterId, CancellationToken ct)
{ {
if (chapterId is not { } id) if (chapterId is not { } id)
@@ -179,18 +241,18 @@ public class CharacterArcService(
return; return;
} }
logger.LogDebug("Checking chapter {ChapterId} belongs to project {ProjectId}", id, character.ProjectId); logger.LogDebug("Checking chapter {ChapterId} belongs to novel {NovelId}", id, character.NovelId);
var belongs = await db.Chapters.AnyAsync(c => c.Id == id && c.ProjectId == character.ProjectId, ct); var belongs = await db.Chapters.AnyAsync(c => c.Id == id && c.NovelId == character.NovelId, ct);
if (!belongs) if (!belongs)
{ {
logger.LogWarning("Rejected arc stage: chapter {ChapterId} does not belong to project {ProjectId}", id, character.ProjectId); logger.LogWarning("Rejected arc stage: chapter {ChapterId} does not belong to novel {NovelId}", id, character.NovelId);
throw new InvalidOperationException( throw new InvalidOperationException(
"An arc stage can only point at a chapter in the same project as its character."); "An arc stage can only point at a chapter in the same novel as its character.");
} }
logger.LogDebug("Chapter {ChapterId} belongs to project {ProjectId}", id, character.ProjectId); logger.LogDebug("Chapter {ChapterId} belongs to novel {NovelId}", id, character.NovelId);
} }
private async Task<int> NextSortOrderAsync(Guid characterId, CancellationToken ct) private async Task<int> NextSortOrderAsync(Guid characterId, CancellationToken ct)
@@ -206,13 +268,16 @@ public class CharacterArcService(
return next; return next;
} }
private async Task RequireCharacterAccessAsync(Guid characterId, ProjectPermission permission, CancellationToken ct) private async Task RequireCharacterAccessAsync(Guid characterId, NovelPermission permission, CancellationToken ct)
{ {
var projectId = await db.Characters.Where(c => c.Id == characterId).Select(c => c.ProjectId).FirstOrDefaultAsync(ct); var novelId = await db.Characters.Where(c => c.Id == characterId).Select(c => c.NovelId).FirstOrDefaultAsync(ct);
await access.RequireAsync(projectId, permission, ct); await access.RequireAsync(novelId, permission, ct);
} }
private IQueryable<CharacterArcStage> Query() => db.CharacterArcStages.Include(s => s.Chapter); private IQueryable<CharacterArcStage> Query() =>
db.CharacterArcStages
.Include(s => s.Chapter)
.Include(s => s.Beats).ThenInclude(b => b.Chapter);
private async Task<CharacterArcStage?> FindAsync(Guid id, CancellationToken ct) private async Task<CharacterArcStage?> FindAsync(Guid id, CancellationToken ct)
{ {
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
namespace Novelly.Api.Characters; namespace Novelly.Api.Characters;
@@ -15,11 +16,13 @@ public class CharacterArcStage
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
public string? Description { get; set; } public string? Result { get; set; }
public Guid? ChapterId { get; set; } public Guid? ChapterId { get; set; }
public Chapter? Chapter { get; init; } public Chapter? Chapter { get; init; }
public List<Beat> Beats { get; set; } = [];
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
} }
@@ -33,5 +36,8 @@ public class CharacterArcStageEntityTypeConfiguration : IEntityTypeConfiguration
entity.HasOne(s => s.Chapter).WithMany() entity.HasOne(s => s.Chapter).WithMany()
.HasForeignKey(s => s.ChapterId).OnDelete(DeleteBehavior.SetNull); .HasForeignKey(s => s.ChapterId).OnDelete(DeleteBehavior.SetNull);
entity.HasMany(s => s.Beats).WithMany(b => b.ArcStages)
.UsingEntity(join => join.ToTable("ArcStageBeats"));
} }
} }
@@ -1,3 +1,4 @@
using Novelly.Api.Beats;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
using Novelly.Api.Tags; using Novelly.Api.Tags;
@@ -5,7 +6,7 @@ namespace Novelly.Api.Characters;
public record CharacterResponse( public record CharacterResponse(
Guid Id, Guid Id,
Guid ProjectId, Guid NovelId,
string Name, string Name,
CharacterRole Role, CharacterRole Role,
CharacterImportance Importance, CharacterImportance Importance,
@@ -162,7 +163,8 @@ file static class CharacterValidation
public record CreateRelationshipRequest( public record CreateRelationshipRequest(
Guid RelatedCharacterId, Guid RelatedCharacterId,
string RelationshipType, string RelationshipType,
string? Description = null); string? Description = null,
string? ReciprocalRelationshipType = null);
public class CreateRelationshipRequestValidator : IModelValidator<CreateRelationshipRequest> public class CreateRelationshipRequestValidator : IModelValidator<CreateRelationshipRequest>
{ {
@@ -175,6 +177,7 @@ public class CreateRelationshipRequestValidator : IModelValidator<CreateRelation
result.AddRequiredTextErrors("RelationshipType", "Relationship Type", model.RelationshipType, 100); result.AddRequiredTextErrors("RelationshipType", "Relationship Type", model.RelationshipType, 100);
result.AddOptionalTextErrors("Description", "Description", model.Description, 2000); result.AddOptionalTextErrors("Description", "Description", model.Description, 2000);
result.AddOptionalTextErrors("ReciprocalRelationshipType", "Reciprocal Relationship Type", model.ReciprocalRelationshipType, 100);
return result; return result;
} }
@@ -205,16 +208,17 @@ public record ArcStageResponse(
Guid CharacterId, Guid CharacterId,
int SortOrder, int SortOrder,
string Title, string Title,
string? Description, string? Result,
Guid? ChapterId, Guid? ChapterId,
int? ChapterNumber, int? ChapterNumber,
string? ChapterTitle, string? ChapterTitle,
IReadOnlyList<CharacterBeatResponse> Beats,
DateTimeOffset UpdatedAt); DateTimeOffset UpdatedAt);
public record CreateArcStageRequest( public record CreateArcStageRequest(
string Title, string Title,
int? SortOrder = null, int? SortOrder = null,
string? Description = null, string? Result = null,
Guid? ChapterId = null); Guid? ChapterId = null);
public class CreateArcStageRequestValidator : IModelValidator<CreateArcStageRequest> public class CreateArcStageRequestValidator : IModelValidator<CreateArcStageRequest>
@@ -224,7 +228,7 @@ public class CreateArcStageRequestValidator : IModelValidator<CreateArcStageRequ
var result = new ValidationResult(); var result = new ValidationResult();
result.AddRequiredTextErrors("Title", "Title", model.Title, 200); result.AddRequiredTextErrors("Title", "Title", model.Title, 200);
ArcStageValidation.OptionalFields(model.SortOrder, model.Description, result); ArcStageValidation.OptionalFields(model.SortOrder, model.Result, result);
return result; return result;
} }
@@ -233,7 +237,7 @@ public class CreateArcStageRequestValidator : IModelValidator<CreateArcStageRequ
public record UpdateArcStageRequest( public record UpdateArcStageRequest(
string? Title = null, string? Title = null,
int? SortOrder = null, int? SortOrder = null,
string? Description = null, string? Result = null,
Guid? ChapterId = null); Guid? ChapterId = null);
public class UpdateArcStageRequestValidator : IModelValidator<UpdateArcStageRequest> public class UpdateArcStageRequestValidator : IModelValidator<UpdateArcStageRequest>
@@ -243,7 +247,7 @@ public class UpdateArcStageRequestValidator : IModelValidator<UpdateArcStageRequ
var result = new ValidationResult(); var result = new ValidationResult();
result.AddUnclearableTextErrors("Title", "Title", model.Title, "an arc stage", 200); result.AddUnclearableTextErrors("Title", "Title", model.Title, "an arc stage", 200);
ArcStageValidation.OptionalFields(model.SortOrder, model.Description, result); ArcStageValidation.OptionalFields(model.SortOrder, model.Result, result);
return result; return result;
} }
@@ -251,13 +255,13 @@ public class UpdateArcStageRequestValidator : IModelValidator<UpdateArcStageRequ
file static class ArcStageValidation file static class ArcStageValidation
{ {
public static void OptionalFields(int? sortOrder, string? description, ValidationResult result) public static void OptionalFields(int? sortOrder, string? result_, ValidationResult result)
{ {
if (sortOrder is < 0) if (sortOrder is < 0)
result.AddError("SortOrder", "'Sort Order' must be zero or greater."); result.AddError("SortOrder", "'Sort Order' must be zero or greater.");
if (description is { Length: > 20000 }) if (result_ is { Length: > 20000 })
result.AddError("Description", "'Description' must be 20,000 characters or fewer."); result.AddError("Result", "'Result' must be 20,000 characters or fewer.");
} }
} }
@@ -276,11 +280,26 @@ public class ReorderArcStagesRequestValidator : IModelValidator<ReorderArcStages
} }
} }
public record SetArcStageBeatsRequest(IReadOnlyList<Guid> BeatIds);
public class SetArcStageBeatsRequestValidator : IModelValidator<SetArcStageBeatsRequest>
{
public ValidationResult Validate(SetArcStageBeatsRequest model)
{
var result = new ValidationResult();
if (model.BeatIds is null)
result.AddError("BeatIds", "'Beat Ids' must not be null.");
return result;
}
}
public static class CharacterMapping public static class CharacterMapping
{ {
public static CharacterResponse ToResponse(this Character c) => new( public static CharacterResponse ToResponse(this Character c) => new(
c.Id, c.ProjectId, c.Name, c.Role, c.Importance, c.Age, c.Pronouns, c.Occupation, c.Id, c.NovelId, c.Name, c.Role, c.Importance, c.Age, c.Pronouns, c.Occupation,
c.Appearance, c.Personality, c.Backstory, c.Want, c.Need, c.Appearance, c.Personality, c.Backstory, c.Want, c.Need,
c.InternalConflict, c.ExternalConflict, c.ArcSummary, c.Voice, c.Notes, c.InternalConflict, c.ExternalConflict, c.ArcSummary, c.Voice, c.Notes,
[.. c.Aliases], [.. c.Aliases],
@@ -305,9 +324,13 @@ public static class CharacterMapping
s.CharacterId, s.CharacterId,
s.SortOrder, s.SortOrder,
s.Title, s.Title,
s.Description, s.Result,
s.ChapterId, s.ChapterId,
s.Chapter?.Number, s.Chapter?.Number,
s.Chapter?.Title, s.Chapter?.Title,
[.. s.Beats
.OrderBy(b => b.Chapter?.Number ?? 0)
.ThenBy(b => b.SortOrder)
.Select(b => b.ToCharacterBeatResponse(s.CharacterId))],
s.UpdatedAt); s.UpdatedAt);
} }
@@ -7,18 +7,18 @@ public static class CharacterEndpoints
{ {
public static IEndpointRouteBuilder MapCharacterEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapCharacterEndpoints(this IEndpointRouteBuilder app)
{ {
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/characters").WithTags("Characters") var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/characters").WithTags("Characters")
.AddEndpointFilter<RequestLoggingEndpointFilter>() .AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>(); .AddEndpointFilter<ValidationEndpointFilter>();
projectScoped.MapGet("/", async (Guid projectId, CharacterService service, CancellationToken ct) => novelScoped.MapGet("/", async (Guid novelId, CharacterService service, CancellationToken ct) =>
Results.Ok((await service.ListAsync(projectId, ct)).Select(c => c.ToResponse()))) Results.Ok((await service.ListAsync(novelId, ct)).Select(c => c.ToResponse())))
.WithSummary("List a project's character dossiers."); .WithSummary("List a novel's character dossiers.");
projectScoped.MapPost("/", async ( novelScoped.MapPost("/", async (
Guid projectId, CreateCharacterRequest request, CharacterService service, CancellationToken ct) => Guid novelId, CreateCharacterRequest request, CharacterService service, CancellationToken ct) =>
{ {
var character = await service.CreateAsync(projectId, request, ct); var character = await service.CreateAsync(novelId, request, ct);
if (character is null) if (character is null)
{ {
return Results.NotFound(); return Results.NotFound();
@@ -49,7 +49,7 @@ public static class CharacterEndpoints
characters.MapPost("/{id:guid}/relationships", async ( characters.MapPost("/{id:guid}/relationships", async (
Guid id, CreateRelationshipRequest request, CharacterService service, CancellationToken ct) => Guid id, CreateRelationshipRequest request, CharacterService service, CancellationToken ct) =>
(await service.AddRelationshipAsync(id, request, ct))?.ToResponse().ToApiResult()) (await service.AddRelationshipAsync(id, request, ct))?.ToResponse().ToApiResult())
.WithSummary("Relate this character to another in the same project."); .WithSummary("Relate this character to another in the same novel.");
characters.MapDelete("/relationships/{relationshipId:guid}", async ( characters.MapDelete("/relationships/{relationshipId:guid}", async (
Guid relationshipId, CharacterService service, CancellationToken ct) => Guid relationshipId, CharacterService service, CancellationToken ct) =>
@@ -59,7 +59,7 @@ public static class CharacterEndpoints
characters.MapPut("/{id:guid}/identity", async ( characters.MapPut("/{id:guid}/identity", async (
Guid id, LinkCharacterIdentityRequest request, CharacterService service, CancellationToken ct) => Guid id, LinkCharacterIdentityRequest request, CharacterService service, CancellationToken ct) =>
(await service.LinkIdentityAsync(id, request, ct))?.ToResponse().ToApiResult()) (await service.LinkIdentityAsync(id, request, ct))?.ToResponse().ToApiResult())
.WithSummary("Link this character as another identity of a character in the same project."); .WithSummary("Link this character as another identity of a character in the same novel.");
characters.MapDelete("/{id:guid}/identity", async ( characters.MapDelete("/{id:guid}/identity", async (
Guid id, CharacterService service, CancellationToken ct) => Guid id, CharacterService service, CancellationToken ct) =>
@@ -107,6 +107,12 @@ public static class CharacterEndpoints
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound()) await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete an arc stage."); .WithSummary("Delete an arc stage.");
arcStages.MapPost("/{id:guid}/beats", async (
Guid id, SetArcStageBeatsRequest request, CharacterArcService service, CancellationToken ct) =>
(await service.SetBeatsAsync(id, request, ct))?.ToResponse().ToApiResult())
.WithSummary("Set which beats belong to this arc stage, replacing its current set. "
+ "A beat moved into this stage leaves any other stage of the same character it was in.");
return app; return app;
} }
} }
+48 -32
View File
@@ -9,7 +9,7 @@ namespace Novelly.Api.Characters;
public class CharacterService( public class CharacterService(
INovelDbContext db, INovelDbContext db,
ProjectAccessService access, NovelAccessService access,
TagService tags, TagService tags,
ILogger<CharacterService> logger, ILogger<CharacterService> logger,
IModelValidator<CreateCharacterRequest> createValidator, IModelValidator<CreateCharacterRequest> createValidator,
@@ -17,16 +17,16 @@ public class CharacterService(
IModelValidator<CreateRelationshipRequest> relationshipValidator, IModelValidator<CreateRelationshipRequest> relationshipValidator,
IModelValidator<LinkCharacterIdentityRequest> identityValidator) IModelValidator<LinkCharacterIdentityRequest> identityValidator)
{ {
public async Task<IReadOnlyList<Character>> ListAsync(Guid projectId, CancellationToken ct = default) public async Task<IReadOnlyList<Character>> ListAsync(Guid novelId, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(novelId, nameof(novelId));
logger.LogInformation("Listing characters for project {ProjectId}", projectId); logger.LogInformation("Listing characters for novel {NovelId}", novelId);
await access.RequireAsync(projectId, ProjectPermission.Read, ct); await access.RequireAsync(novelId, NovelPermission.Read, ct);
var characters = await Query() var characters = await Query()
.Where(c => c.ProjectId == projectId) .Where(c => c.NovelId == novelId)
.ToListAsync(ct); .ToListAsync(ct);
return OrderedInMemoryBySignificanceThenName(characters); return OrderedInMemoryBySignificanceThenName(characters);
@@ -52,29 +52,29 @@ public class CharacterService(
return null; return null;
} }
await access.RequireAsync(character.ProjectId, ProjectPermission.Read, ct); await access.RequireAsync(character.NovelId, NovelPermission.Read, ct);
return character; return character;
} }
public async Task<Character?> CreateAsync(Guid projectId, CreateCharacterRequest request, CancellationToken ct = default) public async Task<Character?> CreateAsync(Guid novelId, CreateCharacterRequest request, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(novelId, nameof(novelId));
Guard.Null(request, nameof(request)); Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid(logger); createValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Creating character {Name} for project {ProjectId}, role {Role}, importance {Importance}", request.Name, projectId, request.Role, request.Importance); logger.LogInformation("Creating character {Name} for novel {NovelId}, role {Role}, importance {Importance}", request.Name, novelId, request.Role, request.Importance);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct)) if (!await db.Novels.AnyAsync(p => p.Id == novelId, ct))
{ {
logger.LogWarning("Rejected character creation: project {ProjectId} not found", projectId); logger.LogWarning("Rejected character creation: novel {NovelId} not found", novelId);
return null; return null;
} }
await access.RequireAsync(projectId, ProjectPermission.CreateContent, ct); await access.RequireAsync(novelId, NovelPermission.CreateContent, ct);
var character = new Character var character = new Character
{ {
ProjectId = projectId, NovelId = novelId,
Name = request.Name, Name = request.Name,
Role = request.Role, Role = request.Role,
Importance = request.Importance, Importance = request.Importance,
@@ -95,7 +95,7 @@ public class CharacterService(
if (request.Tags is { } names) if (request.Tags is { } names)
{ {
character.Tags = await tags.ResolveAsync(projectId, names, ct); character.Tags = await tags.ResolveAsync(novelId, names, ct);
} }
if (request.Aliases is { } aliases) if (request.Aliases is { } aliases)
@@ -123,7 +123,7 @@ public class CharacterService(
return null; return null;
} }
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
character.Name = Patch.Apply(character.Name, request.Name) ?? character.Name; character.Name = Patch.Apply(character.Name, request.Name) ?? character.Name;
character.Role = request.Role ?? character.Role; character.Role = request.Role ?? character.Role;
@@ -145,7 +145,7 @@ public class CharacterService(
if (request.Tags is { } names) if (request.Tags is { } names)
{ {
character.Tags = await tags.ResolveAsync(character.ProjectId, names, ct); character.Tags = await tags.ResolveAsync(character.NovelId, names, ct);
} }
if (request.Aliases is { } aliases) if (request.Aliases is { } aliases)
@@ -169,7 +169,7 @@ public class CharacterService(
return false; return false;
} }
await access.RequireAsync(character.ProjectId, ProjectPermission.DeleteContent, ct); await access.RequireAsync(character.NovelId, NovelPermission.DeleteContent, ct);
db.Characters.Remove(character); db.Characters.Remove(character);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -191,7 +191,7 @@ public class CharacterService(
return null; return null;
} }
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
var related = await db.Characters.FirstOrDefaultAsync(c => c.Id == request.RelatedCharacterId, ct); var related = await db.Characters.FirstOrDefaultAsync(c => c.Id == request.RelatedCharacterId, ct);
if (related is null) if (related is null)
@@ -200,10 +200,10 @@ public class CharacterService(
return null; return null;
} }
if (related.ProjectId != character.ProjectId) if (related.NovelId != character.NovelId)
{ {
logger.LogWarning("Rejected relationship: character {CharacterId} and {RelatedCharacterId} belong to different projects", characterId, request.RelatedCharacterId); logger.LogWarning("Rejected relationship: character {CharacterId} and {RelatedCharacterId} belong to different novels", characterId, request.RelatedCharacterId);
throw new InvalidOperationException("Characters must belong to the same project to be related."); throw new InvalidOperationException("Characters must belong to the same novel to be related.");
} }
db.CharacterRelationships.Add(new CharacterRelationship db.CharacterRelationships.Add(new CharacterRelationship
@@ -214,6 +214,14 @@ public class CharacterService(
Description = request.Description Description = request.Description
}); });
db.CharacterRelationships.Add(new CharacterRelationship
{
CharacterId = request.RelatedCharacterId,
RelatedCharacterId = characterId,
RelationshipType = request.ReciprocalRelationshipType ?? request.RelationshipType,
Description = request.Description
});
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return (await FindAsync(characterId, ct))!; return (await FindAsync(characterId, ct))!;
} }
@@ -233,9 +241,14 @@ public class CharacterService(
return false; return false;
} }
await access.RequireAsync(relationship.Character!.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(relationship.Character!.NovelId, NovelPermission.Write, ct);
var reciprocals = await db.CharacterRelationships
.Where(r => r.CharacterId == relationship.RelatedCharacterId && r.RelatedCharacterId == relationship.CharacterId)
.ToListAsync(ct);
db.CharacterRelationships.Remove(relationship); db.CharacterRelationships.Remove(relationship);
db.CharacterRelationships.RemoveRange(reciprocals);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return true; return true;
} }
@@ -256,7 +269,7 @@ public class CharacterService(
return null; return null;
} }
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
if (request.SameCharacterAsId == characterId) if (request.SameCharacterAsId == characterId)
{ {
@@ -273,12 +286,12 @@ public class CharacterService(
return null; return null;
} }
if (target.ProjectId != character.ProjectId) if (target.NovelId != character.NovelId)
{ {
logger.LogWarning( logger.LogWarning(
"Rejected identity link: character {CharacterId} and {SameCharacterAsId} belong to different projects", "Rejected identity link: character {CharacterId} and {SameCharacterAsId} belong to different novels",
characterId, request.SameCharacterAsId); characterId, request.SameCharacterAsId);
throw new InvalidOperationException("Characters must belong to the same project to be linked."); throw new InvalidOperationException("Characters must belong to the same novel to be linked.");
} }
if (await db.Characters.AnyAsync(c => c.SameCharacterAsId == characterId, ct)) if (await db.Characters.AnyAsync(c => c.SameCharacterAsId == characterId, ct))
@@ -291,11 +304,11 @@ public class CharacterService(
if (request.RevealedInChapterId is { } chapterId) if (request.RevealedInChapterId is { } chapterId)
{ {
var chapterInProject = await db.Chapters.AnyAsync(c => c.Id == chapterId && c.ProjectId == character.ProjectId, ct); var chapterInNovel = await db.Chapters.AnyAsync(c => c.Id == chapterId && c.NovelId == character.NovelId, ct);
if (!chapterInProject) if (!chapterInNovel)
{ {
logger.LogWarning("Rejected identity link: chapter {ChapterId} not in project {ProjectId}", chapterId, character.ProjectId); logger.LogWarning("Rejected identity link: chapter {ChapterId} not in novel {NovelId}", chapterId, character.NovelId);
throw new InvalidOperationException("The reveal chapter must belong to the same project."); throw new InvalidOperationException("The reveal chapter must belong to the same novel.");
} }
} }
@@ -320,7 +333,7 @@ public class CharacterService(
return false; return false;
} }
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
character.SameCharacterAsId = null; character.SameCharacterAsId = null;
character.RevealedInChapterId = null; character.RevealedInChapterId = null;
@@ -338,6 +351,9 @@ public class CharacterService(
.Include(c => c.Tags) .Include(c => c.Tags)
.Include(c => c.ArcStages) .Include(c => c.ArcStages)
.ThenInclude(s => s.Chapter) .ThenInclude(s => s.Chapter)
.Include(c => c.ArcStages)
.ThenInclude(s => s.Beats)
.ThenInclude(b => b.Chapter)
.Include(c => c.SameCharacterAs) .Include(c => c.SameCharacterAs)
.Include(c => c.OtherIdentities) .Include(c => c.OtherIdentities)
.Include(c => c.RevealedInChapter); .Include(c => c.RevealedInChapter);
@@ -13,7 +13,8 @@ using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
using Novelly.Api.Genres; using Novelly.Api.Genres;
using Novelly.Api.Imports; using Novelly.Api.Imports;
using Novelly.Api.Projects; using Novelly.Api.Locations;
using Novelly.Api.Novels;
using Novelly.Api.Questions; using Novelly.Api.Questions;
using Novelly.Api.Tags; using Novelly.Api.Tags;
using Novelly.Api.Users; using Novelly.Api.Users;
@@ -48,7 +49,7 @@ public static class NovellyServiceRegistration
services.AddScoped<IUserClaimsPrincipalFactory<NovellyUser>, NovellyUserClaimsPrincipalFactory>(); services.AddScoped<IUserClaimsPrincipalFactory<NovellyUser>, NovellyUserClaimsPrincipalFactory>();
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddScoped<INovelUserContext, NovelUserContext>(); services.AddScoped<INovelUserContext, NovelUserContext>();
services.AddScoped<ProjectAccessService>(); services.AddScoped<NovelAccessService>();
services.ConfigureApplicationCookie(options => services.ConfigureApplicationCookie(options =>
{ {
@@ -70,13 +71,14 @@ public static class NovellyServiceRegistration
}); });
services.AddScoped<UserAccountService>(); services.AddScoped<UserAccountService>();
services.AddScoped<ProjectMemberService>(); services.AddScoped<NovelMemberService>();
services.AddScoped<ProjectService>(); services.AddScoped<NovelService>();
services.AddScoped<CharacterService>(); services.AddScoped<CharacterService>();
services.AddScoped<CharacterArcService>(); services.AddScoped<CharacterArcService>();
services.AddScoped<BeatService>(); services.AddScoped<BeatService>();
services.AddScoped<TagService>(); services.AddScoped<TagService>();
services.AddScoped<LocationService>();
services.AddScoped<GenreService>(); services.AddScoped<GenreService>();
services.AddScoped<ChapterService>(); services.AddScoped<ChapterService>();
services.AddScoped<OpenQuestionService>(); services.AddScoped<OpenQuestionService>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Novelly.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AddArcStageResultAndBeats : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "Description",
table: "CharacterArcStages",
newName: "Result");
migrationBuilder.CreateTable(
name: "ArcStageBeats",
columns: table => new
{
ArcStagesId = table.Column<Guid>(type: "TEXT", nullable: false),
BeatsId = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ArcStageBeats", x => new { x.ArcStagesId, x.BeatsId });
table.ForeignKey(
name: "FK_ArcStageBeats_Beats_BeatsId",
column: x => x.BeatsId,
principalTable: "Beats",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ArcStageBeats_CharacterArcStages_ArcStagesId",
column: x => x.ArcStagesId,
principalTable: "CharacterArcStages",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ArcStageBeats_BeatsId",
table: "ArcStageBeats",
column: "BeatsId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ArcStageBeats");
migrationBuilder.RenameColumn(
name: "Result",
table: "CharacterArcStages",
newName: "Description");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,363 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Novelly.Api.Data.Migrations
{
/// <inheritdoc />
public partial class RenameProjectToNovel : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Chapters_Projects_ProjectId",
table: "Chapters");
migrationBuilder.DropForeignKey(
name: "FK_Characters_Projects_ProjectId",
table: "Characters");
migrationBuilder.DropForeignKey(
name: "FK_Conversations_Projects_ProjectId",
table: "Conversations");
migrationBuilder.DropForeignKey(
name: "FK_OpenQuestions_Projects_ProjectId",
table: "OpenQuestions");
migrationBuilder.DropForeignKey(
name: "FK_Tags_Projects_ProjectId",
table: "Tags");
migrationBuilder.DropForeignKey(
name: "FK_ProjectMembers_Projects_ProjectId",
table: "ProjectMembers");
migrationBuilder.RenameTable(
name: "Projects",
newName: "Novels");
migrationBuilder.RenameTable(
name: "ProjectMembers",
newName: "NovelMembers");
migrationBuilder.RenameColumn(
name: "ProjectId",
table: "Tags",
newName: "NovelId");
migrationBuilder.RenameIndex(
name: "IX_Tags_ProjectId_Name",
table: "Tags",
newName: "IX_Tags_NovelId_Name");
migrationBuilder.RenameColumn(
name: "ProjectId",
table: "OpenQuestions",
newName: "NovelId");
migrationBuilder.RenameIndex(
name: "IX_OpenQuestions_ProjectId",
table: "OpenQuestions",
newName: "IX_OpenQuestions_NovelId");
migrationBuilder.RenameColumn(
name: "ProjectId",
table: "ImportJobs",
newName: "NovelId");
migrationBuilder.RenameColumn(
name: "ProjectId",
table: "Conversations",
newName: "NovelId");
migrationBuilder.RenameIndex(
name: "IX_Conversations_ProjectId",
table: "Conversations",
newName: "IX_Conversations_NovelId");
migrationBuilder.RenameColumn(
name: "ProjectId",
table: "Characters",
newName: "NovelId");
migrationBuilder.RenameIndex(
name: "IX_Characters_ProjectId",
table: "Characters",
newName: "IX_Characters_NovelId");
migrationBuilder.RenameColumn(
name: "ProjectId",
table: "Chapters",
newName: "NovelId");
migrationBuilder.RenameIndex(
name: "IX_Chapters_ProjectId_Number",
table: "Chapters",
newName: "IX_Chapters_NovelId_Number");
migrationBuilder.RenameColumn(
name: "ProjectId",
table: "NovelMembers",
newName: "NovelId");
migrationBuilder.RenameColumn(
name: "ProjectRole",
table: "NovelMembers",
newName: "NovelRole");
migrationBuilder.RenameIndex(
name: "IX_ProjectMembers_ProjectId_UserId",
table: "NovelMembers",
newName: "IX_NovelMembers_NovelId_UserId");
migrationBuilder.RenameIndex(
name: "IX_ProjectMembers_UserId",
table: "NovelMembers",
newName: "IX_NovelMembers_UserId");
migrationBuilder.RenameIndex(
name: "IX_Projects_OwnerId",
table: "Novels",
newName: "IX_Novels_OwnerId");
migrationBuilder.DropForeignKey(
name: "FK_ProjectMembers_AspNetUsers_UserId",
table: "NovelMembers");
migrationBuilder.AddForeignKey(
name: "FK_NovelMembers_AspNetUsers_UserId",
table: "NovelMembers",
column: "UserId",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_NovelMembers_Novels_NovelId",
table: "NovelMembers",
column: "NovelId",
principalTable: "Novels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Chapters_Novels_NovelId",
table: "Chapters",
column: "NovelId",
principalTable: "Novels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Characters_Novels_NovelId",
table: "Characters",
column: "NovelId",
principalTable: "Novels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Conversations_Novels_NovelId",
table: "Conversations",
column: "NovelId",
principalTable: "Novels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_OpenQuestions_Novels_NovelId",
table: "OpenQuestions",
column: "NovelId",
principalTable: "Novels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Tags_Novels_NovelId",
table: "Tags",
column: "NovelId",
principalTable: "Novels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Chapters_Novels_NovelId",
table: "Chapters");
migrationBuilder.DropForeignKey(
name: "FK_Characters_Novels_NovelId",
table: "Characters");
migrationBuilder.DropForeignKey(
name: "FK_Conversations_Novels_NovelId",
table: "Conversations");
migrationBuilder.DropForeignKey(
name: "FK_OpenQuestions_Novels_NovelId",
table: "OpenQuestions");
migrationBuilder.DropForeignKey(
name: "FK_Tags_Novels_NovelId",
table: "Tags");
migrationBuilder.DropForeignKey(
name: "FK_NovelMembers_Novels_NovelId",
table: "NovelMembers");
migrationBuilder.DropForeignKey(
name: "FK_NovelMembers_AspNetUsers_UserId",
table: "NovelMembers");
migrationBuilder.AddForeignKey(
name: "FK_ProjectMembers_AspNetUsers_UserId",
table: "NovelMembers",
column: "UserId",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.RenameIndex(
name: "IX_Novels_OwnerId",
table: "Novels",
newName: "IX_Projects_OwnerId");
migrationBuilder.RenameIndex(
name: "IX_NovelMembers_UserId",
table: "NovelMembers",
newName: "IX_ProjectMembers_UserId");
migrationBuilder.RenameIndex(
name: "IX_NovelMembers_NovelId_UserId",
table: "NovelMembers",
newName: "IX_ProjectMembers_ProjectId_UserId");
migrationBuilder.RenameColumn(
name: "NovelRole",
table: "NovelMembers",
newName: "ProjectRole");
migrationBuilder.RenameColumn(
name: "NovelId",
table: "NovelMembers",
newName: "ProjectId");
migrationBuilder.RenameIndex(
name: "IX_Chapters_NovelId_Number",
table: "Chapters",
newName: "IX_Chapters_ProjectId_Number");
migrationBuilder.RenameColumn(
name: "NovelId",
table: "Chapters",
newName: "ProjectId");
migrationBuilder.RenameIndex(
name: "IX_Characters_NovelId",
table: "Characters",
newName: "IX_Characters_ProjectId");
migrationBuilder.RenameColumn(
name: "NovelId",
table: "Characters",
newName: "ProjectId");
migrationBuilder.RenameIndex(
name: "IX_Conversations_NovelId",
table: "Conversations",
newName: "IX_Conversations_ProjectId");
migrationBuilder.RenameColumn(
name: "NovelId",
table: "Conversations",
newName: "ProjectId");
migrationBuilder.RenameColumn(
name: "NovelId",
table: "ImportJobs",
newName: "ProjectId");
migrationBuilder.RenameIndex(
name: "IX_OpenQuestions_NovelId",
table: "OpenQuestions",
newName: "IX_OpenQuestions_ProjectId");
migrationBuilder.RenameColumn(
name: "NovelId",
table: "OpenQuestions",
newName: "ProjectId");
migrationBuilder.RenameIndex(
name: "IX_Tags_NovelId_Name",
table: "Tags",
newName: "IX_Tags_ProjectId_Name");
migrationBuilder.RenameColumn(
name: "NovelId",
table: "Tags",
newName: "ProjectId");
migrationBuilder.RenameTable(
name: "NovelMembers",
newName: "ProjectMembers");
migrationBuilder.RenameTable(
name: "Novels",
newName: "Projects");
migrationBuilder.AddForeignKey(
name: "FK_ProjectMembers_Projects_ProjectId",
table: "ProjectMembers",
column: "ProjectId",
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Chapters_Projects_ProjectId",
table: "Chapters",
column: "ProjectId",
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Characters_Projects_ProjectId",
table: "Characters",
column: "ProjectId",
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Conversations_Projects_ProjectId",
table: "Conversations",
column: "ProjectId",
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_OpenQuestions_Projects_ProjectId",
table: "OpenQuestions",
column: "ProjectId",
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Tags_Projects_ProjectId",
table: "Tags",
column: "ProjectId",
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,90 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Novelly.Api.Data.Migrations
{
/// <inheritdoc />
public partial class RenameSettingToLocations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Setting",
table: "Chapters");
migrationBuilder.CreateTable(
name: "Locations",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
NovelId = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false),
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Locations", x => x.Id);
table.ForeignKey(
name: "FK_Locations_Novels_NovelId",
column: x => x.NovelId,
principalTable: "Novels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ChapterLocations",
columns: table => new
{
ChaptersId = table.Column<Guid>(type: "TEXT", nullable: false),
LocationsId = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ChapterLocations", x => new { x.ChaptersId, x.LocationsId });
table.ForeignKey(
name: "FK_ChapterLocations_Chapters_ChaptersId",
column: x => x.ChaptersId,
principalTable: "Chapters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ChapterLocations_Locations_LocationsId",
column: x => x.LocationsId,
principalTable: "Locations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ChapterLocations_LocationsId",
table: "ChapterLocations",
column: "LocationsId");
migrationBuilder.CreateIndex(
name: "IX_Locations_NovelId_Name",
table: "Locations",
columns: new[] { "NovelId", "Name" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ChapterLocations");
migrationBuilder.DropTable(
name: "Locations");
migrationBuilder.AddColumn<string>(
name: "Setting",
table: "Chapters",
type: "TEXT",
nullable: true);
}
}
}
@@ -32,6 +32,21 @@ namespace Novelly.Api.Data.Migrations
b.ToTable("BeatCharacters", (string)null); b.ToTable("BeatCharacters", (string)null);
}); });
modelBuilder.Entity("BeatCharacterArcStage", b =>
{
b.Property<Guid>("ArcStagesId")
.HasColumnType("TEXT");
b.Property<Guid>("BeatsId")
.HasColumnType("TEXT");
b.HasKey("ArcStagesId", "BeatsId");
b.HasIndex("BeatsId");
b.ToTable("ArcStageBeats", (string)null);
});
modelBuilder.Entity("BeatTag", b => modelBuilder.Entity("BeatTag", b =>
{ {
b.Property<Guid>("BeatsId") b.Property<Guid>("BeatsId")
@@ -47,6 +62,21 @@ namespace Novelly.Api.Data.Migrations
b.ToTable("BeatTags", (string)null); b.ToTable("BeatTags", (string)null);
}); });
modelBuilder.Entity("ChapterLocation", b =>
{
b.Property<Guid>("ChaptersId")
.HasColumnType("TEXT");
b.Property<Guid>("LocationsId")
.HasColumnType("TEXT");
b.HasKey("ChaptersId", "LocationsId");
b.HasIndex("LocationsId");
b.ToTable("ChapterLocations", (string)null);
});
modelBuilder.Entity("ChapterTag", b => modelBuilder.Entity("ChapterTag", b =>
{ {
b.Property<Guid>("ChaptersId") b.Property<Guid>("ChaptersId")
@@ -148,7 +178,7 @@ namespace Novelly.Api.Data.Migrations
b.Property<long>("CreatedAt") b.Property<long>("CreatedAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<Guid>("ProjectId") b.Property<Guid>("NovelId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Title") b.Property<string>("Title")
@@ -161,7 +191,7 @@ namespace Novelly.Api.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ProjectId"); b.HasIndex("NovelId");
b.ToTable("Conversations"); b.ToTable("Conversations");
}); });
@@ -249,18 +279,15 @@ namespace Novelly.Api.Data.Migrations
b.Property<string>("Notes") b.Property<string>("Notes")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid>("NovelId")
.HasColumnType("TEXT");
b.Property<int>("Number") b.Property<int>("Number")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("Prose") b.Property<string>("Prose")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Setting")
.HasColumnType("TEXT");
b.Property<string>("Status") b.Property<string>("Status")
.IsRequired() .IsRequired()
.HasMaxLength(32) .HasMaxLength(32)
@@ -285,7 +312,7 @@ namespace Novelly.Api.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ProjectId", "Number"); b.HasIndex("NovelId", "Number");
b.ToTable("Chapters"); b.ToTable("Chapters");
}); });
@@ -340,15 +367,15 @@ namespace Novelly.Api.Data.Migrations
b.Property<string>("Notes") b.Property<string>("Notes")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid>("NovelId")
.HasColumnType("TEXT");
b.Property<string>("Occupation") b.Property<string>("Occupation")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Personality") b.Property<string>("Personality")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("Pronouns") b.Property<string>("Pronouns")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -374,7 +401,7 @@ namespace Novelly.Api.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ProjectId"); b.HasIndex("NovelId");
b.HasIndex("RevealedInChapterId"); b.HasIndex("RevealedInChapterId");
@@ -398,7 +425,7 @@ namespace Novelly.Api.Data.Migrations
b.Property<long>("CreatedAt") b.Property<long>("CreatedAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("Description") b.Property<string>("Result")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int>("SortOrder") b.Property<int>("SortOrder")
@@ -576,7 +603,7 @@ namespace Novelly.Api.Data.Migrations
b.Property<long>("CreatedAt") b.Property<long>("CreatedAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<Guid?>("ProjectId") b.Property<Guid?>("NovelId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid?>("RequestedByUserId") b.Property<Guid?>("RequestedByUserId")
@@ -605,7 +632,32 @@ namespace Novelly.Api.Data.Migrations
b.ToTable("ImportJobs"); b.ToTable("ImportJobs");
}); });
modelBuilder.Entity("Novelly.Api.Projects.Project", b => modelBuilder.Entity("Novelly.Api.Locations.Location", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<Guid>("NovelId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NovelId", "Name")
.IsUnique();
b.ToTable("Locations");
});
modelBuilder.Entity("Novelly.Api.Novels.Novel", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -652,7 +704,7 @@ namespace Novelly.Api.Data.Migrations
b.HasIndex("OwnerId"); b.HasIndex("OwnerId");
b.ToTable("Projects"); b.ToTable("Novels");
}); });
modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b => modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b =>
@@ -673,7 +725,7 @@ namespace Novelly.Api.Data.Migrations
b.Property<string>("Detail") b.Property<string>("Detail")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid>("ProjectId") b.Property<Guid>("NovelId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Question") b.Property<string>("Question")
@@ -696,7 +748,7 @@ namespace Novelly.Api.Data.Migrations
b.HasIndex("CharacterId"); b.HasIndex("CharacterId");
b.HasIndex("ProjectId"); b.HasIndex("NovelId");
b.ToTable("OpenQuestions"); b.ToTable("OpenQuestions");
}); });
@@ -719,17 +771,50 @@ namespace Novelly.Api.Data.Migrations
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid>("ProjectId") b.Property<Guid>("NovelId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ProjectId", "Name") b.HasIndex("NovelId", "Name")
.IsUnique(); .IsUnique();
b.ToTable("Tags"); b.ToTable("Tags");
}); });
modelBuilder.Entity("Novelly.Api.Users.NovelMember", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("GrantedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("GrantedByUserId")
.HasColumnType("TEXT");
b.Property<Guid>("NovelId")
.HasColumnType("TEXT");
b.Property<string>("NovelRole")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("NovelId", "UserId")
.IsUnique();
b.ToTable("NovelMembers");
});
modelBuilder.Entity("Novelly.Api.Users.NovellyUser", b => modelBuilder.Entity("Novelly.Api.Users.NovellyUser", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -808,39 +893,6 @@ namespace Novelly.Api.Data.Migrations
b.ToTable("AspNetUsers", (string)null); b.ToTable("AspNetUsers", (string)null);
}); });
modelBuilder.Entity("Novelly.Api.Users.ProjectMember", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("GrantedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("GrantedByUserId")
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("ProjectRole")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("ProjectId", "UserId")
.IsUnique();
b.ToTable("ProjectMembers");
});
modelBuilder.Entity("BeatCharacter", b => modelBuilder.Entity("BeatCharacter", b =>
{ {
b.HasOne("Novelly.Api.Beats.Beat", null) b.HasOne("Novelly.Api.Beats.Beat", null)
@@ -856,6 +908,21 @@ namespace Novelly.Api.Data.Migrations
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("BeatCharacterArcStage", b =>
{
b.HasOne("Novelly.Api.Characters.CharacterArcStage", null)
.WithMany()
.HasForeignKey("ArcStagesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Novelly.Api.Beats.Beat", null)
.WithMany()
.HasForeignKey("BeatsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("BeatTag", b => modelBuilder.Entity("BeatTag", b =>
{ {
b.HasOne("Novelly.Api.Beats.Beat", null) b.HasOne("Novelly.Api.Beats.Beat", null)
@@ -871,6 +938,21 @@ namespace Novelly.Api.Data.Migrations
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("ChapterLocation", b =>
{
b.HasOne("Novelly.Api.Chapters.Chapter", null)
.WithMany()
.HasForeignKey("ChaptersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Novelly.Api.Locations.Location", null)
.WithMany()
.HasForeignKey("LocationsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ChapterTag", b => modelBuilder.Entity("ChapterTag", b =>
{ {
b.HasOne("Novelly.Api.Chapters.Chapter", null) b.HasOne("Novelly.Api.Chapters.Chapter", null)
@@ -930,13 +1012,13 @@ namespace Novelly.Api.Data.Migrations
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
{ {
b.HasOne("Novelly.Api.Projects.Project", "Project") b.HasOne("Novelly.Api.Novels.Novel", "Novel")
.WithMany("Conversations") .WithMany("Conversations")
.HasForeignKey("ProjectId") .HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.Navigation("Project"); b.Navigation("Novel");
}); });
modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b => modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b =>
@@ -963,20 +1045,20 @@ namespace Novelly.Api.Data.Migrations
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
{ {
b.HasOne("Novelly.Api.Projects.Project", "Project") b.HasOne("Novelly.Api.Novels.Novel", "Novel")
.WithMany("Chapters") .WithMany("Chapters")
.HasForeignKey("ProjectId") .HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.Navigation("Project"); b.Navigation("Novel");
}); });
modelBuilder.Entity("Novelly.Api.Characters.Character", b => modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
{ {
b.HasOne("Novelly.Api.Projects.Project", "Project") b.HasOne("Novelly.Api.Novels.Novel", "Novel")
.WithMany("Characters") .WithMany("Characters")
.HasForeignKey("ProjectId") .HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
@@ -990,7 +1072,7 @@ namespace Novelly.Api.Data.Migrations
.HasForeignKey("SameCharacterAsId") .HasForeignKey("SameCharacterAsId")
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
b.Navigation("Project"); b.Navigation("Novel");
b.Navigation("RevealedInChapter"); b.Navigation("RevealedInChapter");
@@ -1034,7 +1116,18 @@ namespace Novelly.Api.Data.Migrations
b.Navigation("RelatedCharacter"); b.Navigation("RelatedCharacter");
}); });
modelBuilder.Entity("Novelly.Api.Projects.Project", b => modelBuilder.Entity("Novelly.Api.Locations.Location", b =>
{
b.HasOne("Novelly.Api.Novels.Novel", "Novel")
.WithMany()
.HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Novel");
});
modelBuilder.Entity("Novelly.Api.Novels.Novel", b =>
{ {
b.HasOne("Novelly.Api.Users.NovellyUser", "Owner") b.HasOne("Novelly.Api.Users.NovellyUser", "Owner")
.WithMany() .WithMany()
@@ -1056,9 +1149,9 @@ namespace Novelly.Api.Data.Migrations
.HasForeignKey("CharacterId") .HasForeignKey("CharacterId")
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
b.HasOne("Novelly.Api.Projects.Project", "Project") b.HasOne("Novelly.Api.Novels.Novel", "Novel")
.WithMany() .WithMany()
.HasForeignKey("ProjectId") .HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
@@ -1066,25 +1159,25 @@ namespace Novelly.Api.Data.Migrations
b.Navigation("Character"); b.Navigation("Character");
b.Navigation("Project"); b.Navigation("Novel");
}); });
modelBuilder.Entity("Novelly.Api.Tags.Tag", b => modelBuilder.Entity("Novelly.Api.Tags.Tag", b =>
{ {
b.HasOne("Novelly.Api.Projects.Project", "Project") b.HasOne("Novelly.Api.Novels.Novel", "Novel")
.WithMany("Tags") .WithMany("Tags")
.HasForeignKey("ProjectId") .HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.Navigation("Project"); b.Navigation("Novel");
}); });
modelBuilder.Entity("Novelly.Api.Users.ProjectMember", b => modelBuilder.Entity("Novelly.Api.Users.NovelMember", b =>
{ {
b.HasOne("Novelly.Api.Projects.Project", "Project") b.HasOne("Novelly.Api.Novels.Novel", "Novel")
.WithMany("Members") .WithMany("Members")
.HasForeignKey("ProjectId") .HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
@@ -1094,7 +1187,7 @@ namespace Novelly.Api.Data.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.Navigation("Project"); b.Navigation("Novel");
b.Navigation("User"); b.Navigation("User");
}); });
@@ -1118,7 +1211,7 @@ namespace Novelly.Api.Data.Migrations
b.Navigation("Relationships"); b.Navigation("Relationships");
}); });
modelBuilder.Entity("Novelly.Api.Projects.Project", b => modelBuilder.Entity("Novelly.Api.Novels.Novel", b =>
{ {
b.Navigation("Chapters"); b.Navigation("Chapters");
+8 -5
View File
@@ -7,7 +7,8 @@ using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Genres; using Novelly.Api.Genres;
using Novelly.Api.Imports; using Novelly.Api.Imports;
using Novelly.Api.Projects; using Novelly.Api.Locations;
using Novelly.Api.Novels;
using Novelly.Api.Questions; using Novelly.Api.Questions;
using Novelly.Api.Tags; using Novelly.Api.Tags;
using Novelly.Api.Users; using Novelly.Api.Users;
@@ -18,19 +19,20 @@ internal class UtcTicksConverter() : ValueConverter<DateTimeOffset, long>(value
public class NovelDbContext(DbContextOptions<NovelDbContext> options) : IdentityUserContext<NovellyUser, Guid>(options), INovelDbContext public class NovelDbContext(DbContextOptions<NovelDbContext> options) : IdentityUserContext<NovellyUser, Guid>(options), INovelDbContext
{ {
public DbSet<Project> Projects => Set<Project>(); public DbSet<Novel> Novels => Set<Novel>();
public DbSet<Character> Characters => Set<Character>(); public DbSet<Character> Characters => Set<Character>();
public DbSet<CharacterRelationship> CharacterRelationships => Set<CharacterRelationship>(); public DbSet<CharacterRelationship> CharacterRelationships => Set<CharacterRelationship>();
public DbSet<CharacterArcStage> CharacterArcStages => Set<CharacterArcStage>(); public DbSet<CharacterArcStage> CharacterArcStages => Set<CharacterArcStage>();
public DbSet<Beat> Beats => Set<Beat>(); public DbSet<Beat> Beats => Set<Beat>();
public DbSet<Tag> Tags => Set<Tag>(); public DbSet<Tag> Tags => Set<Tag>();
public DbSet<Location> Locations => Set<Location>();
public DbSet<Chapter> Chapters => Set<Chapter>(); public DbSet<Chapter> Chapters => Set<Chapter>();
public DbSet<OpenQuestion> OpenQuestions => Set<OpenQuestion>(); public DbSet<OpenQuestion> OpenQuestions => Set<OpenQuestion>();
public DbSet<AgentConversation> Conversations => Set<AgentConversation>(); public DbSet<AgentConversation> Conversations => Set<AgentConversation>();
public DbSet<AgentMessage> AgentMessages => Set<AgentMessage>(); public DbSet<AgentMessage> AgentMessages => Set<AgentMessage>();
public DbSet<ImportJob> ImportJobs => Set<ImportJob>(); public DbSet<ImportJob> ImportJobs => Set<ImportJob>();
public DbSet<Genre> Genres => Set<Genre>(); public DbSet<Genre> Genres => Set<Genre>();
public DbSet<ProjectMember> ProjectMembers => Set<ProjectMember>(); public DbSet<NovelMember> NovelMembers => Set<NovelMember>();
Task<int> INovelDbContext.SaveChangesAsync(CancellationToken cancellationToken) => base.SaveChangesAsync(cancellationToken); Task<int> INovelDbContext.SaveChangesAsync(CancellationToken cancellationToken) => base.SaveChangesAsync(cancellationToken);
@@ -45,12 +47,13 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options) : Identity
public interface INovelDbContext public interface INovelDbContext
{ {
DbSet<Project> Projects { get; } DbSet<Novel> Novels { get; }
DbSet<Character> Characters { get; } DbSet<Character> Characters { get; }
DbSet<CharacterRelationship> CharacterRelationships { get; } DbSet<CharacterRelationship> CharacterRelationships { get; }
DbSet<CharacterArcStage> CharacterArcStages { get; } DbSet<CharacterArcStage> CharacterArcStages { get; }
DbSet<Beat> Beats { get; } DbSet<Beat> Beats { get; }
DbSet<Tag> Tags { get; } DbSet<Tag> Tags { get; }
DbSet<Location> Locations { get; }
DbSet<Chapter> Chapters { get; } DbSet<Chapter> Chapters { get; }
DbSet<OpenQuestion> OpenQuestions { get; } DbSet<OpenQuestion> OpenQuestions { get; }
DbSet<AgentConversation> Conversations { get; } DbSet<AgentConversation> Conversations { get; }
@@ -58,7 +61,7 @@ public interface INovelDbContext
DbSet<ImportJob> ImportJobs { get; } DbSet<ImportJob> ImportJobs { get; }
DbSet<Genre> Genres { get; } DbSet<Genre> Genres { get; }
DbSet<NovellyUser> Users { get; } DbSet<NovellyUser> Users { get; }
DbSet<ProjectMember> ProjectMembers { get; } DbSet<NovelMember> NovelMembers { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default); Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
} }
+5
View File
@@ -9,6 +9,11 @@ RUN dotnet publish src/Novelly.Api/Novelly.Api.csproj -c Release -o /app
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app WORKDIR /app
# curl is used by the docker-compose healthcheck; the base image ships neither curl nor
# wget, so the healthcheck silently fails as "unhealthy" without it.
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
COPY --from=build /app . COPY --from=build /app .
ENV ASPNETCORE_URLS=http://0.0.0.0:8080 ENV ASPNETCORE_URLS=http://0.0.0.0:8080
+13 -13
View File
@@ -3,7 +3,7 @@ using Novelly.Api.Agent;
namespace Novelly.Api.Imports; namespace Novelly.Api.Imports;
public record ImportRunResult(bool Completed, Guid? ProjectId, int ChaptersCompleted, string? Message); public record ImportRunResult(bool Completed, Guid? NovelId, int ChaptersCompleted, string? Message);
public class ImportAgentService( public class ImportAgentService(
IAgentModelClient model, IAgentModelClient model,
@@ -14,13 +14,13 @@ public class ImportAgentService(
private readonly AgentOptions _options = options.Value; private readonly AgentOptions _options = options.Value;
public async Task<ImportRunResult> RunAsync( public async Task<ImportRunResult> RunAsync(
string sourceRoot, Guid? existingProjectId, int chaptersTotal, CancellationToken ct = default) string sourceRoot, Guid? existingNovelId, int chaptersTotal, CancellationToken ct = default)
{ {
logger.LogInformation( logger.LogInformation(
"Running import for {SourceRoot}, existing project {ExistingProjectId}, {ChaptersTotal} chapters total", "Running import for {SourceRoot}, existing novel {ExistingNovelId}, {ChaptersTotal} chapters total",
sourceRoot, existingProjectId, chaptersTotal); sourceRoot, existingNovelId, chaptersTotal);
toolset.Initialize(sourceRoot, existingProjectId); toolset.Initialize(sourceRoot, existingNovelId);
var startingLedger = toolset.ReadLedgerOrNull(); var startingLedger = toolset.ReadLedgerOrNull();
var systemPrompt = BuildSystemPrompt(sourceRoot); var systemPrompt = BuildSystemPrompt(sourceRoot);
@@ -46,7 +46,7 @@ public class ImportAgentService(
logger.LogInformation("Import for {SourceRoot} completed after {Turns} turns", sourceRoot, turn + 1); logger.LogInformation("Import for {SourceRoot} completed after {Turns} turns", sourceRoot, turn + 1);
return new ImportRunResult( return new ImportRunResult(
Completed: true, Completed: true,
toolset.ProjectId, toolset.NovelId,
ledger?.CompletedChapters?.Count ?? 0, ledger?.CompletedChapters?.Count ?? 0,
null); null);
} }
@@ -60,7 +60,7 @@ public class ImportAgentService(
return new ImportRunResult( return new ImportRunResult(
Completed: false, Completed: false,
toolset.ProjectId, toolset.NovelId,
finalLedger?.CompletedChapters?.Count ?? 0, finalLedger?.CompletedChapters?.Count ?? 0,
"Reached the safety limit for this run without finishing. Starting the import " "Reached the safety limit for this run without finishing. Starting the import "
+ "again for the same folder will resume from the ledger."); + "again for the same folder will resume from the ledger.");
@@ -107,12 +107,12 @@ public class ImportAgentService(
private const string SystemPromptTemplate = """ private const string SystemPromptTemplate = """
You import a novel outline that already exists as markdown files on disk into this You import a novel outline that already exists as markdown files on disk into this
app's project data. You are running unattended nobody will read your replies or app's novel data. You are running unattended nobody will read your replies or
answer questions mid-run, so make the judgment calls the instructions below call answer questions mid-run, so make the judgment calls the instructions below call
for yourself and record anything genuinely ambiguous rather than stalling on it. for yourself and record anything genuinely ambiguous rather than stalling on it.
Your tools give you exactly two things: read-only access to files under the import Your tools give you exactly two things: read-only access to files under the import
source folder, and application tools that create the project's chapters, characters, source folder, and application tools that create the novel's chapters, characters,
beats and arcs the same ones the writer's own UI uses. You cannot write or edit beats and arcs the same ones the writer's own UI uses. You cannot write or edit
anything on disk except the resume ledger, and you cannot read anything outside the anything on disk except the resume ledger, and you cannot read anything outside the
source folder. source folder.
@@ -143,10 +143,10 @@ public class ImportAgentService(
```json ```json
{{ {{
"projectId": "guid", "novelId": "guid",
"characters": {{ "Name": "guid", "Alias": "guid" }}, "characters": {{ "Name": "guid", "Alias": "guid" }},
"chapters": {{ "1": "guid" }}, "chapters": {{ "1": "guid" }},
"completedPasses": ["project", "characters"], "completedPasses": ["novel", "characters"],
"completedChapters": [1, 2, 3] "completedChapters": [1, 2, 3]
}} }}
``` ```
@@ -160,9 +160,9 @@ public class ImportAgentService(
Skip a pass whose completion is already recorded. Jump straight to the first Skip a pass whose completion is already recorded. Jump straight to the first
incomplete one. incomplete one.
1. **Project** skip if `completedPasses` has "project". Parse title and author from 1. **Novel** skip if `completedPasses` has "novel". Parse title and author from
`outline.md`'s heading. The paragraph(s) before the chapter table are the blurb `outline.md`'s heading. The paragraph(s) before the chapter table are the blurb
pass them as `notes` to create_project. Record `projectId`, mark "project" done. pass them as `notes` to create_novel. Record `novelId`, mark "novel" done.
2. **Characters (dossiers)** skip if "characters" is complete. For each 2. **Characters (dossiers)** skip if "characters" is complete. For each
`characters/*.md` not already in the ledger's `characters` map: name from the `#` `characters/*.md` not already in the ledger's `characters` map: name from the `#`
heading, occupation from the tagline, appearance/backstory/want from heading, occupation from the tagline, appearance/backstory/want from
+24 -24
View File
@@ -4,7 +4,7 @@ using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Projects; using Novelly.Api.Novels;
namespace Novelly.Api.Imports; namespace Novelly.Api.Imports;
@@ -20,7 +20,7 @@ internal record ImportAgentTool(
Func<JsonElement, CancellationToken, Task<object?>> Handler); Func<JsonElement, CancellationToken, Task<object?>> Handler);
public class ImportAgentToolset( public class ImportAgentToolset(
ProjectService projects, NovelService novels,
CharacterService characters, CharacterService characters,
CharacterArcService arcs, CharacterArcService arcs,
ChapterService chapters, ChapterService chapters,
@@ -36,15 +36,15 @@ public class ImportAgentToolset(
private string _sourceRoot = string.Empty; private string _sourceRoot = string.Empty;
private Dictionary<string, ImportAgentTool>? _byName; private Dictionary<string, ImportAgentTool>? _byName;
public Guid? ProjectId { get; private set; } public Guid? NovelId { get; private set; }
public IReadOnlyList<AgentToolDefinition> Definitions => public IReadOnlyList<AgentToolDefinition> Definitions =>
[.. ByName.Values.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))]; [.. ByName.Values.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))];
public void Initialize(string sourceRoot, Guid? existingProjectId) public void Initialize(string sourceRoot, Guid? existingNovelId)
{ {
_sourceRoot = sourceRoot; _sourceRoot = sourceRoot;
ProjectId = existingProjectId; NovelId = existingNovelId;
} }
public ImportLedger? ReadLedgerOrNull() => ImportPaths.ReadLedger(_sourceRoot); public ImportLedger? ReadLedgerOrNull() => ImportPaths.ReadLedger(_sourceRoot);
@@ -99,9 +99,9 @@ public class ImportAgentToolset(
} }
} }
private Guid RequireProjectId() => private Guid RequireNovelId() =>
ProjectId ?? throw new InvalidOperationException( NovelId ?? throw new InvalidOperationException(
"No project exists yet for this import — call create_project first."); "No novel exists yet for this import — call create_novel first.");
private Dictionary<string, ImportAgentTool> ByName => _byName ??= Build().ToDictionary(t => t.Name); private Dictionary<string, ImportAgentTool> ByName => _byName ??= Build().ToDictionary(t => t.Name);
@@ -187,8 +187,8 @@ public class ImportAgentToolset(
}); });
yield return new ImportAgentTool( yield return new ImportAgentTool(
"create_project", "create_novel",
"Create the novel project this import populates. Call once, in the first pass.", "Create the novel this import populates. Call once, in the first pass.",
new JsonSchemaBuilder() new JsonSchemaBuilder()
.Str("title", "The book's title.", required: true) .Str("title", "The book's title.", required: true)
.Str("author", "Author name, if known.") .Str("author", "Author name, if known.")
@@ -196,18 +196,18 @@ public class ImportAgentToolset(
.Build(), .Build(),
async (input, ct) => async (input, ct) =>
{ {
var created = await projects.CreateAsync(new CreateProjectRequest( var created = await novels.CreateAsync(new CreateNovelRequest(
JsonInput.RequiredString(input, "title"), JsonInput.RequiredString(input, "title"),
JsonInput.String(input, "author"), JsonInput.String(input, "author"),
Notes: JsonInput.String(input, "notes")), ct); Notes: JsonInput.String(input, "notes")), ct);
ProjectId = created.Id; NovelId = created.Id;
return created.ToResponse(null); return created.ToResponse(null);
}); });
yield return new ImportAgentTool( yield return new ImportAgentTool(
"update_project_brief", "update_novel_brief",
"Revise the project's top-level fields. Only the fields you supply change.", "Revise the novel's top-level fields. Only the fields you supply change.",
new JsonSchemaBuilder() new JsonSchemaBuilder()
.Str("title", "New title.") .Str("title", "New title.")
.Str("author", "Author name.") .Str("author", "Author name.")
@@ -216,15 +216,15 @@ public class ImportAgentToolset(
.Build(), .Build(),
async (input, ct) => async (input, ct) =>
{ {
var projectId = RequireProjectId(); var novelId = RequireNovelId();
var updated = await projects.UpdateAsync(projectId, new UpdateProjectRequest( var updated = await novels.UpdateAsync(novelId, new UpdateNovelRequest(
JsonInput.String(input, "title"), JsonInput.String(input, "title"),
JsonInput.String(input, "author"), JsonInput.String(input, "author"),
JsonInput.String(input, "genre"), JsonInput.String(input, "genre"),
Notes: JsonInput.String(input, "notes")), ct); Notes: JsonInput.String(input, "notes")), ct);
return updated is null return updated is null
? new ImportToolNotFound("Project", projectId) ? new ImportToolNotFound("Novel", novelId)
: updated.ToResponse(null); : updated.ToResponse(null);
}); });
@@ -234,8 +234,8 @@ public class ImportAgentToolset(
CharacterSchema(nameRequired: true).Build(), CharacterSchema(nameRequired: true).Build(),
async (input, ct) => async (input, ct) =>
{ {
var projectId = RequireProjectId(); var novelId = RequireNovelId();
var created = await characters.CreateAsync(projectId, new CreateCharacterRequest( var created = await characters.CreateAsync(novelId, new CreateCharacterRequest(
JsonInput.RequiredString(input, "name"), JsonInput.RequiredString(input, "name"),
Importance: JsonInput.Enum<CharacterImportance>(input, "importance") ?? CharacterImportance.Supporting, Importance: JsonInput.Enum<CharacterImportance>(input, "importance") ?? CharacterImportance.Supporting,
Occupation: JsonInput.String(input, "occupation"), Occupation: JsonInput.String(input, "occupation"),
@@ -245,7 +245,7 @@ public class ImportAgentToolset(
Notes: JsonInput.String(input, "notes")), ct); Notes: JsonInput.String(input, "notes")), ct);
return created is null return created is null
? new ImportToolNotFound("Project", projectId) ? new ImportToolNotFound("Novel", novelId)
: created.ToResponse(); : created.ToResponse();
}); });
@@ -284,8 +284,8 @@ public class ImportAgentToolset(
.Build(), .Build(),
async (input, ct) => async (input, ct) =>
{ {
var projectId = RequireProjectId(); var novelId = RequireNovelId();
var created = await chapters.CreateAsync(projectId, new CreateChapterRequest( var created = await chapters.CreateAsync(novelId, new CreateChapterRequest(
JsonInput.RequiredString(input, "title"), JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "number"), JsonInput.Int(input, "number"),
JsonInput.String(input, "summary"), JsonInput.String(input, "summary"),
@@ -293,7 +293,7 @@ public class ImportAgentToolset(
Tags: JsonInput.Strings(input, "tags")), ct); Tags: JsonInput.Strings(input, "tags")), ct);
return created is null return created is null
? new ImportToolNotFound("Project", projectId) ? new ImportToolNotFound("Novel", novelId)
: created.ToResponse(); : created.ToResponse();
}); });
@@ -359,7 +359,7 @@ public class ImportAgentToolset(
characterId, characterId,
new CreateArcStageRequest( new CreateArcStageRequest(
JsonInput.RequiredString(input, "title"), JsonInput.RequiredString(input, "title"),
Description: JsonInput.String(input, "description"), Result: JsonInput.String(input, "description"),
ChapterId: JsonInput.Guid(input, "chapter_id")), ct); ChapterId: JsonInput.Guid(input, "chapter_id")), ct);
return created is null return created is null
+3 -3
View File
@@ -5,7 +5,7 @@ namespace Novelly.Api.Imports;
public record ImportJobResponse( public record ImportJobResponse(
Guid Id, Guid Id,
string SourceRoot, string SourceRoot,
Guid? ProjectId, Guid? NovelId,
ImportJobStatus Status, ImportJobStatus Status,
string? StatusMessage, string? StatusMessage,
int ChaptersCompleted, int ChaptersCompleted,
@@ -22,7 +22,7 @@ public enum ImportReadiness
public record ImportInspectionResponse( public record ImportInspectionResponse(
ImportReadiness Readiness, ImportReadiness Readiness,
Guid? ProjectId, Guid? NovelId,
int ChaptersCompleted, int ChaptersCompleted,
int ChaptersTotal, int ChaptersTotal,
IReadOnlyList<string> CompletedPasses); IReadOnlyList<string> CompletedPasses);
@@ -62,7 +62,7 @@ public static class ImportMapping
public static ImportJobResponse ToResponse(this ImportJob job) => new( public static ImportJobResponse ToResponse(this ImportJob job) => new(
job.Id, job.Id,
job.SourceRoot, job.SourceRoot,
job.ProjectId, job.NovelId,
job.Status, job.Status,
job.StatusMessage, job.StatusMessage,
job.ChaptersCompleted, job.ChaptersCompleted,
+1 -1
View File
@@ -18,7 +18,7 @@ public class ImportJob
public string SourceRoot { get; init; } = string.Empty; public string SourceRoot { get; init; } = string.Empty;
public Guid? ProjectId { get; set; } public Guid? NovelId { get; set; }
public Guid? RequestedByUserId { get; init; } public Guid? RequestedByUserId { get; init; }
+3 -3
View File
@@ -53,11 +53,11 @@ public class ImportJobRunner(
try try
{ {
var existingProjectId = ImportPaths.ReadLedger(job.SourceRoot)?.ProjectId; var existingNovelId = ImportPaths.ReadLedger(job.SourceRoot)?.NovelId;
var result = await agent.RunAsync(job.SourceRoot, existingProjectId, job.ChaptersTotal, ct); var result = await agent.RunAsync(job.SourceRoot, existingNovelId, job.ChaptersTotal, ct);
job.ProjectId = result.ProjectId; job.NovelId = result.NovelId;
job.ChaptersCompleted = result.ChaptersCompleted; job.ChaptersCompleted = result.ChaptersCompleted;
job.Status = result.Completed ? ImportJobStatus.Completed : ImportJobStatus.Paused; job.Status = result.Completed ? ImportJobStatus.Completed : ImportJobStatus.Paused;
job.StatusMessage = result.Message; job.StatusMessage = result.Message;
+2 -2
View File
@@ -4,7 +4,7 @@ using System.Text.Json.Serialization;
namespace Novelly.Api.Imports; namespace Novelly.Api.Imports;
public record ImportLedger( public record ImportLedger(
Guid? ProjectId, Guid? NovelId,
Dictionary<string, Guid>? Characters, Dictionary<string, Guid>? Characters,
Dictionary<string, Guid>? Chapters, Dictionary<string, Guid>? Chapters,
List<string>? CompletedPasses, List<string>? CompletedPasses,
@@ -100,7 +100,7 @@ internal static class ImportPaths
} }
var passes = ledger.CompletedPasses ?? []; var passes = ledger.CompletedPasses ?? [];
var requiredPasses = new[] { "project", "characters", "chapters", "arcs" }; var requiredPasses = new[] { "novel", "characters", "chapters", "arcs" };
var chaptersDone = ledger.CompletedChapters?.Count ?? 0; var chaptersDone = ledger.CompletedChapters?.Count ?? 0;
return requiredPasses.All(passes.Contains) && (chaptersTotal == 0 || chaptersDone >= chaptersTotal); return requiredPasses.All(passes.Contains) && (chaptersTotal == 0 || chaptersDone >= chaptersTotal);
+6 -6
View File
@@ -3,14 +3,14 @@ using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
using Novelly.Api.Projects; using Novelly.Api.Novels;
using Novelly.Api.Users; using Novelly.Api.Users;
namespace Novelly.Api.Imports; namespace Novelly.Api.Imports;
public class ImportService( public class ImportService(
INovelDbContext db, INovelDbContext db,
ProjectService projects, NovelService novels,
Channel<Guid> queue, Channel<Guid> queue,
INovelUserContext userContext, INovelUserContext userContext,
ILogger<ImportService> logger, ILogger<ImportService> logger,
@@ -37,7 +37,7 @@ public class ImportService(
var readiness = ImportPaths.IsComplete(ledger, total) ? ImportReadiness.Complete : ImportReadiness.Resumable; var readiness = ImportPaths.IsComplete(ledger, total) ? ImportReadiness.Complete : ImportReadiness.Resumable;
return Task.FromResult(new ImportInspectionResponse( return Task.FromResult(new ImportInspectionResponse(
readiness, ledger.ProjectId, chaptersDone, total, ledger.CompletedPasses ?? [])); readiness, ledger.NovelId, chaptersDone, total, ledger.CompletedPasses ?? []));
} }
public async Task<ImportJob> StartOrResumeAsync(StartImportRequest request, CancellationToken ct = default) public async Task<ImportJob> StartOrResumeAsync(StartImportRequest request, CancellationToken ct = default)
@@ -53,11 +53,11 @@ public class ImportService(
if (request.ForceRestart) if (request.ForceRestart)
{ {
var ledger = ImportPaths.ReadLedger(root); var ledger = ImportPaths.ReadLedger(root);
if (ledger?.ProjectId is { } existingProjectId) if (ledger?.NovelId is { } existingNovelId)
{ {
logger.LogWarning( logger.LogWarning(
"Force-restarting import for {SourceRoot}: deleting project {ProjectId}", root, existingProjectId); "Force-restarting import for {SourceRoot}: deleting novel {NovelId}", root, existingNovelId);
await projects.DeleteAsync(existingProjectId, ct); await novels.DeleteAsync(existingNovelId, ct);
} }
ImportPaths.DeleteLedger(root); ImportPaths.DeleteLedger(root);
+33
View File
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Chapters;
using Novelly.Api.Novels;
namespace Novelly.Api.Locations;
public class Location
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid NovelId { get; set; }
public Novel? Novel { get; set; }
public string Name { get; set; } = string.Empty;
public List<Chapter> Chapters { get; set; } = [];
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public class LocationEntityTypeConfiguration : IEntityTypeConfiguration<Location>
{
public void Configure(EntityTypeBuilder<Location> entity)
{
entity.Property(l => l.Name).IsRequired().HasMaxLength(120);
entity.HasIndex(l => new { l.NovelId, l.Name }).IsUnique();
entity.HasMany(l => l.Chapters).WithMany(c => c.Locations)
.UsingEntity(join => join.ToTable("ChapterLocations"));
}
}
@@ -0,0 +1,52 @@
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Locations;
public record LocationResponse(Guid Id, string Name);
public record LocationSummaryResponse(Guid Id, string Name, int ChapterCount);
public record CreateLocationRequest(string Name);
public class CreateLocationRequestValidator : IModelValidator<CreateLocationRequest>
{
public ValidationResult Validate(CreateLocationRequest model)
{
var result = new ValidationResult();
result.AddRequiredTextErrors("Name", "Name", model.Name, 120);
return result;
}
}
public record UpdateLocationRequest(string? Name = null);
public class UpdateLocationRequestValidator : IModelValidator<UpdateLocationRequest>
{
public ValidationResult Validate(UpdateLocationRequest model)
{
var result = new ValidationResult();
result.AddUnclearableTextErrors("Name", "Name", model.Name, "a location", 120);
return result;
}
}
public record LocationReferencesResponse(LocationResponse Location, IReadOnlyList<LocatedChapterResponse> Chapters);
public record LocatedChapterResponse(Guid Id, int Number, string Title, string? Summary);
public static class LocationMapping
{
public static LocationResponse ToResponse(this Location l) => new(l.Id, l.Name);
public static LocationReferencesResponse ToReferencesResponse(this Location location) => new(
location.ToResponse(),
[.. location.Chapters
.OrderBy(c => c.Number)
.Select(c => new LocatedChapterResponse(c.Id, c.Number, c.Title, c.Summary))]);
public static string Normalise(string name) => name.Trim();
}
@@ -0,0 +1,51 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Locations;
public static class LocationEndpoints
{
public static IEndpointRouteBuilder MapLocationEndpoints(this IEndpointRouteBuilder app)
{
var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/locations").WithTags("Locations")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
novelScoped.MapGet("/", async (Guid novelId, LocationService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(novelId, ct)))
.WithSummary("List a novel's locations with usage counts.");
novelScoped.MapPost("/", async (
Guid novelId, CreateLocationRequest request, LocationService service, CancellationToken ct) =>
{
var location = await service.CreateAsync(novelId, request, ct);
if (location is null)
{
return Results.NotFound();
}
var created = location.ToResponse();
return Results.Created($"/api/locations/{created.Id}", created);
})
.WithSummary("Create a location. Locations are also created on demand when applied by name.");
var locations = app.MapGroup("/api/locations").WithTags("Locations")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
locations.MapGet("/{id:guid}/references", async (Guid id, LocationService service, CancellationToken ct) =>
(await service.GetReferencesAsync(id, ct))?.ToReferencesResponse().ToApiResult())
.WithSummary("Cross-reference: every chapter set at this location.");
locations.MapPatch("/{id:guid}", async (
Guid id, UpdateLocationRequest request, LocationService service, CancellationToken ct) =>
(await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult())
.WithSummary("Rename a location.");
locations.MapDelete("/{id:guid}", async (Guid id, LocationService service, CancellationToken ct) =>
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a location. Whatever carried it is left alone.");
return app;
}
}
@@ -0,0 +1,184 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Users;
namespace Novelly.Api.Locations;
public class LocationService(
INovelDbContext db,
NovelAccessService access,
ILogger<LocationService> logger,
IModelValidator<CreateLocationRequest> createValidator,
IModelValidator<UpdateLocationRequest> updateValidator)
{
public async Task<IReadOnlyList<LocationSummaryResponse>> ListAsync(Guid novelId, CancellationToken ct = default)
{
Guard.Default(novelId, nameof(novelId));
logger.LogInformation("Listing locations for novel {NovelId}", novelId);
await access.RequireAsync(novelId, NovelPermission.Read, ct);
return await db.Locations
.Where(l => l.NovelId == novelId)
.OrderBy(l => l.Name)
.Select(l => new LocationSummaryResponse(l.Id, l.Name, l.Chapters.Count))
.ToListAsync(ct);
}
public async Task<Location?> GetReferencesAsync(Guid locationId, CancellationToken ct = default)
{
Guard.Default(locationId, nameof(locationId));
logger.LogInformation("Getting references for location {LocationId}", locationId);
var location = await db.Locations
.Include(l => l.Chapters)
.FirstOrDefaultAsync(l => l.Id == locationId, ct);
if (location is null)
{
logger.LogWarning("Location {LocationId} not found", locationId);
return location;
}
await access.RequireAsync(location.NovelId, NovelPermission.Read, ct);
return location;
}
public async Task<Location?> CreateAsync(Guid novelId, CreateLocationRequest request, CancellationToken ct = default)
{
Guard.Default(novelId, nameof(novelId));
Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Creating location {Name} for novel {NovelId}", request.Name, novelId);
if (!await db.Novels.AnyAsync(p => p.Id == novelId, ct))
{
logger.LogWarning("Rejected location creation: novel {NovelId} not found", novelId);
return null;
}
await access.RequireAsync(novelId, NovelPermission.CreateContent, ct);
var name = LocationMapping.Normalise(request.Name);
var existing = await FindByNameAsync(novelId, name, ct);
if (existing is not null)
{
logger.LogWarning("Rejected location creation for novel {NovelId}: '{Name}' already exists", novelId, existing.Name);
throw new InvalidOperationException($"The novel already has a location called '{existing.Name}'.");
}
var location = new Location { NovelId = novelId, Name = name };
db.Locations.Add(location);
await db.SaveChangesAsync(ct);
return location;
}
public async Task<Location?> UpdateAsync(Guid locationId, UpdateLocationRequest request, CancellationToken ct = default)
{
Guard.Default(locationId, nameof(locationId));
Guard.Null(request, nameof(request));
updateValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Updating location {LocationId}", locationId);
var location = await db.Locations.FirstOrDefaultAsync(l => l.Id == locationId, ct);
if (location is null)
{
logger.LogWarning("Location {LocationId} not found", locationId);
return null;
}
await access.RequireAsync(location.NovelId, NovelPermission.Write, ct);
if (request.Name is not null)
{
var name = LocationMapping.Normalise(request.Name);
var clash = await FindByNameAsync(location.NovelId, name, ct);
if (clash is not null && clash.Id != location.Id)
{
logger.LogWarning("Rejected update for location {LocationId}: '{Name}' already exists as {ClashLocationId}", locationId, clash.Name, clash.Id);
throw new InvalidOperationException($"The novel already has a location called '{clash.Name}'.");
}
location.Name = name;
}
await db.SaveChangesAsync(ct);
return location;
}
public async Task<bool> DeleteAsync(Guid locationId, CancellationToken ct = default)
{
Guard.Default(locationId, nameof(locationId));
logger.LogInformation("Deleting location {LocationId}", locationId);
var location = await db.Locations.FirstOrDefaultAsync(l => l.Id == locationId, ct);
if (location is null)
{
logger.LogWarning("Location {LocationId} not found", locationId);
return false;
}
await access.RequireAsync(location.NovelId, NovelPermission.DeleteContent, ct);
db.Locations.Remove(location);
await db.SaveChangesAsync(ct);
return true;
}
internal async Task<List<Location>> ResolveAsync(
Guid novelId, IReadOnlyList<string> names, CancellationToken ct)
{
Guard.Default(novelId, nameof(novelId));
Guard.Null(names, nameof(names));
logger.LogDebug("Resolving {Count} location names for novel {NovelId}", names.Count, novelId);
var wanted = names
.Select(LocationMapping.Normalise)
.Where(n => !string.IsNullOrWhiteSpace(n))
.DistinctBy(n => n.ToLowerInvariant())
.ToList();
if (wanted.Count == 0)
{
logger.LogDebug("No usable location names for novel {NovelId}", novelId);
return [];
}
var existing = await db.Locations
.Where(l => l.NovelId == novelId)
.ToListAsync(ct);
var resolved = new List<Location>();
foreach (var name in wanted)
{
var match = existing.FirstOrDefault(
l => string.Equals(l.Name, name, StringComparison.OrdinalIgnoreCase));
if (match is null)
{
match = new Location { NovelId = novelId, Name = name };
db.Locations.Add(match);
existing.Add(match);
}
resolved.Add(match);
}
logger.LogDebug("Resolved {Count} locations for novel {NovelId}", resolved.Count, novelId);
return resolved;
}
private async Task<Location?> FindByNameAsync(Guid novelId, string name, CancellationToken ct) =>
await db.Locations.FirstOrDefaultAsync(
l => l.NovelId == novelId && EF.Functions.Like(l.Name, name), ct);
}
+2 -1
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Novelly.ServiceDefaults\Novelly.ServiceDefaults.csproj" /> <ProjectReference Include="..\Novelly.ServiceDefaults\Novelly.ServiceDefaults.csproj" />
@@ -24,6 +24,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>fb455630-f631-46bb-9c60-0deaf01c593e</UserSecretsId>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
@@ -6,9 +6,9 @@ using Novelly.Api.Characters;
using Novelly.Api.Tags; using Novelly.Api.Tags;
using Novelly.Api.Users; using Novelly.Api.Users;
namespace Novelly.Api.Projects; namespace Novelly.Api.Novels;
public class Project public class Novel
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
@@ -24,7 +24,7 @@ public class Project
public int? TargetWordCount { get; set; } public int? TargetWordCount { get; set; }
public ProjectPhase Phase { get; set; } = ProjectPhase.Brainstorming; public NovelPhase Phase { get; set; } = NovelPhase.Brainstorming;
public Guid? OwnerId { get; set; } public Guid? OwnerId { get; set; }
public NovellyUser? Owner { get; set; } public NovellyUser? Owner { get; set; }
@@ -36,25 +36,25 @@ public class Project
public List<Chapter> Chapters { get; set; } = []; public List<Chapter> Chapters { get; set; } = [];
public List<Tag> Tags { get; set; } = []; public List<Tag> Tags { get; set; } = [];
public List<AgentConversation> Conversations { get; set; } = []; public List<AgentConversation> Conversations { get; set; } = [];
public List<ProjectMember> Members { get; set; } = []; public List<NovelMember> Members { get; set; } = [];
} }
public class ProjectEntityTypeConfiguration : IEntityTypeConfiguration<Project> public class NovelEntityTypeConfiguration : IEntityTypeConfiguration<Novel>
{ {
public void Configure(EntityTypeBuilder<Project> entity) public void Configure(EntityTypeBuilder<Novel> entity)
{ {
entity.Property(p => p.Title).IsRequired().HasMaxLength(300); entity.Property(p => p.Title).IsRequired().HasMaxLength(300);
entity.Property(p => p.Phase).HasConversion<string>().HasMaxLength(32); entity.Property(p => p.Phase).HasConversion<string>().HasMaxLength(32);
entity.HasMany(p => p.Characters).WithOne(c => c.Project!) entity.HasMany(p => p.Characters).WithOne(c => c.Novel!)
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(c => c.NovelId).OnDelete(DeleteBehavior.Cascade);
entity.HasMany(p => p.Chapters).WithOne(c => c.Project!) entity.HasMany(p => p.Chapters).WithOne(c => c.Novel!)
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(c => c.NovelId).OnDelete(DeleteBehavior.Cascade);
entity.HasMany(p => p.Tags).WithOne(t => t.Project!) entity.HasMany(p => p.Tags).WithOne(t => t.Novel!)
.HasForeignKey(t => t.ProjectId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(t => t.NovelId).OnDelete(DeleteBehavior.Cascade);
entity.HasMany(p => p.Conversations).WithOne(c => c.Project!) entity.HasMany(p => p.Conversations).WithOne(c => c.Novel!)
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(c => c.NovelId).OnDelete(DeleteBehavior.Cascade);
entity.HasMany(p => p.Members).WithOne(m => m.Project!) entity.HasMany(p => p.Members).WithOne(m => m.Novel!)
.HasForeignKey(m => m.ProjectId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(m => m.NovelId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(p => p.Owner).WithMany() entity.HasOne(p => p.Owner).WithMany()
.HasForeignKey(p => p.OwnerId).OnDelete(DeleteBehavior.Restrict); .HasForeignKey(p => p.OwnerId).OnDelete(DeleteBehavior.Restrict);
} }
@@ -1,21 +1,21 @@
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
namespace Novelly.Api.Projects; namespace Novelly.Api.Novels;
public record ProjectSummaryResponse( public record NovelSummaryResponse(
Guid Id, Guid Id,
string Title, string Title,
string? Author, string? Author,
string? Genre, string? Genre,
string? Logline, string? Logline,
int? TargetWordCount, int? TargetWordCount,
ProjectPhase Phase, NovelPhase Phase,
int CharacterCount, int CharacterCount,
int ChapterCount, int ChapterCount,
int WordCount, int WordCount,
DateTimeOffset UpdatedAt); DateTimeOffset UpdatedAt);
public record ProjectResponse( public record NovelResponse(
Guid Id, Guid Id,
string Title, string Title,
string? Author, string? Author,
@@ -24,13 +24,13 @@ public record ProjectResponse(
string? Synopsis, string? Synopsis,
string? Notes, string? Notes,
int? TargetWordCount, int? TargetWordCount,
ProjectPhase Phase, NovelPhase Phase,
Guid? OwnerId, Guid? OwnerId,
string? MyRole, string? MyRole,
DateTimeOffset CreatedAt, DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt); DateTimeOffset UpdatedAt);
public record CreateProjectRequest( public record CreateNovelRequest(
string Title, string Title,
string? Author = null, string? Author = null,
string? Genre = null, string? Genre = null,
@@ -39,20 +39,20 @@ public record CreateProjectRequest(
string? Notes = null, string? Notes = null,
int? TargetWordCount = null); int? TargetWordCount = null);
public class CreateProjectRequestValidator : IModelValidator<CreateProjectRequest> public class CreateNovelRequestValidator : IModelValidator<CreateNovelRequest>
{ {
public ValidationResult Validate(CreateProjectRequest model) public ValidationResult Validate(CreateNovelRequest model)
{ {
var result = new ValidationResult(); var result = new ValidationResult();
ProjectValidation.Title(model.Title, result); NovelValidation.Title(model.Title, result);
ProjectValidation.OptionalFields(model.Author, model.Genre, model.Logline, model.Synopsis, model.Notes, model.TargetWordCount, result); NovelValidation.OptionalFields(model.Author, model.Genre, model.Logline, model.Synopsis, model.Notes, model.TargetWordCount, result);
return result; return result;
} }
} }
public record UpdateProjectRequest( public record UpdateNovelRequest(
string? Title = null, string? Title = null,
string? Author = null, string? Author = null,
string? Genre = null, string? Genre = null,
@@ -60,28 +60,26 @@ public record UpdateProjectRequest(
string? Synopsis = null, string? Synopsis = null,
string? Notes = null, string? Notes = null,
int? TargetWordCount = null, int? TargetWordCount = null,
ProjectPhase? Phase = null); NovelPhase? Phase = null);
public class UpdateProjectRequestValidator : IModelValidator<UpdateProjectRequest> public class UpdateNovelRequestValidator : IModelValidator<UpdateNovelRequest>
{ {
public ValidationResult Validate(UpdateProjectRequest model) public ValidationResult Validate(UpdateNovelRequest model)
{ {
var result = new ValidationResult(); var result = new ValidationResult();
result.AddUnclearableTextErrors("Title", "Title", model.Title, "a project", 200); result.AddUnclearableTextErrors("Title", "Title", model.Title, "a novel", 200);
ProjectValidation.OptionalFields(model.Author, model.Genre, model.Logline, model.Synopsis, model.Notes, model.TargetWordCount, result); NovelValidation.OptionalFields(model.Author, model.Genre, model.Logline, model.Synopsis, model.Notes, model.TargetWordCount, result);
return result; return result;
} }
} }
file static class ProjectValidation file static class NovelValidation
{ {
public static void Title(string title, ValidationResult result) => public static void Title(string title, ValidationResult result) => result.AddRequiredTextErrors("Title", "Title", title, 200);
result.AddRequiredTextErrors("Title", "Title", title, 200);
public static void OptionalFields( public static void OptionalFields(string? author, string? genre, string? logline, string? synopsis, string? notes, int? targetWordCount, ValidationResult result)
string? author, string? genre, string? logline, string? synopsis, string? notes, int? targetWordCount, ValidationResult result)
{ {
if (author is { Length: > 200 }) if (author is { Length: > 200 })
result.AddError("Author", "'Author' must be 200 characters or fewer."); result.AddError("Author", "'Author' must be 200 characters or fewer.");
@@ -103,9 +101,9 @@ file static class ProjectValidation
} }
} }
public static class ProjectMapping public static class NovelMapping
{ {
public static ProjectResponse ToResponse(this Project p, string? myRole) => new( public static NovelResponse ToResponse(this Novel p, string? myRole) => new(
p.Id, p.Title, p.Author, p.Genre, p.Logline, p.Synopsis, p.Notes, p.Id, p.Title, p.Author, p.Genre, p.Logline, p.Synopsis, p.Notes,
p.TargetWordCount, p.Phase, p.OwnerId, myRole, p.CreatedAt, p.UpdatedAt); p.TargetWordCount, p.Phase, p.OwnerId, myRole, p.CreatedAt, p.UpdatedAt);
} }
+57
View File
@@ -0,0 +1,57 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Users;
namespace Novelly.Api.Novels;
public static class NovelEndpoints
{
public static IEndpointRouteBuilder MapNovelEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/novels").WithTags("Novels")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
group.MapGet("/", async (NovelService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(ct)))
.WithSummary("List all novels.");
group.MapGet("/{id:guid}", async (Guid id, NovelService service, NovelAccessService access, CancellationToken ct) =>
{
var novel = await service.GetAsync(id, ct);
if (novel is null)
return Results.NotFound();
var myRole = await access.GetMyRoleAsync(novel, ct);
return Results.Ok(novel.ToResponse(myRole));
})
.WithSummary("Read a novel's brief.");
group.MapPost("/", async (CreateNovelRequest request, NovelService service, NovelAccessService access, CancellationToken ct) =>
{
var novel = await service.CreateAsync(request, ct);
var myRole = await access.GetMyRoleAsync(novel, ct);
var created = novel.ToResponse(myRole);
return Results.Created($"/api/novels/{created.Id}", created);
})
.WithSummary("Create a novel.");
group.MapPatch("/{id:guid}", async (
Guid id, UpdateNovelRequest request, NovelService service, NovelAccessService access, CancellationToken ct) =>
{
var novel = await service.UpdateAsync(id, request, ct);
if (novel is null)
return Results.NotFound();
var myRole = await access.GetMyRoleAsync(novel, ct);
return Results.Ok(novel.ToResponse(myRole));
})
.WithSummary("Update a novel's brief.");
group.MapDelete("/{id:guid}", async (Guid id, NovelService service, CancellationToken ct) =>
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a novel and everything in it.");
return app;
}
}
@@ -1,6 +1,6 @@
namespace Novelly.Api.Projects; namespace Novelly.Api.Novels;
public enum ProjectPhase public enum NovelPhase
{ {
Brainstorming, Brainstorming,
Outlining, Outlining,
+133
View File
@@ -0,0 +1,133 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Users;
namespace Novelly.Api.Novels;
public class NovelService(
INovelDbContext db,
NovelAccessService access,
INovelUserContext userContext,
ILogger<NovelService> logger,
IModelValidator<CreateNovelRequest> createValidator,
IModelValidator<UpdateNovelRequest> updateValidator)
{
public async Task<IReadOnlyList<NovelSummaryResponse>> ListAsync(CancellationToken ct = default)
{
logger.LogInformation("Listing novels");
return await access.VisibleNovels()
.OrderByDescending(p => p.UpdatedAt)
.Select(p => new NovelSummaryResponse(
p.Id,
p.Title,
p.Author,
p.Genre,
p.Logline,
p.TargetWordCount,
p.Phase,
p.Characters.Count,
p.Chapters.Count,
p.Chapters.Sum(c => (int?)c.WordCount) ?? 0,
p.UpdatedAt))
.ToListAsync(ct);
}
public async Task<Novel?> GetAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Getting novel {NovelId}", id);
var novel = await FindAsync(id, ct);
if (novel is null) return null;
await access.RequireAsync(id, NovelPermission.Read, ct);
return novel;
}
public async Task<Novel> CreateAsync(CreateNovelRequest request, CancellationToken ct = default)
{
Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid(logger);
access.RequireCanCreateNovel();
logger.LogInformation("Creating novel {Title}", request.Title);
var novel = new Novel
{
Title = request.Title,
Author = request.Author,
Genre = request.Genre,
Logline = request.Logline,
Synopsis = request.Synopsis,
Notes = request.Notes,
TargetWordCount = request.TargetWordCount,
OwnerId = userContext.UserId
};
db.Novels.Add(novel);
await db.SaveChangesAsync(ct);
return novel;
}
public async Task<Novel?> UpdateAsync(Guid id, UpdateNovelRequest request, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
updateValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Updating novel {NovelId}", id);
var novel = await FindAsync(id, ct);
if (novel is null) return null;
await access.RequireAsync(id, NovelPermission.Write, ct);
novel.Title = Patch.Apply(novel.Title, request.Title) ?? novel.Title;
novel.Author = Patch.Apply(novel.Author, request.Author);
novel.Genre = Patch.Apply(novel.Genre, request.Genre);
novel.Logline = Patch.Apply(novel.Logline, request.Logline);
novel.Synopsis = Patch.Apply(novel.Synopsis, request.Synopsis);
novel.Notes = Patch.Apply(novel.Notes, request.Notes);
novel.TargetWordCount = request.TargetWordCount ?? novel.TargetWordCount;
novel.Phase = request.Phase ?? novel.Phase;
novel.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return novel;
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Deleting novel {NovelId}", id);
var novel = await FindAsync(id, ct);
if (novel is null) return false;
await access.RequireAsync(id, NovelPermission.DeleteContent, ct);
db.Novels.Remove(novel);
await db.SaveChangesAsync(ct);
return true;
}
private async Task<Novel?> FindAsync(Guid id, CancellationToken ct)
{
logger.LogDebug("Finding novel {NovelId}", id);
var novel = await db.Novels.FirstOrDefaultAsync(p => p.Id == id, ct);
if (novel is null)
{
logger.LogWarning("Novel {NovelId} not found", id);
return novel;
}
logger.LogDebug("Found novel {NovelId}", id);
return novel;
}
}
+5 -3
View File
@@ -10,7 +10,8 @@ using Novelly.Api.Common;
using Novelly.Api.Data; using Novelly.Api.Data;
using Novelly.Api.Genres; using Novelly.Api.Genres;
using Novelly.Api.Imports; using Novelly.Api.Imports;
using Novelly.Api.Projects; using Novelly.Api.Locations;
using Novelly.Api.Novels;
using Novelly.Api.Questions; using Novelly.Api.Questions;
using Novelly.Api.Tags; using Novelly.Api.Tags;
using Novelly.Api.Users; using Novelly.Api.Users;
@@ -88,13 +89,14 @@ app.MapDefaultEndpoints();
app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })).WithTags("Health").AllowAnonymous(); app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })).WithTags("Health").AllowAnonymous();
app.MapUserEndpoints(); app.MapUserEndpoints();
app.MapProjectMemberEndpoints(); app.MapNovelMemberEndpoints();
app.MapProjectEndpoints() app.MapNovelEndpoints()
.MapCharacterEndpoints() .MapCharacterEndpoints()
.MapChapterEndpoints() .MapChapterEndpoints()
.MapBeatEndpoints() .MapBeatEndpoints()
.MapTagEndpoints() .MapTagEndpoints()
.MapLocationEndpoints()
.MapGenreEndpoints() .MapGenreEndpoints()
.MapOpenQuestionEndpoints() .MapOpenQuestionEndpoints()
.MapAgentEndpoints() .MapAgentEndpoints()
@@ -1,57 +0,0 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Users;
namespace Novelly.Api.Projects;
public static class ProjectEndpoints
{
public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/projects").WithTags("Projects")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
group.MapGet("/", async (ProjectService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(ct)))
.WithSummary("List all novel projects.");
group.MapGet("/{id:guid}", async (Guid id, ProjectService service, ProjectAccessService access, CancellationToken ct) =>
{
var project = await service.GetAsync(id, ct);
if (project is null)
return Results.NotFound();
var myRole = await access.GetMyRoleAsync(project, ct);
return Results.Ok(project.ToResponse(myRole));
})
.WithSummary("Read a project's brief.");
group.MapPost("/", async (CreateProjectRequest request, ProjectService service, ProjectAccessService access, CancellationToken ct) =>
{
var project = await service.CreateAsync(request, ct);
var myRole = await access.GetMyRoleAsync(project, ct);
var created = project.ToResponse(myRole);
return Results.Created($"/api/projects/{created.Id}", created);
})
.WithSummary("Create a novel project.");
group.MapPatch("/{id:guid}", async (
Guid id, UpdateProjectRequest request, ProjectService service, ProjectAccessService access, CancellationToken ct) =>
{
var project = await service.UpdateAsync(id, request, ct);
if (project is null)
return Results.NotFound();
var myRole = await access.GetMyRoleAsync(project, ct);
return Results.Ok(project.ToResponse(myRole));
})
.WithSummary("Update a project's brief.");
group.MapDelete("/{id:guid}", async (Guid id, ProjectService service, CancellationToken ct) =>
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a project and everything in it.");
return app;
}
}
-142
View File
@@ -1,142 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Users;
namespace Novelly.Api.Projects;
public class ProjectService(
INovelDbContext db,
ProjectAccessService access,
INovelUserContext userContext,
ILogger<ProjectService> logger,
IModelValidator<CreateProjectRequest> createValidator,
IModelValidator<UpdateProjectRequest> updateValidator)
{
public async Task<IReadOnlyList<ProjectSummaryResponse>> ListAsync(CancellationToken ct = default)
{
logger.LogInformation("Listing projects");
return await access.VisibleProjects()
.OrderByDescending(p => p.UpdatedAt)
.Select(p => new ProjectSummaryResponse(
p.Id,
p.Title,
p.Author,
p.Genre,
p.Logline,
p.TargetWordCount,
p.Phase,
p.Characters.Count,
p.Chapters.Count,
p.Chapters.Sum(c => (int?)c.WordCount) ?? 0,
p.UpdatedAt))
.ToListAsync(ct);
}
public async Task<Project?> GetAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Getting project {ProjectId}", id);
var project = await FindAsync(id, ct);
if (project is null)
{
return null;
}
await access.RequireAsync(id, ProjectPermission.Read, ct);
return project;
}
public async Task<Project> CreateAsync(CreateProjectRequest request, CancellationToken ct = default)
{
Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid(logger);
access.RequireCanCreateProject();
logger.LogInformation("Creating project {Title}", request.Title);
var project = new Project
{
Title = request.Title,
Author = request.Author,
Genre = request.Genre,
Logline = request.Logline,
Synopsis = request.Synopsis,
Notes = request.Notes,
TargetWordCount = request.TargetWordCount,
OwnerId = userContext.UserId
};
db.Projects.Add(project);
await db.SaveChangesAsync(ct);
return project;
}
public async Task<Project?> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
updateValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Updating project {ProjectId}", id);
var project = await FindAsync(id, ct);
if (project is null)
{
return null;
}
await access.RequireAsync(id, ProjectPermission.Write, ct);
project.Title = Patch.Apply(project.Title, request.Title) ?? project.Title;
project.Author = Patch.Apply(project.Author, request.Author);
project.Genre = Patch.Apply(project.Genre, request.Genre);
project.Logline = Patch.Apply(project.Logline, request.Logline);
project.Synopsis = Patch.Apply(project.Synopsis, request.Synopsis);
project.Notes = Patch.Apply(project.Notes, request.Notes);
project.TargetWordCount = request.TargetWordCount ?? project.TargetWordCount;
project.Phase = request.Phase ?? project.Phase;
project.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return project;
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Deleting project {ProjectId}", id);
var project = await FindAsync(id, ct);
if (project is null)
{
return false;
}
await access.RequireAsync(id, ProjectPermission.DeleteContent, ct);
db.Projects.Remove(project);
await db.SaveChangesAsync(ct);
return true;
}
private async Task<Project?> FindAsync(Guid id, CancellationToken ct)
{
logger.LogDebug("Finding project {ProjectId}", id);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == id, ct);
if (project is null)
{
logger.LogWarning("Project {ProjectId} not found", id);
return project;
}
logger.LogDebug("Found project {ProjectId}", id);
return project;
}
}
+6 -6
View File
@@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Projects; using Novelly.Api.Novels;
namespace Novelly.Api.Questions; namespace Novelly.Api.Questions;
@@ -10,8 +10,8 @@ public class OpenQuestion
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; } public Guid NovelId { get; set; }
public Project? Project { get; set; } public Novel? Novel { get; set; }
public string Question { get; set; } = string.Empty; public string Question { get; set; } = string.Empty;
@@ -40,10 +40,10 @@ public class OpenQuestionEntityTypeConfiguration : IEntityTypeConfiguration<Open
entity.Property(q => q.Question).IsRequired().HasMaxLength(500); entity.Property(q => q.Question).IsRequired().HasMaxLength(500);
entity.Ignore(q => q.IsResolved); entity.Ignore(q => q.IsResolved);
entity.HasIndex(q => q.ProjectId); entity.HasIndex(q => q.NovelId);
entity.HasOne(q => q.Project).WithMany() entity.HasOne(q => q.Novel).WithMany()
.HasForeignKey(q => q.ProjectId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(q => q.NovelId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(q => q.Chapter).WithMany() entity.HasOne(q => q.Chapter).WithMany()
.HasForeignKey(q => q.ChapterId).OnDelete(DeleteBehavior.SetNull); .HasForeignKey(q => q.ChapterId).OnDelete(DeleteBehavior.SetNull);
@@ -4,7 +4,7 @@ namespace Novelly.Api.Questions;
public record OpenQuestionResponse( public record OpenQuestionResponse(
Guid Id, Guid Id,
Guid ProjectId, Guid NovelId,
string Question, string Question,
string? Detail, string? Detail,
Guid? ChapterId, Guid? ChapterId,
@@ -76,7 +76,7 @@ public static class OpenQuestionMapping
{ {
public static OpenQuestionResponse ToResponse(this OpenQuestion q) => new( public static OpenQuestionResponse ToResponse(this OpenQuestion q) => new(
q.Id, q.Id,
q.ProjectId, q.NovelId,
q.Question, q.Question,
q.Detail, q.Detail,
q.ChapterId, q.ChapterId,
@@ -7,24 +7,24 @@ public static class OpenQuestionEndpoints
{ {
public static IEndpointRouteBuilder MapOpenQuestionEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapOpenQuestionEndpoints(this IEndpointRouteBuilder app)
{ {
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/questions").WithTags("Questions") var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/questions").WithTags("Questions")
.AddEndpointFilter<RequestLoggingEndpointFilter>() .AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>(); .AddEndpointFilter<ValidationEndpointFilter>();
projectScoped.MapGet("/", async ( novelScoped.MapGet("/", async (
Guid projectId, Guid novelId,
OpenQuestionService service, OpenQuestionService service,
CancellationToken ct, CancellationToken ct,
Guid? chapterId = null, Guid? chapterId = null,
Guid? characterId = null, Guid? characterId = null,
bool includeResolved = false) => bool includeResolved = false) =>
Results.Ok((await service.ListAsync(projectId, chapterId, characterId, includeResolved, ct)).Select(q => q.ToResponse()))) Results.Ok((await service.ListAsync(novelId, chapterId, characterId, includeResolved, ct)).Select(q => q.ToResponse())))
.WithSummary("List a project's open questions, optionally narrowed to one chapter or character."); .WithSummary("List a novel's open questions, optionally narrowed to one chapter or character.");
projectScoped.MapPost("/", async ( novelScoped.MapPost("/", async (
Guid projectId, CreateOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) => Guid novelId, CreateOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) =>
{ {
var question = await service.CreateAsync(projectId, request, ct); var question = await service.CreateAsync(novelId, request, ct);
if (question is null) if (question is null)
{ {
return Results.NotFound(); return Results.NotFound();
@@ -10,28 +10,28 @@ namespace Novelly.Api.Questions;
public class OpenQuestionService( public class OpenQuestionService(
INovelDbContext db, INovelDbContext db,
ProjectAccessService access, NovelAccessService access,
ILogger<OpenQuestionService> logger, ILogger<OpenQuestionService> logger,
IModelValidator<CreateOpenQuestionRequest> createValidator, IModelValidator<CreateOpenQuestionRequest> createValidator,
IModelValidator<UpdateOpenQuestionRequest> updateValidator, IModelValidator<UpdateOpenQuestionRequest> updateValidator,
IModelValidator<ResolveOpenQuestionRequest> resolveValidator) IModelValidator<ResolveOpenQuestionRequest> resolveValidator)
{ {
public async Task<IReadOnlyList<OpenQuestion>> ListAsync( public async Task<IReadOnlyList<OpenQuestion>> ListAsync(
Guid projectId, Guid novelId,
Guid? chapterId = null, Guid? chapterId = null,
Guid? characterId = null, Guid? characterId = null,
bool includeResolved = false, bool includeResolved = false,
CancellationToken ct = default) CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(novelId, nameof(novelId));
logger.LogInformation( logger.LogInformation(
"Listing open questions for project {ProjectId}, chapter {ChapterId}, character {CharacterId}, includeResolved {IncludeResolved}", "Listing open questions for novel {NovelId}, chapter {ChapterId}, character {CharacterId}, includeResolved {IncludeResolved}",
projectId, chapterId, characterId, includeResolved); novelId, chapterId, characterId, includeResolved);
await access.RequireAsync(projectId, ProjectPermission.Read, ct); await access.RequireAsync(novelId, NovelPermission.Read, ct);
var query = Query().Where(q => q.ProjectId == projectId); var query = Query().Where(q => q.NovelId == novelId);
if (chapterId is { } cid) if (chapterId is { } cid)
{ {
@@ -70,31 +70,31 @@ public class OpenQuestionService(
return null; return null;
} }
await access.RequireAsync(question.ProjectId, ProjectPermission.Read, ct); await access.RequireAsync(question.NovelId, NovelPermission.Read, ct);
return question; return question;
} }
public async Task<OpenQuestion?> CreateAsync( public async Task<OpenQuestion?> CreateAsync(
Guid projectId, CreateOpenQuestionRequest request, CancellationToken ct = default) Guid novelId, CreateOpenQuestionRequest request, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(novelId, nameof(novelId));
Guard.Null(request, nameof(request)); Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid(logger); createValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Creating open question for project {ProjectId}", projectId); logger.LogInformation("Creating open question for novel {NovelId}", novelId);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct)) if (!await db.Novels.AnyAsync(p => p.Id == novelId, ct))
{ {
logger.LogWarning("Rejected open question creation: project {ProjectId} not found", projectId); logger.LogWarning("Rejected open question creation: novel {NovelId} not found", novelId);
return null; return null;
} }
await access.RequireAsync(projectId, ProjectPermission.CreateContent, ct); await access.RequireAsync(novelId, NovelPermission.CreateContent, ct);
await ValidateAssociationsAsync(projectId, request.ChapterId, request.CharacterId, ct); await ValidateAssociationsAsync(novelId, request.ChapterId, request.CharacterId, ct);
var question = new OpenQuestion var question = new OpenQuestion
{ {
ProjectId = projectId, NovelId = novelId,
Question = request.Question.Trim(), Question = request.Question.Trim(),
Detail = request.Detail, Detail = request.Detail,
ChapterId = request.ChapterId, ChapterId = request.ChapterId,
@@ -122,8 +122,8 @@ public class OpenQuestionService(
return null; return null;
} }
await access.RequireAsync(question.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(question.NovelId, NovelPermission.Write, ct);
await ValidateAssociationsAsync(question.ProjectId, request.ChapterId, request.CharacterId, ct); await ValidateAssociationsAsync(question.NovelId, request.ChapterId, request.CharacterId, ct);
question.Question = Patch.Apply(question.Question, request.Question) ?? question.Question; question.Question = Patch.Apply(question.Question, request.Question) ?? question.Question;
question.Detail = Patch.Apply(question.Detail, request.Detail); question.Detail = Patch.Apply(question.Detail, request.Detail);
@@ -150,7 +150,7 @@ public class OpenQuestionService(
return null; return null;
} }
await access.RequireAsync(question.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(question.NovelId, NovelPermission.Write, ct);
question.Resolution = request.Resolution.Trim(); question.Resolution = request.Resolution.Trim();
question.ResolvedAt = DateTimeOffset.UtcNow; question.ResolvedAt = DateTimeOffset.UtcNow;
@@ -201,7 +201,7 @@ public class OpenQuestionService(
return null; return null;
} }
await access.RequireAsync(question.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(question.NovelId, NovelPermission.Write, ct);
question.Resolution = null; question.Resolution = null;
question.ResolvedAt = null; question.ResolvedAt = null;
@@ -223,7 +223,7 @@ public class OpenQuestionService(
return false; return false;
} }
await access.RequireAsync(question.ProjectId, ProjectPermission.DeleteContent, ct); await access.RequireAsync(question.NovelId, NovelPermission.DeleteContent, ct);
db.OpenQuestions.Remove(question); db.OpenQuestions.Remove(question);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -234,27 +234,27 @@ public class OpenQuestionService(
string.IsNullOrWhiteSpace(existing) ? note : $"{existing.TrimEnd()}\n\n{note}"; string.IsNullOrWhiteSpace(existing) ? note : $"{existing.TrimEnd()}\n\n{note}";
private async Task ValidateAssociationsAsync( private async Task ValidateAssociationsAsync(
Guid projectId, Guid? chapterId, Guid? characterId, CancellationToken ct) Guid novelId, Guid? chapterId, Guid? characterId, CancellationToken ct)
{ {
logger.LogDebug("Validating associations for project {ProjectId}: chapter {ChapterId}, character {CharacterId}", projectId, chapterId, characterId); logger.LogDebug("Validating associations for novel {NovelId}: chapter {ChapterId}, character {CharacterId}", novelId, chapterId, characterId);
if (chapterId is { } cid if (chapterId is { } cid
&& !await db.Chapters.AnyAsync(c => c.Id == cid && c.ProjectId == projectId, ct)) && !await db.Chapters.AnyAsync(c => c.Id == cid && c.NovelId == novelId, ct))
{ {
logger.LogWarning("Rejected question association: chapter {ChapterId} does not belong to project {ProjectId}", cid, projectId); logger.LogWarning("Rejected question association: chapter {ChapterId} does not belong to novel {NovelId}", cid, novelId);
throw new InvalidOperationException( throw new InvalidOperationException(
"A question can only be attached to a chapter in the same project."); "A question can only be attached to a chapter in the same novel.");
} }
if (characterId is { } chid if (characterId is { } chid
&& !await db.Characters.AnyAsync(c => c.Id == chid && c.ProjectId == projectId, ct)) && !await db.Characters.AnyAsync(c => c.Id == chid && c.NovelId == novelId, ct))
{ {
logger.LogWarning("Rejected question association: character {CharacterId} does not belong to project {ProjectId}", chid, projectId); logger.LogWarning("Rejected question association: character {CharacterId} does not belong to novel {NovelId}", chid, novelId);
throw new InvalidOperationException( throw new InvalidOperationException(
"A question can only be attached to a character in the same project."); "A question can only be attached to a character in the same novel.");
} }
logger.LogDebug("Associations valid for project {ProjectId}: chapter {ChapterId}, character {CharacterId}", projectId, chapterId, characterId); logger.LogDebug("Associations valid for novel {NovelId}: chapter {ChapterId}, character {CharacterId}", novelId, chapterId, characterId);
} }
private IQueryable<OpenQuestion> Query() => private IQueryable<OpenQuestion> Query() =>
+4 -4
View File
@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Beats; using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Projects; using Novelly.Api.Novels;
namespace Novelly.Api.Tags; namespace Novelly.Api.Tags;
@@ -11,8 +11,8 @@ public class Tag
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; } public Guid NovelId { get; set; }
public Project? Project { get; set; } public Novel? Novel { get; set; }
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
@@ -32,7 +32,7 @@ public class TagEntityTypeConfiguration : IEntityTypeConfiguration<Tag>
entity.Property(t => t.Name).IsRequired().HasMaxLength(64); entity.Property(t => t.Name).IsRequired().HasMaxLength(64);
entity.Property(t => t.Color).HasMaxLength(16); entity.Property(t => t.Color).HasMaxLength(16);
entity.HasIndex(t => new { t.ProjectId, t.Name }).IsUnique(); entity.HasIndex(t => new { t.NovelId, t.Name }).IsUnique();
entity.HasMany(t => t.Characters).WithMany(c => c.Tags) entity.HasMany(t => t.Characters).WithMany(c => c.Tags)
.UsingEntity(join => join.ToTable("CharacterTags")); .UsingEntity(join => join.ToTable("CharacterTags"));
+7 -7
View File
@@ -7,18 +7,18 @@ public static class TagEndpoints
{ {
public static IEndpointRouteBuilder MapTagEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapTagEndpoints(this IEndpointRouteBuilder app)
{ {
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/tags").WithTags("Tags") var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/tags").WithTags("Tags")
.AddEndpointFilter<RequestLoggingEndpointFilter>() .AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>(); .AddEndpointFilter<ValidationEndpointFilter>();
projectScoped.MapGet("/", async (Guid projectId, TagService service, CancellationToken ct) => novelScoped.MapGet("/", async (Guid novelId, TagService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(projectId, ct))) Results.Ok(await service.ListAsync(novelId, ct)))
.WithSummary("List a project's tags with usage counts."); .WithSummary("List a novel's tags with usage counts.");
projectScoped.MapPost("/", async ( novelScoped.MapPost("/", async (
Guid projectId, CreateTagRequest request, TagService service, CancellationToken ct) => Guid novelId, CreateTagRequest request, TagService service, CancellationToken ct) =>
{ {
var tag = await service.CreateAsync(projectId, request, ct); var tag = await service.CreateAsync(novelId, request, ct);
if (tag is null) if (tag is null)
{ {
return Results.NotFound(); return Results.NotFound();
+30 -30
View File
@@ -8,21 +8,21 @@ namespace Novelly.Api.Tags;
public class TagService( public class TagService(
INovelDbContext db, INovelDbContext db,
ProjectAccessService access, NovelAccessService access,
ILogger<TagService> logger, ILogger<TagService> logger,
IModelValidator<CreateTagRequest> createValidator, IModelValidator<CreateTagRequest> createValidator,
IModelValidator<UpdateTagRequest> updateValidator) IModelValidator<UpdateTagRequest> updateValidator)
{ {
public async Task<IReadOnlyList<TagSummaryResponse>> ListAsync(Guid projectId, CancellationToken ct = default) public async Task<IReadOnlyList<TagSummaryResponse>> ListAsync(Guid novelId, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(novelId, nameof(novelId));
logger.LogInformation("Listing tags for project {ProjectId}", projectId); logger.LogInformation("Listing tags for novel {NovelId}", novelId);
await access.RequireAsync(projectId, ProjectPermission.Read, ct); await access.RequireAsync(novelId, NovelPermission.Read, ct);
return await db.Tags return await db.Tags
.Where(t => t.ProjectId == projectId) .Where(t => t.NovelId == novelId)
.OrderBy(t => t.Name) .OrderBy(t => t.Name)
.Select(t => new TagSummaryResponse( .Select(t => new TagSummaryResponse(
t.Id, t.Name, t.Color, t.Id, t.Name, t.Color,
@@ -49,36 +49,36 @@ public class TagService(
return tag; return tag;
} }
await access.RequireAsync(tag.ProjectId, ProjectPermission.Read, ct); await access.RequireAsync(tag.NovelId, NovelPermission.Read, ct);
return tag; return tag;
} }
public async Task<Tag?> CreateAsync(Guid projectId, CreateTagRequest request, CancellationToken ct = default) public async Task<Tag?> CreateAsync(Guid novelId, CreateTagRequest request, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(novelId, nameof(novelId));
Guard.Null(request, nameof(request)); Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid(logger); createValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Creating tag {Name} for project {ProjectId}", request.Name, projectId); logger.LogInformation("Creating tag {Name} for novel {NovelId}", request.Name, novelId);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct)) if (!await db.Novels.AnyAsync(p => p.Id == novelId, ct))
{ {
logger.LogWarning("Rejected tag creation: project {ProjectId} not found", projectId); logger.LogWarning("Rejected tag creation: novel {NovelId} not found", novelId);
return null; return null;
} }
await access.RequireAsync(projectId, ProjectPermission.CreateContent, ct); await access.RequireAsync(novelId, NovelPermission.CreateContent, ct);
var name = TagMapping.Normalise(request.Name); var name = TagMapping.Normalise(request.Name);
var existing = await FindByNameAsync(projectId, name, ct); var existing = await FindByNameAsync(novelId, name, ct);
if (existing is not null) if (existing is not null)
{ {
logger.LogWarning("Rejected tag creation for project {ProjectId}: '{Name}' already exists", projectId, existing.Name); logger.LogWarning("Rejected tag creation for novel {NovelId}: '{Name}' already exists", novelId, existing.Name);
throw new InvalidOperationException($"The project already has a tag called '{existing.Name}'."); throw new InvalidOperationException($"The novel already has a tag called '{existing.Name}'.");
} }
var tag = new Tag { ProjectId = projectId, Name = name, Color = request.Color }; var tag = new Tag { NovelId = novelId, Name = name, Color = request.Color };
db.Tags.Add(tag); db.Tags.Add(tag);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return tag; return tag;
@@ -99,17 +99,17 @@ public class TagService(
return null; return null;
} }
await access.RequireAsync(tag.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(tag.NovelId, NovelPermission.Write, ct);
if (request.Name is not null) if (request.Name is not null)
{ {
var name = TagMapping.Normalise(request.Name); var name = TagMapping.Normalise(request.Name);
var clash = await FindByNameAsync(tag.ProjectId, name, ct); var clash = await FindByNameAsync(tag.NovelId, name, ct);
if (clash is not null && clash.Id != tag.Id) if (clash is not null && clash.Id != tag.Id)
{ {
logger.LogWarning("Rejected update for tag {TagId}: '{Name}' already exists as {ClashTagId}", tagId, clash.Name, clash.Id); logger.LogWarning("Rejected update for tag {TagId}: '{Name}' already exists as {ClashTagId}", tagId, clash.Name, clash.Id);
throw new InvalidOperationException($"The project already has a tag called '{clash.Name}'."); throw new InvalidOperationException($"The novel already has a tag called '{clash.Name}'.");
} }
tag.Name = name; tag.Name = name;
@@ -133,7 +133,7 @@ public class TagService(
return false; return false;
} }
await access.RequireAsync(tag.ProjectId, ProjectPermission.DeleteContent, ct); await access.RequireAsync(tag.NovelId, NovelPermission.DeleteContent, ct);
db.Tags.Remove(tag); db.Tags.Remove(tag);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -141,12 +141,12 @@ public class TagService(
} }
internal async Task<List<Tag>> ResolveAsync( internal async Task<List<Tag>> ResolveAsync(
Guid projectId, IReadOnlyList<string> names, CancellationToken ct) Guid novelId, IReadOnlyList<string> names, CancellationToken ct)
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(novelId, nameof(novelId));
Guard.Null(names, nameof(names)); Guard.Null(names, nameof(names));
logger.LogDebug("Resolving {Count} tag names for project {ProjectId}", names.Count, projectId); logger.LogDebug("Resolving {Count} tag names for novel {NovelId}", names.Count, novelId);
var wanted = names var wanted = names
.Select(TagMapping.Normalise) .Select(TagMapping.Normalise)
@@ -156,12 +156,12 @@ public class TagService(
if (wanted.Count == 0) if (wanted.Count == 0)
{ {
logger.LogDebug("No usable tag names for project {ProjectId}", projectId); logger.LogDebug("No usable tag names for novel {NovelId}", novelId);
return []; return [];
} }
var existing = await db.Tags var existing = await db.Tags
.Where(t => t.ProjectId == projectId) .Where(t => t.NovelId == novelId)
.ToListAsync(ct); .ToListAsync(ct);
var resolved = new List<Tag>(); var resolved = new List<Tag>();
@@ -172,7 +172,7 @@ public class TagService(
if (match is null) if (match is null)
{ {
match = new Tag { ProjectId = projectId, Name = name }; match = new Tag { NovelId = novelId, Name = name };
db.Tags.Add(match); db.Tags.Add(match);
existing.Add(match); existing.Add(match);
} }
@@ -180,11 +180,11 @@ public class TagService(
resolved.Add(match); resolved.Add(match);
} }
logger.LogDebug("Resolved {Count} tags for project {ProjectId}", resolved.Count, projectId); logger.LogDebug("Resolved {Count} tags for novel {NovelId}", resolved.Count, novelId);
return resolved; return resolved;
} }
private async Task<Tag?> FindByNameAsync(Guid projectId, string name, CancellationToken ct) => private async Task<Tag?> FindByNameAsync(Guid novelId, string name, CancellationToken ct) =>
await db.Tags.FirstOrDefaultAsync( await db.Tags.FirstOrDefaultAsync(
t => t.ProjectId == projectId && EF.Functions.Like(t.Name, name), ct); t => t.NovelId == novelId && EF.Functions.Like(t.Name, name), ct);
} }
@@ -0,0 +1,89 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Data;
using Novelly.Api.Novels;
namespace Novelly.Api.Users;
public enum NovelPermission
{
Read,
Write,
CreateContent,
DeleteContent,
ManageAccess
}
public class NovelAccessService(INovelDbContext db, INovelUserContext userContext, ILogger<NovelAccessService> logger)
{
public void RequireCanCreateNovel()
{
if (userContext.GlobalRole is GlobalRole.Admin or GlobalRole.Writer)
return;
logger.LogWarning("User {UserId} denied novel creation, global role {GlobalRole}", userContext.UserId, userContext.GlobalRole);
throw new NotAuthorizedException("Only writers and admins can create novels.");
}
public async Task RequireAsync(Guid novelId, NovelPermission permission, CancellationToken ct = default)
{
if (userContext.GlobalRole == GlobalRole.Admin)
return;
var novel = await db.Novels.AsNoTracking().Select(p => new { p.Id, p.OwnerId }).FirstOrDefaultAsync(p => p.Id == novelId, ct);
if (novel is null)
{
logger.LogWarning("Access check against missing novel {NovelId}", novelId);
throw new NotAuthorizedException("Not permitted.");
}
if (novel.OwnerId is not null && novel.OwnerId == userContext.UserId)
return;
var member = userContext.UserId is null
? null
: await db.NovelMembers.AsNoTracking().FirstOrDefaultAsync(m => m.NovelId == novelId && m.UserId == userContext.UserId, ct);
if (!IsAllowed(permission, member?.NovelRole))
{
logger.LogWarning("User {UserId} denied {Permission} on novel {NovelId}", userContext.UserId, permission, novelId);
throw new NotAuthorizedException($"Not permitted to {permission} on this novel.");
}
}
public async Task<string?> GetMyRoleAsync(Novel novel, CancellationToken ct = default)
{
if (userContext.GlobalRole == GlobalRole.Admin)
return "Admin";
if (novel.OwnerId is not null && novel.OwnerId == userContext.UserId)
return "Owner";
if (userContext.UserId is null)
return null;
var member = await db.NovelMembers.AsNoTracking()
.FirstOrDefaultAsync(m => m.NovelId == novel.Id && m.UserId == userContext.UserId, ct);
return member?.NovelRole.ToString();
}
public IQueryable<Novel> VisibleNovels()
{
if (userContext.GlobalRole == GlobalRole.Admin)
return db.Novels;
var userId = userContext.UserId;
return db.Novels.Where(p => p.OwnerId == userId || p.Members.Any(m => m.UserId == userId));
}
private static bool IsAllowed(NovelPermission permission, NovelRole? role) => permission switch
{
NovelPermission.Read => role is not null,
NovelPermission.Write => role is NovelRole.Writer or NovelRole.Editor,
NovelPermission.CreateContent => role is NovelRole.Writer,
NovelPermission.DeleteContent => role is NovelRole.Writer,
NovelPermission.ManageAccess => false,
_ => false
};
}
@@ -1,31 +1,31 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Projects; using Novelly.Api.Novels;
namespace Novelly.Api.Users; namespace Novelly.Api.Users;
public class ProjectMember public class NovelMember
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; } public Guid NovelId { get; set; }
public Project? Project { get; set; } public Novel? Novel { get; set; }
public Guid UserId { get; set; } public Guid UserId { get; set; }
public NovellyUser? User { get; set; } public NovellyUser? User { get; set; }
public ProjectRole ProjectRole { get; set; } public NovelRole NovelRole { get; set; }
public DateTimeOffset GrantedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset GrantedAt { get; set; } = DateTimeOffset.UtcNow;
public Guid GrantedByUserId { get; set; } public Guid GrantedByUserId { get; set; }
} }
public class ProjectMemberEntityTypeConfiguration : IEntityTypeConfiguration<ProjectMember> public class NovelMemberEntityTypeConfiguration : IEntityTypeConfiguration<NovelMember>
{ {
public void Configure(EntityTypeBuilder<ProjectMember> entity) public void Configure(EntityTypeBuilder<NovelMember> entity)
{ {
entity.Property(m => m.ProjectRole).HasConversion<string>().HasMaxLength(32); entity.Property(m => m.NovelRole).HasConversion<string>().HasMaxLength(32);
entity.HasIndex(m => new { m.ProjectId, m.UserId }).IsUnique(); entity.HasIndex(m => new { m.NovelId, m.UserId }).IsUnique();
entity.HasOne(m => m.User).WithMany() entity.HasOne(m => m.User).WithMany()
.HasForeignKey(m => m.UserId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(m => m.UserId).OnDelete(DeleteBehavior.Cascade);
@@ -2,9 +2,9 @@ using Novelly.Api.Common.Validation;
namespace Novelly.Api.Users; namespace Novelly.Api.Users;
public record GrantAccessRequest(string Email, ProjectRole ProjectRole); public record GrantAccessRequest(string Email, NovelRole NovelRole);
public record ProjectMemberResponse(Guid UserId, string Email, string DisplayName, ProjectRole ProjectRole, DateTimeOffset GrantedAt); public record NovelMemberResponse(Guid UserId, string Email, string DisplayName, NovelRole NovelRole, DateTimeOffset GrantedAt);
public class GrantAccessRequestValidator : IModelValidator<GrantAccessRequest> public class GrantAccessRequestValidator : IModelValidator<GrantAccessRequest>
{ {
@@ -16,8 +16,8 @@ public class GrantAccessRequestValidator : IModelValidator<GrantAccessRequest>
} }
} }
public static class ProjectMemberMapping public static class NovelMemberMapping
{ {
public static ProjectMemberResponse ToResponse(this ProjectMember m) => public static NovelMemberResponse ToResponse(this NovelMember m) =>
new(m.UserId, m.User!.Email ?? string.Empty, m.User.DisplayName, m.ProjectRole, m.GrantedAt); new(m.UserId, m.User!.Email ?? string.Empty, m.User.DisplayName, m.NovelRole, m.GrantedAt);
} }
@@ -0,0 +1,28 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Users;
public static class NovelMemberEndpoints
{
public static IEndpointRouteBuilder MapNovelMemberEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/novels/{novelId:guid}/members").WithTags("NovelMembers")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
group.MapGet("/", async (Guid novelId, NovelMemberService service, CancellationToken ct) =>
(await service.ListAsync(novelId, ct))?.ToApiResult())
.WithSummary("List everyone granted access to a novel.");
group.MapPost("/", async (Guid novelId, GrantAccessRequest request, NovelMemberService service, CancellationToken ct) =>
(await service.GrantAsync(novelId, request, ct))?.ToApiResult())
.WithSummary("Grant a role on a novel to another account.");
group.MapDelete("/{userId:guid}", async (Guid novelId, Guid userId, NovelMemberService service, CancellationToken ct) =>
await service.RevokeAsync(novelId, userId, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Revoke an account's access to a novel.");
return app;
}
}
+107
View File
@@ -0,0 +1,107 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
namespace Novelly.Api.Users;
public class NovelMemberService(
INovelDbContext db,
NovelAccessService access,
UserManager<NovellyUser> userManager,
INovelUserContext userContext,
ILogger<NovelMemberService> logger,
IModelValidator<GrantAccessRequest> grantValidator)
{
public async Task<IReadOnlyList<NovelMemberResponse>?> ListAsync(Guid novelId, CancellationToken ct = default)
{
Guard.Default(novelId, nameof(novelId));
logger.LogInformation("Listing members for novel {NovelId}", novelId);
if (!await db.Novels.AnyAsync(p => p.Id == novelId, ct))
{
logger.LogWarning("Rejected member listing: novel {NovelId} not found", novelId);
return null;
}
await access.RequireAsync(novelId, NovelPermission.ManageAccess, ct);
var members = await db.NovelMembers
.Include(m => m.User)
.Where(m => m.NovelId == novelId)
.ToListAsync(ct);
return [.. members.Select(m => m.ToResponse())];
}
public async Task<NovelMemberResponse?> GrantAsync(Guid novelId, GrantAccessRequest request, CancellationToken ct = default)
{
Guard.Default(novelId, nameof(novelId));
Guard.Null(request, nameof(request));
grantValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Granting {NovelRole} on novel {NovelId}", request.NovelRole, novelId);
if (!await db.Novels.AnyAsync(p => p.Id == novelId, ct))
{
logger.LogWarning("Rejected access grant: novel {NovelId} not found", novelId);
return null;
}
await access.RequireAsync(novelId, NovelPermission.ManageAccess, ct);
var user = await userManager.FindByEmailAsync(request.Email);
if (user is null)
{
logger.LogWarning("Rejected access grant: no account for the given email");
throw new ArgumentException("No account exists with that email.");
}
var member = await db.NovelMembers.FirstOrDefaultAsync(m => m.NovelId == novelId && m.UserId == user.Id, ct);
if (member is null)
{
member = new NovelMember
{
NovelId = novelId,
UserId = user.Id,
NovelRole = request.NovelRole,
GrantedByUserId = userContext.UserId ?? Guid.Empty
};
db.NovelMembers.Add(member);
}
else
{
member.NovelRole = request.NovelRole;
member.GrantedByUserId = userContext.UserId ?? Guid.Empty;
member.GrantedAt = DateTimeOffset.UtcNow;
}
await db.SaveChangesAsync(ct);
member.User = user;
return member.ToResponse();
}
public async Task<bool> RevokeAsync(Guid novelId, Guid userId, CancellationToken ct = default)
{
Guard.Default(novelId, nameof(novelId));
Guard.Default(userId, nameof(userId));
logger.LogInformation("Revoking access on novel {NovelId} for user {UserId}", novelId, userId);
var member = await db.NovelMembers.FirstOrDefaultAsync(m => m.NovelId == novelId && m.UserId == userId, ct);
if (member is null)
{
logger.LogWarning("No membership found for user {UserId} on novel {NovelId}", userId, novelId);
return false;
}
await access.RequireAsync(novelId, NovelPermission.ManageAccess, ct);
db.NovelMembers.Remove(member);
await db.SaveChangesAsync(ct);
return true;
}
}
@@ -1,6 +1,6 @@
namespace Novelly.Api.Users; namespace Novelly.Api.Users;
public enum ProjectRole public enum NovelRole
{ {
Writer, Writer,
Editor, Editor,
+2 -2
View File
@@ -6,9 +6,9 @@ namespace Novelly.Api.Users;
public class NovellyUser : IdentityUser<Guid> public class NovellyUser : IdentityUser<Guid>
{ {
public string DisplayName { get; set; } = string.Empty; public string DisplayName { get; init; } = string.Empty;
public GlobalRole GlobalRole { get; set; } = GlobalRole.Reviewer; public GlobalRole GlobalRole { get; set; } = GlobalRole.Reviewer;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
} }
public class NovellyUserEntityTypeConfiguration : IEntityTypeConfiguration<NovellyUser> public class NovellyUserEntityTypeConfiguration : IEntityTypeConfiguration<NovellyUser>
@@ -1,89 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Data;
using Novelly.Api.Projects;
namespace Novelly.Api.Users;
public enum ProjectPermission
{
Read,
Write,
CreateContent,
DeleteContent,
ManageAccess
}
public class ProjectAccessService(INovelDbContext db, INovelUserContext userContext, ILogger<ProjectAccessService> logger)
{
public void RequireCanCreateProject()
{
if (userContext.GlobalRole is GlobalRole.Admin or GlobalRole.Writer)
return;
logger.LogWarning("User {UserId} denied novel creation, global role {GlobalRole}", userContext.UserId, userContext.GlobalRole);
throw new NotAuthorizedException("Only writers and admins can create novels.");
}
public async Task RequireAsync(Guid projectId, ProjectPermission permission, CancellationToken ct = default)
{
if (userContext.GlobalRole == GlobalRole.Admin)
return;
var project = await db.Projects.AsNoTracking().Select(p => new { p.Id, p.OwnerId }).FirstOrDefaultAsync(p => p.Id == projectId, ct);
if (project is null)
{
logger.LogWarning("Access check against missing project {ProjectId}", projectId);
throw new NotAuthorizedException("Not permitted.");
}
if (project.OwnerId is not null && project.OwnerId == userContext.UserId)
return;
var member = userContext.UserId is null
? null
: await db.ProjectMembers.AsNoTracking().FirstOrDefaultAsync(m => m.ProjectId == projectId && m.UserId == userContext.UserId, ct);
if (!IsAllowed(permission, member?.ProjectRole))
{
logger.LogWarning("User {UserId} denied {Permission} on project {ProjectId}", userContext.UserId, permission, projectId);
throw new NotAuthorizedException($"Not permitted to {permission} on this novel.");
}
}
public async Task<string?> GetMyRoleAsync(Project project, CancellationToken ct = default)
{
if (userContext.GlobalRole == GlobalRole.Admin)
return "Admin";
if (project.OwnerId is not null && project.OwnerId == userContext.UserId)
return "Owner";
if (userContext.UserId is null)
return null;
var member = await db.ProjectMembers.AsNoTracking()
.FirstOrDefaultAsync(m => m.ProjectId == project.Id && m.UserId == userContext.UserId, ct);
return member?.ProjectRole.ToString();
}
public IQueryable<Project> VisibleProjects()
{
if (userContext.GlobalRole == GlobalRole.Admin)
return db.Projects;
var userId = userContext.UserId;
return db.Projects.Where(p => p.OwnerId == userId || p.Members.Any(m => m.UserId == userId));
}
private static bool IsAllowed(ProjectPermission permission, ProjectRole? role) => permission switch
{
ProjectPermission.Read => role is not null,
ProjectPermission.Write => role is ProjectRole.Writer or ProjectRole.Editor,
ProjectPermission.CreateContent => role is ProjectRole.Writer,
ProjectPermission.DeleteContent => role is ProjectRole.Writer,
ProjectPermission.ManageAccess => false,
_ => false
};
}
@@ -1,28 +0,0 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Users;
public static class ProjectMemberEndpoints
{
public static IEndpointRouteBuilder MapProjectMemberEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/projects/{projectId:guid}/members").WithTags("ProjectMembers")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
group.MapGet("/", async (Guid projectId, ProjectMemberService service, CancellationToken ct) =>
(await service.ListAsync(projectId, ct))?.ToApiResult())
.WithSummary("List everyone granted access to a novel.");
group.MapPost("/", async (Guid projectId, GrantAccessRequest request, ProjectMemberService service, CancellationToken ct) =>
(await service.GrantAsync(projectId, request, ct))?.ToApiResult())
.WithSummary("Grant a role on a novel to another account.");
group.MapDelete("/{userId:guid}", async (Guid projectId, Guid userId, ProjectMemberService service, CancellationToken ct) =>
await service.RevokeAsync(projectId, userId, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Revoke an account's access to a novel.");
return app;
}
}
@@ -1,107 +0,0 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
namespace Novelly.Api.Users;
public class ProjectMemberService(
INovelDbContext db,
ProjectAccessService access,
UserManager<NovellyUser> userManager,
INovelUserContext userContext,
ILogger<ProjectMemberService> logger,
IModelValidator<GrantAccessRequest> grantValidator)
{
public async Task<IReadOnlyList<ProjectMemberResponse>?> ListAsync(Guid projectId, CancellationToken ct = default)
{
Guard.Default(projectId, nameof(projectId));
logger.LogInformation("Listing members for project {ProjectId}", projectId);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{
logger.LogWarning("Rejected member listing: project {ProjectId} not found", projectId);
return null;
}
await access.RequireAsync(projectId, ProjectPermission.ManageAccess, ct);
var members = await db.ProjectMembers
.Include(m => m.User)
.Where(m => m.ProjectId == projectId)
.ToListAsync(ct);
return [.. members.Select(m => m.ToResponse())];
}
public async Task<ProjectMemberResponse?> GrantAsync(Guid projectId, GrantAccessRequest request, CancellationToken ct = default)
{
Guard.Default(projectId, nameof(projectId));
Guard.Null(request, nameof(request));
grantValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Granting {ProjectRole} on project {ProjectId}", request.ProjectRole, projectId);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{
logger.LogWarning("Rejected access grant: project {ProjectId} not found", projectId);
return null;
}
await access.RequireAsync(projectId, ProjectPermission.ManageAccess, ct);
var user = await userManager.FindByEmailAsync(request.Email);
if (user is null)
{
logger.LogWarning("Rejected access grant: no account for the given email");
throw new ArgumentException("No account exists with that email.");
}
var member = await db.ProjectMembers.FirstOrDefaultAsync(m => m.ProjectId == projectId && m.UserId == user.Id, ct);
if (member is null)
{
member = new ProjectMember
{
ProjectId = projectId,
UserId = user.Id,
ProjectRole = request.ProjectRole,
GrantedByUserId = userContext.UserId ?? Guid.Empty
};
db.ProjectMembers.Add(member);
}
else
{
member.ProjectRole = request.ProjectRole;
member.GrantedByUserId = userContext.UserId ?? Guid.Empty;
member.GrantedAt = DateTimeOffset.UtcNow;
}
await db.SaveChangesAsync(ct);
member.User = user;
return member.ToResponse();
}
public async Task<bool> RevokeAsync(Guid projectId, Guid userId, CancellationToken ct = default)
{
Guard.Default(projectId, nameof(projectId));
Guard.Default(userId, nameof(userId));
logger.LogInformation("Revoking access on project {ProjectId} for user {UserId}", projectId, userId);
var member = await db.ProjectMembers.FirstOrDefaultAsync(m => m.ProjectId == projectId && m.UserId == userId, ct);
if (member is null)
{
logger.LogWarning("No membership found for user {UserId} on project {ProjectId}", userId, projectId);
return false;
}
await access.RequireAsync(projectId, ProjectPermission.ManageAccess, ct);
db.ProjectMembers.Remove(member);
await db.SaveChangesAsync(ct);
return true;
}
}
+1 -1
View File
@@ -44,7 +44,7 @@ public class UserAccountService(
if (isFirstAccount) if (isFirstAccount)
{ {
logger.LogInformation("Adopting orphaned novels under first account {UserId}", user.Id); logger.LogInformation("Adopting orphaned novels under first account {UserId}", user.Id);
await db.Projects.Where(p => p.OwnerId == null).ExecuteUpdateAsync(set => set.SetProperty(p => p.OwnerId, user.Id), ct); await db.Novels.Where(p => p.OwnerId == null).ExecuteUpdateAsync(set => set.SetProperty(p => p.OwnerId, user.Id), ct);
} }
await signInManager.SignInAsync(user, isPersistent: true); await signInManager.SignInAsync(user, isPersistent: true);
+31 -14
View File
@@ -8,12 +8,12 @@ namespace Novelly.Mcp.Tools;
public static class CharacterTools public static class CharacterTools
{ {
[McpServerTool(Name = "list_characters")] [McpServerTool(Name = "list_characters")]
[Description("List a project's character dossiers in full, including their relationships.")] [Description("List a novel's character dossiers in full, including their relationships.")]
public static Task<CallToolResult> ListCharacters( public static Task<CallToolResult> ListCharacters(
NovelApiClient api, NovelApiClient api,
[Description("The project's id.")] Guid projectId, [Description("The novel's id.")] Guid novelId,
CancellationToken ct) => CancellationToken ct) =>
api.GetAsync($"/api/projects/{projectId}/characters", ct); api.GetAsync($"/api/novels/{novelId}/characters", ct);
[McpServerTool(Name = "get_character")] [McpServerTool(Name = "get_character")]
[Description("Read one character's dossier.")] [Description("Read one character's dossier.")]
@@ -24,11 +24,11 @@ public static class CharacterTools
api.GetAsync($"/api/characters/{characterId}", ct); api.GetAsync($"/api/characters/{characterId}", ct);
[McpServerTool(Name = "create_character")] [McpServerTool(Name = "create_character")]
[Description("Add a character dossier to a project. Name is the only requirement — leave a field " [Description("Add a character dossier to a novel. Name is the only requirement — leave a field "
+ "blank when the writer has not decided it yet rather than inventing detail.")] + "blank when the writer has not decided it yet rather than inventing detail.")]
public static Task<CallToolResult> CreateCharacter( public static Task<CallToolResult> CreateCharacter(
NovelApiClient api, NovelApiClient api,
[Description("The project's id.")] Guid projectId, [Description("The novel's id.")] Guid novelId,
[Description("The character's name.")] string name, [Description("The character's name.")] string name,
CancellationToken ct, CancellationToken ct,
[Description("Protagonist, Antagonist, Deuteragonist, Supporting, Minor, Mentor, LoveInterest or Foil.")] [Description("Protagonist, Antagonist, Deuteragonist, Supporting, Minor, Mentor, LoveInterest or Foil.")]
@@ -50,7 +50,7 @@ public static class CharacterTools
[Description("Anything else worth recording.")] string? notes = null, [Description("Anything else worth recording.")] string? notes = null,
[Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null, [Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null,
[Description("Other names this character is known by.")] string[]? aliases = null) => [Description("Other names this character is known by.")] string[]? aliases = null) =>
api.PostAsync($"/api/projects/{projectId}/characters", new api.PostAsync($"/api/novels/{novelId}/characters", new
{ {
name, name,
role = role ?? "Supporting", role = role ?? "Supporting",
@@ -147,11 +147,11 @@ public static class CharacterTools
[Description("Id of the character whose arc to add to.")] Guid characterId, [Description("Id of the character whose arc to add to.")] Guid characterId,
[Description("A short handle for the change, three to five words.")] string title, [Description("A short handle for the change, three to five words.")] string title,
CancellationToken ct, CancellationToken ct,
[Description("What shifts in the character here, and what it costs them.")] string? description = null, [Description("What this stage of the arc results in for the character — what shifts, and what it costs them.")] string? result = null,
[Description("Id of the chapter where this stage lands, if it is pinned to one.")] Guid? chapterId = null, [Description("Id of the chapter where this stage lands, if it is pinned to one.")] Guid? chapterId = null,
[Description("Position in the arc. Appended to the end when omitted.")] int? sortOrder = null) => [Description("Position in the arc. Appended to the end when omitted.")] int? sortOrder = null) =>
api.PostAsync($"/api/characters/{characterId}/arc", api.PostAsync($"/api/characters/{characterId}/arc",
new { title, sortOrder, description, chapterId }, ct); new { title, sortOrder, result, chapterId }, ct);
[McpServerTool(Name = "update_arc_stage")] [McpServerTool(Name = "update_arc_stage")]
[Description("Revise a stage of a character's arc. Only the fields you supply change.")] [Description("Revise a stage of a character's arc. Only the fields you supply change.")]
@@ -160,11 +160,11 @@ public static class CharacterTools
[Description("The arc stage's id.")] Guid arcStageId, [Description("The arc stage's id.")] Guid arcStageId,
CancellationToken ct, CancellationToken ct,
[Description("New title for the stage.")] string? title = null, [Description("New title for the stage.")] string? title = null,
[Description("What shifts in the character here.")] string? description = null, [Description("What this stage of the arc results in for the character.")] string? result = null,
[Description("Id of the chapter where this stage lands.")] Guid? chapterId = null, [Description("Id of the chapter where this stage lands.")] Guid? chapterId = null,
[Description("Position in the arc.")] int? sortOrder = null) => [Description("Position in the arc.")] int? sortOrder = null) =>
api.PatchAsync($"/api/arc-stages/{arcStageId}", api.PatchAsync($"/api/arc-stages/{arcStageId}",
new { title, sortOrder, description, chapterId }, ct); new { title, sortOrder, result, chapterId }, ct);
[McpServerTool(Name = "delete_arc_stage")] [McpServerTool(Name = "delete_arc_stage")]
[Description("Remove a stage from a character's arc.")] [Description("Remove a stage from a character's arc.")]
@@ -184,21 +184,38 @@ public static class CharacterTools
CancellationToken ct) => CancellationToken ct) =>
api.PostAsync($"/api/characters/{characterId}/arc/reorder", new { stageIds }, ct); api.PostAsync($"/api/characters/{characterId}/arc/reorder", new { stageIds }, ct);
[McpServerTool(Name = "set_arc_stage_beats")]
[Description("Set which beats belong to an arc stage, replacing its current set. This groups the "
+ "chapter-level beats that establish or pay off this stage of the character's arc. A "
+ "beat moved into this stage leaves any other stage of the same character it was in. "
+ "Each beat must already include this character.")]
public static Task<CallToolResult> SetArcStageBeats(
NovelApiClient api,
[Description("The arc stage's id.")] Guid arcStageId,
[Description("Beat ids that belong to this stage, replacing whatever was there before.")] string[] beatIds,
CancellationToken ct) =>
api.PostAsync($"/api/arc-stages/{arcStageId}/beats", new { beatIds }, ct);
[McpServerTool(Name = "relate_characters")] [McpServerTool(Name = "relate_characters")]
[Description("Record a relationship from one character to another in the same project.")] [Description("Record a relationship between two characters in the same novel. Creates both directions "
+ "at once — characterId's side and relatedCharacterId's side — so the pair always shows up "
+ "on both dossiers.")]
public static Task<CallToolResult> RelateCharacters( public static Task<CallToolResult> RelateCharacters(
NovelApiClient api, NovelApiClient api,
[Description("Id of the character the relationship belongs to.")] Guid characterId, [Description("Id of the character the relationship belongs to.")] Guid characterId,
[Description("Id of the character they are related to.")] Guid relatedCharacterId, [Description("Id of the character they are related to.")] Guid relatedCharacterId,
[Description("How they are related, e.g. 'sister', 'rival', 'former mentor'.")] string relationshipType, [Description("How characterId is related to relatedCharacterId, e.g. 'sister', 'rival', 'former mentor'.")] string relationshipType,
CancellationToken ct, CancellationToken ct,
[Description("How relatedCharacterId is related back to characterId, if different — e.g. 'brother' for "
+ "'sister'. Defaults to relationshipType when the relation is symmetric, like 'rival'.")]
string? reciprocalRelationshipType = null,
[Description("What the relationship is like, and where it is headed.")] string? description = null) => [Description("What the relationship is like, and where it is headed.")] string? description = null) =>
api.PostAsync($"/api/characters/{characterId}/relationships", api.PostAsync($"/api/characters/{characterId}/relationships",
new { relatedCharacterId, relationshipType, description }, ct); new { relatedCharacterId, relationshipType, reciprocalRelationshipType, description }, ct);
[McpServerTool(Name = "link_character_identity")] [McpServerTool(Name = "link_character_identity")]
[Description("Record that this character is really another character — e.g. a character introduced " [Description("Record that this character is really another character — e.g. a character introduced "
+ "under one name who is later revealed to be a character already in the project under " + "under one name who is later revealed to be a character already in the novel under "
+ "another name. Both characters keep their own dossier and beats; the canonical identity " + "another name. Both characters keep their own dossier and beats; the canonical identity "
+ "is whichever character you link to.")] + "is whichever character you link to.")]
public static Task<CallToolResult> LinkCharacterIdentity( public static Task<CallToolResult> LinkCharacterIdentity(
+53
View File
@@ -0,0 +1,53 @@
using System.ComponentModel;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace Novelly.Mcp.Tools;
[McpServerToolType]
public static class LocationTools
{
[McpServerTool(Name = "list_locations")]
[Description("List a novel's locations with how many chapters are set there. "
+ "Read this before inventing a new location so you reuse the writer's vocabulary.")]
public static Task<CallToolResult> ListLocations(
NovelApiClient api,
[Description("The novel's id.")] Guid novelId,
CancellationToken ct) =>
api.GetAsync($"/api/novels/{novelId}/locations", ct);
[McpServerTool(Name = "get_location_references")]
[Description("Cross-reference a location: every chapter set there.")]
public static Task<CallToolResult> GetLocationReferences(
NovelApiClient api,
[Description("The location's id.")] Guid locationId,
CancellationToken ct) =>
api.GetAsync($"/api/locations/{locationId}/references", ct);
[McpServerTool(Name = "create_location")]
[Description("Create a location explicitly. Applying an unknown location by name to a chapter "
+ "also creates it, so this is only needed to set one up ahead of time.")]
public static Task<CallToolResult> CreateLocation(
NovelApiClient api,
[Description("The novel's id.")] Guid novelId,
[Description("The location's name. Unique within the novel, matched case-insensitively.")] string name,
CancellationToken ct) =>
api.PostAsync($"/api/novels/{novelId}/locations", new { name }, ct);
[McpServerTool(Name = "update_location")]
[Description("Rename a location. Renaming updates it everywhere it is applied.")]
public static Task<CallToolResult> UpdateLocation(
NovelApiClient api,
[Description("The location's id.")] Guid locationId,
[Description("New name.")] string name,
CancellationToken ct) =>
api.PatchAsync($"/api/locations/{locationId}", new { name }, ct);
[McpServerTool(Name = "delete_location")]
[Description("Delete a location. Whatever carried it is left alone — only the label goes.")]
public static Task<CallToolResult> DeleteLocation(
NovelApiClient api,
[Description("The location's id.")] Guid locationId,
CancellationToken ct) =>
api.DeleteAsync($"/api/locations/{locationId}", ct);
}
+11 -11
View File
@@ -8,12 +8,12 @@ namespace Novelly.Mcp.Tools;
public static class ManuscriptTools public static class ManuscriptTools
{ {
[McpServerTool(Name = "list_chapters")] [McpServerTool(Name = "list_chapters")]
[Description("List a project's chapters in manuscript order, with beat and word counts.")] [Description("List a novel's chapters in manuscript order, with beat and word counts.")]
public static Task<CallToolResult> ListChapters( public static Task<CallToolResult> ListChapters(
NovelApiClient api, NovelApiClient api,
[Description("The project's id.")] Guid projectId, [Description("The novel's id.")] Guid novelId,
CancellationToken ct) => CancellationToken ct) =>
api.GetAsync($"/api/projects/{projectId}/chapters", ct); api.GetAsync($"/api/novels/{novelId}/chapters", ct);
[McpServerTool(Name = "get_chapter")] [McpServerTool(Name = "get_chapter")]
[Description("Read one chapter in full: its outline (beats) and its drafted prose.")] [Description("Read one chapter in full: its outline (beats) and its drafted prose.")]
@@ -24,25 +24,25 @@ public static class ManuscriptTools
api.GetAsync($"/api/chapters/{chapterId}", ct); api.GetAsync($"/api/chapters/{chapterId}", ct);
[McpServerTool(Name = "create_chapter")] [McpServerTool(Name = "create_chapter")]
[Description("Add a chapter to a project. It goes at the end of the manuscript unless you supply a number.")] [Description("Add a chapter to a novel. It goes at the end of the manuscript unless you supply a number.")]
public static Task<CallToolResult> CreateChapter( public static Task<CallToolResult> CreateChapter(
NovelApiClient api, NovelApiClient api,
[Description("The project's id.")] Guid projectId, [Description("The novel's id.")] Guid novelId,
[Description("Chapter title.")] string title, [Description("Chapter title.")] string title,
CancellationToken ct, CancellationToken ct,
[Description("Position in the manuscript, 1-based.")] int? number = null, [Description("Position in the manuscript, 1-based.")] int? number = null,
[Description("The chapter's outline summary paragraph.")] string? summary = null, [Description("The chapter's outline summary paragraph.")] string? summary = null,
[Description("Where and when the chapter takes place.")] string? setting = null, [Description("Where and when the chapter takes place. Unknown locations are created.")] string[]? locations = null,
[Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null, [Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null,
[Description("Target length in words.")] int? targetWordCount = null, [Description("Target length in words.")] int? targetWordCount = null,
[Description("The chapter's drafted text, in markdown, if you are writing it now.")] string? prose = null, [Description("The chapter's drafted text, in markdown, if you are writing it now.")] string? prose = null,
[Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null) => [Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null) =>
api.PostAsync($"/api/projects/{projectId}/chapters", new api.PostAsync($"/api/novels/{novelId}/chapters", new
{ {
title, title,
number, number,
summary, summary,
setting, locations,
status = status ?? "Planned", status = status ?? "Planned",
targetWordCount, targetWordCount,
prose, prose,
@@ -50,7 +50,7 @@ public static class ManuscriptTools
}, ct); }, ct);
[McpServerTool(Name = "update_chapter")] [McpServerTool(Name = "update_chapter")]
[Description("Revise a chapter's title, number, summary, setting, notes, status " [Description("Revise a chapter's title, number, summary, locations, notes, status "
+ "or drafted prose. Use 'prose' to write or replace the chapter's draft text in " + "or drafted prose. Use 'prose' to write or replace the chapter's draft text in "
+ "markdown; the word count is recomputed automatically.")] + "markdown; the word count is recomputed automatically.")]
public static Task<CallToolResult> UpdateChapter( public static Task<CallToolResult> UpdateChapter(
@@ -60,12 +60,12 @@ public static class ManuscriptTools
[Description("New title.")] string? title = null, [Description("New title.")] string? title = null,
[Description("Position in the manuscript.")] int? number = null, [Description("Position in the manuscript.")] int? number = null,
[Description("The chapter's outline summary paragraph.")] string? summary = null, [Description("The chapter's outline summary paragraph.")] string? summary = null,
[Description("Where and when the chapter takes place.")] string? setting = null, [Description("Where and when the chapter takes place. Replaces the existing locations. Unknown locations are created.")] string[]? locations = null,
[Description("Anything else worth recording.")] string? notes = null, [Description("Anything else worth recording.")] string? notes = null,
[Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null, [Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null,
[Description("Target length in words.")] int? targetWordCount = null, [Description("Target length in words.")] int? targetWordCount = null,
[Description("The chapter's drafted text, in markdown.")] string? prose = null, [Description("The chapter's drafted text, in markdown.")] string? prose = null,
[Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null) => [Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null) =>
api.PatchAsync($"/api/chapters/{chapterId}", api.PatchAsync($"/api/chapters/{chapterId}",
new { title, number, summary, setting, notes, status, targetWordCount, prose, tags }, ct); new { title, number, summary, locations, notes, status, targetWordCount, prose, tags }, ct);
} }
@@ -5,25 +5,25 @@ using ModelContextProtocol.Server;
namespace Novelly.Mcp.Tools; namespace Novelly.Mcp.Tools;
[McpServerToolType] [McpServerToolType]
public static class ProjectTools public static class NovelTools
{ {
[McpServerTool(Name = "list_projects")] [McpServerTool(Name = "list_novels")]
[Description("List every novel project, with counts of characters, chapters and drafted words. " [Description("List every novel, with counts of characters, chapters and drafted words. "
+ "Start here to find the project id everything else needs.")] + "Start here to find the novel id everything else needs.")]
public static Task<CallToolResult> ListProjects(NovelApiClient api, CancellationToken ct) => public static Task<CallToolResult> ListNovels(NovelApiClient api, CancellationToken ct) =>
api.GetAsync("/api/projects", ct); api.GetAsync("/api/novels", ct);
[McpServerTool(Name = "get_project_brief")] [McpServerTool(Name = "get_novel_brief")]
[Description("Read a project's title, author, genre, logline, synopsis, notes and word-count target.")] [Description("Read a novel's title, author, genre, logline, synopsis, notes and word-count target.")]
public static Task<CallToolResult> GetProject( public static Task<CallToolResult> GetNovel(
NovelApiClient api, NovelApiClient api,
[Description("The project's id.")] Guid projectId, [Description("The novel's id.")] Guid novelId,
CancellationToken ct) => CancellationToken ct) =>
api.GetAsync($"/api/projects/{projectId}", ct); api.GetAsync($"/api/novels/{novelId}", ct);
[McpServerTool(Name = "create_project")] [McpServerTool(Name = "create_novel")]
[Description("Create a new novel project.")] [Description("Create a new novel.")]
public static Task<CallToolResult> CreateProject( public static Task<CallToolResult> CreateNovel(
NovelApiClient api, NovelApiClient api,
[Description("Working title.")] string title, [Description("Working title.")] string title,
CancellationToken ct, CancellationToken ct,
@@ -33,14 +33,14 @@ public static class ProjectTools
[Description("Paragraph-length summary of the whole book.")] string? synopsis = null, [Description("Paragraph-length summary of the whole book.")] string? synopsis = null,
[Description("Free-form notes on theme, tone, comparable titles.")] string? notes = null, [Description("Free-form notes on theme, tone, comparable titles.")] string? notes = null,
[Description("Target manuscript length in words.")] int? targetWordCount = null) => [Description("Target manuscript length in words.")] int? targetWordCount = null) =>
api.PostAsync("/api/projects", new { title, author, genre, logline, synopsis, notes, targetWordCount }, ct); api.PostAsync("/api/novels", new { title, author, genre, logline, synopsis, notes, targetWordCount }, ct);
[McpServerTool(Name = "update_project_brief")] [McpServerTool(Name = "update_novel_brief")]
[Description("Revise a project's top-level fields. Only the fields you supply change; " [Description("Revise a novel's top-level fields. Only the fields you supply change; "
+ "pass an empty string to clear one.")] + "pass an empty string to clear one.")]
public static Task<CallToolResult> UpdateProject( public static Task<CallToolResult> UpdateNovel(
NovelApiClient api, NovelApiClient api,
[Description("The project's id.")] Guid projectId, [Description("The novel's id.")] Guid novelId,
CancellationToken ct, CancellationToken ct,
[Description("New title.")] string? title = null, [Description("New title.")] string? title = null,
[Description("Author name.")] string? author = null, [Description("Author name.")] string? author = null,
@@ -49,6 +49,6 @@ public static class ProjectTools
[Description("Paragraph-length summary of the whole book.")] string? synopsis = null, [Description("Paragraph-length summary of the whole book.")] string? synopsis = null,
[Description("Free-form notes on theme, tone, comparable titles.")] string? notes = null, [Description("Free-form notes on theme, tone, comparable titles.")] string? notes = null,
[Description("Target manuscript length in words.")] int? targetWordCount = null) => [Description("Target manuscript length in words.")] int? targetWordCount = null) =>
api.PatchAsync($"/api/projects/{projectId}", api.PatchAsync($"/api/novels/{novelId}",
new { title, author, genre, logline, synopsis, notes, targetWordCount }, ct); new { title, author, genre, logline, synopsis, notes, targetWordCount }, ct);
} }
+4 -4
View File
@@ -13,7 +13,7 @@ public static class QuestionTools
+ "thinking, not a gap to fill in for them.")] + "thinking, not a gap to fill in for them.")]
public static Task<CallToolResult> ListOpenQuestions( public static Task<CallToolResult> ListOpenQuestions(
NovelApiClient api, NovelApiClient api,
[Description("The project's id.")] Guid projectId, [Description("The novel's id.")] Guid novelId,
CancellationToken ct, CancellationToken ct,
[Description("Narrow to questions about one chapter outline.")] Guid? chapterId = null, [Description("Narrow to questions about one chapter outline.")] Guid? chapterId = null,
[Description("Narrow to questions about one character.")] Guid? characterId = null, [Description("Narrow to questions about one character.")] Guid? characterId = null,
@@ -31,7 +31,7 @@ public static class QuestionTools
query.Add($"characterId={character}"); query.Add($"characterId={character}");
} }
return api.GetAsync($"/api/projects/{projectId}/questions?{string.Join('&', query)}", ct); return api.GetAsync($"/api/novels/{novelId}/questions?{string.Join('&', query)}", ct);
} }
[McpServerTool(Name = "raise_open_question")] [McpServerTool(Name = "raise_open_question")]
@@ -39,13 +39,13 @@ public static class QuestionTools
+ "and/or the character it is about. Prefer raising a question over guessing.")] + "and/or the character it is about. Prefer raising a question over guessing.")]
public static Task<CallToolResult> RaiseOpenQuestion( public static Task<CallToolResult> RaiseOpenQuestion(
NovelApiClient api, NovelApiClient api,
[Description("The project's id.")] Guid projectId, [Description("The novel's id.")] Guid novelId,
[Description("The question, in one line.")] string question, [Description("The question, in one line.")] string question,
CancellationToken ct, CancellationToken ct,
[Description("The thinking around it — options considered, and what each costs.")] string? detail = null, [Description("The thinking around it — options considered, and what each costs.")] string? detail = null,
[Description("Id of the chapter outline this is about, if any.")] Guid? chapterId = null, [Description("Id of the chapter outline this is about, if any.")] Guid? chapterId = null,
[Description("Id of the character this is about, if any.")] Guid? characterId = null) => [Description("Id of the character this is about, if any.")] Guid? characterId = null) =>
api.PostAsync($"/api/projects/{projectId}/questions", api.PostAsync($"/api/novels/{novelId}/questions",
new { question, detail, chapterId, characterId }, ct); new { question, detail, chapterId, characterId }, ct);
[McpServerTool(Name = "update_open_question")] [McpServerTool(Name = "update_open_question")]
+6 -6
View File
@@ -8,13 +8,13 @@ namespace Novelly.Mcp.Tools;
public static class TagTools public static class TagTools
{ {
[McpServerTool(Name = "list_tags")] [McpServerTool(Name = "list_tags")]
[Description("List a project's tags with how many characters, chapters and beats carry each. " [Description("List a novel's tags with how many characters, chapters and beats carry each. "
+ "Read this before inventing a new tag so you reuse the writer's vocabulary.")] + "Read this before inventing a new tag so you reuse the writer's vocabulary.")]
public static Task<CallToolResult> ListTags( public static Task<CallToolResult> ListTags(
NovelApiClient api, NovelApiClient api,
[Description("The project's id.")] Guid projectId, [Description("The novel's id.")] Guid novelId,
CancellationToken ct) => CancellationToken ct) =>
api.GetAsync($"/api/projects/{projectId}/tags", ct); api.GetAsync($"/api/novels/{novelId}/tags", ct);
[McpServerTool(Name = "get_tag_references")] [McpServerTool(Name = "get_tag_references")]
[Description("Cross-reference a tag: every character, chapter and beat carrying it. Use this " [Description("Cross-reference a tag: every character, chapter and beat carrying it. Use this "
@@ -30,11 +30,11 @@ public static class TagTools
+ "chapter or beat also creates it, so this is only needed to set a colour up front.")] + "chapter or beat also creates it, so this is only needed to set a colour up front.")]
public static Task<CallToolResult> CreateTag( public static Task<CallToolResult> CreateTag(
NovelApiClient api, NovelApiClient api,
[Description("The project's id.")] Guid projectId, [Description("The novel's id.")] Guid novelId,
[Description("The tag's name. Unique within the project, matched case-insensitively.")] string name, [Description("The tag's name. Unique within the novel, matched case-insensitively.")] string name,
CancellationToken ct, CancellationToken ct,
[Description("Optional hex colour for the UI, e.g. \"#9a4a2f\".")] string? color = null) => [Description("Optional hex colour for the UI, e.g. \"#9a4a2f\".")] string? color = null) =>
api.PostAsync($"/api/projects/{projectId}/tags", new { name, color }, ct); api.PostAsync($"/api/novels/{novelId}/tags", new { name, color }, ct);
[McpServerTool(Name = "update_tag")] [McpServerTool(Name = "update_tag")]
[Description("Rename or recolour a tag. Renaming updates it everywhere it is applied.")] [Description("Rename or recolour a tag. Renaming updates it everywhere it is applied.")]
+2 -2
View File
@@ -11,8 +11,8 @@ using OpenTelemetry.Trace;
namespace Microsoft.Extensions.Hosting; namespace Microsoft.Extensions.Hosting;
// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. // Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
// This project should be referenced by each service project in your solution. // This novel should be referenced by each service novel in your solution.
// To learn more about using this project, see https://aka.ms/aspire/service-defaults // To learn more about using this novel, see https://aka.ms/aspire/service-defaults
public static class Extensions public static class Extensions
{ {
private const string HealthEndpointPath = "/health"; private const string HealthEndpointPath = "/health";
+7 -5
View File
@@ -1,10 +1,11 @@
import { Navigate, Outlet, Route, Routes } from 'react-router-dom' import { Navigate, Outlet, Route, Routes } from 'react-router-dom'
import ProjectsPage from './pages/ProjectsPage' import NovelsPage from './pages/NovelsPage'
import ProjectLayout from './pages/ProjectLayout' import NovelLayout from './pages/NovelLayout'
import DashboardPage from './pages/DashboardPage' import DashboardPage from './pages/DashboardPage'
import CharactersPage from './pages/CharactersPage' import CharactersPage from './pages/CharactersPage'
import CharacterDetailPage from './pages/CharacterDetailPage' import CharacterDetailPage from './pages/CharacterDetailPage'
import TagsPage from './pages/TagsPage' import TagsPage from './pages/TagsPage'
import LocationsPage from './pages/LocationsPage'
import ChaptersPage from './pages/ChaptersPage' import ChaptersPage from './pages/ChaptersPage'
import ChapterPage from './pages/ChapterPage' import ChapterPage from './pages/ChapterPage'
import AgentPage from './pages/AgentPage' import AgentPage from './pages/AgentPage'
@@ -34,18 +35,19 @@ export default function App() {
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route element={<RequireAuth />}> <Route element={<RequireAuth />}>
<Route path="/" element={<ProjectsPage />} /> <Route path="/" element={<NovelsPage />} />
<Route path="/projects/:projectId" element={<ProjectLayout />}> <Route path="/novels/:novelId" element={<NovelLayout />}>
<Route index element={<DashboardPage />} /> <Route index element={<DashboardPage />} />
<Route path="characters" element={<CharactersPage />} /> <Route path="characters" element={<CharactersPage />} />
<Route path="characters/:characterId" element={<CharacterDetailPage />} /> <Route path="characters/:characterId" element={<CharacterDetailPage />} />
<Route path="chapters" element={<ChaptersPage />} /> <Route path="chapters" element={<ChaptersPage />} />
<Route path="chapters/:chapterId" element={<ChapterPage />} /> <Route path="chapters/:chapterId" element={<ChapterPage />} />
<Route path="tags" element={<TagsPage />} /> <Route path="tags" element={<TagsPage />} />
<Route path="locations" element={<LocationsPage />} />
<Route path="agent" element={<AgentPage />} /> <Route path="agent" element={<AgentPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
</Route> </Route>
<Route path="*" element={<ProjectsPage />} /> <Route path="*" element={<NovelsPage />} />
</Route> </Route>
</Routes> </Routes>
</HelpOverlayProvider> </HelpOverlayProvider>
+200 -111
View File
@@ -15,10 +15,12 @@ import type {
ImportJob, ImportJob,
ImportJobStatus, ImportJobStatus,
OpenQuestion, OpenQuestion,
Project, LocationReferences,
ProjectMember, LocationSummary,
ProjectRole, Novel,
ProjectSummary, NovelMember,
NovelRole,
NovelSummary,
TagReferences, TagReferences,
TagSummary, TagSummary,
User, User,
@@ -26,18 +28,20 @@ import type {
export const keys = { export const keys = {
me: ['me'] as const, me: ['me'] as const,
members: (projectId: string) => ['projects', projectId, 'members'] as const, members: (novelId: string) => ['novels', novelId, 'members'] as const,
projects: ['projects'] as const, novels: ['novels'] as const,
genres: ['genres'] as const, genres: ['genres'] as const,
project: (id: string) => ['projects', id] as const, novel: (id: string) => ['novels', id] as const,
characters: (projectId: string) => ['projects', projectId, 'characters'] as const, characters: (novelId: string) => ['novels', novelId, 'characters'] as const,
tags: (projectId: string) => ['projects', projectId, 'tags'] as const, tags: (novelId: string) => ['novels', novelId, 'tags'] as const,
tagRefs: (tagId: string) => ['tags', tagId, 'references'] as const, tagRefs: (tagId: string) => ['tags', tagId, 'references'] as const,
locations: (novelId: string) => ['novels', novelId, 'locations'] as const,
locationRefs: (locationId: string) => ['locations', locationId, 'references'] as const,
characterBeats: (characterId: string) => ['characters', characterId, 'beats'] as const, characterBeats: (characterId: string) => ['characters', characterId, 'beats'] as const,
chapters: (projectId: string) => ['projects', projectId, 'chapters'] as const, chapters: (novelId: string) => ['novels', novelId, 'chapters'] as const,
questions: (projectId: string) => ['projects', projectId, 'questions'] as const, questions: (novelId: string) => ['novels', novelId, 'questions'] as const,
chapter: (id: string) => ['chapters', id] as const, chapter: (id: string) => ['chapters', id] as const,
conversations: (projectId: string) => ['projects', projectId, 'conversations'] as const, conversations: (novelId: string) => ['novels', novelId, 'conversations'] as const,
conversation: (id: string) => ['conversations', id] as const, conversation: (id: string) => ['conversations', id] as const,
importJob: (id: string) => ['imports', id] as const, importJob: (id: string) => ['imports', id] as const,
} }
@@ -82,103 +86,103 @@ export function useLogout() {
}) })
} }
export const useProjectMembers = (projectId: string) => export const useNovelMembers = (novelId: string) =>
useQuery({ useQuery({
queryKey: keys.members(projectId), queryKey: keys.members(novelId),
queryFn: () => api.get<ProjectMember[]>(`/api/projects/${projectId}/members`), queryFn: () => api.get<NovelMember[]>(`/api/novels/${novelId}/members`),
retry: false, retry: false,
}) })
export function useGrantAccess(projectId: string) { export function useGrantAccess(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (body: { email: string; projectRole: ProjectRole }) => mutationFn: (body: { email: string; novelRole: NovelRole }) =>
api.post<ProjectMember>(`/api/projects/${projectId}/members`, body), api.post<NovelMember>(`/api/novels/${novelId}/members`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.members(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.members(novelId) }),
}) })
} }
export function useRevokeAccess(projectId: string) { export function useRevokeAccess(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (userId: string) => api.delete(`/api/projects/${projectId}/members/${userId}`), mutationFn: (userId: string) => api.delete(`/api/novels/${novelId}/members/${userId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.members(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.members(novelId) }),
}) })
} }
export const useProjects = () => export const useNovels = () =>
useQuery({ queryKey: keys.projects, queryFn: () => api.get<ProjectSummary[]>('/api/projects') }) useQuery({ queryKey: keys.novels, queryFn: () => api.get<NovelSummary[]>('/api/novels') })
export const useProject = (id: string) => export const useNovel = (id: string) =>
useQuery({ queryKey: keys.project(id), queryFn: () => api.get<Project>(`/api/projects/${id}`) }) useQuery({ queryKey: keys.novel(id), queryFn: () => api.get<Novel>(`/api/novels/${id}`) })
export function useCreateProject() { export function useCreateNovel() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (body: { title: string; author?: string; genre?: string; logline?: string }) => mutationFn: (body: { title: string; author?: string; genre?: string; logline?: string }) =>
api.post<Project>('/api/projects', body), api.post<Novel>('/api/novels', body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.projects }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.novels }),
}) })
} }
export function useUpdateProject(id: string) { export function useUpdateNovel(id: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (body: Partial<Project>) => api.patch<Project>(`/api/projects/${id}`, body), mutationFn: (body: Partial<Novel>) => api.patch<Novel>(`/api/novels/${id}`, body),
onSuccess: (updated) => { onSuccess: (updated) => {
qc.setQueryData(keys.project(id), updated) qc.setQueryData(keys.novel(id), updated)
qc.invalidateQueries({ queryKey: keys.projects }) qc.invalidateQueries({ queryKey: keys.novels })
}, },
}) })
} }
export function useDeleteProject() { export function useDeleteNovel() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (id: string) => api.delete(`/api/projects/${id}`), mutationFn: (id: string) => api.delete(`/api/novels/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.projects }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.novels }),
}) })
} }
export const useCharacters = (projectId: string) => export const useCharacters = (novelId: string) =>
useQuery({ useQuery({
queryKey: keys.characters(projectId), queryKey: keys.characters(novelId),
queryFn: () => api.get<Character[]>(`/api/projects/${projectId}/characters`), queryFn: () => api.get<Character[]>(`/api/novels/${novelId}/characters`),
}) })
export function useCreateCharacter(projectId: string) { export function useCreateCharacter(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (body: Partial<Character> & { name: string }) => mutationFn: (body: Partial<Character> & { name: string }) =>
api.post<Character>(`/api/projects/${projectId}/characters`, body), api.post<Character>(`/api/novels/${novelId}/characters`, body),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.characters(projectId) }) qc.invalidateQueries({ queryKey: keys.characters(novelId) })
qc.invalidateQueries({ queryKey: keys.tags(projectId) }) qc.invalidateQueries({ queryKey: keys.tags(novelId) })
}, },
}) })
} }
export function useUpdateCharacter(projectId: string) { export function useUpdateCharacter(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ id, ...body }: Partial<Omit<Character, 'tags'>> & { id: string; tags?: string[] }) => mutationFn: ({ id, ...body }: Partial<Omit<Character, 'tags'>> & { id: string; tags?: string[] }) =>
api.patch<Character>(`/api/characters/${id}`, body), api.patch<Character>(`/api/characters/${id}`, body),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.characters(projectId) }) qc.invalidateQueries({ queryKey: keys.characters(novelId) })
qc.invalidateQueries({ queryKey: keys.tags(projectId) }) qc.invalidateQueries({ queryKey: keys.tags(novelId) })
}, },
}) })
} }
export function useDeleteCharacter(projectId: string) { export function useDeleteCharacter(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (id: string) => api.delete(`/api/characters/${id}`), mutationFn: (id: string) => api.delete(`/api/characters/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(novelId) }),
}) })
} }
export function useLinkCharacterIdentity(projectId: string) { export function useLinkCharacterIdentity(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ mutationFn: ({
@@ -192,15 +196,49 @@ export function useLinkCharacterIdentity(projectId: string) {
revealedInChapterId?: string | null revealedInChapterId?: string | null
note?: string | null note?: string | null
}) => api.put<Character>(`/api/characters/${id}/identity`, { sameCharacterAsId, revealedInChapterId, note }), }) => api.put<Character>(`/api/characters/${id}/identity`, { sameCharacterAsId, revealedInChapterId, note }),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(novelId) }),
}) })
} }
export function useUnlinkCharacterIdentity(projectId: string) { export function useUnlinkCharacterIdentity(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (id: string) => api.delete(`/api/characters/${id}/identity`), mutationFn: (id: string) => api.delete(`/api/characters/${id}/identity`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(novelId) }),
})
}
export function useAddRelationship(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({
id,
relatedCharacterId,
relationshipType,
reciprocalRelationshipType,
description,
}: {
id: string
relatedCharacterId: string
relationshipType: string
reciprocalRelationshipType?: string | null
description?: string | null
}) =>
api.post<Character>(`/api/characters/${id}/relationships`, {
relatedCharacterId,
relationshipType,
reciprocalRelationshipType,
description,
}),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(novelId) }),
})
}
export function useRemoveRelationship(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (relationshipId: string) => api.delete(`/api/characters/relationships/${relationshipId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(novelId) }),
}) })
} }
@@ -211,43 +249,55 @@ export const useCharacterBeats = (characterId: string | undefined) =>
enabled: Boolean(characterId), enabled: Boolean(characterId),
}) })
export function useCreateArcStage(projectId: string) { export function useCreateArcStage(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ characterId, ...body }: { characterId: string; title: string; description?: string; chapterId?: string }) => mutationFn: ({ characterId, ...body }: { characterId: string; title: string; result?: string; chapterId?: string }) =>
api.post<ArcStage>(`/api/characters/${characterId}/arc`, body), api.post<ArcStage>(`/api/characters/${characterId}/arc`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(novelId) }),
}) })
} }
export function useUpdateArcStage(projectId: string) { export function useUpdateArcStage(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ id, ...body }: { id: string; title?: string; description?: string; chapterId?: string }) => mutationFn: ({ id, ...body }: { id: string; title?: string; result?: string; chapterId?: string }) =>
api.patch<ArcStage>(`/api/arc-stages/${id}`, body), api.patch<ArcStage>(`/api/arc-stages/${id}`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(novelId) }),
}) })
} }
export function useDeleteArcStage(projectId: string) { export function useSetArcStageBeats(novelId: string, characterId: string | undefined) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, beatIds }: { id: string; beatIds: string[] }) =>
api.post<ArcStage>(`/api/arc-stages/${id}/beats`, { beatIds }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.characters(novelId) })
qc.invalidateQueries({ queryKey: keys.characterBeats(characterId ?? '') })
},
})
}
export function useDeleteArcStage(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (id: string) => api.delete(`/api/arc-stages/${id}`), mutationFn: (id: string) => api.delete(`/api/arc-stages/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(novelId) }),
}) })
} }
export function useReorderArcStages(projectId: string) { export function useReorderArcStages(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ characterId, stageIds }: { characterId: string; stageIds: string[] }) => mutationFn: ({ characterId, stageIds }: { characterId: string; stageIds: string[] }) =>
api.post<ArcStage[]>(`/api/characters/${characterId}/arc/reorder`, { stageIds }), api.post<ArcStage[]>(`/api/characters/${characterId}/arc/reorder`, { stageIds }),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(novelId) }),
}) })
} }
export const useOpenQuestions = ( export const useOpenQuestions = (
projectId: string, novelId: string,
filter: { chapterId?: string; characterId?: string; includeResolved?: boolean } = {}, filter: { chapterId?: string; characterId?: string; includeResolved?: boolean } = {},
) => { ) => {
const params = new URLSearchParams() const params = new URLSearchParams()
@@ -257,66 +307,66 @@ export const useOpenQuestions = (
const query = params.toString() const query = params.toString()
return useQuery({ return useQuery({
queryKey: [...keys.questions(projectId), query] as const, queryKey: [...keys.questions(novelId), query] as const,
queryFn: () => queryFn: () =>
api.get<OpenQuestion[]>(`/api/projects/${projectId}/questions${query ? `?${query}` : ''}`), api.get<OpenQuestion[]>(`/api/novels/${novelId}/questions${query ? `?${query}` : ''}`),
}) })
} }
export function useRaiseQuestion(projectId: string) { export function useRaiseQuestion(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (body: { question: string; detail?: string; chapterId?: string; characterId?: string }) => mutationFn: (body: { question: string; detail?: string; chapterId?: string; characterId?: string }) =>
api.post<OpenQuestion>(`/api/projects/${projectId}/questions`, body), api.post<OpenQuestion>(`/api/novels/${novelId}/questions`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(novelId) }),
}) })
} }
export function useUpdateQuestion(projectId: string) { export function useUpdateQuestion(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ id, ...body }: { id: string; question?: string; detail?: string }) => mutationFn: ({ id, ...body }: { id: string; question?: string; detail?: string }) =>
api.patch<OpenQuestion>(`/api/questions/${id}`, body), api.patch<OpenQuestion>(`/api/questions/${id}`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(novelId) }),
}) })
} }
export function useResolveQuestion(projectId: string) { export function useResolveQuestion(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ id, resolution, appendToNotes }: { id: string; resolution: string; appendToNotes: boolean }) => mutationFn: ({ id, resolution, appendToNotes }: { id: string; resolution: string; appendToNotes: boolean }) =>
api.post<OpenQuestion>(`/api/questions/${id}/resolve`, { resolution, appendToNotes }), api.post<OpenQuestion>(`/api/questions/${id}/resolve`, { resolution, appendToNotes }),
onSuccess: (question) => { onSuccess: (question) => {
qc.invalidateQueries({ queryKey: keys.questions(projectId) }) qc.invalidateQueries({ queryKey: keys.questions(novelId) })
qc.invalidateQueries({ queryKey: keys.characters(projectId) }) qc.invalidateQueries({ queryKey: keys.characters(novelId) })
if (question.chapterId) qc.invalidateQueries({ queryKey: keys.chapter(question.chapterId) }) if (question.chapterId) qc.invalidateQueries({ queryKey: keys.chapter(question.chapterId) })
}, },
}) })
} }
export function useReopenQuestion(projectId: string) { export function useReopenQuestion(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (id: string) => api.post<OpenQuestion>(`/api/questions/${id}/reopen`, {}), mutationFn: (id: string) => api.post<OpenQuestion>(`/api/questions/${id}/reopen`, {}),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(novelId) }),
}) })
} }
export function useDeleteQuestion(projectId: string) { export function useDeleteQuestion(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (id: string) => api.delete(`/api/questions/${id}`), mutationFn: (id: string) => api.delete(`/api/questions/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(novelId) }),
}) })
} }
export const useGenres = () => export const useGenres = () =>
useQuery({ queryKey: keys.genres, queryFn: () => api.get<Genre[]>('/api/genres') }) useQuery({ queryKey: keys.genres, queryFn: () => api.get<Genre[]>('/api/genres') })
export const useTags = (projectId: string) => export const useTags = (novelId: string) =>
useQuery({ useQuery({
queryKey: keys.tags(projectId), queryKey: keys.tags(novelId),
queryFn: () => api.get<TagSummary[]>(`/api/projects/${projectId}/tags`), queryFn: () => api.get<TagSummary[]>(`/api/novels/${novelId}/tags`),
}) })
export const useTagReferences = (tagId: string | undefined) => export const useTagReferences = (tagId: string | undefined) =>
@@ -326,12 +376,15 @@ export const useTagReferences = (tagId: string | undefined) =>
enabled: Boolean(tagId), enabled: Boolean(tagId),
}) })
export function useUpdateTag(projectId: string) { export function useUpdateTag(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ id, ...body }: { id: string; name?: string; color?: string }) => mutationFn: ({ id, ...body }: { id: string; name?: string; color?: string }) =>
api.patch<TagSummary>(`/api/tags/${id}`, body), api.patch<TagSummary>(`/api/tags/${id}`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.tags(projectId) }), onSuccess: (_, { id }) => {
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
qc.invalidateQueries({ queryKey: keys.tagRefs(id) })
},
}) })
} }
@@ -343,7 +396,40 @@ export function useDeleteTag() {
}) })
} }
export function useCreateBeat(chapterId: string, projectId: string) { export const useLocations = (novelId: string) =>
useQuery({
queryKey: keys.locations(novelId),
queryFn: () => api.get<LocationSummary[]>(`/api/novels/${novelId}/locations`),
})
export const useLocationReferences = (locationId: string | undefined) =>
useQuery({
queryKey: keys.locationRefs(locationId ?? ''),
queryFn: () => api.get<LocationReferences>(`/api/locations/${locationId}/references`),
enabled: Boolean(locationId),
})
export function useUpdateLocation(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, ...body }: { id: string; name?: string }) =>
api.patch<LocationSummary>(`/api/locations/${id}`, body),
onSuccess: (_, { id }) => {
qc.invalidateQueries({ queryKey: keys.locations(novelId) })
qc.invalidateQueries({ queryKey: keys.locationRefs(id) })
},
})
}
export function useDeleteLocation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.delete(`/api/locations/${id}`),
onSuccess: () => qc.invalidateQueries(),
})
}
export function useCreateBeat(chapterId: string, novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ( mutationFn: (
@@ -351,12 +437,12 @@ export function useCreateBeat(chapterId: string, projectId: string) {
) => api.post<Beat>(`/api/chapters/${chapterId}/beats`, body), ) => api.post<Beat>(`/api/chapters/${chapterId}/beats`, body),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.chapter(chapterId) }) qc.invalidateQueries({ queryKey: keys.chapter(chapterId) })
qc.invalidateQueries({ queryKey: keys.tags(projectId) }) qc.invalidateQueries({ queryKey: keys.tags(novelId) })
}, },
}) })
} }
export function useUpdateBeat(chapterId: string, projectId: string) { export function useUpdateBeat(chapterId: string, novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ mutationFn: ({
@@ -366,7 +452,7 @@ export function useUpdateBeat(chapterId: string, projectId: string) {
api.patch<Beat>(`/api/beats/${id}`, body), api.patch<Beat>(`/api/beats/${id}`, body),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.chapter(chapterId) }) qc.invalidateQueries({ queryKey: keys.chapter(chapterId) })
qc.invalidateQueries({ queryKey: keys.tags(projectId) }) qc.invalidateQueries({ queryKey: keys.tags(novelId) })
}, },
}) })
} }
@@ -409,10 +495,10 @@ export function useMoveBeats(chapterId: string) {
}) })
} }
export const useChapters = (projectId: string) => export const useChapters = (novelId: string) =>
useQuery({ useQuery({
queryKey: keys.chapters(projectId), queryKey: keys.chapters(novelId),
queryFn: () => api.get<ChapterSummary[]>(`/api/projects/${projectId}/chapters`), queryFn: () => api.get<ChapterSummary[]>(`/api/novels/${novelId}/chapters`),
}) })
export const useChapter = (id: string | undefined) => export const useChapter = (id: string | undefined) =>
@@ -422,40 +508,43 @@ export const useChapter = (id: string | undefined) =>
enabled: Boolean(id), enabled: Boolean(id),
}) })
export function useCreateChapter(projectId: string) { export function useCreateChapter(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (body: Partial<Chapter> & { title: string }) => mutationFn: (
api.post<Chapter>(`/api/projects/${projectId}/chapters`, body), body: Partial<Omit<Chapter, 'tags' | 'locations'>> & { title: string; tags?: string[]; locations?: string[] },
onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapters(projectId) }), ) => api.post<Chapter>(`/api/novels/${novelId}/chapters`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapters(novelId) }),
}) })
} }
export function useUpdateChapter(projectId: string) { export function useUpdateChapter(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ id, ...body }: Partial<Omit<Chapter, 'tags'>> & { id: string; tags?: string[] }) => mutationFn: (
api.patch<Chapter>(`/api/chapters/${id}`, body), { id, ...body }: Partial<Omit<Chapter, 'tags' | 'locations'>> & { id: string; tags?: string[]; locations?: string[] },
) => api.patch<Chapter>(`/api/chapters/${id}`, body),
onSuccess: (updated) => { onSuccess: (updated) => {
qc.setQueryData(keys.chapter(updated.id), updated) qc.setQueryData(keys.chapter(updated.id), updated)
qc.invalidateQueries({ queryKey: keys.chapters(projectId) }) qc.invalidateQueries({ queryKey: keys.chapters(novelId) })
qc.invalidateQueries({ queryKey: keys.tags(projectId) }) qc.invalidateQueries({ queryKey: keys.tags(novelId) })
qc.invalidateQueries({ queryKey: keys.locations(novelId) })
}, },
}) })
} }
export function useDeleteChapter(projectId: string) { export function useDeleteChapter(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (id: string) => api.delete(`/api/chapters/${id}`), mutationFn: (id: string) => api.delete(`/api/chapters/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapters(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapters(novelId) }),
}) })
} }
export const useConversations = (projectId: string) => export const useConversations = (novelId: string) =>
useQuery({ useQuery({
queryKey: keys.conversations(projectId), queryKey: keys.conversations(novelId),
queryFn: () => api.get<ConversationSummary[]>(`/api/projects/${projectId}/agent/conversations`), queryFn: () => api.get<ConversationSummary[]>(`/api/novels/${novelId}/agent/conversations`),
}) })
export const useConversation = (id: string | undefined) => export const useConversation = (id: string | undefined) =>
@@ -465,19 +554,19 @@ export const useConversation = (id: string | undefined) =>
enabled: Boolean(id), enabled: Boolean(id),
}) })
export function useSendAgentMessage(projectId: string) { export function useSendAgentMessage(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (body: { message: string; conversationId?: string }) => mutationFn: (body: { message: string; conversationId?: string }) =>
api.post<AgentTurn>(`/api/projects/${projectId}/agent/messages`, body), api.post<AgentTurn>(`/api/novels/${novelId}/agent/messages`, body),
onSuccess: (turn) => { onSuccess: (turn) => {
qc.invalidateQueries({ queryKey: keys.conversations(projectId) }) qc.invalidateQueries({ queryKey: keys.conversations(novelId) })
qc.invalidateQueries({ queryKey: keys.conversation(turn.conversationId) }) qc.invalidateQueries({ queryKey: keys.conversation(turn.conversationId) })
qc.invalidateQueries({ queryKey: keys.characters(projectId) }) qc.invalidateQueries({ queryKey: keys.characters(novelId) })
qc.invalidateQueries({ queryKey: keys.tags(projectId) }) qc.invalidateQueries({ queryKey: keys.tags(novelId) })
qc.invalidateQueries({ queryKey: keys.chapters(projectId) }) qc.invalidateQueries({ queryKey: keys.chapters(novelId) })
qc.invalidateQueries({ queryKey: keys.questions(projectId) }) qc.invalidateQueries({ queryKey: keys.questions(novelId) })
qc.invalidateQueries({ queryKey: keys.project(projectId) }) qc.invalidateQueries({ queryKey: keys.novel(novelId) })
}, },
}) })
} }
+36 -20
View File
@@ -28,19 +28,19 @@ export type DraftStatus = 'Planned' | 'Outlined' | 'Drafted' | 'Revised' | 'Fina
export const draftStatuses: DraftStatus[] = ['Planned', 'Outlined', 'Drafted', 'Revised', 'Final'] export const draftStatuses: DraftStatus[] = ['Planned', 'Outlined', 'Drafted', 'Revised', 'Final']
export type ProjectPhase = 'Brainstorming' | 'Outlining' | 'Writing' | 'Editing' | 'Complete' export type NovelPhase = 'Brainstorming' | 'Outlining' | 'Writing' | 'Editing' | 'Complete'
export const projectPhases: ProjectPhase[] = ['Brainstorming', 'Outlining', 'Writing', 'Editing', 'Complete'] export const novelPhases: NovelPhase[] = ['Brainstorming', 'Outlining', 'Writing', 'Editing', 'Complete']
export type GlobalRole = 'Admin' | 'Writer' | 'Editor' | 'Reviewer' export type GlobalRole = 'Admin' | 'Writer' | 'Editor' | 'Reviewer'
export const globalRoles: GlobalRole[] = ['Admin', 'Writer', 'Editor', 'Reviewer'] export const globalRoles: GlobalRole[] = ['Admin', 'Writer', 'Editor', 'Reviewer']
export type ProjectRole = 'Writer' | 'Editor' | 'Reviewer' export type NovelRole = 'Writer' | 'Editor' | 'Reviewer'
export const projectRoles: ProjectRole[] = ['Writer', 'Editor', 'Reviewer'] export const novelRoles: NovelRole[] = ['Writer', 'Editor', 'Reviewer']
export type ProjectMyRole = 'Admin' | 'Owner' | 'Writer' | 'Editor' | 'Reviewer' export type NovelMyRole = 'Admin' | 'Owner' | 'Writer' | 'Editor' | 'Reviewer'
export interface User { export interface User {
id: string id: string
@@ -49,11 +49,11 @@ export interface User {
globalRole: GlobalRole globalRole: GlobalRole
} }
export interface ProjectMember { export interface NovelMember {
userId: string userId: string
email: string email: string
displayName: string displayName: string
projectRole: ProjectRole novelRole: NovelRole
grantedAt: string grantedAt: string
} }
@@ -62,21 +62,21 @@ export interface Genre {
name: string name: string
} }
export interface ProjectSummary { export interface NovelSummary {
id: string id: string
title: string title: string
author: string | null author: string | null
genre: string | null genre: string | null
logline: string | null logline: string | null
targetWordCount: number | null targetWordCount: number | null
phase: ProjectPhase phase: NovelPhase
characterCount: number characterCount: number
chapterCount: number chapterCount: number
wordCount: number wordCount: number
updatedAt: string updatedAt: string
} }
export interface Project { export interface Novel {
id: string id: string
title: string title: string
author: string | null author: string | null
@@ -85,9 +85,9 @@ export interface Project {
synopsis: string | null synopsis: string | null
notes: string | null notes: string | null
targetWordCount: number | null targetWordCount: number | null
phase: ProjectPhase phase: NovelPhase
ownerId: string | null ownerId: string | null
myRole: ProjectMyRole | null myRole: NovelMyRole | null
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }
@@ -121,6 +121,20 @@ export interface TagReferences {
}[] }[]
} }
export interface Location {
id: string
name: string
}
export interface LocationSummary extends Location {
chapterCount: number
}
export interface LocationReferences {
location: Location
chapters: { id: string; number: number; title: string; summary: string | null }[]
}
export interface BeatCharacter { export interface BeatCharacter {
id: string id: string
name: string name: string
@@ -151,10 +165,11 @@ export interface ArcStage {
characterId: string characterId: string
sortOrder: number sortOrder: number
title: string title: string
description: string | null result: string | null
chapterId: string | null chapterId: string | null
chapterNumber: number | null chapterNumber: number | null
chapterTitle: string | null chapterTitle: string | null
beats: CharacterBeat[]
updatedAt: string updatedAt: string
} }
@@ -167,11 +182,12 @@ export interface CharacterBeat {
title: string title: string
whatHappened: string | null whatHappened: string | null
whatsNext: string | null whatsNext: string | null
arcStageId: string | null
} }
export interface Character { export interface Character {
id: string id: string
projectId: string novelId: string
name: string name: string
role: CharacterRole role: CharacterRole
importance: CharacterImportance importance: CharacterImportance
@@ -208,11 +224,11 @@ export interface CharacterIdentity {
export interface ChapterSummary { export interface ChapterSummary {
id: string id: string
projectId: string novelId: string
number: number number: number
title: string title: string
summary: string | null summary: string | null
setting: string | null locations: Location[]
status: DraftStatus status: DraftStatus
targetWordCount: number | null targetWordCount: number | null
beatCount: number beatCount: number
@@ -231,7 +247,7 @@ export interface Chapter extends Omit<ChapterSummary, 'beatCount' | 'wordCount'>
export interface OpenQuestion { export interface OpenQuestion {
id: string id: string
projectId: string novelId: string
question: string question: string
detail: string | null detail: string | null
chapterId: string | null chapterId: string | null
@@ -262,7 +278,7 @@ export interface AgentMessage {
export interface ConversationSummary { export interface ConversationSummary {
id: string id: string
projectId: string novelId: string
title: string title: string
messageCount: number messageCount: number
updatedAt: string updatedAt: string
@@ -282,7 +298,7 @@ export type ImportJobStatus = 'Pending' | 'Running' | 'Completed' | 'Failed' | '
export interface ImportJob { export interface ImportJob {
id: string id: string
sourceRoot: string sourceRoot: string
projectId: string | null novelId: string | null
status: ImportJobStatus status: ImportJobStatus
statusMessage: string | null statusMessage: string | null
chaptersCompleted: number chaptersCompleted: number
@@ -295,7 +311,7 @@ export type ImportReadiness = 'Fresh' | 'Resumable' | 'Complete'
export interface ImportInspection { export interface ImportInspection {
readiness: ImportReadiness readiness: ImportReadiness
projectId: string | null novelId: string | null
chaptersCompleted: number chaptersCompleted: number
chaptersTotal: number chaptersTotal: number
completedPasses: string[] completedPasses: string[]
+6 -6
View File
@@ -1,10 +1,10 @@
import { createContext, useContext, useMemo, type ReactNode } from 'react' import { createContext, useContext, useMemo, type ReactNode } from 'react'
import { useMe } from '../api/hooks' import { useMe } from '../api/hooks'
import type { Project, ProjectMyRole, User } from '../api/types' import type { Novel, NovelMyRole, User } from '../api/types'
export type AuthPermission = 'CreateNovel' | 'Write' | 'CreateContent' | 'DeleteContent' | 'ManageAccess' export type AuthPermission = 'CreateNovel' | 'Write' | 'CreateContent' | 'DeleteContent' | 'ManageAccess'
const projectPermissionsByRole: Record<ProjectMyRole, AuthPermission[]> = { const novelPermissionsByRole: Record<NovelMyRole, AuthPermission[]> = {
Admin: ['Write', 'CreateContent', 'DeleteContent', 'ManageAccess'], Admin: ['Write', 'CreateContent', 'DeleteContent', 'ManageAccess'],
Owner: ['Write', 'CreateContent', 'DeleteContent', 'ManageAccess'], Owner: ['Write', 'CreateContent', 'DeleteContent', 'ManageAccess'],
Writer: ['Write', 'CreateContent', 'DeleteContent'], Writer: ['Write', 'CreateContent', 'DeleteContent'],
@@ -15,7 +15,7 @@ const projectPermissionsByRole: Record<ProjectMyRole, AuthPermission[]> = {
interface AuthValue { interface AuthValue {
user: User | null user: User | null
isPending: boolean isPending: boolean
can: (permission: AuthPermission, project?: Pick<Project, 'myRole'> | null) => boolean can: (permission: AuthPermission, novel?: Pick<Novel, 'myRole'> | null) => boolean
} }
const AuthContext = createContext<AuthValue>({ user: null, isPending: true, can: () => false }) const AuthContext = createContext<AuthValue>({ user: null, isPending: true, can: () => false })
@@ -28,10 +28,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
() => ({ () => ({
user, user,
isPending, isPending,
can: (permission, project) => { can: (permission, novel) => {
if (permission === 'CreateNovel') return user?.globalRole === 'Admin' || user?.globalRole === 'Writer' if (permission === 'CreateNovel') return user?.globalRole === 'Admin' || user?.globalRole === 'Writer'
const myRole = project?.myRole const myRole = novel?.myRole
return myRole ? projectPermissionsByRole[myRole].includes(permission) : false return myRole ? novelPermissionsByRole[myRole].includes(permission) : false
}, },
}), }),
[user, isPending], [user, isPending],
+87 -17
View File
@@ -1,35 +1,39 @@
import { useState } from 'react' import { useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { import {
useCharacterBeats,
useChapters, useChapters,
useCreateArcStage, useCreateArcStage,
useDeleteArcStage, useDeleteArcStage,
useReorderArcStages, useReorderArcStages,
useSetArcStageBeats,
useUpdateArcStage, useUpdateArcStage,
} from '../api/hooks' } from '../api/hooks'
import type { ArcStage, Character } from '../api/types' import type { ArcStage, Character } from '../api/types'
import { AutoField, ErrorNote } from './ui' import { AutoField, ErrorNote } from './ui'
export function CharacterArc({ export function CharacterArc({
projectId, novelId,
character, character,
canWrite, canWrite,
canCreate, canCreate,
canDelete, canDelete,
}: { }: {
projectId: string novelId: string
character: Character character: Character
canWrite: boolean canWrite: boolean
canCreate: boolean canCreate: boolean
canDelete: boolean canDelete: boolean
}) { }) {
const { data: chapters } = useChapters(projectId) const { data: chapters } = useChapters(novelId)
const create = useCreateArcStage(projectId) const { data: beats } = useCharacterBeats(character.id)
const reorder = useReorderArcStages(projectId) const create = useCreateArcStage(novelId)
const reorder = useReorderArcStages(novelId)
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const stages = character.arcStages const stages = character.arcStages
const unassignedBeats = (beats ?? []).filter((b) => b.arcStageId === null)
const submit = (e: React.FormEvent) => { const submit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@@ -56,8 +60,9 @@ export function CharacterArc({
)} )}
</div> </div>
<p className="mb-3 text-xs muted"> <p className="mb-3 text-xs muted">
The changes {character.name} goes through, in order. Pin a stage to the chapter it The sections {character.name}&rsquo;s arc breaks into, in order each one a short span of
lands in and it links into that outline. beats and what it results in for them. Pin a section to the chapter it lands in and it
links into that outline.
</p> </p>
{stages.length > 0 && ( {stages.length > 0 && (
@@ -65,9 +70,10 @@ export function CharacterArc({
{stages.map((stage, index) => ( {stages.map((stage, index) => (
<ArcStageRow <ArcStageRow
key={stage.id} key={stage.id}
projectId={projectId} novelId={novelId}
stage={stage} stage={stage}
chapters={chapters ?? []} chapters={chapters ?? []}
unassignedBeats={unassignedBeats}
canMoveUp={index > 0} canMoveUp={index > 0}
canMoveDown={index < stages.length - 1} canMoveDown={index < stages.length - 1}
onMove={(delta) => move(index, delta)} onMove={(delta) => move(index, delta)}
@@ -78,11 +84,18 @@ export function CharacterArc({
</ol> </ol>
)} )}
{unassignedBeats.length > 0 && (
<p className="mt-3 text-xs muted">
{unassignedBeats.length} beat{unassignedBeats.length === 1 ? '' : 's'} not yet grouped
into a section add {character.name} to a section above, or check the Beats list below.
</p>
)}
{canCreate && ( {canCreate && (
<form onSubmit={submit} className="mt-3 flex gap-2"> <form onSubmit={submit} className="mt-3 flex gap-2">
<input <input
className="input flex-1" className="input flex-1"
placeholder="Add a stage — three to five words, e.g. “she stops covering for him”" placeholder="Add a section — a short title, e.g. “spoiled noble”"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
/> />
@@ -102,26 +115,38 @@ export function CharacterArc({
} }
function ArcStageRow({ function ArcStageRow({
projectId, novelId,
stage, stage,
chapters, chapters,
unassignedBeats,
canMoveUp, canMoveUp,
canMoveDown, canMoveDown,
onMove, onMove,
canWrite, canWrite,
canDelete, canDelete,
}: { }: {
projectId: string novelId: string
stage: ArcStage stage: ArcStage
chapters: { id: string; number: number; title: string }[] chapters: { id: string; number: number; title: string }[]
unassignedBeats: { id: string; chapterNumber: number; sortOrder: number; title: string }[]
canMoveUp: boolean canMoveUp: boolean
canMoveDown: boolean canMoveDown: boolean
onMove: (delta: number) => void onMove: (delta: number) => void
canWrite: boolean canWrite: boolean
canDelete: boolean canDelete: boolean
}) { }) {
const update = useUpdateArcStage(projectId) const update = useUpdateArcStage(novelId)
const remove = useDeleteArcStage(projectId) const remove = useDeleteArcStage(novelId)
const setBeats = useSetArcStageBeats(novelId, stage.characterId)
const addBeat = (beatId: string) => {
if (!beatId) return
setBeats.mutate({ id: stage.id, beatIds: [...stage.beats.map((b) => b.id), beatId] })
}
const removeBeat = (beatId: string) => {
setBeats.mutate({ id: stage.id, beatIds: stage.beats.filter((b) => b.id !== beatId).map((b) => b.id) })
}
return ( return (
<li <li
@@ -138,14 +163,59 @@ function ArcStageRow({
readOnly={!canWrite} readOnly={!canWrite}
/> />
<AutoField <AutoField
value={stage.description} value={stage.result}
multiline multiline
rows={2} rows={2}
placeholder="What shifts here, and what it costs them." placeholder="What this results in for them — what shifts, and what it costs."
onCommit={(description) => update.mutate({ id: stage.id, description })} onCommit={(result) => update.mutate({ id: stage.id, result })}
readOnly={!canWrite} readOnly={!canWrite}
/> />
{stage.beats.length > 0 && (
<ul className="grid gap-1">
{stage.beats.map((beat) => (
<li
key={beat.id}
className="flex items-center gap-2 rounded px-2 py-1 text-xs"
style={{ background: 'var(--surface-1, rgba(0,0,0,0.015))' }}
>
<Link
className="shrink-0 tabular-nums underline"
style={{ color: 'var(--accent)' }}
to={`/novels/${novelId}/chapters/${beat.chapterId}#beat-${beat.id}`}
>
{beat.chapterNumber}.{beat.sortOrder}
</Link>
<span className="min-w-0 flex-1 truncate">{beat.title}</span>
{canWrite && (
<button
className="btn shrink-0 px-1.5 py-0 text-xs"
onClick={() => removeBeat(beat.id)}
aria-label={`Remove beat ${beat.title} from this section`}
>
</button>
)}
</li>
))}
</ul>
)}
{canWrite && unassignedBeats.length > 0 && (
<select
className="input py-1 text-xs"
value=""
onChange={(e) => addBeat(e.target.value)}
>
<option value="">Add a beat to this section</option>
{unassignedBeats.map((beat) => (
<option key={beat.id} value={beat.id}>
{beat.chapterNumber}.{beat.sortOrder} {beat.title}
</option>
))}
</select>
)}
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<select <select
className="input max-w-[16rem] py-1 text-xs" className="input max-w-[16rem] py-1 text-xs"
@@ -165,7 +235,7 @@ function ArcStageRow({
<Link <Link
className="text-xs underline" className="text-xs underline"
style={{ color: 'var(--accent)' }} style={{ color: 'var(--accent)' }}
to={`/projects/${projectId}/chapters/${stage.chapterId}`} to={`/novels/${novelId}/chapters/${stage.chapterId}`}
> >
Open outline Open outline
</Link> </Link>
@@ -1,17 +1,21 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useCharacterBeats } from '../api/hooks' import { useCharacterBeats } from '../api/hooks'
import type { ArcStage } from '../api/types'
import { ErrorNote, Spinner } from './ui' import { ErrorNote, Spinner } from './ui'
export function CharacterBeats({ export function CharacterBeats({
projectId, novelId,
characterId, characterId,
characterName, characterName,
arcStages,
}: { }: {
projectId: string novelId: string
characterId: string characterId: string
characterName: string characterName: string
arcStages: ArcStage[]
}) { }) {
const { data: beats, isPending, error } = useCharacterBeats(characterId) const { data: beats, isPending, error } = useCharacterBeats(characterId)
const stageTitleById = new Map(arcStages.map((s) => [s.id, s.title]))
return ( return (
<section className="card mt-6 p-5"> <section className="card mt-6 p-5">
@@ -33,6 +37,7 @@ export function CharacterBeats({
<tr className="text-left text-xs uppercase muted"> <tr className="text-left text-xs uppercase muted">
<th className="py-1 pr-3 font-semibold">Chapter</th> <th className="py-1 pr-3 font-semibold">Chapter</th>
<th className="py-1 pr-3 font-semibold">Beat</th> <th className="py-1 pr-3 font-semibold">Beat</th>
<th className="py-1 pr-3 font-semibold">Arc section</th>
<th className="py-1 pr-3 font-semibold">What happened</th> <th className="py-1 pr-3 font-semibold">What happened</th>
<th className="py-1 font-semibold">What&rsquo;s next</th> <th className="py-1 font-semibold">What&rsquo;s next</th>
</tr> </tr>
@@ -44,13 +49,16 @@ export function CharacterBeats({
<Link <Link
className="underline" className="underline"
style={{ color: 'var(--accent)' }} style={{ color: 'var(--accent)' }}
to={`/projects/${projectId}/chapters/${beat.chapterId}#beat-${beat.id}`} to={`/novels/${novelId}/chapters/${beat.chapterId}#beat-${beat.id}`}
> >
{beat.chapterNumber}.{beat.sortOrder} {beat.chapterNumber}.{beat.sortOrder}
</Link> </Link>
<div className="text-xs muted">{beat.chapterTitle}</div> <div className="text-xs muted">{beat.chapterTitle}</div>
</td> </td>
<td className="py-2 pr-3 font-medium">{beat.title}</td> <td className="py-2 pr-3 font-medium">{beat.title}</td>
<td className="py-2 pr-3 muted">
{beat.arcStageId ? (stageTitleById.get(beat.arcStageId) ?? '—') : '—'}
</td>
<td className="py-2 pr-3 muted">{beat.whatHappened}</td> <td className="py-2 pr-3 muted">{beat.whatHappened}</td>
<td className="py-2 muted">{beat.whatsNext}</td> <td className="py-2 muted">{beat.whatsNext}</td>
</tr> </tr>

Some files were not shown because too many files have changed in this diff Show More