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 logger, IModelValidator createValidator, IModelValidator updateValidator) { public async Task> 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 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 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 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 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> ResolveAsync( Guid novelId, IReadOnlyList 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(); 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 FindByNameAsync(Guid novelId, string name, CancellationToken ct) => await db.Locations.FirstOrDefaultAsync( l => l.NovelId == novelId && EF.Functions.Like(l.Name, name), ct); }