using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Novelly.Api.Activity; using Novelly.Api.Common; using Novelly.Api.Data; using Novelly.Api.Users; namespace Novelly.Api.Trash; public class TrashService( INovelDbContext db, NovelAccessService access, ActivityLog activity, IOptions options, ILogger logger) { public async Task> ListAsync(Guid novelId, CancellationToken ct = default) { Guard.Default(novelId, nameof(novelId)); logger.LogInformation("Listing trash for novel {NovelId}", novelId); await access.RequireAsync(novelId, NovelPermission.Read, ct); var retentionDays = options.Value.RetentionDays; var characters = await db.Characters .IgnoreQueryFilters() .Where(c => c.NovelId == novelId && c.DeletedAt != null) .Select(c => new TrashedItemResponse( c.Id, TrashEntityKind.Character, c.Name, c.ArcStages.Count > 0 ? $"{c.ArcStages.Count} arc stages" : null, c.DeletedAt!.Value, c.DeletedAt!.Value.AddDays(retentionDays))) .ToListAsync(ct); var chapters = await db.Chapters .IgnoreQueryFilters() .Where(c => c.NovelId == novelId && c.DeletedAt != null) .Select(c => new TrashedItemResponse( c.Id, TrashEntityKind.Chapter, c.Title, c.Beats.Count > 0 ? $"{c.Beats.Count} beats" : null, c.DeletedAt!.Value, c.DeletedAt!.Value.AddDays(retentionDays))) .ToListAsync(ct); var locations = await db.Locations .IgnoreQueryFilters() .Where(l => l.NovelId == novelId && l.DeletedAt != null) .Select(l => new TrashedItemResponse( l.Id, TrashEntityKind.Location, l.Name, l.Chapters.Count > 0 ? $"used by {l.Chapters.Count} chapters" : null, l.DeletedAt!.Value, l.DeletedAt!.Value.AddDays(retentionDays))) .ToListAsync(ct); return [.. characters.Concat(chapters).Concat(locations).OrderByDescending(i => i.DeletedAt)]; } public async Task RestoreAsync(TrashEntityKind kind, Guid id, CancellationToken ct = default) { Guard.Default(id, nameof(id)); logger.LogInformation("Restoring {Kind} {ItemId} from trash", kind, id); return kind switch { TrashEntityKind.Character => await RestoreCharacterAsync(id, ct), TrashEntityKind.Chapter => await RestoreChapterAsync(id, ct), TrashEntityKind.Location => await RestoreLocationAsync(id, ct), _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null) }; } public async Task PurgeAsync(TrashEntityKind kind, Guid id, CancellationToken ct = default) { Guard.Default(id, nameof(id)); logger.LogInformation("Purging {Kind} {ItemId} from trash", kind, id); var novelId = await NovelIdOfTrashedAsync(kind, id, ct); if (novelId is null) { return false; } await access.RequireAsync(novelId.Value, NovelPermission.DeleteContent, ct); var removed = await TrashPurge.RemoveAsync(db, kind, id, ct); if (!removed) { return false; } await db.SaveChangesAsync(ct); return true; } public async Task EmptyAsync(Guid novelId, CancellationToken ct = default) { Guard.Default(novelId, nameof(novelId)); logger.LogInformation("Emptying trash for novel {NovelId}", novelId); await access.RequireAsync(novelId, NovelPermission.DeleteContent, ct); var items = await ListAsync(novelId, ct); var purged = 0; foreach (var item in items) { if (await TrashPurge.RemoveAsync(db, item.Kind, item.Id, ct)) { purged++; } } await db.SaveChangesAsync(ct); logger.LogInformation("Emptied {Count} items from trash for novel {NovelId}", purged, novelId); return purged; } private async Task RestoreCharacterAsync(Guid id, CancellationToken ct) { var character = await db.Characters.IgnoreQueryFilters().FirstOrDefaultAsync(c => c.Id == id && c.DeletedAt != null, ct); if (character is null) { logger.LogWarning("Trashed character {CharacterId} not found", id); return false; } await access.RequireAsync(character.NovelId, NovelPermission.DeleteContent, ct); character.DeletedAt = null; activity.Record(character.NovelId, ActivityEntityKind.Character, ActivityAction.Restored, character.Id); await db.SaveChangesAsync(ct); return true; } private async Task RestoreChapterAsync(Guid id, CancellationToken ct) { var chapter = await db.Chapters.IgnoreQueryFilters().FirstOrDefaultAsync(c => c.Id == id && c.DeletedAt != null, ct); if (chapter is null) { logger.LogWarning("Trashed chapter {ChapterId} not found", id); return false; } await access.RequireAsync(chapter.NovelId, NovelPermission.DeleteContent, ct); chapter.DeletedAt = null; activity.Record(chapter.NovelId, ActivityEntityKind.Chapter, ActivityAction.Restored, chapter.Id, chapter.WordCount); await db.SaveChangesAsync(ct); return true; } private async Task RestoreLocationAsync(Guid id, CancellationToken ct) { var location = await db.Locations.IgnoreQueryFilters().FirstOrDefaultAsync(l => l.Id == id && l.DeletedAt != null, ct); if (location is null) { logger.LogWarning("Trashed location {LocationId} not found", id); return false; } await access.RequireAsync(location.NovelId, NovelPermission.DeleteContent, ct); var clash = await db.Locations.FirstOrDefaultAsync( l => l.NovelId == location.NovelId && EF.Functions.Like(l.Name, location.Name), ct); if (clash is not null) { logger.LogWarning("Rejected restore of location {LocationId}: '{Name}' already exists as {ClashLocationId}", id, location.Name, clash.Id); throw new InvalidOperationException($"The novel already has a location called '{clash.Name}'."); } location.DeletedAt = null; activity.Record(location.NovelId, ActivityEntityKind.Location, ActivityAction.Restored, location.Id); await db.SaveChangesAsync(ct); return true; } private async Task NovelIdOfTrashedAsync(TrashEntityKind kind, Guid id, CancellationToken ct) => kind switch { TrashEntityKind.Character => (await db.Characters.IgnoreQueryFilters().Where(c => c.Id == id && c.DeletedAt != null).Select(c => (Guid?)c.NovelId).FirstOrDefaultAsync(ct)), TrashEntityKind.Chapter => (await db.Chapters.IgnoreQueryFilters().Where(c => c.Id == id && c.DeletedAt != null).Select(c => (Guid?)c.NovelId).FirstOrDefaultAsync(ct)), TrashEntityKind.Location => (await db.Locations.IgnoreQueryFilters().Where(l => l.Id == id && l.DeletedAt != null).Select(l => (Guid?)l.NovelId).FirstOrDefaultAsync(ct)), _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null) }; }