Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44f722019b | ||
|
|
56e64c6f06 | ||
|
|
c620ddd626 | ||
|
|
4313c8f206 | ||
|
|
0ab4f568b5 | ||
|
|
bc85a36e18 | ||
|
|
17facba3b9 | ||
|
|
eb1efcf9f8 | ||
|
|
c879d9bfce | ||
|
|
cb66ef7343 |
@@ -104,6 +104,9 @@ dotnet_style_qualification_for_property = false:suggestion
|
||||
dotnet_style_qualification_for_method = false:warning
|
||||
dotnet_style_qualification_for_event = false:warning
|
||||
|
||||
|
||||
dotnet_diagnostic.CA1873.severity = silent
|
||||
|
||||
[*.cs]
|
||||
csharp_using_directive_placement = outside_namespace:silent
|
||||
csharp_prefer_simple_using_statement = true:suggestion
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
ANTHROPIC_API_KEY=
|
||||
@@ -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
|
||||
@@ -176,6 +176,7 @@ _TeamCity*
|
||||
coverage*.json
|
||||
coverage*.xml
|
||||
coverage*.info
|
||||
coverage/
|
||||
|
||||
# Visual Studio code coverage results
|
||||
*.coverage
|
||||
@@ -436,9 +437,11 @@ dist/
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
mcp-server/
|
||||
.mcp.json
|
||||
|
||||
# Toolchains installed locally by scripts/ci/lib.sh
|
||||
.dotnet/
|
||||
.dotnet-tools/
|
||||
.node/
|
||||
|
||||
.idea/
|
||||
|
||||
@@ -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
@@ -3,7 +3,8 @@
|
||||
"novelly": {
|
||||
"command": "./mcp-server/Novelly.Mcp",
|
||||
"env": {
|
||||
"NOVELLY_API_URL": "http://localhost:5080"
|
||||
"NOVELLY_API_URL": "http://localhost:5080",
|
||||
"NOVELLY_API_KEY": "<matches the API's Auth:ServiceApiKey user secret>"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ Novelly: software plan + write novel. ASP.NET Core 10, C#, TypeScript, React, .N
|
||||
## Structure
|
||||
|
||||
- `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
|
||||
`DbContext` + EF migrations.
|
||||
- `src/Novelly.AppHost/` — .NET Aspire orchestration; run this to bring up API + web client
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Novelly
|
||||
|
||||
[](https://github.com/wamplerj/novelly/actions/workflows/ci.yml)
|
||||
[](https://git.wampler.us/wamplerj/novelly/actions?workflow=ci.yml)
|
||||

|
||||
|
||||
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
|
||||
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 |
|
||||
|
||||
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
|
||||
folder rather than four. `Common/` holds what genuinely crosses features and `Data/` holds
|
||||
the `DbContext` and migrations.
|
||||
@@ -65,7 +69,7 @@ and everything else keeps working.
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
dotnet test # 73 tests
|
||||
dotnet test # 174 tests
|
||||
cd src/Novelly.Web && npm run build # typecheck + bundle
|
||||
```
|
||||
|
||||
@@ -98,13 +102,13 @@ out of `appsettings.json` and use user-secrets or the environment.
|
||||
## The data model
|
||||
|
||||
```
|
||||
Project ──┬── Character ──┬── CharacterRelationship
|
||||
│ └── CharacterArcStage (the arc: flat, ordered)
|
||||
├── Chapter ──┬── Beat (the outline: flat, ordered)
|
||||
│ └── Scene (the prose)
|
||||
├── Tag (applied to characters, chapters and beats)
|
||||
├── OpenQuestion (attached to a chapter and/or a character)
|
||||
└── AgentConversation ── AgentMessage
|
||||
Novel ──┬── Character ──┬── CharacterRelationship
|
||||
│ └── CharacterArcStage (the arc: flat, ordered)
|
||||
├── Chapter ──── Beat (the outline: flat, ordered)
|
||||
├── Tag (applied to characters, chapters and beats)
|
||||
├── Location (applied to chapters)
|
||||
├── OpenQuestion (attached to a chapter and/or a character)
|
||||
└── AgentConversation ── AgentMessage
|
||||
```
|
||||
|
||||
**A chapter outline is a paragraph plus a table.** The paragraph is the chapter's
|
||||
@@ -113,18 +117,20 @@ Project ──┬── Character ──┬── CharacterRelationship
|
||||
| Column | What goes in it |
|
||||
|---|---|
|
||||
| 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'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
|
||||
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
|
||||
is for working out what happens, and a scene is where you write it. A beat's `SceneId` is
|
||||
the optional link between them, and it is nullable in both directions — deleting a scene
|
||||
ungroups its beats rather than deleting the plan.
|
||||
**Beats plan; the chapter's `Prose` carries the draft.** A chapter has one prose field,
|
||||
written and redrafted in place; `WordCount` is recomputed from it whenever it changes.
|
||||
There is no separate scene entity — the outline (beats) and the draft (prose) are the
|
||||
two views of the same chapter.
|
||||
|
||||
**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
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
`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
|
||||
stops asking. It has 29 tools covering the brief, characters and their arcs, chapter outlines
|
||||
(beats), scenes, tags and open questions — all of them going through the same application
|
||||
services the REST API uses.
|
||||
stops asking. It has 33 tools covering the brief, characters and their arcs, chapter
|
||||
outlines (beats), tags, locations and open questions — all of them going through the same
|
||||
application services the REST API uses.
|
||||
|
||||
A few deliberate choices worth knowing about:
|
||||
|
||||
@@ -179,30 +192,41 @@ A few deliberate choices worth knowing about:
|
||||
|
||||
## 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.
|
||||
|
||||
Build it, then point your MCP client at the produced binary:
|
||||
|
||||
```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
|
||||
{
|
||||
"mcpServers": {
|
||||
"novelly": {
|
||||
"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
|
||||
rather than failing opaquely.
|
||||
The API must be running, with `Auth:ServiceApiKey` set (e.g. via
|
||||
`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
|
||||
|
||||
@@ -219,19 +243,20 @@ running and `.mcp.json` is set up.
|
||||
|
||||
| Resource | Routes |
|
||||
|---|---|
|
||||
| Projects | `GET\|POST /api/projects`, `GET\|PATCH\|DELETE /api/projects/{id}` |
|
||||
| Characters | `GET\|POST /api/projects/{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}` |
|
||||
| Novels | `GET\|POST /api/novels`, `GET\|PATCH\|DELETE /api/novels/{id}` |
|
||||
| Characters | `GET\|POST /api/novels/{id}/characters`, `GET\|PATCH\|DELETE /api/characters/{id}`, `POST /api/characters/{id}/relationships` |
|
||||
| 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}` |
|
||||
| Scenes | `GET\|POST /api/chapters/{id}/scenes`, `GET\|PATCH\|DELETE /api/scenes/{id}` |
|
||||
| Tags | `GET\|POST /api/projects/{id}/tags`, `GET /api/tags/{id}/references`, `PATCH\|DELETE /api/tags/{id}` |
|
||||
| Tags | `GET\|POST /api/novels/{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}` |
|
||||
| Questions | `GET\|POST /api/projects/{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}` |
|
||||
| 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/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
|
||||
`tags` array replaces that item's tags outright and creates any names the project has not
|
||||
seen; omitting it leaves tags untouched.
|
||||
`tags` array replaces that item's tags outright and creates any names the novel has not
|
||||
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
|
||||
OpenAPI document is at `/openapi/v1.json`.
|
||||
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
@@ -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:
|
||||
Executable
+19
@@ -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"
|
||||
Executable
+31
@@ -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"
|
||||
Executable
+36
@@ -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}"
|
||||
Executable
+25
@@ -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"
|
||||
Executable
+17
@@ -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"
|
||||
@@ -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.
|
||||
ensure_dotnet() {
|
||||
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
|
||||
fi
|
||||
|
||||
@@ -51,3 +55,45 @@ ensure_dotnet() {
|
||||
ensure_node() {
|
||||
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
|
||||
}
|
||||
|
||||
Executable
+30
@@ -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"
|
||||
Executable
+22
@@ -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"
|
||||
Executable
+15
@@ -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."
|
||||
@@ -1,14 +1,14 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
|
||||
namespace Novelly.Api.Agent;
|
||||
|
||||
public class AgentConversation
|
||||
{
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
public Guid ProjectId { get; init; }
|
||||
public Project? Project { get; init; }
|
||||
public Guid NovelId { get; init; }
|
||||
public Novel? Novel { get; init; }
|
||||
|
||||
public string Title { get; init; } = "New conversation";
|
||||
|
||||
|
||||
@@ -7,22 +7,22 @@ public static class AgentEndpoints
|
||||
{
|
||||
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<ValidationEndpointFilter>();
|
||||
|
||||
projectScoped.MapGet("/conversations", async (
|
||||
Guid projectId, NovelAgentService agent, CancellationToken ct) =>
|
||||
Results.Ok(await agent.ListConversationsAsync(projectId, ct)))
|
||||
.WithSummary("List the project's agent conversations.");
|
||||
novelScoped.MapGet("/conversations", async (
|
||||
Guid novelId, NovelAgentService agent, CancellationToken ct) =>
|
||||
Results.Ok(await agent.ListConversationsAsync(novelId, ct)))
|
||||
.WithSummary("List the novel's agent conversations.");
|
||||
|
||||
projectScoped.MapPost("/messages", async (
|
||||
Guid projectId,
|
||||
novelScoped.MapPost("/messages", async (
|
||||
Guid novelId,
|
||||
SendAgentMessageRequest request,
|
||||
NovelAgentService agent,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var reply = await agent.SendMessageAsync(projectId, request, ct);
|
||||
var reply = await agent.SendMessageAsync(novelId, request, ct);
|
||||
return reply is null
|
||||
? Results.NotFound()
|
||||
: Results.Ok(new AgentTurnResponse(reply.ConversationId, reply.ToResponse()));
|
||||
|
||||
@@ -4,9 +4,9 @@ using Novelly.Api.Common.Validation;
|
||||
|
||||
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);
|
||||
|
||||
@@ -46,7 +46,7 @@ public static class AgentMapping
|
||||
|
||||
public static ConversationResponse ToResponse(this AgentConversation conversation) => new(
|
||||
conversation.Id,
|
||||
conversation.ProjectId,
|
||||
conversation.NovelId,
|
||||
conversation.Title,
|
||||
[.. conversation.Messages.OrderBy(m => m.Sequence).Select(m => m.ToResponse())],
|
||||
conversation.UpdatedAt);
|
||||
|
||||
@@ -6,7 +6,7 @@ using Microsoft.Extensions.Options;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
|
||||
namespace Novelly.Api.Agent;
|
||||
|
||||
@@ -26,14 +26,14 @@ public class NovelAgentService(
|
||||
private readonly AgentOptions _options = options.Value;
|
||||
|
||||
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
|
||||
.Where(c => c.ProjectId == projectId)
|
||||
.Where(c => c.NovelId == novelId)
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -63,39 +63,39 @@ public class NovelAgentService(
|
||||
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));
|
||||
sendMessageValidator.Validate(request).ThrowIfInvalid(logger);
|
||||
|
||||
logger.LogInformation(
|
||||
"Sending agent message for project {ProjectId}, conversation {ConversationId}, message length {MessageLength}",
|
||||
projectId, request.ConversationId, request.Message.Length);
|
||||
"Sending agent message for novel {NovelId}, conversation {ConversationId}, message length {MessageLength}",
|
||||
novelId, request.ConversationId, request.Message.Length);
|
||||
|
||||
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct);
|
||||
if (project is null)
|
||||
var novel = await db.Novels.FirstOrDefaultAsync(p => p.Id == novelId, ct);
|
||||
if (novel is null)
|
||||
{
|
||||
logger.LogWarning("Project {ProjectId} not found", projectId);
|
||||
logger.LogWarning("Novel {NovelId} not found", novelId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var conversation = request.ConversationId is { } id
|
||||
? await FindConversationAsync(id, ct)
|
||||
: StartConversation(projectId, request.Message);
|
||||
: StartConversation(novelId, request.Message);
|
||||
|
||||
if (conversation is null) return null;
|
||||
|
||||
await AppendMessageAsync(conversation, AgentRole.User, request.Message, null, ct);
|
||||
|
||||
var systemPrompt = BuildSystemPrompt(project);
|
||||
var systemPrompt = BuildSystemPrompt(novel);
|
||||
var transcript = BuildTranscript(conversation);
|
||||
var toolCalls = new List<ToolCallResponse>();
|
||||
var text = new StringBuilder();
|
||||
|
||||
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);
|
||||
|
||||
@@ -113,9 +113,9 @@ public class NovelAgentService(
|
||||
var results = new List<AgentContentBlock>();
|
||||
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));
|
||||
results.Add(new AgentToolResultBlock(call.Id, outcome.Content, outcome.IsError));
|
||||
@@ -125,7 +125,7 @@ public class NovelAgentService(
|
||||
|
||||
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._");
|
||||
}
|
||||
@@ -165,19 +165,19 @@ public class NovelAgentService(
|
||||
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
|
||||
{
|
||||
ProjectId = projectId,
|
||||
NovelId = novelId,
|
||||
Title = Summarise(firstMessage)
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -209,25 +209,25 @@ public class NovelAgentService(
|
||||
[new AgentTextBlock(m.Content)]))
|
||||
];
|
||||
|
||||
private static string BuildSystemPrompt(Project project)
|
||||
private static string BuildSystemPrompt(Novel novel)
|
||||
{
|
||||
var brief = new StringBuilder();
|
||||
brief.AppendLine($"Title: {project.Title}");
|
||||
if (!string.IsNullOrWhiteSpace(project.Genre)) brief.AppendLine($"Genre: {project.Genre}");
|
||||
if (!string.IsNullOrWhiteSpace(project.Logline)) brief.AppendLine($"Logline: {project.Logline}");
|
||||
if (project.TargetWordCount is { } target) brief.AppendLine($"Target length: {target:N0} words");
|
||||
brief.AppendLine($"Title: {novel.Title}");
|
||||
if (!string.IsNullOrWhiteSpace(novel.Genre)) brief.AppendLine($"Genre: {novel.Genre}");
|
||||
if (!string.IsNullOrWhiteSpace(novel.Logline)) brief.AppendLine($"Logline: {novel.Logline}");
|
||||
if (novel.TargetWordCount is { } target) brief.AppendLine($"Target length: {target:N0} words");
|
||||
|
||||
return $"""
|
||||
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
|
||||
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.
|
||||
|
||||
The project you are working on:
|
||||
The novel you are working on:
|
||||
{brief}
|
||||
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.
|
||||
- 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.
|
||||
@@ -239,7 +239,7 @@ public class NovelAgentService(
|
||||
genuinely in tension, what the outline is missing — over line-level polish,
|
||||
unless the writer asks for prose.
|
||||
- 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
|
||||
go-ahead first.
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Locations;
|
||||
using Novelly.Api.Novels;
|
||||
using Novelly.Api.Questions;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
@@ -23,12 +24,13 @@ public record AgentTool(
|
||||
Func<Guid, JsonElement, CancellationToken, Task<object?>> Handler);
|
||||
|
||||
public class NovelAgentToolset(
|
||||
ProjectService projects,
|
||||
NovelService novels,
|
||||
CharacterService characters,
|
||||
CharacterArcService arcs,
|
||||
ChapterService chapters,
|
||||
BeatService beats,
|
||||
TagService tags,
|
||||
LocationService locations,
|
||||
OpenQuestionService questions,
|
||||
ILogger<NovelAgentToolset> logger)
|
||||
{
|
||||
@@ -45,7 +47,7 @@ public class NovelAgentToolset(
|
||||
public IReadOnlyList<AgentToolDefinition> Definitions =>
|
||||
[.. 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))
|
||||
{
|
||||
@@ -53,29 +55,29 @@ public class NovelAgentToolset(
|
||||
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
|
||||
{
|
||||
var result = await tool.Handler(projectId, input, ct);
|
||||
var result = await tool.Handler(novelId, input, ct);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -95,15 +97,15 @@ public class NovelAgentToolset(
|
||||
private IEnumerable<AgentTool> Build()
|
||||
{
|
||||
yield return new AgentTool(
|
||||
"get_project_brief",
|
||||
"Read the project's title, logline, synopsis, genre, notes and word-count target. "
|
||||
"get_novel_brief",
|
||||
"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.",
|
||||
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(
|
||||
"update_project_brief",
|
||||
"Revise the project's top-level fields. Only the fields you supply change; "
|
||||
"update_novel_brief",
|
||||
"Revise the novel's top-level fields. Only the fields you supply change; "
|
||||
+ "pass an empty string to clear a field.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("title", "New title.")
|
||||
@@ -114,27 +116,27 @@ public class NovelAgentToolset(
|
||||
.Str("notes", "Free-form notes on theme, tone, comparable titles.")
|
||||
.Int("target_word_count", "Target manuscript length in words.")
|
||||
.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, "author"),
|
||||
JsonInput.String(input, "genre"),
|
||||
JsonInput.String(input, "logline"),
|
||||
JsonInput.String(input, "synopsis"),
|
||||
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(
|
||||
"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(),
|
||||
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(
|
||||
"create_character",
|
||||
"Add a character dossier. Name is the only requirement — leave fields blank when "
|
||||
+ "the writer has not decided them yet rather than inventing detail.",
|
||||
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.Enum<CharacterRole>(input, "role") ?? CharacterRole.Supporting,
|
||||
JsonInput.Enum<CharacterImportance>(input, "importance") ?? CharacterImportance.Supporting,
|
||||
@@ -152,7 +154,7 @@ public class NovelAgentToolset(
|
||||
JsonInput.String(input, "voice"),
|
||||
JsonInput.String(input, "notes"),
|
||||
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(
|
||||
"update_character",
|
||||
@@ -189,7 +191,7 @@ public class NovelAgentToolset(
|
||||
yield return new AgentTool(
|
||||
"link_character_identity",
|
||||
"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.",
|
||||
new JsonSchemaBuilder()
|
||||
.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(
|
||||
"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.",
|
||||
new JsonSchemaBuilder().Build(),
|
||||
async (projectId, _, ct) => await tags.ListAsync(projectId, ct));
|
||||
async (novelId, _, ct) => await tags.ListAsync(novelId, ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"get_tag_references",
|
||||
@@ -367,10 +369,29 @@ public class NovelAgentToolset(
|
||||
});
|
||||
|
||||
yield return new AgentTool(
|
||||
"list_chapters",
|
||||
"List the project's chapters in manuscript order with beat and word counts.",
|
||||
"list_locations",
|
||||
"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(),
|
||||
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(
|
||||
"get_chapter",
|
||||
@@ -391,27 +412,27 @@ public class NovelAgentToolset(
|
||||
.Str("title", "Chapter title.", required: true)
|
||||
.Int("number", "Position in the manuscript, 1-based.")
|
||||
.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.")
|
||||
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
|
||||
.Int("target_word_count", "Target length in words.")
|
||||
.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.")
|
||||
.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.Int(input, "number"),
|
||||
JsonInput.String(input, "summary"),
|
||||
JsonInput.String(input, "setting"),
|
||||
JsonInput.Strings(input, "locations"),
|
||||
JsonInput.String(input, "notes"),
|
||||
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
|
||||
JsonInput.Int(input, "target_word_count"),
|
||||
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(
|
||||
"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 "
|
||||
+ "word count is recomputed automatically.",
|
||||
new JsonSchemaBuilder()
|
||||
@@ -419,7 +440,7 @@ public class NovelAgentToolset(
|
||||
.Str("title", "New title.")
|
||||
.Int("number", "Position in the manuscript.")
|
||||
.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.")
|
||||
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
|
||||
.Int("target_word_count", "Target length in words.")
|
||||
@@ -435,7 +456,7 @@ public class NovelAgentToolset(
|
||||
JsonInput.String(input, "title"),
|
||||
JsonInput.Int(input, "number"),
|
||||
JsonInput.String(input, "summary"),
|
||||
JsonInput.String(input, "setting"),
|
||||
JsonInput.Strings(input, "locations"),
|
||||
JsonInput.String(input, "notes"),
|
||||
JsonInput.Enum<DraftStatus>(input, "status"),
|
||||
JsonInput.Int(input, "target_word_count"),
|
||||
@@ -456,7 +477,7 @@ public class NovelAgentToolset(
|
||||
var characterId = JsonInput.RequiredGuid(input, "character_id");
|
||||
return await OrNotFound(
|
||||
beats.ListForCharacterAsync(characterId, ct),
|
||||
list => list.Select(b => b.ToCharacterBeatResponse()),
|
||||
list => list.Select(b => b.ToCharacterBeatResponse(characterId)),
|
||||
"Character",
|
||||
characterId);
|
||||
});
|
||||
@@ -548,8 +569,8 @@ public class NovelAgentToolset(
|
||||
.Str("character_id", "Narrow to questions about one character.")
|
||||
.Bool("include_resolved", "Include questions already settled. Defaults to false.")
|
||||
.Build(),
|
||||
async (projectId, input, ct) => (await questions.ListAsync(
|
||||
projectId,
|
||||
async (novelId, input, ct) => (await questions.ListAsync(
|
||||
novelId,
|
||||
JsonInput.Guid(input, "chapter_id"),
|
||||
JsonInput.Guid(input, "character_id"),
|
||||
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("character_id", "The character this is about, if any.")
|
||||
.Build(),
|
||||
async (projectId, input, ct) => await OrNotFound(questions.CreateAsync(
|
||||
projectId,
|
||||
async (novelId, input, ct) => await OrNotFound(questions.CreateAsync(
|
||||
novelId,
|
||||
new CreateOpenQuestionRequest(
|
||||
JsonInput.RequiredString(input, "question"),
|
||||
JsonInput.String(input, "detail"),
|
||||
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(
|
||||
"resolve_open_question",
|
||||
|
||||
@@ -19,6 +19,8 @@ public class Beat
|
||||
|
||||
public List<Character> Characters { get; set; } = [];
|
||||
|
||||
public List<CharacterArcStage> ArcStages { get; set; } = [];
|
||||
|
||||
public string? WhatHappened { get; set; }
|
||||
|
||||
public string? WhatsNext { get; set; }
|
||||
|
||||
@@ -84,7 +84,8 @@ public record CharacterBeatResponse(
|
||||
int SortOrder,
|
||||
string Title,
|
||||
string? WhatHappened,
|
||||
string? WhatsNext);
|
||||
string? WhatsNext,
|
||||
Guid? ArcStageId);
|
||||
|
||||
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.UpdatedAt);
|
||||
|
||||
public static CharacterBeatResponse ToCharacterBeatResponse(this Beat b) => new(
|
||||
public static CharacterBeatResponse ToCharacterBeatResponse(this Beat b, Guid characterId) => new(
|
||||
b.Id,
|
||||
b.ChapterId,
|
||||
b.Chapter?.Number ?? 0,
|
||||
@@ -158,5 +159,6 @@ public static class BeatMapping
|
||||
b.SortOrder,
|
||||
b.Title,
|
||||
b.WhatHappened,
|
||||
b.WhatsNext);
|
||||
b.WhatsNext,
|
||||
b.ArcStages.FirstOrDefault(s => s.CharacterId == characterId)?.Id);
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ public static class BeatEndpoints
|
||||
|
||||
app.MapGet("/api/characters/{characterId:guid}/beats", async (
|
||||
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")
|
||||
.WithSummary("Every beat this character appears in, in manuscript order.");
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace Novelly.Api.Beats;
|
||||
|
||||
public class BeatService(
|
||||
INovelDbContext db,
|
||||
ProjectAccessService access,
|
||||
NovelAccessService access,
|
||||
TagService tags,
|
||||
ILogger<BeatService> logger,
|
||||
IModelValidator<CreateBeatRequest> createValidator,
|
||||
@@ -26,7 +26,7 @@ public class BeatService(
|
||||
|
||||
logger.LogInformation("Listing beats for chapter {ChapterId}", chapterId);
|
||||
|
||||
await RequireChapterAccessAsync(chapterId, ProjectPermission.Read, ct);
|
||||
await RequireChapterAccessAsync(chapterId, NovelPermission.Read, ct);
|
||||
|
||||
return await Query()
|
||||
.Where(b => b.ChapterId == chapterId)
|
||||
@@ -46,7 +46,7 @@ public class BeatService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await RequireBeatAccessAsync(beat, ProjectPermission.Read, ct);
|
||||
await RequireBeatAccessAsync(beat, NovelPermission.Read, ct);
|
||||
return beat;
|
||||
}
|
||||
|
||||
@@ -57,17 +57,18 @@ public class BeatService(
|
||||
|
||||
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);
|
||||
if (characterProjectId is null)
|
||||
var characterNovelId = await db.Characters.Where(c => c.Id == characterId).Select(c => (Guid?)c.NovelId).FirstOrDefaultAsync(ct);
|
||||
if (characterNovelId is null)
|
||||
{
|
||||
logger.LogWarning("Character {CharacterId} not found", characterId);
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(characterProjectId.Value, ProjectPermission.Read, ct);
|
||||
await access.RequireAsync(characterNovelId.Value, NovelPermission.Read, ct);
|
||||
|
||||
var beats = await db.Beats
|
||||
.Include(b => b.Chapter)
|
||||
.Include(b => b.ArcStages)
|
||||
.Where(b => b.Characters.Any(c => c.Id == characterId))
|
||||
.ToListAsync(ct);
|
||||
|
||||
@@ -94,7 +95,7 @@ public class BeatService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(chapter.ProjectId, ProjectPermission.CreateContent, ct);
|
||||
await access.RequireAsync(chapter.NovelId, NovelPermission.CreateContent, ct);
|
||||
|
||||
var beat = new Beat
|
||||
{
|
||||
@@ -107,14 +108,16 @@ public class BeatService(
|
||||
|
||||
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)
|
||||
{
|
||||
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);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
@@ -142,22 +145,23 @@ public class BeatService(
|
||||
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.SortOrder = request.SortOrder ?? beat.SortOrder;
|
||||
beat.WhatHappened = Patch.Apply(beat.WhatHappened, request.WhatHappened);
|
||||
beat.WhatsNext = Patch.Apply(beat.WhatsNext, request.WhatsNext);
|
||||
beat.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
chapter.UpdatedAt = beat.UpdatedAt;
|
||||
|
||||
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)
|
||||
{
|
||||
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
|
||||
beat.Tags = await tags.ResolveAsync(chapter.NovelId, names, ct);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
@@ -176,7 +180,10 @@ public class BeatService(
|
||||
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);
|
||||
await db.SaveChangesAsync(ct);
|
||||
@@ -192,7 +199,7 @@ public class BeatService(
|
||||
|
||||
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);
|
||||
|
||||
@@ -214,6 +221,9 @@ public class BeatService(
|
||||
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);
|
||||
return await ListAsync(chapterId, ct);
|
||||
}
|
||||
@@ -236,15 +246,15 @@ public class BeatService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Write, ct);
|
||||
await access.RequireAsync(chapter.NovelId, NovelPermission.Write, ct);
|
||||
|
||||
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)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Rejected character assignment: character {CharacterId} not found in project {ProjectId}",
|
||||
request.CharacterId, chapter.ProjectId);
|
||||
"Rejected character assignment: character {CharacterId} not found in novel {NovelId}",
|
||||
request.CharacterId, chapter.NovelId);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -261,6 +271,7 @@ public class BeatService(
|
||||
{
|
||||
beat.Characters.Add(character);
|
||||
beat.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
chapter.UpdatedAt = beat.UpdatedAt;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
@@ -286,16 +297,16 @@ public class BeatService(
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Rejected beat move: target chapter {TargetChapterId} not found in project {ProjectId}",
|
||||
request.TargetChapterId, chapter.ProjectId);
|
||||
"Rejected beat move: target chapter {TargetChapterId} not found in novel {NovelId}",
|
||||
request.TargetChapterId, chapter.NovelId);
|
||||
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 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 now = DateTimeOffset.UtcNow;
|
||||
foreach (var beatId in request.BeatIds)
|
||||
{
|
||||
var beat = beats.Single(b => b.Id == beatId);
|
||||
beat.ChapterId = request.TargetChapterId;
|
||||
beat.SortOrder = nextSortOrder++;
|
||||
beat.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
beat.UpdatedAt = now;
|
||||
}
|
||||
|
||||
chapter.UpdatedAt = now;
|
||||
targetChapter.UpdatedAt = now;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
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();
|
||||
if (distinct.Count == 0)
|
||||
@@ -334,17 +349,17 @@ public class BeatService(
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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(
|
||||
"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;
|
||||
}
|
||||
|
||||
@@ -361,13 +376,13 @@ public class BeatService(
|
||||
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);
|
||||
await access.RequireAsync(projectId, permission, ct);
|
||||
var novelId = await db.Chapters.Where(c => c.Id == chapterId).Select(c => c.NovelId).FirstOrDefaultAsync(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);
|
||||
|
||||
private IQueryable<Beat> Query() =>
|
||||
|
||||
@@ -2,7 +2,8 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Locations;
|
||||
using Novelly.Api.Novels;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Chapters;
|
||||
@@ -10,8 +11,8 @@ namespace Novelly.Api.Chapters;
|
||||
public class Chapter
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid ProjectId { get; set; }
|
||||
public Project? Project { get; set; }
|
||||
public Guid NovelId { get; set; }
|
||||
public Novel? Novel { get; set; }
|
||||
|
||||
public int Number { get; set; }
|
||||
|
||||
@@ -19,7 +20,6 @@ public class Chapter
|
||||
|
||||
public string? Summary { get; set; }
|
||||
|
||||
public string? Setting { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public DraftStatus Status { get; set; } = DraftStatus.Planned;
|
||||
@@ -35,6 +35,7 @@ public class Chapter
|
||||
public List<Beat> Beats { get; set; } = [];
|
||||
|
||||
public List<Tag> Tags { get; set; } = [];
|
||||
public List<Location> Locations { get; set; } = [];
|
||||
}
|
||||
|
||||
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.Status).HasConversion<string>().HasMaxLength(32);
|
||||
entity.HasIndex(c => new { c.ProjectId, c.Number });
|
||||
entity.HasIndex(c => new { c.NovelId, c.Number });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Locations;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Chapters;
|
||||
|
||||
public record ChapterSummaryResponse(
|
||||
Guid Id,
|
||||
Guid ProjectId,
|
||||
Guid NovelId,
|
||||
int Number,
|
||||
string Title,
|
||||
string? Summary,
|
||||
string? Setting,
|
||||
IReadOnlyList<LocationResponse> Locations,
|
||||
DraftStatus Status,
|
||||
int? TargetWordCount,
|
||||
int BeatCount,
|
||||
@@ -21,11 +22,11 @@ public record ChapterSummaryResponse(
|
||||
|
||||
public record ChapterResponse(
|
||||
Guid Id,
|
||||
Guid ProjectId,
|
||||
Guid NovelId,
|
||||
int Number,
|
||||
string Title,
|
||||
string? Summary,
|
||||
string? Setting,
|
||||
IReadOnlyList<LocationResponse> Locations,
|
||||
string? Notes,
|
||||
DraftStatus Status,
|
||||
int? TargetWordCount,
|
||||
@@ -39,7 +40,7 @@ public record CreateChapterRequest(
|
||||
string Title,
|
||||
int? Number = null,
|
||||
string? Summary = null,
|
||||
string? Setting = null,
|
||||
IReadOnlyList<string>? Locations = null,
|
||||
string? Notes = null,
|
||||
DraftStatus Status = DraftStatus.Planned,
|
||||
int? TargetWordCount = null,
|
||||
@@ -53,7 +54,7 @@ public class CreateChapterRequestValidator : IModelValidator<CreateChapterReques
|
||||
var result = new ValidationResult();
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -63,7 +64,7 @@ public record UpdateChapterRequest(
|
||||
string? Title = null,
|
||||
int? Number = null,
|
||||
string? Summary = null,
|
||||
string? Setting = null,
|
||||
IReadOnlyList<string>? Locations = null,
|
||||
string? Notes = null,
|
||||
DraftStatus? Status = null,
|
||||
int? TargetWordCount = null,
|
||||
@@ -77,7 +78,7 @@ public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterReques
|
||||
var result = new ValidationResult();
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -86,7 +87,7 @@ public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterReques
|
||||
file static class ChapterValidation
|
||||
{
|
||||
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)
|
||||
{
|
||||
if (number is <= 0)
|
||||
@@ -95,8 +96,8 @@ file static class ChapterValidation
|
||||
if (summary is { Length: > 20000 })
|
||||
result.AddError("Summary", "'Summary' must be 20,000 characters or fewer.");
|
||||
|
||||
if (setting is { Length: > 500 })
|
||||
result.AddError("Setting", "'Setting' must be 500 characters or fewer.");
|
||||
if (locations is not null && locations.Any(string.IsNullOrWhiteSpace))
|
||||
result.AddError("Locations", "'Locations' must not contain blank entries.");
|
||||
|
||||
if (notes is { Length: > 20000 })
|
||||
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 ChapterResponse ToResponse(this Chapter c) => new(
|
||||
c.Id, c.ProjectId, c.Number, c.Title, c.Summary,
|
||||
c.Setting, c.Notes,
|
||||
c.Id, c.NovelId, c.Number, c.Title, c.Summary,
|
||||
[.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())],
|
||||
c.Notes,
|
||||
c.Status, c.TargetWordCount,
|
||||
[.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())],
|
||||
c.Prose, c.WordCount,
|
||||
@@ -124,8 +126,9 @@ public static class ChapterMapping
|
||||
c.UpdatedAt);
|
||||
|
||||
public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new(
|
||||
c.Id, c.ProjectId, c.Number, c.Title, c.Summary,
|
||||
c.Setting, c.Status, c.TargetWordCount,
|
||||
c.Id, c.NovelId, c.Number, c.Title, c.Summary,
|
||||
[.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())],
|
||||
c.Status, c.TargetWordCount,
|
||||
c.Beats.Count, c.WordCount,
|
||||
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
|
||||
c.UpdatedAt);
|
||||
|
||||
@@ -7,18 +7,18 @@ public static class ChapterEndpoints
|
||||
{
|
||||
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<ValidationEndpointFilter>();
|
||||
|
||||
projectScoped.MapGet("/", async (Guid projectId, ChapterService service, CancellationToken ct) =>
|
||||
Results.Ok((await service.ListAsync(projectId, ct)).Select(c => c.ToSummaryResponse())))
|
||||
.WithSummary("List a project's chapters in manuscript order.");
|
||||
novelScoped.MapGet("/", async (Guid novelId, ChapterService service, CancellationToken ct) =>
|
||||
Results.Ok((await service.ListAsync(novelId, ct)).Select(c => c.ToSummaryResponse())))
|
||||
.WithSummary("List a novel's chapters in manuscript order.");
|
||||
|
||||
projectScoped.MapPost("/", async (
|
||||
Guid projectId, CreateChapterRequest request, ChapterService service, CancellationToken ct) =>
|
||||
novelScoped.MapPost("/", async (
|
||||
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)
|
||||
{
|
||||
return Results.NotFound();
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Locations;
|
||||
using Novelly.Api.Tags;
|
||||
using Novelly.Api.Users;
|
||||
|
||||
@@ -9,24 +10,26 @@ namespace Novelly.Api.Chapters;
|
||||
|
||||
public class ChapterService(
|
||||
INovelDbContext db,
|
||||
ProjectAccessService access,
|
||||
NovelAccessService access,
|
||||
TagService tags,
|
||||
LocationService locations,
|
||||
ILogger<ChapterService> logger,
|
||||
IModelValidator<CreateChapterRequest> createValidator,
|
||||
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
|
||||
.Include(c => c.Beats)
|
||||
.Include(c => c.Tags)
|
||||
.Where(c => c.ProjectId == projectId)
|
||||
.Include(c => c.Locations)
|
||||
.Where(c => c.NovelId == novelId)
|
||||
.OrderBy(c => c.Number)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
@@ -43,33 +46,32 @@ public class ChapterService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Read, ct);
|
||||
await access.RequireAsync(chapter.NovelId, NovelPermission.Read, ct);
|
||||
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));
|
||||
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;
|
||||
}
|
||||
|
||||
await access.RequireAsync(projectId, ProjectPermission.CreateContent, ct);
|
||||
await access.RequireAsync(novelId, NovelPermission.CreateContent, ct);
|
||||
|
||||
var chapter = new Chapter
|
||||
{
|
||||
ProjectId = projectId,
|
||||
NovelId = novelId,
|
||||
Title = request.Title,
|
||||
Number = request.Number ?? await NextChapterNumberAsync(projectId, ct),
|
||||
Number = request.Number ?? await NextChapterNumberAsync(novelId, ct),
|
||||
Summary = request.Summary,
|
||||
Setting = request.Setting,
|
||||
Notes = request.Notes,
|
||||
Status = request.Status,
|
||||
TargetWordCount = request.TargetWordCount,
|
||||
@@ -79,7 +81,12 @@ public class ChapterService(
|
||||
|
||||
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);
|
||||
@@ -102,12 +109,11 @@ public class ChapterService(
|
||||
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.Number = request.Number ?? chapter.Number;
|
||||
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.Status = request.Status ?? chapter.Status;
|
||||
chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount;
|
||||
@@ -122,7 +128,12 @@ public class ChapterService(
|
||||
|
||||
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);
|
||||
@@ -141,23 +152,23 @@ public class ChapterService(
|
||||
return false;
|
||||
}
|
||||
|
||||
await access.RequireAsync(chapter.ProjectId, ProjectPermission.DeleteContent, ct);
|
||||
await access.RequireAsync(chapter.NovelId, NovelPermission.DeleteContent, ct);
|
||||
|
||||
db.Chapters.Remove(chapter);
|
||||
await db.SaveChangesAsync(ct);
|
||||
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
|
||||
.Where(c => c.ProjectId == projectId)
|
||||
.Where(c => c.NovelId == novelId)
|
||||
.MaxAsync(c => (int?)c.Number, ct);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -169,6 +180,7 @@ public class ChapterService(
|
||||
.Include(c => c.Beats).ThenInclude(b => b.Characters)
|
||||
.Include(c => c.Beats).ThenInclude(b => b.Tags)
|
||||
.Include(c => c.Tags)
|
||||
.Include(c => c.Locations)
|
||||
.FirstOrDefaultAsync(c => c.Id == id, ct);
|
||||
|
||||
if (chapter is null)
|
||||
|
||||
@@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Characters;
|
||||
@@ -10,8 +10,8 @@ namespace Novelly.Api.Characters;
|
||||
public class Character
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid ProjectId { get; set; }
|
||||
public Project? Project { get; set; }
|
||||
public Guid NovelId { get; set; }
|
||||
public Novel? Novel { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
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.Role).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.HasMany(c => c.Relationships).WithOne(r => r.Character!)
|
||||
|
||||
@@ -8,11 +8,12 @@ namespace Novelly.Api.Characters;
|
||||
|
||||
public class CharacterArcService(
|
||||
INovelDbContext db,
|
||||
ProjectAccessService access,
|
||||
NovelAccessService access,
|
||||
ILogger<CharacterArcService> logger,
|
||||
IModelValidator<CreateArcStageRequest> createValidator,
|
||||
IModelValidator<UpdateArcStageRequest> updateValidator,
|
||||
IModelValidator<ReorderArcStagesRequest> reorderValidator)
|
||||
IModelValidator<ReorderArcStagesRequest> reorderValidator,
|
||||
IModelValidator<SetArcStageBeatsRequest> setBeatsValidator)
|
||||
{
|
||||
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);
|
||||
|
||||
await RequireCharacterAccessAsync(characterId, ProjectPermission.Read, ct);
|
||||
await RequireCharacterAccessAsync(characterId, NovelPermission.Read, ct);
|
||||
|
||||
var stages = await Query()
|
||||
.Where(s => s.CharacterId == characterId)
|
||||
@@ -42,7 +43,7 @@ public class CharacterArcService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await RequireCharacterAccessAsync(stage.CharacterId, ProjectPermission.Read, ct);
|
||||
await RequireCharacterAccessAsync(stage.CharacterId, NovelPermission.Read, ct);
|
||||
return stage;
|
||||
}
|
||||
|
||||
@@ -62,15 +63,15 @@ public class CharacterArcService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(character.ProjectId, ProjectPermission.CreateContent, ct);
|
||||
await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct);
|
||||
await access.RequireAsync(character.NovelId, NovelPermission.CreateContent, ct);
|
||||
await EnsureChapterIsInSameNovelAsync(character, request.ChapterId, ct);
|
||||
|
||||
var stage = new CharacterArcStage
|
||||
{
|
||||
CharacterId = characterId,
|
||||
Title = request.Title,
|
||||
SortOrder = request.SortOrder ?? await NextSortOrderAsync(characterId, ct),
|
||||
Description = request.Description,
|
||||
Result = request.Result,
|
||||
ChapterId = request.ChapterId
|
||||
};
|
||||
|
||||
@@ -102,12 +103,12 @@ public class CharacterArcService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct);
|
||||
await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct);
|
||||
await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
|
||||
await EnsureChapterIsInSameNovelAsync(character, request.ChapterId, ct);
|
||||
|
||||
stage.Title = Patch.Apply(stage.Title, request.Title) ?? stage.Title;
|
||||
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.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
@@ -127,7 +128,7 @@ public class CharacterArcService(
|
||||
return false;
|
||||
}
|
||||
|
||||
await RequireCharacterAccessAsync(stage.CharacterId, ProjectPermission.DeleteContent, ct);
|
||||
await RequireCharacterAccessAsync(stage.CharacterId, NovelPermission.DeleteContent, ct);
|
||||
|
||||
db.CharacterArcStages.Remove(stage);
|
||||
await db.SaveChangesAsync(ct);
|
||||
@@ -143,7 +144,7 @@ public class CharacterArcService(
|
||||
|
||||
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
|
||||
.Where(s => s.CharacterId == characterId)
|
||||
@@ -171,7 +172,68 @@ public class CharacterArcService(
|
||||
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)
|
||||
{
|
||||
if (chapterId is not { } id)
|
||||
@@ -179,18 +241,18 @@ public class CharacterArcService(
|
||||
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)
|
||||
{
|
||||
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(
|
||||
"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)
|
||||
@@ -206,13 +268,16 @@ public class CharacterArcService(
|
||||
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);
|
||||
await access.RequireAsync(projectId, permission, ct);
|
||||
var novelId = await db.Characters.Where(c => c.Id == characterId).Select(c => c.NovelId).FirstOrDefaultAsync(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)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
|
||||
namespace Novelly.Api.Characters;
|
||||
@@ -15,11 +16,13 @@ public class CharacterArcStage
|
||||
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
public string? Description { get; set; }
|
||||
public string? Result { get; set; }
|
||||
|
||||
public Guid? ChapterId { get; set; }
|
||||
public Chapter? Chapter { get; init; }
|
||||
|
||||
public List<Beat> Beats { get; set; } = [];
|
||||
|
||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -33,5 +36,8 @@ public class CharacterArcStageEntityTypeConfiguration : IEntityTypeConfiguration
|
||||
|
||||
entity.HasOne(s => s.Chapter).WithMany()
|
||||
.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.Tags;
|
||||
|
||||
@@ -5,7 +6,7 @@ namespace Novelly.Api.Characters;
|
||||
|
||||
public record CharacterResponse(
|
||||
Guid Id,
|
||||
Guid ProjectId,
|
||||
Guid NovelId,
|
||||
string Name,
|
||||
CharacterRole Role,
|
||||
CharacterImportance Importance,
|
||||
@@ -162,7 +163,8 @@ file static class CharacterValidation
|
||||
public record CreateRelationshipRequest(
|
||||
Guid RelatedCharacterId,
|
||||
string RelationshipType,
|
||||
string? Description = null);
|
||||
string? Description = null,
|
||||
string? ReciprocalRelationshipType = null);
|
||||
|
||||
public class CreateRelationshipRequestValidator : IModelValidator<CreateRelationshipRequest>
|
||||
{
|
||||
@@ -175,6 +177,7 @@ public class CreateRelationshipRequestValidator : IModelValidator<CreateRelation
|
||||
|
||||
result.AddRequiredTextErrors("RelationshipType", "Relationship Type", model.RelationshipType, 100);
|
||||
result.AddOptionalTextErrors("Description", "Description", model.Description, 2000);
|
||||
result.AddOptionalTextErrors("ReciprocalRelationshipType", "Reciprocal Relationship Type", model.ReciprocalRelationshipType, 100);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -205,16 +208,17 @@ public record ArcStageResponse(
|
||||
Guid CharacterId,
|
||||
int SortOrder,
|
||||
string Title,
|
||||
string? Description,
|
||||
string? Result,
|
||||
Guid? ChapterId,
|
||||
int? ChapterNumber,
|
||||
string? ChapterTitle,
|
||||
IReadOnlyList<CharacterBeatResponse> Beats,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public record CreateArcStageRequest(
|
||||
string Title,
|
||||
int? SortOrder = null,
|
||||
string? Description = null,
|
||||
string? Result = null,
|
||||
Guid? ChapterId = null);
|
||||
|
||||
public class CreateArcStageRequestValidator : IModelValidator<CreateArcStageRequest>
|
||||
@@ -224,7 +228,7 @@ public class CreateArcStageRequestValidator : IModelValidator<CreateArcStageRequ
|
||||
var result = new ValidationResult();
|
||||
|
||||
result.AddRequiredTextErrors("Title", "Title", model.Title, 200);
|
||||
ArcStageValidation.OptionalFields(model.SortOrder, model.Description, result);
|
||||
ArcStageValidation.OptionalFields(model.SortOrder, model.Result, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -233,7 +237,7 @@ public class CreateArcStageRequestValidator : IModelValidator<CreateArcStageRequ
|
||||
public record UpdateArcStageRequest(
|
||||
string? Title = null,
|
||||
int? SortOrder = null,
|
||||
string? Description = null,
|
||||
string? Result = null,
|
||||
Guid? ChapterId = null);
|
||||
|
||||
public class UpdateArcStageRequestValidator : IModelValidator<UpdateArcStageRequest>
|
||||
@@ -243,7 +247,7 @@ public class UpdateArcStageRequestValidator : IModelValidator<UpdateArcStageRequ
|
||||
var result = new ValidationResult();
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -251,13 +255,13 @@ public class UpdateArcStageRequestValidator : IModelValidator<UpdateArcStageRequ
|
||||
|
||||
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)
|
||||
result.AddError("SortOrder", "'Sort Order' must be zero or greater.");
|
||||
|
||||
if (description is { Length: > 20000 })
|
||||
result.AddError("Description", "'Description' must be 20,000 characters or fewer.");
|
||||
if (result_ is { Length: > 20000 })
|
||||
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 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.InternalConflict, c.ExternalConflict, c.ArcSummary, c.Voice, c.Notes,
|
||||
[.. c.Aliases],
|
||||
@@ -305,9 +324,13 @@ public static class CharacterMapping
|
||||
s.CharacterId,
|
||||
s.SortOrder,
|
||||
s.Title,
|
||||
s.Description,
|
||||
s.Result,
|
||||
s.ChapterId,
|
||||
s.Chapter?.Number,
|
||||
s.Chapter?.Title,
|
||||
[.. s.Beats
|
||||
.OrderBy(b => b.Chapter?.Number ?? 0)
|
||||
.ThenBy(b => b.SortOrder)
|
||||
.Select(b => b.ToCharacterBeatResponse(s.CharacterId))],
|
||||
s.UpdatedAt);
|
||||
}
|
||||
|
||||
@@ -7,18 +7,18 @@ public static class CharacterEndpoints
|
||||
{
|
||||
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<ValidationEndpointFilter>();
|
||||
|
||||
projectScoped.MapGet("/", async (Guid projectId, CharacterService service, CancellationToken ct) =>
|
||||
Results.Ok((await service.ListAsync(projectId, ct)).Select(c => c.ToResponse())))
|
||||
.WithSummary("List a project's character dossiers.");
|
||||
novelScoped.MapGet("/", async (Guid novelId, CharacterService service, CancellationToken ct) =>
|
||||
Results.Ok((await service.ListAsync(novelId, ct)).Select(c => c.ToResponse())))
|
||||
.WithSummary("List a novel's character dossiers.");
|
||||
|
||||
projectScoped.MapPost("/", async (
|
||||
Guid projectId, CreateCharacterRequest request, CharacterService service, CancellationToken ct) =>
|
||||
novelScoped.MapPost("/", async (
|
||||
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)
|
||||
{
|
||||
return Results.NotFound();
|
||||
@@ -49,7 +49,7 @@ public static class CharacterEndpoints
|
||||
characters.MapPost("/{id:guid}/relationships", async (
|
||||
Guid id, CreateRelationshipRequest request, CharacterService service, CancellationToken ct) =>
|
||||
(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 (
|
||||
Guid relationshipId, CharacterService service, CancellationToken ct) =>
|
||||
@@ -59,7 +59,7 @@ public static class CharacterEndpoints
|
||||
characters.MapPut("/{id:guid}/identity", async (
|
||||
Guid id, LinkCharacterIdentityRequest request, CharacterService service, CancellationToken ct) =>
|
||||
(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 (
|
||||
Guid id, CharacterService service, CancellationToken ct) =>
|
||||
@@ -107,6 +107,12 @@ public static class CharacterEndpoints
|
||||
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace Novelly.Api.Characters;
|
||||
|
||||
public class CharacterService(
|
||||
INovelDbContext db,
|
||||
ProjectAccessService access,
|
||||
NovelAccessService access,
|
||||
TagService tags,
|
||||
ILogger<CharacterService> logger,
|
||||
IModelValidator<CreateCharacterRequest> createValidator,
|
||||
@@ -17,16 +17,16 @@ public class CharacterService(
|
||||
IModelValidator<CreateRelationshipRequest> relationshipValidator,
|
||||
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()
|
||||
.Where(c => c.ProjectId == projectId)
|
||||
.Where(c => c.NovelId == novelId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return OrderedInMemoryBySignificanceThenName(characters);
|
||||
@@ -52,29 +52,29 @@ public class CharacterService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(character.ProjectId, ProjectPermission.Read, ct);
|
||||
await access.RequireAsync(character.NovelId, NovelPermission.Read, ct);
|
||||
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));
|
||||
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;
|
||||
}
|
||||
|
||||
await access.RequireAsync(projectId, ProjectPermission.CreateContent, ct);
|
||||
await access.RequireAsync(novelId, NovelPermission.CreateContent, ct);
|
||||
|
||||
var character = new Character
|
||||
{
|
||||
ProjectId = projectId,
|
||||
NovelId = novelId,
|
||||
Name = request.Name,
|
||||
Role = request.Role,
|
||||
Importance = request.Importance,
|
||||
@@ -95,7 +95,7 @@ public class CharacterService(
|
||||
|
||||
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)
|
||||
@@ -123,7 +123,7 @@ public class CharacterService(
|
||||
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.Role = request.Role ?? character.Role;
|
||||
@@ -145,7 +145,7 @@ public class CharacterService(
|
||||
|
||||
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)
|
||||
@@ -169,7 +169,7 @@ public class CharacterService(
|
||||
return false;
|
||||
}
|
||||
|
||||
await access.RequireAsync(character.ProjectId, ProjectPermission.DeleteContent, ct);
|
||||
await access.RequireAsync(character.NovelId, NovelPermission.DeleteContent, ct);
|
||||
|
||||
db.Characters.Remove(character);
|
||||
await db.SaveChangesAsync(ct);
|
||||
@@ -191,7 +191,7 @@ public class CharacterService(
|
||||
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);
|
||||
if (related is null)
|
||||
@@ -200,10 +200,10 @@ public class CharacterService(
|
||||
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);
|
||||
throw new InvalidOperationException("Characters must belong to the same project to be related.");
|
||||
logger.LogWarning("Rejected relationship: character {CharacterId} and {RelatedCharacterId} belong to different novels", characterId, request.RelatedCharacterId);
|
||||
throw new InvalidOperationException("Characters must belong to the same novel to be related.");
|
||||
}
|
||||
|
||||
db.CharacterRelationships.Add(new CharacterRelationship
|
||||
@@ -214,6 +214,14 @@ public class CharacterService(
|
||||
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);
|
||||
return (await FindAsync(characterId, ct))!;
|
||||
}
|
||||
@@ -233,9 +241,14 @@ public class CharacterService(
|
||||
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.RemoveRange(reciprocals);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
@@ -256,7 +269,7 @@ public class CharacterService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct);
|
||||
await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
|
||||
|
||||
if (request.SameCharacterAsId == characterId)
|
||||
{
|
||||
@@ -273,12 +286,12 @@ public class CharacterService(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (target.ProjectId != character.ProjectId)
|
||||
if (target.NovelId != character.NovelId)
|
||||
{
|
||||
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);
|
||||
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))
|
||||
@@ -291,11 +304,11 @@ public class CharacterService(
|
||||
|
||||
if (request.RevealedInChapterId is { } chapterId)
|
||||
{
|
||||
var chapterInProject = await db.Chapters.AnyAsync(c => c.Id == chapterId && c.ProjectId == character.ProjectId, ct);
|
||||
if (!chapterInProject)
|
||||
var chapterInNovel = await db.Chapters.AnyAsync(c => c.Id == chapterId && c.NovelId == character.NovelId, ct);
|
||||
if (!chapterInNovel)
|
||||
{
|
||||
logger.LogWarning("Rejected identity link: chapter {ChapterId} not in project {ProjectId}", chapterId, character.ProjectId);
|
||||
throw new InvalidOperationException("The reveal chapter must belong to the same project.");
|
||||
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 novel.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,7 +333,7 @@ public class CharacterService(
|
||||
return false;
|
||||
}
|
||||
|
||||
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct);
|
||||
await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
|
||||
|
||||
character.SameCharacterAsId = null;
|
||||
character.RevealedInChapterId = null;
|
||||
@@ -338,6 +351,9 @@ public class CharacterService(
|
||||
.Include(c => c.Tags)
|
||||
.Include(c => c.ArcStages)
|
||||
.ThenInclude(s => s.Chapter)
|
||||
.Include(c => c.ArcStages)
|
||||
.ThenInclude(s => s.Beats)
|
||||
.ThenInclude(b => b.Chapter)
|
||||
.Include(c => c.SameCharacterAs)
|
||||
.Include(c => c.OtherIdentities)
|
||||
.Include(c => c.RevealedInChapter);
|
||||
|
||||
@@ -13,7 +13,8 @@ using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Genres;
|
||||
using Novelly.Api.Imports;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Locations;
|
||||
using Novelly.Api.Novels;
|
||||
using Novelly.Api.Questions;
|
||||
using Novelly.Api.Tags;
|
||||
using Novelly.Api.Users;
|
||||
@@ -48,7 +49,7 @@ public static class NovellyServiceRegistration
|
||||
services.AddScoped<IUserClaimsPrincipalFactory<NovellyUser>, NovellyUserClaimsPrincipalFactory>();
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddScoped<INovelUserContext, NovelUserContext>();
|
||||
services.AddScoped<ProjectAccessService>();
|
||||
services.AddScoped<NovelAccessService>();
|
||||
|
||||
services.ConfigureApplicationCookie(options =>
|
||||
{
|
||||
@@ -70,13 +71,14 @@ public static class NovellyServiceRegistration
|
||||
});
|
||||
|
||||
services.AddScoped<UserAccountService>();
|
||||
services.AddScoped<ProjectMemberService>();
|
||||
services.AddScoped<NovelMemberService>();
|
||||
|
||||
services.AddScoped<ProjectService>();
|
||||
services.AddScoped<NovelService>();
|
||||
services.AddScoped<CharacterService>();
|
||||
services.AddScoped<CharacterArcService>();
|
||||
services.AddScoped<BeatService>();
|
||||
services.AddScoped<TagService>();
|
||||
services.AddScoped<LocationService>();
|
||||
services.AddScoped<GenreService>();
|
||||
services.AddScoped<ChapterService>();
|
||||
services.AddScoped<OpenQuestionService>();
|
||||
|
||||
+1169
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1169
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1232
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);
|
||||
});
|
||||
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("BeatsId")
|
||||
@@ -47,6 +62,21 @@ namespace Novelly.Api.Data.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("ChaptersId")
|
||||
@@ -148,7 +178,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
b.Property<Guid>("NovelId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
@@ -161,7 +191,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
b.HasIndex("NovelId");
|
||||
|
||||
b.ToTable("Conversations");
|
||||
});
|
||||
@@ -249,18 +279,15 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("NovelId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Number")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Prose")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Setting")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
@@ -285,7 +312,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId", "Number");
|
||||
b.HasIndex("NovelId", "Number");
|
||||
|
||||
b.ToTable("Chapters");
|
||||
});
|
||||
@@ -340,15 +367,15 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("NovelId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Occupation")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Personality")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Pronouns")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -374,7 +401,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
b.HasIndex("NovelId");
|
||||
|
||||
b.HasIndex("RevealedInChapterId");
|
||||
|
||||
@@ -398,7 +425,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
b.Property<string>("Result")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
@@ -576,7 +603,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("ProjectId")
|
||||
b.Property<Guid?>("NovelId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("RequestedByUserId")
|
||||
@@ -605,7 +632,32 @@ namespace Novelly.Api.Data.Migrations
|
||||
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")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -652,7 +704,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
|
||||
b.HasIndex("OwnerId");
|
||||
|
||||
b.ToTable("Projects");
|
||||
b.ToTable("Novels");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b =>
|
||||
@@ -673,7 +725,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Property<string>("Detail")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
b.Property<Guid>("NovelId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Question")
|
||||
@@ -696,7 +748,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
|
||||
b.HasIndex("CharacterId");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
b.HasIndex("NovelId");
|
||||
|
||||
b.ToTable("OpenQuestions");
|
||||
});
|
||||
@@ -719,17 +771,50 @@ namespace Novelly.Api.Data.Migrations
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
b.Property<Guid>("NovelId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId", "Name")
|
||||
b.HasIndex("NovelId", "Name")
|
||||
.IsUnique();
|
||||
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -808,39 +893,6 @@ namespace Novelly.Api.Data.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Beats.Beat", null)
|
||||
@@ -856,6 +908,21 @@ namespace Novelly.Api.Data.Migrations
|
||||
.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 =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Beats.Beat", null)
|
||||
@@ -871,6 +938,21 @@ namespace Novelly.Api.Data.Migrations
|
||||
.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 =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Chapters.Chapter", null)
|
||||
@@ -930,13 +1012,13 @@ namespace Novelly.Api.Data.Migrations
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
b.HasOne("Novelly.Api.Novels.Novel", "Novel")
|
||||
.WithMany("Conversations")
|
||||
.HasForeignKey("ProjectId")
|
||||
.HasForeignKey("NovelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Project");
|
||||
b.Navigation("Novel");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b =>
|
||||
@@ -963,20 +1045,20 @@ namespace Novelly.Api.Data.Migrations
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
b.HasOne("Novelly.Api.Novels.Novel", "Novel")
|
||||
.WithMany("Chapters")
|
||||
.HasForeignKey("ProjectId")
|
||||
.HasForeignKey("NovelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Project");
|
||||
b.Navigation("Novel");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
b.HasOne("Novelly.Api.Novels.Novel", "Novel")
|
||||
.WithMany("Characters")
|
||||
.HasForeignKey("ProjectId")
|
||||
.HasForeignKey("NovelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
@@ -990,7 +1072,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
.HasForeignKey("SameCharacterAsId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Project");
|
||||
b.Navigation("Novel");
|
||||
|
||||
b.Navigation("RevealedInChapter");
|
||||
|
||||
@@ -1034,7 +1116,18 @@ namespace Novelly.Api.Data.Migrations
|
||||
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")
|
||||
.WithMany()
|
||||
@@ -1056,9 +1149,9 @@ namespace Novelly.Api.Data.Migrations
|
||||
.HasForeignKey("CharacterId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
b.HasOne("Novelly.Api.Novels.Novel", "Novel")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProjectId")
|
||||
.HasForeignKey("NovelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
@@ -1066,25 +1159,25 @@ namespace Novelly.Api.Data.Migrations
|
||||
|
||||
b.Navigation("Character");
|
||||
|
||||
b.Navigation("Project");
|
||||
b.Navigation("Novel");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Tags.Tag", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
b.HasOne("Novelly.Api.Novels.Novel", "Novel")
|
||||
.WithMany("Tags")
|
||||
.HasForeignKey("ProjectId")
|
||||
.HasForeignKey("NovelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.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")
|
||||
.HasForeignKey("ProjectId")
|
||||
.HasForeignKey("NovelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
@@ -1094,7 +1187,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Project");
|
||||
b.Navigation("Novel");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
@@ -1118,7 +1211,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Navigation("Relationships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Projects.Project", b =>
|
||||
modelBuilder.Entity("Novelly.Api.Novels.Novel", b =>
|
||||
{
|
||||
b.Navigation("Chapters");
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Genres;
|
||||
using Novelly.Api.Imports;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Locations;
|
||||
using Novelly.Api.Novels;
|
||||
using Novelly.Api.Questions;
|
||||
using Novelly.Api.Tags;
|
||||
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 DbSet<Project> Projects => Set<Project>();
|
||||
public DbSet<Novel> Novels => Set<Novel>();
|
||||
public DbSet<Character> Characters => Set<Character>();
|
||||
public DbSet<CharacterRelationship> CharacterRelationships => Set<CharacterRelationship>();
|
||||
public DbSet<CharacterArcStage> CharacterArcStages => Set<CharacterArcStage>();
|
||||
public DbSet<Beat> Beats => Set<Beat>();
|
||||
public DbSet<Tag> Tags => Set<Tag>();
|
||||
public DbSet<Location> Locations => Set<Location>();
|
||||
public DbSet<Chapter> Chapters => Set<Chapter>();
|
||||
public DbSet<OpenQuestion> OpenQuestions => Set<OpenQuestion>();
|
||||
public DbSet<AgentConversation> Conversations => Set<AgentConversation>();
|
||||
public DbSet<AgentMessage> AgentMessages => Set<AgentMessage>();
|
||||
public DbSet<ImportJob> ImportJobs => Set<ImportJob>();
|
||||
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);
|
||||
|
||||
@@ -45,12 +47,13 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options) : Identity
|
||||
|
||||
public interface INovelDbContext
|
||||
{
|
||||
DbSet<Project> Projects { get; }
|
||||
DbSet<Novel> Novels { get; }
|
||||
DbSet<Character> Characters { get; }
|
||||
DbSet<CharacterRelationship> CharacterRelationships { get; }
|
||||
DbSet<CharacterArcStage> CharacterArcStages { get; }
|
||||
DbSet<Beat> Beats { get; }
|
||||
DbSet<Tag> Tags { get; }
|
||||
DbSet<Location> Locations { get; }
|
||||
DbSet<Chapter> Chapters { get; }
|
||||
DbSet<OpenQuestion> OpenQuestions { get; }
|
||||
DbSet<AgentConversation> Conversations { get; }
|
||||
@@ -58,7 +61,7 @@ public interface INovelDbContext
|
||||
DbSet<ImportJob> ImportJobs { get; }
|
||||
DbSet<Genre> Genres { get; }
|
||||
DbSet<NovellyUser> Users { get; }
|
||||
DbSet<ProjectMember> ProjectMembers { get; }
|
||||
DbSet<NovelMember> NovelMembers { get; }
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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 .
|
||||
|
||||
ENV ASPNETCORE_URLS=http://0.0.0.0:8080
|
||||
|
||||
@@ -3,7 +3,7 @@ using Novelly.Api.Agent;
|
||||
|
||||
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(
|
||||
IAgentModelClient model,
|
||||
@@ -14,13 +14,13 @@ public class ImportAgentService(
|
||||
private readonly AgentOptions _options = options.Value;
|
||||
|
||||
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(
|
||||
"Running import for {SourceRoot}, existing project {ExistingProjectId}, {ChaptersTotal} chapters total",
|
||||
sourceRoot, existingProjectId, chaptersTotal);
|
||||
"Running import for {SourceRoot}, existing novel {ExistingNovelId}, {ChaptersTotal} chapters total",
|
||||
sourceRoot, existingNovelId, chaptersTotal);
|
||||
|
||||
toolset.Initialize(sourceRoot, existingProjectId);
|
||||
toolset.Initialize(sourceRoot, existingNovelId);
|
||||
|
||||
var startingLedger = toolset.ReadLedgerOrNull();
|
||||
var systemPrompt = BuildSystemPrompt(sourceRoot);
|
||||
@@ -46,7 +46,7 @@ public class ImportAgentService(
|
||||
logger.LogInformation("Import for {SourceRoot} completed after {Turns} turns", sourceRoot, turn + 1);
|
||||
return new ImportRunResult(
|
||||
Completed: true,
|
||||
toolset.ProjectId,
|
||||
toolset.NovelId,
|
||||
ledger?.CompletedChapters?.Count ?? 0,
|
||||
null);
|
||||
}
|
||||
@@ -60,7 +60,7 @@ public class ImportAgentService(
|
||||
|
||||
return new ImportRunResult(
|
||||
Completed: false,
|
||||
toolset.ProjectId,
|
||||
toolset.NovelId,
|
||||
finalLedger?.CompletedChapters?.Count ?? 0,
|
||||
"Reached the safety limit for this run without finishing. Starting the import "
|
||||
+ "again for the same folder will resume from the ledger.");
|
||||
@@ -107,12 +107,12 @@ public class ImportAgentService(
|
||||
|
||||
private const string SystemPromptTemplate = """
|
||||
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
|
||||
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
|
||||
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
|
||||
anything on disk except the resume ledger, and you cannot read anything outside the
|
||||
source folder.
|
||||
@@ -143,10 +143,10 @@ public class ImportAgentService(
|
||||
|
||||
```json
|
||||
{{
|
||||
"projectId": "guid",
|
||||
"novelId": "guid",
|
||||
"characters": {{ "Name": "guid", "Alias": "guid" }},
|
||||
"chapters": {{ "1": "guid" }},
|
||||
"completedPasses": ["project", "characters"],
|
||||
"completedPasses": ["novel", "characters"],
|
||||
"completedChapters": [1, 2, 3]
|
||||
}}
|
||||
```
|
||||
@@ -160,9 +160,9 @@ public class ImportAgentService(
|
||||
Skip a pass whose completion is already recorded. Jump straight to the first
|
||||
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 —
|
||||
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
|
||||
`characters/*.md` not already in the ledger's `characters` map: name from the `#`
|
||||
heading, occupation from the tagline, appearance/backstory/want from
|
||||
|
||||
@@ -4,7 +4,7 @@ using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
|
||||
namespace Novelly.Api.Imports;
|
||||
|
||||
@@ -20,7 +20,7 @@ internal record ImportAgentTool(
|
||||
Func<JsonElement, CancellationToken, Task<object?>> Handler);
|
||||
|
||||
public class ImportAgentToolset(
|
||||
ProjectService projects,
|
||||
NovelService novels,
|
||||
CharacterService characters,
|
||||
CharacterArcService arcs,
|
||||
ChapterService chapters,
|
||||
@@ -36,15 +36,15 @@ public class ImportAgentToolset(
|
||||
private string _sourceRoot = string.Empty;
|
||||
private Dictionary<string, ImportAgentTool>? _byName;
|
||||
|
||||
public Guid? ProjectId { get; private set; }
|
||||
public Guid? NovelId { get; private set; }
|
||||
|
||||
public IReadOnlyList<AgentToolDefinition> Definitions =>
|
||||
[.. 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;
|
||||
ProjectId = existingProjectId;
|
||||
NovelId = existingNovelId;
|
||||
}
|
||||
|
||||
public ImportLedger? ReadLedgerOrNull() => ImportPaths.ReadLedger(_sourceRoot);
|
||||
@@ -99,9 +99,9 @@ public class ImportAgentToolset(
|
||||
}
|
||||
}
|
||||
|
||||
private Guid RequireProjectId() =>
|
||||
ProjectId ?? throw new InvalidOperationException(
|
||||
"No project exists yet for this import — call create_project first.");
|
||||
private Guid RequireNovelId() =>
|
||||
NovelId ?? throw new InvalidOperationException(
|
||||
"No novel exists yet for this import — call create_novel first.");
|
||||
|
||||
private Dictionary<string, ImportAgentTool> ByName => _byName ??= Build().ToDictionary(t => t.Name);
|
||||
|
||||
@@ -187,8 +187,8 @@ public class ImportAgentToolset(
|
||||
});
|
||||
|
||||
yield return new ImportAgentTool(
|
||||
"create_project",
|
||||
"Create the novel project this import populates. Call once, in the first pass.",
|
||||
"create_novel",
|
||||
"Create the novel this import populates. Call once, in the first pass.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("title", "The book's title.", required: true)
|
||||
.Str("author", "Author name, if known.")
|
||||
@@ -196,18 +196,18 @@ public class ImportAgentToolset(
|
||||
.Build(),
|
||||
async (input, ct) =>
|
||||
{
|
||||
var created = await projects.CreateAsync(new CreateProjectRequest(
|
||||
var created = await novels.CreateAsync(new CreateNovelRequest(
|
||||
JsonInput.RequiredString(input, "title"),
|
||||
JsonInput.String(input, "author"),
|
||||
Notes: JsonInput.String(input, "notes")), ct);
|
||||
|
||||
ProjectId = created.Id;
|
||||
NovelId = created.Id;
|
||||
return created.ToResponse(null);
|
||||
});
|
||||
|
||||
yield return new ImportAgentTool(
|
||||
"update_project_brief",
|
||||
"Revise the project's top-level fields. Only the fields you supply change.",
|
||||
"update_novel_brief",
|
||||
"Revise the novel's top-level fields. Only the fields you supply change.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("title", "New title.")
|
||||
.Str("author", "Author name.")
|
||||
@@ -216,15 +216,15 @@ public class ImportAgentToolset(
|
||||
.Build(),
|
||||
async (input, ct) =>
|
||||
{
|
||||
var projectId = RequireProjectId();
|
||||
var updated = await projects.UpdateAsync(projectId, new UpdateProjectRequest(
|
||||
var novelId = RequireNovelId();
|
||||
var updated = await novels.UpdateAsync(novelId, new UpdateNovelRequest(
|
||||
JsonInput.String(input, "title"),
|
||||
JsonInput.String(input, "author"),
|
||||
JsonInput.String(input, "genre"),
|
||||
Notes: JsonInput.String(input, "notes")), ct);
|
||||
|
||||
return updated is null
|
||||
? new ImportToolNotFound("Project", projectId)
|
||||
? new ImportToolNotFound("Novel", novelId)
|
||||
: updated.ToResponse(null);
|
||||
});
|
||||
|
||||
@@ -234,8 +234,8 @@ public class ImportAgentToolset(
|
||||
CharacterSchema(nameRequired: true).Build(),
|
||||
async (input, ct) =>
|
||||
{
|
||||
var projectId = RequireProjectId();
|
||||
var created = await characters.CreateAsync(projectId, new CreateCharacterRequest(
|
||||
var novelId = RequireNovelId();
|
||||
var created = await characters.CreateAsync(novelId, new CreateCharacterRequest(
|
||||
JsonInput.RequiredString(input, "name"),
|
||||
Importance: JsonInput.Enum<CharacterImportance>(input, "importance") ?? CharacterImportance.Supporting,
|
||||
Occupation: JsonInput.String(input, "occupation"),
|
||||
@@ -245,7 +245,7 @@ public class ImportAgentToolset(
|
||||
Notes: JsonInput.String(input, "notes")), ct);
|
||||
|
||||
return created is null
|
||||
? new ImportToolNotFound("Project", projectId)
|
||||
? new ImportToolNotFound("Novel", novelId)
|
||||
: created.ToResponse();
|
||||
});
|
||||
|
||||
@@ -284,8 +284,8 @@ public class ImportAgentToolset(
|
||||
.Build(),
|
||||
async (input, ct) =>
|
||||
{
|
||||
var projectId = RequireProjectId();
|
||||
var created = await chapters.CreateAsync(projectId, new CreateChapterRequest(
|
||||
var novelId = RequireNovelId();
|
||||
var created = await chapters.CreateAsync(novelId, new CreateChapterRequest(
|
||||
JsonInput.RequiredString(input, "title"),
|
||||
JsonInput.Int(input, "number"),
|
||||
JsonInput.String(input, "summary"),
|
||||
@@ -293,7 +293,7 @@ public class ImportAgentToolset(
|
||||
Tags: JsonInput.Strings(input, "tags")), ct);
|
||||
|
||||
return created is null
|
||||
? new ImportToolNotFound("Project", projectId)
|
||||
? new ImportToolNotFound("Novel", novelId)
|
||||
: created.ToResponse();
|
||||
});
|
||||
|
||||
@@ -359,7 +359,7 @@ public class ImportAgentToolset(
|
||||
characterId,
|
||||
new CreateArcStageRequest(
|
||||
JsonInput.RequiredString(input, "title"),
|
||||
Description: JsonInput.String(input, "description"),
|
||||
Result: JsonInput.String(input, "description"),
|
||||
ChapterId: JsonInput.Guid(input, "chapter_id")), ct);
|
||||
|
||||
return created is null
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace Novelly.Api.Imports;
|
||||
public record ImportJobResponse(
|
||||
Guid Id,
|
||||
string SourceRoot,
|
||||
Guid? ProjectId,
|
||||
Guid? NovelId,
|
||||
ImportJobStatus Status,
|
||||
string? StatusMessage,
|
||||
int ChaptersCompleted,
|
||||
@@ -22,7 +22,7 @@ public enum ImportReadiness
|
||||
|
||||
public record ImportInspectionResponse(
|
||||
ImportReadiness Readiness,
|
||||
Guid? ProjectId,
|
||||
Guid? NovelId,
|
||||
int ChaptersCompleted,
|
||||
int ChaptersTotal,
|
||||
IReadOnlyList<string> CompletedPasses);
|
||||
@@ -62,7 +62,7 @@ public static class ImportMapping
|
||||
public static ImportJobResponse ToResponse(this ImportJob job) => new(
|
||||
job.Id,
|
||||
job.SourceRoot,
|
||||
job.ProjectId,
|
||||
job.NovelId,
|
||||
job.Status,
|
||||
job.StatusMessage,
|
||||
job.ChaptersCompleted,
|
||||
|
||||
@@ -18,7 +18,7 @@ public class ImportJob
|
||||
|
||||
public string SourceRoot { get; init; } = string.Empty;
|
||||
|
||||
public Guid? ProjectId { get; set; }
|
||||
public Guid? NovelId { get; set; }
|
||||
|
||||
public Guid? RequestedByUserId { get; init; }
|
||||
|
||||
|
||||
@@ -53,11 +53,11 @@ public class ImportJobRunner(
|
||||
|
||||
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.Status = result.Completed ? ImportJobStatus.Completed : ImportJobStatus.Paused;
|
||||
job.StatusMessage = result.Message;
|
||||
|
||||
@@ -4,7 +4,7 @@ using System.Text.Json.Serialization;
|
||||
namespace Novelly.Api.Imports;
|
||||
|
||||
public record ImportLedger(
|
||||
Guid? ProjectId,
|
||||
Guid? NovelId,
|
||||
Dictionary<string, Guid>? Characters,
|
||||
Dictionary<string, Guid>? Chapters,
|
||||
List<string>? CompletedPasses,
|
||||
@@ -100,7 +100,7 @@ internal static class ImportPaths
|
||||
}
|
||||
|
||||
var passes = ledger.CompletedPasses ?? [];
|
||||
var requiredPasses = new[] { "project", "characters", "chapters", "arcs" };
|
||||
var requiredPasses = new[] { "novel", "characters", "chapters", "arcs" };
|
||||
var chaptersDone = ledger.CompletedChapters?.Count ?? 0;
|
||||
|
||||
return requiredPasses.All(passes.Contains) && (chaptersTotal == 0 || chaptersDone >= chaptersTotal);
|
||||
|
||||
@@ -3,14 +3,14 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
using Novelly.Api.Users;
|
||||
|
||||
namespace Novelly.Api.Imports;
|
||||
|
||||
public class ImportService(
|
||||
INovelDbContext db,
|
||||
ProjectService projects,
|
||||
NovelService novels,
|
||||
Channel<Guid> queue,
|
||||
INovelUserContext userContext,
|
||||
ILogger<ImportService> logger,
|
||||
@@ -37,7 +37,7 @@ public class ImportService(
|
||||
var readiness = ImportPaths.IsComplete(ledger, total) ? ImportReadiness.Complete : ImportReadiness.Resumable;
|
||||
|
||||
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)
|
||||
@@ -53,11 +53,11 @@ public class ImportService(
|
||||
if (request.ForceRestart)
|
||||
{
|
||||
var ledger = ImportPaths.ReadLedger(root);
|
||||
if (ledger?.ProjectId is { } existingProjectId)
|
||||
if (ledger?.NovelId is { } existingNovelId)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Force-restarting import for {SourceRoot}: deleting project {ProjectId}", root, existingProjectId);
|
||||
await projects.DeleteAsync(existingProjectId, ct);
|
||||
"Force-restarting import for {SourceRoot}: deleting novel {NovelId}", root, existingNovelId);
|
||||
await novels.DeleteAsync(existingNovelId, ct);
|
||||
}
|
||||
|
||||
ImportPaths.DeleteLedger(root);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Novelly.ServiceDefaults\Novelly.ServiceDefaults.csproj" />
|
||||
@@ -24,6 +24,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>fb455630-f631-46bb-9c60-0deaf01c593e</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -6,9 +6,9 @@ using Novelly.Api.Characters;
|
||||
using Novelly.Api.Tags;
|
||||
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();
|
||||
|
||||
@@ -24,7 +24,7 @@ public class Project
|
||||
|
||||
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 NovellyUser? Owner { get; set; }
|
||||
@@ -36,25 +36,25 @@ public class Project
|
||||
public List<Chapter> Chapters { get; set; } = [];
|
||||
public List<Tag> Tags { 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.Phase).HasConversion<string>().HasMaxLength(32);
|
||||
entity.HasMany(p => p.Characters).WithOne(c => c.Project!)
|
||||
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasMany(p => p.Chapters).WithOne(c => c.Project!)
|
||||
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasMany(p => p.Tags).WithOne(t => t.Project!)
|
||||
.HasForeignKey(t => t.ProjectId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasMany(p => p.Conversations).WithOne(c => c.Project!)
|
||||
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasMany(p => p.Members).WithOne(m => m.Project!)
|
||||
.HasForeignKey(m => m.ProjectId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasMany(p => p.Characters).WithOne(c => c.Novel!)
|
||||
.HasForeignKey(c => c.NovelId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasMany(p => p.Chapters).WithOne(c => c.Novel!)
|
||||
.HasForeignKey(c => c.NovelId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasMany(p => p.Tags).WithOne(t => t.Novel!)
|
||||
.HasForeignKey(t => t.NovelId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasMany(p => p.Conversations).WithOne(c => c.Novel!)
|
||||
.HasForeignKey(c => c.NovelId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasMany(p => p.Members).WithOne(m => m.Novel!)
|
||||
.HasForeignKey(m => m.NovelId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(p => p.Owner).WithMany()
|
||||
.HasForeignKey(p => p.OwnerId).OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
+21
-23
@@ -1,21 +1,21 @@
|
||||
using Novelly.Api.Common.Validation;
|
||||
|
||||
namespace Novelly.Api.Projects;
|
||||
namespace Novelly.Api.Novels;
|
||||
|
||||
public record ProjectSummaryResponse(
|
||||
public record NovelSummaryResponse(
|
||||
Guid Id,
|
||||
string Title,
|
||||
string? Author,
|
||||
string? Genre,
|
||||
string? Logline,
|
||||
int? TargetWordCount,
|
||||
ProjectPhase Phase,
|
||||
NovelPhase Phase,
|
||||
int CharacterCount,
|
||||
int ChapterCount,
|
||||
int WordCount,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public record ProjectResponse(
|
||||
public record NovelResponse(
|
||||
Guid Id,
|
||||
string Title,
|
||||
string? Author,
|
||||
@@ -24,13 +24,13 @@ public record ProjectResponse(
|
||||
string? Synopsis,
|
||||
string? Notes,
|
||||
int? TargetWordCount,
|
||||
ProjectPhase Phase,
|
||||
NovelPhase Phase,
|
||||
Guid? OwnerId,
|
||||
string? MyRole,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public record CreateProjectRequest(
|
||||
public record CreateNovelRequest(
|
||||
string Title,
|
||||
string? Author = null,
|
||||
string? Genre = null,
|
||||
@@ -39,20 +39,20 @@ public record CreateProjectRequest(
|
||||
string? Notes = 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();
|
||||
|
||||
ProjectValidation.Title(model.Title, result);
|
||||
ProjectValidation.OptionalFields(model.Author, model.Genre, model.Logline, model.Synopsis, model.Notes, model.TargetWordCount, result);
|
||||
NovelValidation.Title(model.Title, result);
|
||||
NovelValidation.OptionalFields(model.Author, model.Genre, model.Logline, model.Synopsis, model.Notes, model.TargetWordCount, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateProjectRequest(
|
||||
public record UpdateNovelRequest(
|
||||
string? Title = null,
|
||||
string? Author = null,
|
||||
string? Genre = null,
|
||||
@@ -60,28 +60,26 @@ public record UpdateProjectRequest(
|
||||
string? Synopsis = null,
|
||||
string? Notes = 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();
|
||||
|
||||
result.AddUnclearableTextErrors("Title", "Title", model.Title, "a project", 200);
|
||||
ProjectValidation.OptionalFields(model.Author, model.Genre, model.Logline, model.Synopsis, model.Notes, model.TargetWordCount, result);
|
||||
result.AddUnclearableTextErrors("Title", "Title", model.Title, "a novel", 200);
|
||||
NovelValidation.OptionalFields(model.Author, model.Genre, model.Logline, model.Synopsis, model.Notes, model.TargetWordCount, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
file static class ProjectValidation
|
||||
file static class NovelValidation
|
||||
{
|
||||
public static void Title(string title, ValidationResult result) =>
|
||||
result.AddRequiredTextErrors("Title", "Title", title, 200);
|
||||
public static void Title(string title, ValidationResult result) => result.AddRequiredTextErrors("Title", "Title", title, 200);
|
||||
|
||||
public static void OptionalFields(
|
||||
string? author, string? genre, string? logline, string? synopsis, string? notes, int? targetWordCount, ValidationResult result)
|
||||
public static void OptionalFields(string? author, string? genre, string? logline, string? synopsis, string? notes, int? targetWordCount, ValidationResult result)
|
||||
{
|
||||
if (author is { Length: > 200 })
|
||||
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.TargetWordCount, p.Phase, p.OwnerId, myRole, p.CreatedAt, p.UpdatedAt);
|
||||
}
|
||||
@@ -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,
|
||||
Outlining,
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,8 @@ using Novelly.Api.Common;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Genres;
|
||||
using Novelly.Api.Imports;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Locations;
|
||||
using Novelly.Api.Novels;
|
||||
using Novelly.Api.Questions;
|
||||
using Novelly.Api.Tags;
|
||||
using Novelly.Api.Users;
|
||||
@@ -88,13 +89,14 @@ app.MapDefaultEndpoints();
|
||||
app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })).WithTags("Health").AllowAnonymous();
|
||||
|
||||
app.MapUserEndpoints();
|
||||
app.MapProjectMemberEndpoints();
|
||||
app.MapNovelMemberEndpoints();
|
||||
|
||||
app.MapProjectEndpoints()
|
||||
app.MapNovelEndpoints()
|
||||
.MapCharacterEndpoints()
|
||||
.MapChapterEndpoints()
|
||||
.MapBeatEndpoints()
|
||||
.MapTagEndpoints()
|
||||
.MapLocationEndpoints()
|
||||
.MapGenreEndpoints()
|
||||
.MapOpenQuestionEndpoints()
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
|
||||
namespace Novelly.Api.Questions;
|
||||
|
||||
@@ -10,8 +10,8 @@ public class OpenQuestion
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid ProjectId { get; set; }
|
||||
public Project? Project { get; set; }
|
||||
public Guid NovelId { get; set; }
|
||||
public Novel? Novel { get; set; }
|
||||
|
||||
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.Ignore(q => q.IsResolved);
|
||||
|
||||
entity.HasIndex(q => q.ProjectId);
|
||||
entity.HasIndex(q => q.NovelId);
|
||||
|
||||
entity.HasOne(q => q.Project).WithMany()
|
||||
.HasForeignKey(q => q.ProjectId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(q => q.Novel).WithMany()
|
||||
.HasForeignKey(q => q.NovelId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(q => q.Chapter).WithMany()
|
||||
.HasForeignKey(q => q.ChapterId).OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace Novelly.Api.Questions;
|
||||
|
||||
public record OpenQuestionResponse(
|
||||
Guid Id,
|
||||
Guid ProjectId,
|
||||
Guid NovelId,
|
||||
string Question,
|
||||
string? Detail,
|
||||
Guid? ChapterId,
|
||||
@@ -76,7 +76,7 @@ public static class OpenQuestionMapping
|
||||
{
|
||||
public static OpenQuestionResponse ToResponse(this OpenQuestion q) => new(
|
||||
q.Id,
|
||||
q.ProjectId,
|
||||
q.NovelId,
|
||||
q.Question,
|
||||
q.Detail,
|
||||
q.ChapterId,
|
||||
|
||||
@@ -7,24 +7,24 @@ public static class OpenQuestionEndpoints
|
||||
{
|
||||
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<ValidationEndpointFilter>();
|
||||
|
||||
projectScoped.MapGet("/", async (
|
||||
Guid projectId,
|
||||
novelScoped.MapGet("/", async (
|
||||
Guid novelId,
|
||||
OpenQuestionService service,
|
||||
CancellationToken ct,
|
||||
Guid? chapterId = null,
|
||||
Guid? characterId = null,
|
||||
bool includeResolved = false) =>
|
||||
Results.Ok((await service.ListAsync(projectId, chapterId, characterId, includeResolved, ct)).Select(q => q.ToResponse())))
|
||||
.WithSummary("List a project's open questions, optionally narrowed to one chapter or character.");
|
||||
Results.Ok((await service.ListAsync(novelId, chapterId, characterId, includeResolved, ct)).Select(q => q.ToResponse())))
|
||||
.WithSummary("List a novel's open questions, optionally narrowed to one chapter or character.");
|
||||
|
||||
projectScoped.MapPost("/", async (
|
||||
Guid projectId, CreateOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) =>
|
||||
novelScoped.MapPost("/", async (
|
||||
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)
|
||||
{
|
||||
return Results.NotFound();
|
||||
|
||||
@@ -10,28 +10,28 @@ namespace Novelly.Api.Questions;
|
||||
|
||||
public class OpenQuestionService(
|
||||
INovelDbContext db,
|
||||
ProjectAccessService access,
|
||||
NovelAccessService access,
|
||||
ILogger<OpenQuestionService> logger,
|
||||
IModelValidator<CreateOpenQuestionRequest> createValidator,
|
||||
IModelValidator<UpdateOpenQuestionRequest> updateValidator,
|
||||
IModelValidator<ResolveOpenQuestionRequest> resolveValidator)
|
||||
{
|
||||
public async Task<IReadOnlyList<OpenQuestion>> ListAsync(
|
||||
Guid projectId,
|
||||
Guid novelId,
|
||||
Guid? chapterId = null,
|
||||
Guid? characterId = null,
|
||||
bool includeResolved = false,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
Guard.Default(novelId, nameof(novelId));
|
||||
|
||||
logger.LogInformation(
|
||||
"Listing open questions for project {ProjectId}, chapter {ChapterId}, character {CharacterId}, includeResolved {IncludeResolved}",
|
||||
projectId, chapterId, characterId, includeResolved);
|
||||
"Listing open questions for novel {NovelId}, chapter {ChapterId}, character {CharacterId}, includeResolved {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)
|
||||
{
|
||||
@@ -70,31 +70,31 @@ public class OpenQuestionService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(question.ProjectId, ProjectPermission.Read, ct);
|
||||
await access.RequireAsync(question.NovelId, NovelPermission.Read, ct);
|
||||
return question;
|
||||
}
|
||||
|
||||
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));
|
||||
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;
|
||||
}
|
||||
|
||||
await access.RequireAsync(projectId, ProjectPermission.CreateContent, ct);
|
||||
await ValidateAssociationsAsync(projectId, request.ChapterId, request.CharacterId, ct);
|
||||
await access.RequireAsync(novelId, NovelPermission.CreateContent, ct);
|
||||
await ValidateAssociationsAsync(novelId, request.ChapterId, request.CharacterId, ct);
|
||||
|
||||
var question = new OpenQuestion
|
||||
{
|
||||
ProjectId = projectId,
|
||||
NovelId = novelId,
|
||||
Question = request.Question.Trim(),
|
||||
Detail = request.Detail,
|
||||
ChapterId = request.ChapterId,
|
||||
@@ -122,8 +122,8 @@ public class OpenQuestionService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(question.ProjectId, ProjectPermission.Write, ct);
|
||||
await ValidateAssociationsAsync(question.ProjectId, request.ChapterId, request.CharacterId, ct);
|
||||
await access.RequireAsync(question.NovelId, NovelPermission.Write, ct);
|
||||
await ValidateAssociationsAsync(question.NovelId, request.ChapterId, request.CharacterId, ct);
|
||||
|
||||
question.Question = Patch.Apply(question.Question, request.Question) ?? question.Question;
|
||||
question.Detail = Patch.Apply(question.Detail, request.Detail);
|
||||
@@ -150,7 +150,7 @@ public class OpenQuestionService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(question.ProjectId, ProjectPermission.Write, ct);
|
||||
await access.RequireAsync(question.NovelId, NovelPermission.Write, ct);
|
||||
|
||||
question.Resolution = request.Resolution.Trim();
|
||||
question.ResolvedAt = DateTimeOffset.UtcNow;
|
||||
@@ -201,7 +201,7 @@ public class OpenQuestionService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(question.ProjectId, ProjectPermission.Write, ct);
|
||||
await access.RequireAsync(question.NovelId, NovelPermission.Write, ct);
|
||||
|
||||
question.Resolution = null;
|
||||
question.ResolvedAt = null;
|
||||
@@ -223,7 +223,7 @@ public class OpenQuestionService(
|
||||
return false;
|
||||
}
|
||||
|
||||
await access.RequireAsync(question.ProjectId, ProjectPermission.DeleteContent, ct);
|
||||
await access.RequireAsync(question.NovelId, NovelPermission.DeleteContent, ct);
|
||||
|
||||
db.OpenQuestions.Remove(question);
|
||||
await db.SaveChangesAsync(ct);
|
||||
@@ -234,27 +234,27 @@ public class OpenQuestionService(
|
||||
string.IsNullOrWhiteSpace(existing) ? note : $"{existing.TrimEnd()}\n\n{note}";
|
||||
|
||||
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
|
||||
&& !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(
|
||||
"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
|
||||
&& !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(
|
||||
"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() =>
|
||||
|
||||
@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
|
||||
namespace Novelly.Api.Tags;
|
||||
|
||||
@@ -11,8 +11,8 @@ public class Tag
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid ProjectId { get; set; }
|
||||
public Project? Project { get; set; }
|
||||
public Guid NovelId { get; set; }
|
||||
public Novel? Novel { get; set; }
|
||||
|
||||
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.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)
|
||||
.UsingEntity(join => join.ToTable("CharacterTags"));
|
||||
|
||||
@@ -7,18 +7,18 @@ public static class TagEndpoints
|
||||
{
|
||||
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<ValidationEndpointFilter>();
|
||||
|
||||
projectScoped.MapGet("/", async (Guid projectId, TagService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ListAsync(projectId, ct)))
|
||||
.WithSummary("List a project's tags with usage counts.");
|
||||
novelScoped.MapGet("/", async (Guid novelId, TagService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ListAsync(novelId, ct)))
|
||||
.WithSummary("List a novel's tags with usage counts.");
|
||||
|
||||
projectScoped.MapPost("/", async (
|
||||
Guid projectId, CreateTagRequest request, TagService service, CancellationToken ct) =>
|
||||
novelScoped.MapPost("/", async (
|
||||
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)
|
||||
{
|
||||
return Results.NotFound();
|
||||
|
||||
@@ -8,21 +8,21 @@ namespace Novelly.Api.Tags;
|
||||
|
||||
public class TagService(
|
||||
INovelDbContext db,
|
||||
ProjectAccessService access,
|
||||
NovelAccessService access,
|
||||
ILogger<TagService> logger,
|
||||
IModelValidator<CreateTagRequest> createValidator,
|
||||
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
|
||||
.Where(t => t.ProjectId == projectId)
|
||||
.Where(t => t.NovelId == novelId)
|
||||
.OrderBy(t => t.Name)
|
||||
.Select(t => new TagSummaryResponse(
|
||||
t.Id, t.Name, t.Color,
|
||||
@@ -49,36 +49,36 @@ public class TagService(
|
||||
return tag;
|
||||
}
|
||||
|
||||
await access.RequireAsync(tag.ProjectId, ProjectPermission.Read, ct);
|
||||
await access.RequireAsync(tag.NovelId, NovelPermission.Read, ct);
|
||||
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));
|
||||
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;
|
||||
}
|
||||
|
||||
await access.RequireAsync(projectId, ProjectPermission.CreateContent, ct);
|
||||
await access.RequireAsync(novelId, NovelPermission.CreateContent, ct);
|
||||
|
||||
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)
|
||||
{
|
||||
logger.LogWarning("Rejected tag creation for project {ProjectId}: '{Name}' already exists", projectId, existing.Name);
|
||||
throw new InvalidOperationException($"The project already has a tag called '{existing.Name}'.");
|
||||
logger.LogWarning("Rejected tag creation for novel {NovelId}: '{Name}' already exists", novelId, 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);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return tag;
|
||||
@@ -99,17 +99,17 @@ public class TagService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(tag.ProjectId, ProjectPermission.Write, ct);
|
||||
await access.RequireAsync(tag.NovelId, NovelPermission.Write, ct);
|
||||
|
||||
if (request.Name is not null)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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;
|
||||
@@ -133,7 +133,7 @@ public class TagService(
|
||||
return false;
|
||||
}
|
||||
|
||||
await access.RequireAsync(tag.ProjectId, ProjectPermission.DeleteContent, ct);
|
||||
await access.RequireAsync(tag.NovelId, NovelPermission.DeleteContent, ct);
|
||||
|
||||
db.Tags.Remove(tag);
|
||||
await db.SaveChangesAsync(ct);
|
||||
@@ -141,12 +141,12 @@ public class TagService(
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
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
|
||||
.Select(TagMapping.Normalise)
|
||||
@@ -156,12 +156,12 @@ public class TagService(
|
||||
|
||||
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 [];
|
||||
}
|
||||
|
||||
var existing = await db.Tags
|
||||
.Where(t => t.ProjectId == projectId)
|
||||
.Where(t => t.NovelId == novelId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var resolved = new List<Tag>();
|
||||
@@ -172,7 +172,7 @@ public class TagService(
|
||||
|
||||
if (match is null)
|
||||
{
|
||||
match = new Tag { ProjectId = projectId, Name = name };
|
||||
match = new Tag { NovelId = novelId, Name = name };
|
||||
db.Tags.Add(match);
|
||||
existing.Add(match);
|
||||
}
|
||||
@@ -180,11 +180,11 @@ public class TagService(
|
||||
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;
|
||||
}
|
||||
|
||||
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(
|
||||
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.Metadata.Builders;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
|
||||
namespace Novelly.Api.Users;
|
||||
|
||||
public class ProjectMember
|
||||
public class NovelMember
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid ProjectId { get; set; }
|
||||
public Project? Project { get; set; }
|
||||
public Guid NovelId { get; set; }
|
||||
public Novel? Novel { get; set; }
|
||||
|
||||
public Guid UserId { 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 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.HasIndex(m => new { m.ProjectId, m.UserId }).IsUnique();
|
||||
entity.Property(m => m.NovelRole).HasConversion<string>().HasMaxLength(32);
|
||||
entity.HasIndex(m => new { m.NovelId, m.UserId }).IsUnique();
|
||||
|
||||
entity.HasOne(m => m.User).WithMany()
|
||||
.HasForeignKey(m => m.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||
+5
-5
@@ -2,9 +2,9 @@ using Novelly.Api.Common.Validation;
|
||||
|
||||
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>
|
||||
{
|
||||
@@ -16,8 +16,8 @@ public class GrantAccessRequestValidator : IModelValidator<GrantAccessRequest>
|
||||
}
|
||||
}
|
||||
|
||||
public static class ProjectMemberMapping
|
||||
public static class NovelMemberMapping
|
||||
{
|
||||
public static ProjectMemberResponse ToResponse(this ProjectMember m) =>
|
||||
new(m.UserId, m.User!.Email ?? string.Empty, m.User.DisplayName, m.ProjectRole, m.GrantedAt);
|
||||
public static NovelMemberResponse ToResponse(this NovelMember m) =>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
public enum ProjectRole
|
||||
public enum NovelRole
|
||||
{
|
||||
Writer,
|
||||
Editor,
|
||||
@@ -6,9 +6,9 @@ namespace Novelly.Api.Users;
|
||||
|
||||
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 DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ public class UserAccountService(
|
||||
if (isFirstAccount)
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -8,12 +8,12 @@ namespace Novelly.Mcp.Tools;
|
||||
public static class CharacterTools
|
||||
{
|
||||
[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(
|
||||
NovelApiClient api,
|
||||
[Description("The project's id.")] Guid projectId,
|
||||
[Description("The novel's id.")] Guid novelId,
|
||||
CancellationToken ct) =>
|
||||
api.GetAsync($"/api/projects/{projectId}/characters", ct);
|
||||
api.GetAsync($"/api/novels/{novelId}/characters", ct);
|
||||
|
||||
[McpServerTool(Name = "get_character")]
|
||||
[Description("Read one character's dossier.")]
|
||||
@@ -24,11 +24,11 @@ public static class CharacterTools
|
||||
api.GetAsync($"/api/characters/{characterId}", ct);
|
||||
|
||||
[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.")]
|
||||
public static Task<CallToolResult> CreateCharacter(
|
||||
NovelApiClient api,
|
||||
[Description("The project's id.")] Guid projectId,
|
||||
[Description("The novel's id.")] Guid novelId,
|
||||
[Description("The character's name.")] string name,
|
||||
CancellationToken ct,
|
||||
[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("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = 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,
|
||||
role = role ?? "Supporting",
|
||||
@@ -147,11 +147,11 @@ public static class CharacterTools
|
||||
[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,
|
||||
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("Position in the arc. Appended to the end when omitted.")] int? sortOrder = null) =>
|
||||
api.PostAsync($"/api/characters/{characterId}/arc",
|
||||
new { title, sortOrder, description, chapterId }, ct);
|
||||
new { title, sortOrder, result, chapterId }, ct);
|
||||
|
||||
[McpServerTool(Name = "update_arc_stage")]
|
||||
[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,
|
||||
CancellationToken ct,
|
||||
[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("Position in the arc.")] int? sortOrder = null) =>
|
||||
api.PatchAsync($"/api/arc-stages/{arcStageId}",
|
||||
new { title, sortOrder, description, chapterId }, ct);
|
||||
new { title, sortOrder, result, chapterId }, ct);
|
||||
|
||||
[McpServerTool(Name = "delete_arc_stage")]
|
||||
[Description("Remove a stage from a character's arc.")]
|
||||
@@ -184,21 +184,38 @@ public static class CharacterTools
|
||||
CancellationToken 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")]
|
||||
[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(
|
||||
NovelApiClient api,
|
||||
[Description("Id of the character the relationship belongs to.")] Guid characterId,
|
||||
[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,
|
||||
[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) =>
|
||||
api.PostAsync($"/api/characters/{characterId}/relationships",
|
||||
new { relatedCharacterId, relationshipType, description }, ct);
|
||||
new { relatedCharacterId, relationshipType, reciprocalRelationshipType, description }, ct);
|
||||
|
||||
[McpServerTool(Name = "link_character_identity")]
|
||||
[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 "
|
||||
+ "is whichever character you link to.")]
|
||||
public static Task<CallToolResult> LinkCharacterIdentity(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -8,12 +8,12 @@ namespace Novelly.Mcp.Tools;
|
||||
public static class ManuscriptTools
|
||||
{
|
||||
[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(
|
||||
NovelApiClient api,
|
||||
[Description("The project's id.")] Guid projectId,
|
||||
[Description("The novel's id.")] Guid novelId,
|
||||
CancellationToken ct) =>
|
||||
api.GetAsync($"/api/projects/{projectId}/chapters", ct);
|
||||
api.GetAsync($"/api/novels/{novelId}/chapters", ct);
|
||||
|
||||
[McpServerTool(Name = "get_chapter")]
|
||||
[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);
|
||||
|
||||
[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(
|
||||
NovelApiClient api,
|
||||
[Description("The project's id.")] Guid projectId,
|
||||
[Description("The novel's id.")] Guid novelId,
|
||||
[Description("Chapter title.")] string title,
|
||||
CancellationToken ct,
|
||||
[Description("Position in the manuscript, 1-based.")] int? number = 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("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("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,
|
||||
number,
|
||||
summary,
|
||||
setting,
|
||||
locations,
|
||||
status = status ?? "Planned",
|
||||
targetWordCount,
|
||||
prose,
|
||||
@@ -50,7 +50,7 @@ public static class ManuscriptTools
|
||||
}, ct);
|
||||
|
||||
[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 "
|
||||
+ "markdown; the word count is recomputed automatically.")]
|
||||
public static Task<CallToolResult> UpdateChapter(
|
||||
@@ -60,12 +60,12 @@ public static class ManuscriptTools
|
||||
[Description("New title.")] string? title = null,
|
||||
[Description("Position in the manuscript.")] int? number = 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("Planned, Outlined, Drafted, Revised or Final.")] string? status = null,
|
||||
[Description("Target length in words.")] int? targetWordCount = null,
|
||||
[Description("The chapter's drafted text, in markdown.")] string? prose = null,
|
||||
[Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null) =>
|
||||
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;
|
||||
|
||||
[McpServerToolType]
|
||||
public static class ProjectTools
|
||||
public static class NovelTools
|
||||
{
|
||||
[McpServerTool(Name = "list_projects")]
|
||||
[Description("List every novel project, with counts of characters, chapters and drafted words. "
|
||||
+ "Start here to find the project id everything else needs.")]
|
||||
public static Task<CallToolResult> ListProjects(NovelApiClient api, CancellationToken ct) =>
|
||||
api.GetAsync("/api/projects", ct);
|
||||
[McpServerTool(Name = "list_novels")]
|
||||
[Description("List every novel, with counts of characters, chapters and drafted words. "
|
||||
+ "Start here to find the novel id everything else needs.")]
|
||||
public static Task<CallToolResult> ListNovels(NovelApiClient api, CancellationToken ct) =>
|
||||
api.GetAsync("/api/novels", ct);
|
||||
|
||||
[McpServerTool(Name = "get_project_brief")]
|
||||
[Description("Read a project's title, author, genre, logline, synopsis, notes and word-count target.")]
|
||||
public static Task<CallToolResult> GetProject(
|
||||
[McpServerTool(Name = "get_novel_brief")]
|
||||
[Description("Read a novel's title, author, genre, logline, synopsis, notes and word-count target.")]
|
||||
public static Task<CallToolResult> GetNovel(
|
||||
NovelApiClient api,
|
||||
[Description("The project's id.")] Guid projectId,
|
||||
[Description("The novel's id.")] Guid novelId,
|
||||
CancellationToken ct) =>
|
||||
api.GetAsync($"/api/projects/{projectId}", ct);
|
||||
api.GetAsync($"/api/novels/{novelId}", ct);
|
||||
|
||||
[McpServerTool(Name = "create_project")]
|
||||
[Description("Create a new novel project.")]
|
||||
public static Task<CallToolResult> CreateProject(
|
||||
[McpServerTool(Name = "create_novel")]
|
||||
[Description("Create a new novel.")]
|
||||
public static Task<CallToolResult> CreateNovel(
|
||||
NovelApiClient api,
|
||||
[Description("Working title.")] string title,
|
||||
CancellationToken ct,
|
||||
@@ -33,14 +33,14 @@ public static class ProjectTools
|
||||
[Description("Paragraph-length summary of the whole book.")] string? synopsis = null,
|
||||
[Description("Free-form notes on theme, tone, comparable titles.")] string? notes = 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")]
|
||||
[Description("Revise a project's top-level fields. Only the fields you supply change; "
|
||||
[McpServerTool(Name = "update_novel_brief")]
|
||||
[Description("Revise a novel's top-level fields. Only the fields you supply change; "
|
||||
+ "pass an empty string to clear one.")]
|
||||
public static Task<CallToolResult> UpdateProject(
|
||||
public static Task<CallToolResult> UpdateNovel(
|
||||
NovelApiClient api,
|
||||
[Description("The project's id.")] Guid projectId,
|
||||
[Description("The novel's id.")] Guid novelId,
|
||||
CancellationToken ct,
|
||||
[Description("New title.")] string? title = 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("Free-form notes on theme, tone, comparable titles.")] string? notes = 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);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ public static class QuestionTools
|
||||
+ "thinking, not a gap to fill in for them.")]
|
||||
public static Task<CallToolResult> ListOpenQuestions(
|
||||
NovelApiClient api,
|
||||
[Description("The project's id.")] Guid projectId,
|
||||
[Description("The novel's id.")] Guid novelId,
|
||||
CancellationToken ct,
|
||||
[Description("Narrow to questions about one chapter outline.")] Guid? chapterId = null,
|
||||
[Description("Narrow to questions about one character.")] Guid? characterId = null,
|
||||
@@ -31,7 +31,7 @@ public static class QuestionTools
|
||||
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")]
|
||||
@@ -39,13 +39,13 @@ public static class QuestionTools
|
||||
+ "and/or the character it is about. Prefer raising a question over guessing.")]
|
||||
public static Task<CallToolResult> RaiseOpenQuestion(
|
||||
NovelApiClient api,
|
||||
[Description("The project's id.")] Guid projectId,
|
||||
[Description("The novel's id.")] Guid novelId,
|
||||
[Description("The question, in one line.")] string question,
|
||||
CancellationToken ct,
|
||||
[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 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);
|
||||
|
||||
[McpServerTool(Name = "update_open_question")]
|
||||
|
||||
@@ -8,13 +8,13 @@ namespace Novelly.Mcp.Tools;
|
||||
public static class TagTools
|
||||
{
|
||||
[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.")]
|
||||
public static Task<CallToolResult> ListTags(
|
||||
NovelApiClient api,
|
||||
[Description("The project's id.")] Guid projectId,
|
||||
[Description("The novel's id.")] Guid novelId,
|
||||
CancellationToken ct) =>
|
||||
api.GetAsync($"/api/projects/{projectId}/tags", ct);
|
||||
api.GetAsync($"/api/novels/{novelId}/tags", ct);
|
||||
|
||||
[McpServerTool(Name = "get_tag_references")]
|
||||
[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.")]
|
||||
public static Task<CallToolResult> CreateTag(
|
||||
NovelApiClient api,
|
||||
[Description("The project's id.")] Guid projectId,
|
||||
[Description("The tag's name. Unique within the project, matched case-insensitively.")] string name,
|
||||
[Description("The novel's id.")] Guid novelId,
|
||||
[Description("The tag's name. Unique within the novel, matched case-insensitively.")] string name,
|
||||
CancellationToken ct,
|
||||
[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")]
|
||||
[Description("Rename or recolour a tag. Renaming updates it everywhere it is applied.")]
|
||||
|
||||
@@ -11,8 +11,8 @@ using OpenTelemetry.Trace;
|
||||
namespace Microsoft.Extensions.Hosting;
|
||||
|
||||
// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
|
||||
// This project should be referenced by each service project in your solution.
|
||||
// To learn more about using this project, see https://aka.ms/aspire/service-defaults
|
||||
// This novel should be referenced by each service novel in your solution.
|
||||
// To learn more about using this novel, see https://aka.ms/aspire/service-defaults
|
||||
public static class Extensions
|
||||
{
|
||||
private const string HealthEndpointPath = "/health";
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Navigate, Outlet, Route, Routes } from 'react-router-dom'
|
||||
import ProjectsPage from './pages/ProjectsPage'
|
||||
import ProjectLayout from './pages/ProjectLayout'
|
||||
import NovelsPage from './pages/NovelsPage'
|
||||
import NovelLayout from './pages/NovelLayout'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import CharactersPage from './pages/CharactersPage'
|
||||
import CharacterDetailPage from './pages/CharacterDetailPage'
|
||||
import TagsPage from './pages/TagsPage'
|
||||
import LocationsPage from './pages/LocationsPage'
|
||||
import ChaptersPage from './pages/ChaptersPage'
|
||||
import ChapterPage from './pages/ChapterPage'
|
||||
import AgentPage from './pages/AgentPage'
|
||||
@@ -34,18 +35,19 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route path="/" element={<ProjectsPage />} />
|
||||
<Route path="/projects/:projectId" element={<ProjectLayout />}>
|
||||
<Route path="/" element={<NovelsPage />} />
|
||||
<Route path="/novels/:novelId" element={<NovelLayout />}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="characters" element={<CharactersPage />} />
|
||||
<Route path="characters/:characterId" element={<CharacterDetailPage />} />
|
||||
<Route path="chapters" element={<ChaptersPage />} />
|
||||
<Route path="chapters/:chapterId" element={<ChapterPage />} />
|
||||
<Route path="tags" element={<TagsPage />} />
|
||||
<Route path="locations" element={<LocationsPage />} />
|
||||
<Route path="agent" element={<AgentPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<ProjectsPage />} />
|
||||
<Route path="*" element={<NovelsPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</HelpOverlayProvider>
|
||||
|
||||
+200
-111
@@ -15,10 +15,12 @@ import type {
|
||||
ImportJob,
|
||||
ImportJobStatus,
|
||||
OpenQuestion,
|
||||
Project,
|
||||
ProjectMember,
|
||||
ProjectRole,
|
||||
ProjectSummary,
|
||||
LocationReferences,
|
||||
LocationSummary,
|
||||
Novel,
|
||||
NovelMember,
|
||||
NovelRole,
|
||||
NovelSummary,
|
||||
TagReferences,
|
||||
TagSummary,
|
||||
User,
|
||||
@@ -26,18 +28,20 @@ import type {
|
||||
|
||||
export const keys = {
|
||||
me: ['me'] as const,
|
||||
members: (projectId: string) => ['projects', projectId, 'members'] as const,
|
||||
projects: ['projects'] as const,
|
||||
members: (novelId: string) => ['novels', novelId, 'members'] as const,
|
||||
novels: ['novels'] as const,
|
||||
genres: ['genres'] as const,
|
||||
project: (id: string) => ['projects', id] as const,
|
||||
characters: (projectId: string) => ['projects', projectId, 'characters'] as const,
|
||||
tags: (projectId: string) => ['projects', projectId, 'tags'] as const,
|
||||
novel: (id: string) => ['novels', id] as const,
|
||||
characters: (novelId: string) => ['novels', novelId, 'characters'] as const,
|
||||
tags: (novelId: string) => ['novels', novelId, 'tags'] 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,
|
||||
chapters: (projectId: string) => ['projects', projectId, 'chapters'] as const,
|
||||
questions: (projectId: string) => ['projects', projectId, 'questions'] as const,
|
||||
chapters: (novelId: string) => ['novels', novelId, 'chapters'] as const,
|
||||
questions: (novelId: string) => ['novels', novelId, 'questions'] 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,
|
||||
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({
|
||||
queryKey: keys.members(projectId),
|
||||
queryFn: () => api.get<ProjectMember[]>(`/api/projects/${projectId}/members`),
|
||||
queryKey: keys.members(novelId),
|
||||
queryFn: () => api.get<NovelMember[]>(`/api/novels/${novelId}/members`),
|
||||
retry: false,
|
||||
})
|
||||
|
||||
export function useGrantAccess(projectId: string) {
|
||||
export function useGrantAccess(novelId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: { email: string; projectRole: ProjectRole }) =>
|
||||
api.post<ProjectMember>(`/api/projects/${projectId}/members`, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.members(projectId) }),
|
||||
mutationFn: (body: { email: string; novelRole: NovelRole }) =>
|
||||
api.post<NovelMember>(`/api/novels/${novelId}/members`, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.members(novelId) }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useRevokeAccess(projectId: string) {
|
||||
export function useRevokeAccess(novelId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (userId: string) => api.delete(`/api/projects/${projectId}/members/${userId}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.members(projectId) }),
|
||||
mutationFn: (userId: string) => api.delete(`/api/novels/${novelId}/members/${userId}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.members(novelId) }),
|
||||
})
|
||||
}
|
||||
|
||||
export const useProjects = () =>
|
||||
useQuery({ queryKey: keys.projects, queryFn: () => api.get<ProjectSummary[]>('/api/projects') })
|
||||
export const useNovels = () =>
|
||||
useQuery({ queryKey: keys.novels, queryFn: () => api.get<NovelSummary[]>('/api/novels') })
|
||||
|
||||
export const useProject = (id: string) =>
|
||||
useQuery({ queryKey: keys.project(id), queryFn: () => api.get<Project>(`/api/projects/${id}`) })
|
||||
export const useNovel = (id: string) =>
|
||||
useQuery({ queryKey: keys.novel(id), queryFn: () => api.get<Novel>(`/api/novels/${id}`) })
|
||||
|
||||
export function useCreateProject() {
|
||||
export function useCreateNovel() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: { title: string; author?: string; genre?: string; logline?: string }) =>
|
||||
api.post<Project>('/api/projects', body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.projects }),
|
||||
api.post<Novel>('/api/novels', body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.novels }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateProject(id: string) {
|
||||
export function useUpdateNovel(id: string) {
|
||||
const qc = useQueryClient()
|
||||
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) => {
|
||||
qc.setQueryData(keys.project(id), updated)
|
||||
qc.invalidateQueries({ queryKey: keys.projects })
|
||||
qc.setQueryData(keys.novel(id), updated)
|
||||
qc.invalidateQueries({ queryKey: keys.novels })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteProject() {
|
||||
export function useDeleteNovel() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/api/projects/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.projects }),
|
||||
mutationFn: (id: string) => api.delete(`/api/novels/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.novels }),
|
||||
})
|
||||
}
|
||||
|
||||
export const useCharacters = (projectId: string) =>
|
||||
export const useCharacters = (novelId: string) =>
|
||||
useQuery({
|
||||
queryKey: keys.characters(projectId),
|
||||
queryFn: () => api.get<Character[]>(`/api/projects/${projectId}/characters`),
|
||||
queryKey: keys.characters(novelId),
|
||||
queryFn: () => api.get<Character[]>(`/api/novels/${novelId}/characters`),
|
||||
})
|
||||
|
||||
export function useCreateCharacter(projectId: string) {
|
||||
export function useCreateCharacter(novelId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: Partial<Character> & { name: string }) =>
|
||||
api.post<Character>(`/api/projects/${projectId}/characters`, body),
|
||||
api.post<Character>(`/api/novels/${novelId}/characters`, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: keys.characters(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.characters(novelId) })
|
||||
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateCharacter(projectId: string) {
|
||||
export function useUpdateCharacter(novelId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...body }: Partial<Omit<Character, 'tags'>> & { id: string; tags?: string[] }) =>
|
||||
api.patch<Character>(`/api/characters/${id}`, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: keys.characters(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.characters(novelId) })
|
||||
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteCharacter(projectId: string) {
|
||||
export function useDeleteCharacter(novelId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
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()
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
@@ -192,15 +196,49 @@ export function useLinkCharacterIdentity(projectId: string) {
|
||||
revealedInChapterId?: string | null
|
||||
note?: string | null
|
||||
}) => 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()
|
||||
return useMutation({
|
||||
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),
|
||||
})
|
||||
|
||||
export function useCreateArcStage(projectId: string) {
|
||||
export function useCreateArcStage(novelId: string) {
|
||||
const qc = useQueryClient()
|
||||
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),
|
||||
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()
|
||||
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),
|
||||
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()
|
||||
return useMutation({
|
||||
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()
|
||||
return useMutation({
|
||||
mutationFn: ({ characterId, stageIds }: { characterId: string; stageIds: string[] }) =>
|
||||
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 = (
|
||||
projectId: string,
|
||||
novelId: string,
|
||||
filter: { chapterId?: string; characterId?: string; includeResolved?: boolean } = {},
|
||||
) => {
|
||||
const params = new URLSearchParams()
|
||||
@@ -257,66 +307,66 @@ export const useOpenQuestions = (
|
||||
const query = params.toString()
|
||||
|
||||
return useQuery({
|
||||
queryKey: [...keys.questions(projectId), query] as const,
|
||||
queryKey: [...keys.questions(novelId), query] as const,
|
||||
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()
|
||||
return useMutation({
|
||||
mutationFn: (body: { question: string; detail?: string; chapterId?: string; characterId?: string }) =>
|
||||
api.post<OpenQuestion>(`/api/projects/${projectId}/questions`, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(projectId) }),
|
||||
api.post<OpenQuestion>(`/api/novels/${novelId}/questions`, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(novelId) }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateQuestion(projectId: string) {
|
||||
export function useUpdateQuestion(novelId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...body }: { id: string; question?: string; detail?: string }) =>
|
||||
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()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, resolution, appendToNotes }: { id: string; resolution: string; appendToNotes: boolean }) =>
|
||||
api.post<OpenQuestion>(`/api/questions/${id}/resolve`, { resolution, appendToNotes }),
|
||||
onSuccess: (question) => {
|
||||
qc.invalidateQueries({ queryKey: keys.questions(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.characters(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.questions(novelId) })
|
||||
qc.invalidateQueries({ queryKey: keys.characters(novelId) })
|
||||
if (question.chapterId) qc.invalidateQueries({ queryKey: keys.chapter(question.chapterId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useReopenQuestion(projectId: string) {
|
||||
export function useReopenQuestion(novelId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
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()
|
||||
return useMutation({
|
||||
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 = () =>
|
||||
useQuery({ queryKey: keys.genres, queryFn: () => api.get<Genre[]>('/api/genres') })
|
||||
|
||||
export const useTags = (projectId: string) =>
|
||||
export const useTags = (novelId: string) =>
|
||||
useQuery({
|
||||
queryKey: keys.tags(projectId),
|
||||
queryFn: () => api.get<TagSummary[]>(`/api/projects/${projectId}/tags`),
|
||||
queryKey: keys.tags(novelId),
|
||||
queryFn: () => api.get<TagSummary[]>(`/api/novels/${novelId}/tags`),
|
||||
})
|
||||
|
||||
export const useTagReferences = (tagId: string | undefined) =>
|
||||
@@ -326,12 +376,15 @@ export const useTagReferences = (tagId: string | undefined) =>
|
||||
enabled: Boolean(tagId),
|
||||
})
|
||||
|
||||
export function useUpdateTag(projectId: string) {
|
||||
export function useUpdateTag(novelId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...body }: { id: string; name?: string; color?: string }) =>
|
||||
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()
|
||||
return useMutation({
|
||||
mutationFn: (
|
||||
@@ -351,12 +437,12 @@ export function useCreateBeat(chapterId: string, projectId: string) {
|
||||
) => api.post<Beat>(`/api/chapters/${chapterId}/beats`, body),
|
||||
onSuccess: () => {
|
||||
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()
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
@@ -366,7 +452,7 @@ export function useUpdateBeat(chapterId: string, projectId: string) {
|
||||
api.patch<Beat>(`/api/beats/${id}`, body),
|
||||
onSuccess: () => {
|
||||
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({
|
||||
queryKey: keys.chapters(projectId),
|
||||
queryFn: () => api.get<ChapterSummary[]>(`/api/projects/${projectId}/chapters`),
|
||||
queryKey: keys.chapters(novelId),
|
||||
queryFn: () => api.get<ChapterSummary[]>(`/api/novels/${novelId}/chapters`),
|
||||
})
|
||||
|
||||
export const useChapter = (id: string | undefined) =>
|
||||
@@ -422,40 +508,43 @@ export const useChapter = (id: string | undefined) =>
|
||||
enabled: Boolean(id),
|
||||
})
|
||||
|
||||
export function useCreateChapter(projectId: string) {
|
||||
export function useCreateChapter(novelId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: Partial<Chapter> & { title: string }) =>
|
||||
api.post<Chapter>(`/api/projects/${projectId}/chapters`, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapters(projectId) }),
|
||||
mutationFn: (
|
||||
body: Partial<Omit<Chapter, 'tags' | 'locations'>> & { title: string; tags?: string[]; locations?: string[] },
|
||||
) => 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()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...body }: Partial<Omit<Chapter, 'tags'>> & { id: string; tags?: string[] }) =>
|
||||
api.patch<Chapter>(`/api/chapters/${id}`, body),
|
||||
mutationFn: (
|
||||
{ id, ...body }: Partial<Omit<Chapter, 'tags' | 'locations'>> & { id: string; tags?: string[]; locations?: string[] },
|
||||
) => api.patch<Chapter>(`/api/chapters/${id}`, body),
|
||||
onSuccess: (updated) => {
|
||||
qc.setQueryData(keys.chapter(updated.id), updated)
|
||||
qc.invalidateQueries({ queryKey: keys.chapters(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.chapters(novelId) })
|
||||
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()
|
||||
return useMutation({
|
||||
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({
|
||||
queryKey: keys.conversations(projectId),
|
||||
queryFn: () => api.get<ConversationSummary[]>(`/api/projects/${projectId}/agent/conversations`),
|
||||
queryKey: keys.conversations(novelId),
|
||||
queryFn: () => api.get<ConversationSummary[]>(`/api/novels/${novelId}/agent/conversations`),
|
||||
})
|
||||
|
||||
export const useConversation = (id: string | undefined) =>
|
||||
@@ -465,19 +554,19 @@ export const useConversation = (id: string | undefined) =>
|
||||
enabled: Boolean(id),
|
||||
})
|
||||
|
||||
export function useSendAgentMessage(projectId: string) {
|
||||
export function useSendAgentMessage(novelId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
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) => {
|
||||
qc.invalidateQueries({ queryKey: keys.conversations(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.conversations(novelId) })
|
||||
qc.invalidateQueries({ queryKey: keys.conversation(turn.conversationId) })
|
||||
qc.invalidateQueries({ queryKey: keys.characters(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.chapters(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.questions(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.project(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.characters(novelId) })
|
||||
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
|
||||
qc.invalidateQueries({ queryKey: keys.chapters(novelId) })
|
||||
qc.invalidateQueries({ queryKey: keys.questions(novelId) })
|
||||
qc.invalidateQueries({ queryKey: keys.novel(novelId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -28,19 +28,19 @@ export type DraftStatus = 'Planned' | 'Outlined' | 'Drafted' | 'Revised' | 'Fina
|
||||
|
||||
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 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 {
|
||||
id: string
|
||||
@@ -49,11 +49,11 @@ export interface User {
|
||||
globalRole: GlobalRole
|
||||
}
|
||||
|
||||
export interface ProjectMember {
|
||||
export interface NovelMember {
|
||||
userId: string
|
||||
email: string
|
||||
displayName: string
|
||||
projectRole: ProjectRole
|
||||
novelRole: NovelRole
|
||||
grantedAt: string
|
||||
}
|
||||
|
||||
@@ -62,21 +62,21 @@ export interface Genre {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ProjectSummary {
|
||||
export interface NovelSummary {
|
||||
id: string
|
||||
title: string
|
||||
author: string | null
|
||||
genre: string | null
|
||||
logline: string | null
|
||||
targetWordCount: number | null
|
||||
phase: ProjectPhase
|
||||
phase: NovelPhase
|
||||
characterCount: number
|
||||
chapterCount: number
|
||||
wordCount: number
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
export interface Novel {
|
||||
id: string
|
||||
title: string
|
||||
author: string | null
|
||||
@@ -85,9 +85,9 @@ export interface Project {
|
||||
synopsis: string | null
|
||||
notes: string | null
|
||||
targetWordCount: number | null
|
||||
phase: ProjectPhase
|
||||
phase: NovelPhase
|
||||
ownerId: string | null
|
||||
myRole: ProjectMyRole | null
|
||||
myRole: NovelMyRole | null
|
||||
createdAt: 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 {
|
||||
id: string
|
||||
name: string
|
||||
@@ -151,10 +165,11 @@ export interface ArcStage {
|
||||
characterId: string
|
||||
sortOrder: number
|
||||
title: string
|
||||
description: string | null
|
||||
result: string | null
|
||||
chapterId: string | null
|
||||
chapterNumber: number | null
|
||||
chapterTitle: string | null
|
||||
beats: CharacterBeat[]
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
@@ -167,11 +182,12 @@ export interface CharacterBeat {
|
||||
title: string
|
||||
whatHappened: string | null
|
||||
whatsNext: string | null
|
||||
arcStageId: string | null
|
||||
}
|
||||
|
||||
export interface Character {
|
||||
id: string
|
||||
projectId: string
|
||||
novelId: string
|
||||
name: string
|
||||
role: CharacterRole
|
||||
importance: CharacterImportance
|
||||
@@ -208,11 +224,11 @@ export interface CharacterIdentity {
|
||||
|
||||
export interface ChapterSummary {
|
||||
id: string
|
||||
projectId: string
|
||||
novelId: string
|
||||
number: number
|
||||
title: string
|
||||
summary: string | null
|
||||
setting: string | null
|
||||
locations: Location[]
|
||||
status: DraftStatus
|
||||
targetWordCount: number | null
|
||||
beatCount: number
|
||||
@@ -231,7 +247,7 @@ export interface Chapter extends Omit<ChapterSummary, 'beatCount' | 'wordCount'>
|
||||
|
||||
export interface OpenQuestion {
|
||||
id: string
|
||||
projectId: string
|
||||
novelId: string
|
||||
question: string
|
||||
detail: string | null
|
||||
chapterId: string | null
|
||||
@@ -262,7 +278,7 @@ export interface AgentMessage {
|
||||
|
||||
export interface ConversationSummary {
|
||||
id: string
|
||||
projectId: string
|
||||
novelId: string
|
||||
title: string
|
||||
messageCount: number
|
||||
updatedAt: string
|
||||
@@ -282,7 +298,7 @@ export type ImportJobStatus = 'Pending' | 'Running' | 'Completed' | 'Failed' | '
|
||||
export interface ImportJob {
|
||||
id: string
|
||||
sourceRoot: string
|
||||
projectId: string | null
|
||||
novelId: string | null
|
||||
status: ImportJobStatus
|
||||
statusMessage: string | null
|
||||
chaptersCompleted: number
|
||||
@@ -295,7 +311,7 @@ export type ImportReadiness = 'Fresh' | 'Resumable' | 'Complete'
|
||||
|
||||
export interface ImportInspection {
|
||||
readiness: ImportReadiness
|
||||
projectId: string | null
|
||||
novelId: string | null
|
||||
chaptersCompleted: number
|
||||
chaptersTotal: number
|
||||
completedPasses: string[]
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createContext, useContext, useMemo, type ReactNode } from 'react'
|
||||
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'
|
||||
|
||||
const projectPermissionsByRole: Record<ProjectMyRole, AuthPermission[]> = {
|
||||
const novelPermissionsByRole: Record<NovelMyRole, AuthPermission[]> = {
|
||||
Admin: ['Write', 'CreateContent', 'DeleteContent', 'ManageAccess'],
|
||||
Owner: ['Write', 'CreateContent', 'DeleteContent', 'ManageAccess'],
|
||||
Writer: ['Write', 'CreateContent', 'DeleteContent'],
|
||||
@@ -15,7 +15,7 @@ const projectPermissionsByRole: Record<ProjectMyRole, AuthPermission[]> = {
|
||||
interface AuthValue {
|
||||
user: User | null
|
||||
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 })
|
||||
@@ -28,10 +28,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
() => ({
|
||||
user,
|
||||
isPending,
|
||||
can: (permission, project) => {
|
||||
can: (permission, novel) => {
|
||||
if (permission === 'CreateNovel') return user?.globalRole === 'Admin' || user?.globalRole === 'Writer'
|
||||
const myRole = project?.myRole
|
||||
return myRole ? projectPermissionsByRole[myRole].includes(permission) : false
|
||||
const myRole = novel?.myRole
|
||||
return myRole ? novelPermissionsByRole[myRole].includes(permission) : false
|
||||
},
|
||||
}),
|
||||
[user, isPending],
|
||||
|
||||
@@ -1,35 +1,39 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
useCharacterBeats,
|
||||
useChapters,
|
||||
useCreateArcStage,
|
||||
useDeleteArcStage,
|
||||
useReorderArcStages,
|
||||
useSetArcStageBeats,
|
||||
useUpdateArcStage,
|
||||
} from '../api/hooks'
|
||||
import type { ArcStage, Character } from '../api/types'
|
||||
import { AutoField, ErrorNote } from './ui'
|
||||
|
||||
export function CharacterArc({
|
||||
projectId,
|
||||
novelId,
|
||||
character,
|
||||
canWrite,
|
||||
canCreate,
|
||||
canDelete,
|
||||
}: {
|
||||
projectId: string
|
||||
novelId: string
|
||||
character: Character
|
||||
canWrite: boolean
|
||||
canCreate: boolean
|
||||
canDelete: boolean
|
||||
}) {
|
||||
const { data: chapters } = useChapters(projectId)
|
||||
const create = useCreateArcStage(projectId)
|
||||
const reorder = useReorderArcStages(projectId)
|
||||
const { data: chapters } = useChapters(novelId)
|
||||
const { data: beats } = useCharacterBeats(character.id)
|
||||
const create = useCreateArcStage(novelId)
|
||||
const reorder = useReorderArcStages(novelId)
|
||||
|
||||
const [title, setTitle] = useState('')
|
||||
|
||||
const stages = character.arcStages
|
||||
const unassignedBeats = (beats ?? []).filter((b) => b.arcStageId === null)
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -56,8 +60,9 @@ export function CharacterArc({
|
||||
)}
|
||||
</div>
|
||||
<p className="mb-3 text-xs muted">
|
||||
The changes {character.name} goes through, in order. Pin a stage to the chapter it
|
||||
lands in and it links into that outline.
|
||||
The sections {character.name}’s arc breaks into, in order — each one a short span of
|
||||
beats and what it results in for them. Pin a section to the chapter it lands in and it
|
||||
links into that outline.
|
||||
</p>
|
||||
|
||||
{stages.length > 0 && (
|
||||
@@ -65,9 +70,10 @@ export function CharacterArc({
|
||||
{stages.map((stage, index) => (
|
||||
<ArcStageRow
|
||||
key={stage.id}
|
||||
projectId={projectId}
|
||||
novelId={novelId}
|
||||
stage={stage}
|
||||
chapters={chapters ?? []}
|
||||
unassignedBeats={unassignedBeats}
|
||||
canMoveUp={index > 0}
|
||||
canMoveDown={index < stages.length - 1}
|
||||
onMove={(delta) => move(index, delta)}
|
||||
@@ -78,11 +84,18 @@ export function CharacterArc({
|
||||
</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 && (
|
||||
<form onSubmit={submit} className="mt-3 flex gap-2">
|
||||
<input
|
||||
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}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
@@ -102,26 +115,38 @@ export function CharacterArc({
|
||||
}
|
||||
|
||||
function ArcStageRow({
|
||||
projectId,
|
||||
novelId,
|
||||
stage,
|
||||
chapters,
|
||||
unassignedBeats,
|
||||
canMoveUp,
|
||||
canMoveDown,
|
||||
onMove,
|
||||
canWrite,
|
||||
canDelete,
|
||||
}: {
|
||||
projectId: string
|
||||
novelId: string
|
||||
stage: ArcStage
|
||||
chapters: { id: string; number: number; title: string }[]
|
||||
unassignedBeats: { id: string; chapterNumber: number; sortOrder: number; title: string }[]
|
||||
canMoveUp: boolean
|
||||
canMoveDown: boolean
|
||||
onMove: (delta: number) => void
|
||||
canWrite: boolean
|
||||
canDelete: boolean
|
||||
}) {
|
||||
const update = useUpdateArcStage(projectId)
|
||||
const remove = useDeleteArcStage(projectId)
|
||||
const update = useUpdateArcStage(novelId)
|
||||
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 (
|
||||
<li
|
||||
@@ -138,14 +163,59 @@ function ArcStageRow({
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
<AutoField
|
||||
value={stage.description}
|
||||
value={stage.result}
|
||||
multiline
|
||||
rows={2}
|
||||
placeholder="What shifts here, and what it costs them."
|
||||
onCommit={(description) => update.mutate({ id: stage.id, description })}
|
||||
placeholder="What this results in for them — what shifts, and what it costs."
|
||||
onCommit={(result) => update.mutate({ id: stage.id, result })}
|
||||
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">
|
||||
<select
|
||||
className="input max-w-[16rem] py-1 text-xs"
|
||||
@@ -165,7 +235,7 @@ function ArcStageRow({
|
||||
<Link
|
||||
className="text-xs underline"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
to={`/projects/${projectId}/chapters/${stage.chapterId}`}
|
||||
to={`/novels/${novelId}/chapters/${stage.chapterId}`}
|
||||
>
|
||||
Open outline
|
||||
</Link>
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useCharacterBeats } from '../api/hooks'
|
||||
import type { ArcStage } from '../api/types'
|
||||
import { ErrorNote, Spinner } from './ui'
|
||||
|
||||
export function CharacterBeats({
|
||||
projectId,
|
||||
novelId,
|
||||
characterId,
|
||||
characterName,
|
||||
arcStages,
|
||||
}: {
|
||||
projectId: string
|
||||
novelId: string
|
||||
characterId: string
|
||||
characterName: string
|
||||
arcStages: ArcStage[]
|
||||
}) {
|
||||
const { data: beats, isPending, error } = useCharacterBeats(characterId)
|
||||
const stageTitleById = new Map(arcStages.map((s) => [s.id, s.title]))
|
||||
|
||||
return (
|
||||
<section className="card mt-6 p-5">
|
||||
@@ -33,6 +37,7 @@ export function CharacterBeats({
|
||||
<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">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 font-semibold">What’s next</th>
|
||||
</tr>
|
||||
@@ -44,13 +49,16 @@ export function CharacterBeats({
|
||||
<Link
|
||||
className="underline"
|
||||
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}
|
||||
</Link>
|
||||
<div className="text-xs muted">{beat.chapterTitle}</div>
|
||||
</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 muted">{beat.whatsNext}</td>
|
||||
</tr>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user