using System.Security.Claims; using FictionArchive.Service.UserNovelDataService.Models.Database; using FictionArchive.Service.UserNovelDataService.Models.DTOs; using FictionArchive.Service.UserNovelDataService.Services; using HotChocolate.Authorization; using HotChocolate.Types; using Microsoft.EntityFrameworkCore; namespace FictionArchive.Service.UserNovelDataService.GraphQL; public class Mutation { [Authorize] [Error] public async Task UpsertBookmark( UserNovelDataServiceDbContext dbContext, ClaimsPrincipal claimsPrincipal, UpsertBookmarkInput input) { var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (string.IsNullOrEmpty(oAuthProviderId)) { throw new InvalidOperationException("Unable to determine current user identity"); } var user = await dbContext.Users .FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId); if (user == null) { // Auto-create user if not exists user = new User { OAuthProviderId = oAuthProviderId }; dbContext.Users.Add(user); await dbContext.SaveChangesAsync(); } var existingBookmark = await dbContext.Bookmarks .FirstOrDefaultAsync(b => b.UserId == user.Id && b.ChapterId == input.ChapterId); if (existingBookmark != null) { // Update existing existingBookmark.Description = input.Description; } else { // Create new existingBookmark = new Bookmark { UserId = user.Id, NovelId = input.NovelId, ChapterId = input.ChapterId, Description = input.Description }; dbContext.Bookmarks.Add(existingBookmark); } await dbContext.SaveChangesAsync(); return new BookmarkPayload { Success = true, Bookmark = new BookmarkDto { Id = existingBookmark.Id, ChapterId = existingBookmark.ChapterId, NovelId = existingBookmark.NovelId, Description = existingBookmark.Description, CreatedTime = existingBookmark.CreatedTime } }; } [Authorize] [Error] public async Task RemoveBookmark( UserNovelDataServiceDbContext dbContext, ClaimsPrincipal claimsPrincipal, uint chapterId) { var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (string.IsNullOrEmpty(oAuthProviderId)) { throw new InvalidOperationException("Unable to determine current user identity"); } var user = await dbContext.Users .AsNoTracking() .FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId); if (user == null) { return new BookmarkPayload { Success = false }; } var bookmark = await dbContext.Bookmarks .FirstOrDefaultAsync(b => b.UserId == user.Id && b.ChapterId == chapterId); if (bookmark == null) { return new BookmarkPayload { Success = false }; } dbContext.Bookmarks.Remove(bookmark); await dbContext.SaveChangesAsync(); return new BookmarkPayload { Success = true }; } [Authorize] [Error] public async Task CreateReadingList( UserNovelDataServiceDbContext dbContext, ClaimsPrincipal claimsPrincipal, CreateReadingListInput input) { var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (string.IsNullOrEmpty(oAuthProviderId)) { throw new InvalidOperationException("Unable to determine current user identity"); } if (string.IsNullOrWhiteSpace(input.Name)) { throw new InvalidOperationException("Reading list name is required"); } var user = await dbContext.Users .FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId); if (user == null) { user = new User { OAuthProviderId = oAuthProviderId }; dbContext.Users.Add(user); await dbContext.SaveChangesAsync(); } var readingList = new ReadingList { UserId = user.Id, Name = input.Name.Trim(), Description = input.Description?.Trim() }; dbContext.ReadingLists.Add(readingList); await dbContext.SaveChangesAsync(); return new ReadingListPayload { Success = true, ReadingList = new ReadingListDto { Id = readingList.Id, Name = readingList.Name, Description = readingList.Description, Items = [], ItemCount = 0, CreatedTime = readingList.CreatedTime } }; } [Authorize] [Error] public async Task UpdateReadingList( UserNovelDataServiceDbContext dbContext, ClaimsPrincipal claimsPrincipal, UpdateReadingListInput input) { var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (string.IsNullOrEmpty(oAuthProviderId)) { throw new InvalidOperationException("Unable to determine current user identity"); } if (string.IsNullOrWhiteSpace(input.Name)) { throw new InvalidOperationException("Reading list name is required"); } var user = await dbContext.Users .AsNoTracking() .FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId); if (user == null) { return new ReadingListPayload { Success = false }; } var readingList = await dbContext.ReadingLists .Include(r => r.Items) .FirstOrDefaultAsync(r => r.Id == input.Id && r.UserId == user.Id); if (readingList == null) { return new ReadingListPayload { Success = false }; } readingList.Name = input.Name.Trim(); readingList.Description = input.Description?.Trim(); await dbContext.SaveChangesAsync(); return new ReadingListPayload { Success = true, ReadingList = new ReadingListDto { Id = readingList.Id, Name = readingList.Name, Description = readingList.Description, Items = readingList.Items.OrderBy(i => i.Order).Select(i => new ReadingListItemDto { NovelId = i.NovelId, Order = i.Order, AddedTime = i.CreatedTime }), ItemCount = readingList.Items.Count, CreatedTime = readingList.CreatedTime } }; } [Authorize] [Error] public async Task DeleteReadingList( UserNovelDataServiceDbContext dbContext, ClaimsPrincipal claimsPrincipal, int id) { var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (string.IsNullOrEmpty(oAuthProviderId)) { throw new InvalidOperationException("Unable to determine current user identity"); } var user = await dbContext.Users .AsNoTracking() .FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId); if (user == null) { return new DeleteReadingListPayload { Success = false }; } var readingList = await dbContext.ReadingLists .FirstOrDefaultAsync(r => r.Id == id && r.UserId == user.Id); if (readingList == null) { return new DeleteReadingListPayload { Success = false }; } dbContext.ReadingLists.Remove(readingList); await dbContext.SaveChangesAsync(); return new DeleteReadingListPayload { Success = true }; } [Authorize] [Error] public async Task AddToReadingList( UserNovelDataServiceDbContext dbContext, ClaimsPrincipal claimsPrincipal, AddToReadingListInput input) { var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (string.IsNullOrEmpty(oAuthProviderId)) { throw new InvalidOperationException("Unable to determine current user identity"); } var user = await dbContext.Users .AsNoTracking() .FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId); if (user == null) { return new ReadingListPayload { Success = false }; } var readingList = await dbContext.ReadingLists .Include(r => r.Items) .FirstOrDefaultAsync(r => r.Id == input.ReadingListId && r.UserId == user.Id); if (readingList == null) { return new ReadingListPayload { Success = false }; } // Idempotent: if already in list, return success var existingItem = readingList.Items.FirstOrDefault(i => i.NovelId == input.NovelId); if (existingItem != null) { return new ReadingListPayload { Success = true, ReadingList = new ReadingListDto { Id = readingList.Id, Name = readingList.Name, Description = readingList.Description, Items = readingList.Items.OrderBy(i => i.Order).Select(i => new ReadingListItemDto { NovelId = i.NovelId, Order = i.Order, AddedTime = i.CreatedTime }), ItemCount = readingList.Items.Count, CreatedTime = readingList.CreatedTime } }; } // Add at the end (highest order + 1) var maxOrder = readingList.Items.Any() ? readingList.Items.Max(i => i.Order) : -1; var newItem = new ReadingListItem { ReadingListId = readingList.Id, NovelId = input.NovelId, Order = maxOrder + 1 }; dbContext.ReadingListItems.Add(newItem); await dbContext.SaveChangesAsync(); // Reload to get updated items readingList = await dbContext.ReadingLists .AsNoTracking() .Include(r => r.Items.OrderBy(i => i.Order)) .FirstAsync(r => r.Id == input.ReadingListId); return new ReadingListPayload { Success = true, ReadingList = new ReadingListDto { Id = readingList.Id, Name = readingList.Name, Description = readingList.Description, Items = readingList.Items.Select(i => new ReadingListItemDto { NovelId = i.NovelId, Order = i.Order, AddedTime = i.CreatedTime }), ItemCount = readingList.Items.Count, CreatedTime = readingList.CreatedTime } }; } [Authorize] [Error] public async Task RemoveFromReadingList( UserNovelDataServiceDbContext dbContext, ClaimsPrincipal claimsPrincipal, int listId, uint novelId) { var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (string.IsNullOrEmpty(oAuthProviderId)) { throw new InvalidOperationException("Unable to determine current user identity"); } var user = await dbContext.Users .AsNoTracking() .FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId); if (user == null) { return new ReadingListPayload { Success = false }; } var readingList = await dbContext.ReadingLists .Include(r => r.Items) .FirstOrDefaultAsync(r => r.Id == listId && r.UserId == user.Id); if (readingList == null) { return new ReadingListPayload { Success = false }; } var item = readingList.Items.FirstOrDefault(i => i.NovelId == novelId); if (item != null) { dbContext.ReadingListItems.Remove(item); await dbContext.SaveChangesAsync(); } // Reload to get updated items readingList = await dbContext.ReadingLists .AsNoTracking() .Include(r => r.Items.OrderBy(i => i.Order)) .FirstAsync(r => r.Id == listId); return new ReadingListPayload { Success = true, ReadingList = new ReadingListDto { Id = readingList.Id, Name = readingList.Name, Description = readingList.Description, Items = readingList.Items.Select(i => new ReadingListItemDto { NovelId = i.NovelId, Order = i.Order, AddedTime = i.CreatedTime }), ItemCount = readingList.Items.Count, CreatedTime = readingList.CreatedTime } }; } [Authorize] [Error] public async Task ReorderReadingListItem( UserNovelDataServiceDbContext dbContext, ClaimsPrincipal claimsPrincipal, ReorderReadingListItemInput input) { var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (string.IsNullOrEmpty(oAuthProviderId)) { throw new InvalidOperationException("Unable to determine current user identity"); } var user = await dbContext.Users .AsNoTracking() .FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId); if (user == null) { return new ReadingListPayload { Success = false }; } var readingList = await dbContext.ReadingLists .Include(r => r.Items) .FirstOrDefaultAsync(r => r.Id == input.ReadingListId && r.UserId == user.Id); if (readingList == null) { return new ReadingListPayload { Success = false }; } var item = readingList.Items.FirstOrDefault(i => i.NovelId == input.NovelId); if (item == null) { throw new InvalidOperationException("Novel not found in reading list"); } var oldOrder = item.Order; var newOrder = input.NewOrder; // Shift other items if (newOrder < oldOrder) { // Moving up: shift items between newOrder and oldOrder down foreach (var i in readingList.Items.Where(x => x.Order >= newOrder && x.Order < oldOrder)) { i.Order++; } } else if (newOrder > oldOrder) { // Moving down: shift items between oldOrder and newOrder up foreach (var i in readingList.Items.Where(x => x.Order > oldOrder && x.Order <= newOrder)) { i.Order--; } } item.Order = newOrder; await dbContext.SaveChangesAsync(); return new ReadingListPayload { Success = true, ReadingList = new ReadingListDto { Id = readingList.Id, Name = readingList.Name, Description = readingList.Description, Items = readingList.Items.OrderBy(i => i.Order).Select(i => new ReadingListItemDto { NovelId = i.NovelId, Order = i.Order, AddedTime = i.CreatedTime }), ItemCount = readingList.Items.Count, CreatedTime = readingList.CreatedTime } }; } }