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.
185 lines
6.5 KiB
C#
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);
|
|
}
|