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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user