using Microsoft.EntityFrameworkCore; using Novelly.Api.Common; using Novelly.Api.Common.Validation; using Novelly.Api.Data; namespace Novelly.Api.Characters; /// /// A character's arc: a flat, ordered list of the changes they go through. Same shape as /// a chapter's beats, and for the same reason — an arc is a sequence, not a tree. /// /// /// Arcs are only really worth keeping for main characters, but nothing here refuses one /// on a supporting character. Demoting someone should not delete work, and a character /// who turns out to matter gets promoted after the arc is already sketched. /// public class CharacterArcService( INovelDbContext db, ILogger logger, IModelValidator createValidator, IModelValidator updateValidator, IModelValidator reorderValidator) { public async Task> ListAsync(Guid characterId, CancellationToken ct = default) { Guard.Default(characterId, nameof(characterId)); logger.LogInformation("Listing arc stages for character {CharacterId}", characterId); var stages = await Query() .Where(s => s.CharacterId == characterId) .OrderBy(s => s.SortOrder) .ToListAsync(ct); return [.. stages.Select(s => s.ToDto())]; } /// Null when no arc stage has this id — a lookup miss is expected, not exceptional. public async Task GetAsync(Guid id, CancellationToken ct = default) { Guard.Default(id, nameof(id)); logger.LogInformation("Getting arc stage {ArcStageId}", id); return (await FindAsync(id, ct))?.ToDto(); } public async Task CreateAsync( Guid characterId, CreateArcStageRequest request, CancellationToken ct = default) { Guard.Default(characterId, nameof(characterId)); Guard.Null(request, nameof(request)); createValidator.Validate(request).ThrowIfInvalid(); logger.LogInformation("Creating arc stage {Title} for character {CharacterId}", request.Title, characterId); var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == characterId, ct); if (character is null) { logger.LogWarning("Rejected arc stage creation: character {CharacterId} not found", characterId); throw new NotFoundException(nameof(Character), characterId); } await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct); var stage = new CharacterArcStage { CharacterId = characterId, Title = request.Title, SortOrder = request.SortOrder ?? await NextSortOrderAsync(characterId, ct), Description = request.Description, ChapterId = request.ChapterId }; db.CharacterArcStages.Add(stage); await db.SaveChangesAsync(ct); // Just created it — the reload is only to pick up includes, not to check existence. return (await FindAsync(stage.Id, ct))!.ToDto(); } public async Task UpdateAsync( Guid id, UpdateArcStageRequest request, CancellationToken ct = default) { Guard.Default(id, nameof(id)); Guard.Null(request, nameof(request)); updateValidator.Validate(request).ThrowIfInvalid(); logger.LogInformation("Updating arc stage {ArcStageId}", id); var stage = await FindAsync(id, ct); if (stage is null) { return null; } var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == stage.CharacterId, ct); if (character is null) { // The stage's own character should always exist via the FK — this is an // invariant failing, not a caller mistake, so it stays exceptional. logger.LogError("Arc stage {ArcStageId} references character {CharacterId} which does not exist", id, stage.CharacterId); throw new NotFoundException(nameof(Character), stage.CharacterId); } await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct); stage.Title = Patch.Apply(stage.Title, request.Title) ?? stage.Title; stage.SortOrder = request.SortOrder ?? stage.SortOrder; stage.Description = Patch.Apply(stage.Description, request.Description); stage.ChapterId = request.ChapterId ?? stage.ChapterId; stage.UpdatedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(ct); return (await FindAsync(id, ct))!.ToDto(); } /// True if an arc stage was deleted; false if no stage had this id. public async Task DeleteAsync(Guid id, CancellationToken ct = default) { Guard.Default(id, nameof(id)); logger.LogInformation("Deleting arc stage {ArcStageId}", id); var stage = await FindAsync(id, ct); if (stage is null) { return false; } db.CharacterArcStages.Remove(stage); await db.SaveChangesAsync(ct); return true; } /// /// Renumbers a character's arc to match the order given. Stages left out keep their /// relative position after the ones listed, exactly as beat reordering works. /// public async Task> ReorderAsync( Guid characterId, ReorderArcStagesRequest request, CancellationToken ct = default) { Guard.Default(characterId, nameof(characterId)); Guard.Null(request, nameof(request)); reorderValidator.Validate(request).ThrowIfInvalid(); logger.LogInformation("Reordering {Count} arc stages for character {CharacterId}", request.StageIds.Count, characterId); var stages = await db.CharacterArcStages .Where(s => s.CharacterId == characterId) .ToListAsync(ct); var missing = request.StageIds.Where(id => stages.All(s => s.Id != id)).ToList(); if (missing.Count > 0) { logger.LogWarning("Reorder for character {CharacterId} referenced missing arc stage {ArcStageId}", characterId, missing[0]); throw new NotFoundException(nameof(CharacterArcStage), missing[0]); } var order = 1; foreach (var id in request.StageIds) { stages.Single(s => s.Id == id).SortOrder = order++; } foreach (var stage in stages.Where(s => !request.StageIds.Contains(s.Id)).OrderBy(s => s.SortOrder)) { stage.SortOrder = order++; } await db.SaveChangesAsync(ct); return await ListAsync(characterId, ct); } private async Task EnsureChapterIsInSameProjectAsync( Character character, Guid? chapterId, CancellationToken ct) { if (chapterId is not { } id) { return; } logger.LogDebug("Checking chapter {ChapterId} belongs to project {ProjectId}", id, character.ProjectId); var belongs = await db.Chapters.AnyAsync(c => c.Id == id && c.ProjectId == character.ProjectId, ct); if (!belongs) { logger.LogWarning("Rejected arc stage: chapter {ChapterId} does not belong to project {ProjectId}", id, character.ProjectId); throw new InvalidOperationException( "An arc stage can only point at a chapter in the same project as its character."); } } private async Task NextSortOrderAsync(Guid characterId, CancellationToken ct) { logger.LogDebug("Computing next sort order for character {CharacterId}", characterId); var max = await db.CharacterArcStages .Where(s => s.CharacterId == characterId) .MaxAsync(s => (int?)s.SortOrder, ct); return (max ?? 0) + 1; } private IQueryable Query() => db.CharacterArcStages.Include(s => s.Chapter); private async Task FindAsync(Guid id, CancellationToken ct) { logger.LogDebug("Finding arc stage {ArcStageId}", id); var stage = await Query().FirstOrDefaultAsync(s => s.Id == id, ct); if (stage is null) { logger.LogInformation("CharacterArcStage {ArcStageId} not found", id); } else { logger.LogDebug("Found arc stage {ArcStageId}", id); } return stage; } }