Files
novelly/src/Novelly.Api/Locations/LocationService.cs
T
James Wampler c620ddd626 Replace chapter setting with multi-select locations; add chapter character summary
Chapters now carry many Locations (new Tags-style entity with cross-referencing)
instead of a single free-text Setting field, with a Locations tab on the novel
for browsing them and seeing every chapter set at each one. Also surfaces the
distinct characters appearing in a chapter's beats, linked, under the beat/word
count on the outline tab.
2026-08-18 11:36:13 -07:00

185 lines
6.5 KiB
C#

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);
}