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