[FA-24] Reading lists
This commit is contained in:
@@ -106,4 +106,393 @@ public class Mutation
|
||||
|
||||
return new BookmarkPayload { Success = true };
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[Error<InvalidOperationException>]
|
||||
public async Task<ReadingListPayload> 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<InvalidOperationException>]
|
||||
public async Task<ReadingListPayload> 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<InvalidOperationException>]
|
||||
public async Task<DeleteReadingListPayload> 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<InvalidOperationException>]
|
||||
public async Task<ReadingListPayload> 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<InvalidOperationException>]
|
||||
public async Task<ReadingListPayload> 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<InvalidOperationException>]
|
||||
public async Task<ReadingListPayload> 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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Security.Claims;
|
||||
using FictionArchive.Service.UserNovelDataService.Models.Database;
|
||||
using FictionArchive.Service.UserNovelDataService.Models.DTOs;
|
||||
using FictionArchive.Service.UserNovelDataService.Services;
|
||||
using HotChocolate.Authorization;
|
||||
@@ -42,4 +43,94 @@ public class Query
|
||||
CreatedTime = b.CreatedTime
|
||||
});
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
public async Task<IEnumerable<ReadingListDto>> GetReadingLists(
|
||||
UserNovelDataServiceDbContext dbContext,
|
||||
ClaimsPrincipal claimsPrincipal)
|
||||
{
|
||||
var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (string.IsNullOrEmpty(oAuthProviderId))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var user = await dbContext.Users
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var lists = await dbContext.ReadingLists
|
||||
.AsNoTracking()
|
||||
.Include(r => r.Items)
|
||||
.Where(r => r.UserId == user.Id)
|
||||
.OrderByDescending(r => r.LastUpdatedTime)
|
||||
.ToListAsync();
|
||||
|
||||
return lists.Select(r => new ReadingListDto
|
||||
{
|
||||
Id = r.Id,
|
||||
Name = r.Name,
|
||||
Description = r.Description,
|
||||
ItemCount = r.Items.Count,
|
||||
Items = r.Items.Select(i => new ReadingListItemDto
|
||||
{
|
||||
NovelId = i.NovelId,
|
||||
Order = i.Order,
|
||||
AddedTime = i.CreatedTime
|
||||
}),
|
||||
CreatedTime = r.CreatedTime
|
||||
});
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
public async Task<ReadingListDto?> GetReadingList(
|
||||
UserNovelDataServiceDbContext dbContext,
|
||||
ClaimsPrincipal claimsPrincipal,
|
||||
int id)
|
||||
{
|
||||
var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (string.IsNullOrEmpty(oAuthProviderId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var user = await dbContext.Users
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var readingList = await dbContext.ReadingLists
|
||||
.AsNoTracking()
|
||||
.Include(r => r.Items.OrderBy(i => i.Order))
|
||||
.FirstOrDefaultAsync(r => r.Id == id && r.UserId == user.Id);
|
||||
|
||||
if (readingList == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ReadingListDto
|
||||
{
|
||||
Id = readingList.Id,
|
||||
Name = readingList.Name,
|
||||
Description = readingList.Description,
|
||||
ItemCount = readingList.Items.Count,
|
||||
Items = readingList.Items.Select(i => new ReadingListItemDto
|
||||
{
|
||||
NovelId = i.NovelId,
|
||||
Order = i.Order,
|
||||
AddedTime = i.CreatedTime
|
||||
}),
|
||||
CreatedTime = readingList.CreatedTime
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
289
FictionArchive.Service.UserNovelDataService/Migrations/20260120014840_AddReadingLists.Designer.cs
generated
Normal file
289
FictionArchive.Service.UserNovelDataService/Migrations/20260120014840_AddReadingLists.Designer.cs
generated
Normal file
@@ -0,0 +1,289 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using FictionArchive.Service.UserNovelDataService.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NodaTime;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FictionArchive.Service.UserNovelDataService.Migrations
|
||||
{
|
||||
[DbContext(typeof(UserNovelDataServiceDbContext))]
|
||||
[Migration("20260120014840_AddReadingLists")]
|
||||
partial class AddReadingLists
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Bookmark", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<long>("ChapterId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Instant>("CreatedTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Instant>("LastUpdatedTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("NovelId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "ChapterId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId", "NovelId");
|
||||
|
||||
b.ToTable("Bookmarks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Chapter", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<Instant>("CreatedTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Instant>("LastUpdatedTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("VolumeId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("VolumeId");
|
||||
|
||||
b.ToTable("Chapters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<Instant>("CreatedTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Instant>("LastUpdatedTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Novels");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<Instant>("CreatedTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Instant>("LastUpdatedTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("ReadingLists");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingListItem", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<Instant>("CreatedTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Instant>("LastUpdatedTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("NovelId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("Order")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ReadingListId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReadingListId", "NovelId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("ReadingListId", "Order");
|
||||
|
||||
b.ToTable("ReadingListItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Instant>("CreatedTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Instant>("LastUpdatedTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("OAuthProviderId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<Instant>("CreatedTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Instant>("LastUpdatedTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("NovelId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NovelId");
|
||||
|
||||
b.ToTable("Volumes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Bookmark", b =>
|
||||
{
|
||||
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Chapter", b =>
|
||||
{
|
||||
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", "Volume")
|
||||
.WithMany("Chapters")
|
||||
.HasForeignKey("VolumeId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Volume");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", b =>
|
||||
{
|
||||
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingListItem", b =>
|
||||
{
|
||||
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", "ReadingList")
|
||||
.WithMany("Items")
|
||||
.HasForeignKey("ReadingListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("ReadingList");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b =>
|
||||
{
|
||||
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", "Novel")
|
||||
.WithMany("Volumes")
|
||||
.HasForeignKey("NovelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Novel");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", b =>
|
||||
{
|
||||
b.Navigation("Volumes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", b =>
|
||||
{
|
||||
b.Navigation("Items");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b =>
|
||||
{
|
||||
b.Navigation("Chapters");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NodaTime;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FictionArchive.Service.UserNovelDataService.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddReadingLists : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ReadingLists",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Description = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ReadingLists", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ReadingLists_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ReadingListItems",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
ReadingListId = table.Column<int>(type: "integer", nullable: false),
|
||||
NovelId = table.Column<long>(type: "bigint", nullable: false),
|
||||
Order = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ReadingListItems", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ReadingListItems_ReadingLists_ReadingListId",
|
||||
column: x => x.ReadingListId,
|
||||
principalTable: "ReadingLists",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ReadingListItems_ReadingListId_NovelId",
|
||||
table: "ReadingListItems",
|
||||
columns: new[] { "ReadingListId", "NovelId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ReadingListItems_ReadingListId_Order",
|
||||
table: "ReadingListItems",
|
||||
columns: new[] { "ReadingListId", "Order" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ReadingLists_UserId",
|
||||
table: "ReadingLists",
|
||||
column: "UserId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ReadingListItems");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ReadingLists");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,70 @@ namespace FictionArchive.Service.UserNovelDataService.Migrations
|
||||
b.ToTable("Novels");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<Instant>("CreatedTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Instant>("LastUpdatedTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("ReadingLists");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingListItem", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<Instant>("CreatedTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Instant>("LastUpdatedTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("NovelId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("Order")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ReadingListId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReadingListId", "NovelId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("ReadingListId", "Order");
|
||||
|
||||
b.ToTable("ReadingListItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -169,6 +233,28 @@ namespace FictionArchive.Service.UserNovelDataService.Migrations
|
||||
b.Navigation("Volume");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", b =>
|
||||
{
|
||||
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingListItem", b =>
|
||||
{
|
||||
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", "ReadingList")
|
||||
.WithMany("Items")
|
||||
.HasForeignKey("ReadingListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("ReadingList");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b =>
|
||||
{
|
||||
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", "Novel")
|
||||
@@ -185,6 +271,11 @@ namespace FictionArchive.Service.UserNovelDataService.Migrations
|
||||
b.Navigation("Volumes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", b =>
|
||||
{
|
||||
b.Navigation("Items");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b =>
|
||||
{
|
||||
b.Navigation("Chapters");
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace FictionArchive.Service.UserNovelDataService.Models.DTOs;
|
||||
|
||||
public record AddToReadingListInput(int ReadingListId, uint NovelId);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace FictionArchive.Service.UserNovelDataService.Models.DTOs;
|
||||
|
||||
public record CreateReadingListInput(string Name, string? Description);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace FictionArchive.Service.UserNovelDataService.Models.DTOs;
|
||||
|
||||
public class DeleteReadingListPayload
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using NodaTime;
|
||||
|
||||
namespace FictionArchive.Service.UserNovelDataService.Models.DTOs;
|
||||
|
||||
public class ReadingListDto
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public IEnumerable<ReadingListItemDto> Items { get; init; } = [];
|
||||
public int ItemCount { get; init; }
|
||||
public Instant CreatedTime { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using NodaTime;
|
||||
|
||||
namespace FictionArchive.Service.UserNovelDataService.Models.DTOs;
|
||||
|
||||
public class ReadingListItemDto
|
||||
{
|
||||
public uint NovelId { get; init; }
|
||||
public int Order { get; init; }
|
||||
public Instant AddedTime { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace FictionArchive.Service.UserNovelDataService.Models.DTOs;
|
||||
|
||||
public class ReadingListPayload
|
||||
{
|
||||
public ReadingListDto? ReadingList { get; init; }
|
||||
public bool Success { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace FictionArchive.Service.UserNovelDataService.Models.DTOs;
|
||||
|
||||
public record ReorderReadingListItemInput(int ReadingListId, uint NovelId, int NewOrder);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace FictionArchive.Service.UserNovelDataService.Models.DTOs;
|
||||
|
||||
public record UpdateReadingListInput(int Id, string Name, string? Description);
|
||||
@@ -0,0 +1,14 @@
|
||||
using FictionArchive.Service.Shared.Models;
|
||||
|
||||
namespace FictionArchive.Service.UserNovelDataService.Models.Database;
|
||||
|
||||
public class ReadingList : BaseEntity<int>
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public virtual User User { get; set; } = null!;
|
||||
|
||||
public required string Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
|
||||
public virtual ICollection<ReadingListItem> Items { get; set; } = new List<ReadingListItem>();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using FictionArchive.Service.Shared.Models;
|
||||
|
||||
namespace FictionArchive.Service.UserNovelDataService.Models.Database;
|
||||
|
||||
public class ReadingListItem : BaseEntity<int>
|
||||
{
|
||||
public int ReadingListId { get; set; }
|
||||
public virtual ReadingList ReadingList { get; set; } = null!;
|
||||
|
||||
public uint NovelId { get; set; }
|
||||
public int Order { get; set; }
|
||||
}
|
||||
@@ -11,6 +11,8 @@ public class UserNovelDataServiceDbContext : FictionArchiveDbContext
|
||||
public DbSet<Novel> Novels { get; set; }
|
||||
public DbSet<Volume> Volumes { get; set; }
|
||||
public DbSet<Chapter> Chapters { get; set; }
|
||||
public DbSet<ReadingList> ReadingLists { get; set; }
|
||||
public DbSet<ReadingListItem> ReadingListItems { get; set; }
|
||||
|
||||
public UserNovelDataServiceDbContext(DbContextOptions options, ILogger<UserNovelDataServiceDbContext> logger) : base(options, logger)
|
||||
{
|
||||
@@ -34,5 +36,32 @@ public class UserNovelDataServiceDbContext : FictionArchiveDbContext
|
||||
.HasForeignKey(b => b.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ReadingList>(entity =>
|
||||
{
|
||||
// Index for fetching user's lists
|
||||
entity.HasIndex(r => r.UserId);
|
||||
|
||||
// User relationship with cascade delete
|
||||
entity.HasOne(r => r.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(r => r.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ReadingListItem>(entity =>
|
||||
{
|
||||
// Unique constraint: one entry per novel per list
|
||||
entity.HasIndex(i => new { i.ReadingListId, i.NovelId }).IsUnique();
|
||||
|
||||
// Index for efficient ordered retrieval
|
||||
entity.HasIndex(i => new { i.ReadingListId, i.Order });
|
||||
|
||||
// ReadingList relationship with cascade delete
|
||||
entity.HasOne(i => i.ReadingList)
|
||||
.WithMany(r => r.Items)
|
||||
.HasForeignKey(i => i.ReadingListId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user