Replace chapter setting with multi-select locations; add chapter character summary

Chapters now carry many Locations (new Tags-style entity with cross-referencing)
instead of a single free-text Setting field, with a Locations tab on the novel
for browsing them and seeing every chapter set at each one. Also surfaces the
distinct characters appearing in a chapter's beats, linked, under the beat/word
count on the outline tab.
This commit is contained in:
James Wampler
2026-08-18 11:36:13 -07:00
parent 4313c8f206
commit c620ddd626
27 changed files with 2380 additions and 44 deletions
+26 -5
View File
@@ -3,6 +3,7 @@ using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Common;
using Novelly.Api.Locations;
using Novelly.Api.Novels;
using Novelly.Api.Questions;
using Novelly.Api.Tags;
@@ -29,6 +30,7 @@ public class NovelAgentToolset(
ChapterService chapters,
BeatService beats,
TagService tags,
LocationService locations,
OpenQuestionService questions,
ILogger<NovelAgentToolset> logger)
{
@@ -366,6 +368,25 @@ public class NovelAgentToolset(
return await OrNotFound(tags.GetReferencesAsync(tagId, ct), t => t.ToReferencesResponse(), "Tag", tagId);
});
yield return new AgentTool(
"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 (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.",
@@ -391,7 +412,7 @@ 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.")
@@ -402,7 +423,7 @@ public class NovelAgentToolset(
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"),
@@ -411,7 +432,7 @@ public class NovelAgentToolset(
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"),
+2 -1
View File
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Beats;
using Novelly.Api.Common;
using Novelly.Api.Locations;
using Novelly.Api.Novels;
using Novelly.Api.Tags;
@@ -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>
+14 -11
View File
@@ -1,6 +1,7 @@
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;
@@ -11,7 +12,7 @@ public record ChapterSummaryResponse(
int Number,
string Title,
string? Summary,
string? Setting,
IReadOnlyList<LocationResponse> Locations,
DraftStatus Status,
int? TargetWordCount,
int BeatCount,
@@ -25,7 +26,7 @@ public record ChapterResponse(
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.");
@@ -116,7 +117,8 @@ public static class ChapterMapping
{
public static ChapterResponse ToResponse(this Chapter c) => new(
c.Id, c.NovelId, c.Number, c.Title, c.Summary,
c.Setting, c.Notes,
[.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())],
c.Notes,
c.Status, c.TargetWordCount,
[.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())],
c.Prose, c.WordCount,
@@ -125,7 +127,8 @@ public static class ChapterMapping
public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new(
c.Id, c.NovelId, c.Number, c.Title, c.Summary,
c.Setting, c.Status, c.TargetWordCount,
[.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())],
c.Status, c.TargetWordCount,
c.Beats.Count, c.WordCount,
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
c.UpdatedAt);
+14 -2
View File
@@ -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;
@@ -11,6 +12,7 @@ public class ChapterService(
INovelDbContext db,
NovelAccessService access,
TagService tags,
LocationService locations,
ILogger<ChapterService> logger,
IModelValidator<CreateChapterRequest> createValidator,
IModelValidator<UpdateChapterRequest> updateValidator)
@@ -26,6 +28,7 @@ public class ChapterService(
return await db.Chapters
.Include(c => c.Beats)
.Include(c => c.Tags)
.Include(c => c.Locations)
.Where(c => c.NovelId == novelId)
.OrderBy(c => c.Number)
.ToListAsync(ct);
@@ -69,7 +72,6 @@ public class ChapterService(
Title = request.Title,
Number = request.Number ?? await NextChapterNumberAsync(novelId, ct),
Summary = request.Summary,
Setting = request.Setting,
Notes = request.Notes,
Status = request.Status,
TargetWordCount = request.TargetWordCount,
@@ -82,6 +84,11 @@ public class ChapterService(
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);
await db.SaveChangesAsync(ct);
@@ -107,7 +114,6 @@ public class ChapterService(
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;
@@ -125,6 +131,11 @@ public class ChapterService(
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);
return (await FindAsync(id, ct))!;
}
@@ -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)
@@ -13,6 +13,7 @@ using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Genres;
using Novelly.Api.Imports;
using Novelly.Api.Locations;
using Novelly.Api.Novels;
using Novelly.Api.Questions;
using Novelly.Api.Tags;
@@ -77,6 +78,7 @@ public static class NovellyServiceRegistration
services.AddScoped<CharacterArcService>();
services.AddScoped<BeatService>();
services.AddScoped<TagService>();
services.AddScoped<LocationService>();
services.AddScoped<GenreService>();
services.AddScoped<ChapterService>();
services.AddScoped<OpenQuestionService>();
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);
}
}
}
@@ -62,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")
@@ -273,9 +288,6 @@ namespace Novelly.Api.Data.Migrations
b.Property<string>("Prose")
.HasColumnType("TEXT");
b.Property<string>("Setting")
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
@@ -620,6 +632,31 @@ namespace Novelly.Api.Data.Migrations
b.ToTable("ImportJobs");
});
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")
@@ -901,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)
@@ -1064,6 +1116,17 @@ namespace Novelly.Api.Data.Migrations
b.Navigation("RelatedCharacter");
});
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")
+3
View File
@@ -7,6 +7,7 @@ using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Genres;
using Novelly.Api.Imports;
using Novelly.Api.Locations;
using Novelly.Api.Novels;
using Novelly.Api.Questions;
using Novelly.Api.Tags;
@@ -24,6 +25,7 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options) : Identity
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>();
@@ -51,6 +53,7 @@ public interface INovelDbContext
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; }
+33
View File
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Chapters;
using Novelly.Api.Novels;
namespace Novelly.Api.Locations;
public class Location
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid NovelId { get; set; }
public Novel? Novel { get; set; }
public string Name { get; set; } = string.Empty;
public List<Chapter> Chapters { get; set; } = [];
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public class LocationEntityTypeConfiguration : IEntityTypeConfiguration<Location>
{
public void Configure(EntityTypeBuilder<Location> entity)
{
entity.Property(l => l.Name).IsRequired().HasMaxLength(120);
entity.HasIndex(l => new { l.NovelId, l.Name }).IsUnique();
entity.HasMany(l => l.Chapters).WithMany(c => c.Locations)
.UsingEntity(join => join.ToTable("ChapterLocations"));
}
}
@@ -0,0 +1,52 @@
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Locations;
public record LocationResponse(Guid Id, string Name);
public record LocationSummaryResponse(Guid Id, string Name, int ChapterCount);
public record CreateLocationRequest(string Name);
public class CreateLocationRequestValidator : IModelValidator<CreateLocationRequest>
{
public ValidationResult Validate(CreateLocationRequest model)
{
var result = new ValidationResult();
result.AddRequiredTextErrors("Name", "Name", model.Name, 120);
return result;
}
}
public record UpdateLocationRequest(string? Name = null);
public class UpdateLocationRequestValidator : IModelValidator<UpdateLocationRequest>
{
public ValidationResult Validate(UpdateLocationRequest model)
{
var result = new ValidationResult();
result.AddUnclearableTextErrors("Name", "Name", model.Name, "a location", 120);
return result;
}
}
public record LocationReferencesResponse(LocationResponse Location, IReadOnlyList<LocatedChapterResponse> Chapters);
public record LocatedChapterResponse(Guid Id, int Number, string Title, string? Summary);
public static class LocationMapping
{
public static LocationResponse ToResponse(this Location l) => new(l.Id, l.Name);
public static LocationReferencesResponse ToReferencesResponse(this Location location) => new(
location.ToResponse(),
[.. location.Chapters
.OrderBy(c => c.Number)
.Select(c => new LocatedChapterResponse(c.Id, c.Number, c.Title, c.Summary))]);
public static string Normalise(string name) => name.Trim();
}
@@ -0,0 +1,51 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Locations;
public static class LocationEndpoints
{
public static IEndpointRouteBuilder MapLocationEndpoints(this IEndpointRouteBuilder app)
{
var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/locations").WithTags("Locations")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
novelScoped.MapGet("/", async (Guid novelId, LocationService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(novelId, ct)))
.WithSummary("List a novel's locations with usage counts.");
novelScoped.MapPost("/", async (
Guid novelId, CreateLocationRequest request, LocationService service, CancellationToken ct) =>
{
var location = await service.CreateAsync(novelId, request, ct);
if (location is null)
{
return Results.NotFound();
}
var created = location.ToResponse();
return Results.Created($"/api/locations/{created.Id}", created);
})
.WithSummary("Create a location. Locations are also created on demand when applied by name.");
var locations = app.MapGroup("/api/locations").WithTags("Locations")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
locations.MapGet("/{id:guid}/references", async (Guid id, LocationService service, CancellationToken ct) =>
(await service.GetReferencesAsync(id, ct))?.ToReferencesResponse().ToApiResult())
.WithSummary("Cross-reference: every chapter set at this location.");
locations.MapPatch("/{id:guid}", async (
Guid id, UpdateLocationRequest request, LocationService service, CancellationToken ct) =>
(await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult())
.WithSummary("Rename a location.");
locations.MapDelete("/{id:guid}", async (Guid id, LocationService service, CancellationToken ct) =>
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a location. Whatever carried it is left alone.");
return app;
}
}
@@ -0,0 +1,184 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Users;
namespace Novelly.Api.Locations;
public class LocationService(
INovelDbContext db,
NovelAccessService access,
ILogger<LocationService> logger,
IModelValidator<CreateLocationRequest> createValidator,
IModelValidator<UpdateLocationRequest> updateValidator)
{
public async Task<IReadOnlyList<LocationSummaryResponse>> ListAsync(Guid novelId, CancellationToken ct = default)
{
Guard.Default(novelId, nameof(novelId));
logger.LogInformation("Listing locations for novel {NovelId}", novelId);
await access.RequireAsync(novelId, NovelPermission.Read, ct);
return await db.Locations
.Where(l => l.NovelId == novelId)
.OrderBy(l => l.Name)
.Select(l => new LocationSummaryResponse(l.Id, l.Name, l.Chapters.Count))
.ToListAsync(ct);
}
public async Task<Location?> GetReferencesAsync(Guid locationId, CancellationToken ct = default)
{
Guard.Default(locationId, nameof(locationId));
logger.LogInformation("Getting references for location {LocationId}", locationId);
var location = await db.Locations
.Include(l => l.Chapters)
.FirstOrDefaultAsync(l => l.Id == locationId, ct);
if (location is null)
{
logger.LogWarning("Location {LocationId} not found", locationId);
return location;
}
await access.RequireAsync(location.NovelId, NovelPermission.Read, ct);
return location;
}
public async Task<Location?> CreateAsync(Guid novelId, CreateLocationRequest request, CancellationToken ct = default)
{
Guard.Default(novelId, nameof(novelId));
Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Creating location {Name} for novel {NovelId}", request.Name, novelId);
if (!await db.Novels.AnyAsync(p => p.Id == novelId, ct))
{
logger.LogWarning("Rejected location creation: novel {NovelId} not found", novelId);
return null;
}
await access.RequireAsync(novelId, NovelPermission.CreateContent, ct);
var name = LocationMapping.Normalise(request.Name);
var existing = await FindByNameAsync(novelId, name, ct);
if (existing is not null)
{
logger.LogWarning("Rejected location creation for novel {NovelId}: '{Name}' already exists", novelId, existing.Name);
throw new InvalidOperationException($"The novel already has a location called '{existing.Name}'.");
}
var location = new Location { NovelId = novelId, Name = name };
db.Locations.Add(location);
await db.SaveChangesAsync(ct);
return location;
}
public async Task<Location?> UpdateAsync(Guid locationId, UpdateLocationRequest request, CancellationToken ct = default)
{
Guard.Default(locationId, nameof(locationId));
Guard.Null(request, nameof(request));
updateValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Updating location {LocationId}", locationId);
var location = await db.Locations.FirstOrDefaultAsync(l => l.Id == locationId, ct);
if (location is null)
{
logger.LogWarning("Location {LocationId} not found", locationId);
return null;
}
await access.RequireAsync(location.NovelId, NovelPermission.Write, ct);
if (request.Name is not null)
{
var name = LocationMapping.Normalise(request.Name);
var clash = await FindByNameAsync(location.NovelId, name, ct);
if (clash is not null && clash.Id != location.Id)
{
logger.LogWarning("Rejected update for location {LocationId}: '{Name}' already exists as {ClashLocationId}", locationId, clash.Name, clash.Id);
throw new InvalidOperationException($"The novel already has a location called '{clash.Name}'.");
}
location.Name = name;
}
await db.SaveChangesAsync(ct);
return location;
}
public async Task<bool> DeleteAsync(Guid locationId, CancellationToken ct = default)
{
Guard.Default(locationId, nameof(locationId));
logger.LogInformation("Deleting location {LocationId}", locationId);
var location = await db.Locations.FirstOrDefaultAsync(l => l.Id == locationId, ct);
if (location is null)
{
logger.LogWarning("Location {LocationId} not found", locationId);
return false;
}
await access.RequireAsync(location.NovelId, NovelPermission.DeleteContent, ct);
db.Locations.Remove(location);
await db.SaveChangesAsync(ct);
return true;
}
internal async Task<List<Location>> ResolveAsync(
Guid novelId, IReadOnlyList<string> names, CancellationToken ct)
{
Guard.Default(novelId, nameof(novelId));
Guard.Null(names, nameof(names));
logger.LogDebug("Resolving {Count} location names for novel {NovelId}", names.Count, novelId);
var wanted = names
.Select(LocationMapping.Normalise)
.Where(n => !string.IsNullOrWhiteSpace(n))
.DistinctBy(n => n.ToLowerInvariant())
.ToList();
if (wanted.Count == 0)
{
logger.LogDebug("No usable location names for novel {NovelId}", novelId);
return [];
}
var existing = await db.Locations
.Where(l => l.NovelId == novelId)
.ToListAsync(ct);
var resolved = new List<Location>();
foreach (var name in wanted)
{
var match = existing.FirstOrDefault(
l => string.Equals(l.Name, name, StringComparison.OrdinalIgnoreCase));
if (match is null)
{
match = new Location { NovelId = novelId, Name = name };
db.Locations.Add(match);
existing.Add(match);
}
resolved.Add(match);
}
logger.LogDebug("Resolved {Count} locations for novel {NovelId}", resolved.Count, novelId);
return resolved;
}
private async Task<Location?> FindByNameAsync(Guid novelId, string name, CancellationToken ct) =>
await db.Locations.FirstOrDefaultAsync(
l => l.NovelId == novelId && EF.Functions.Like(l.Name, name), ct);
}
+2
View File
@@ -10,6 +10,7 @@ using Novelly.Api.Common;
using Novelly.Api.Data;
using Novelly.Api.Genres;
using Novelly.Api.Imports;
using Novelly.Api.Locations;
using Novelly.Api.Novels;
using Novelly.Api.Questions;
using Novelly.Api.Tags;
@@ -95,6 +96,7 @@ app.MapNovelEndpoints()
.MapChapterEndpoints()
.MapBeatEndpoints()
.MapTagEndpoints()
.MapLocationEndpoints()
.MapGenreEndpoints()
.MapOpenQuestionEndpoints()
.MapAgentEndpoints()