Add project files.

This commit is contained in:
2025-08-26 09:20:13 -04:00
parent 6c6a149821
commit d2201d6f9b
118 changed files with 1924 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
using JSMR.Application.Circles.Queries.GetCreators;
using Microsoft.EntityFrameworkCore;
namespace JSMR.Infrastructure.Data.Repositories.Circles;
public class CircleCreatorsProvider(AppDbContext context) : ICircleCreatorsProvider
{
public async Task<GetCircleCreatorsResponse> HandleAsync(GetCircleCreatorsRequest request, CancellationToken cancellationToken = default)
{
int? circleId = await CircleLookup.ResolveCircleIdAsync(context, request.CircleId, request.NameOrMakerId, cancellationToken);
if (circleId is null)
return new GetCircleCreatorsResponse([]);
IQueryable<CircleCreatorItem> query =
from voiceWork in context.VoiceWorks.AsNoTracking()
where voiceWork.CircleId == circleId.Value
join voiceWorkCreator in context.VoiceWorkCreators.AsNoTracking() on voiceWork.VoiceWorkId equals voiceWorkCreator.VoiceWorkId
join creator in context.Creators.AsNoTracking() on voiceWorkCreator.CreatorId equals creator.CreatorId
group new { creator.Name, voiceWork.SalesDate, voiceWork.Downloads } by creator.Name into g
select new CircleCreatorItem(
g.Key,
g.Min(x => x.SalesDate),
g.Max(x => x.SalesDate),
g.Sum(x => x.Downloads ?? 0),
g.Count());
CircleCreatorItem[] items = await query
.OrderByDescending(x => x.Count)
.ThenByDescending(x => x.Downloads)
.ToArrayAsync(cancellationToken);
return new GetCircleCreatorsResponse(items);
}
}

View File

@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
namespace JSMR.Infrastructure.Data.Repositories.Circles;
internal static class CircleLookup
{
public static async Task<int?> ResolveCircleIdAsync(AppDbContext context, int? circleId, string? nameOrMakerId, CancellationToken cancellationToken)
{
if (circleId.HasValue)
return circleId.Value;
if (!string.IsNullOrWhiteSpace(nameOrMakerId))
{
return await context.Circles
.Where(c => c.Name == nameOrMakerId || c.MakerId == nameOrMakerId)
.Select(c => (int?)c.CircleId)
.FirstOrDefaultAsync(cancellationToken);
}
return null;
}
}

View File

@@ -0,0 +1,136 @@
using JSMR.Application.Circles.Queries.Search;
using JSMR.Infrastructure.Common.Queries;
using Microsoft.EntityFrameworkCore;
using System.Linq.Expressions;
namespace JSMR.Infrastructure.Data.Repositories.Circles;
public class CircleSearchProvider(AppDbContext context) : SearchProvider<CircleSearchItem, CircleSearchCriteria, CircleSortField, CircleSearchItem>, ICircleSearchProvider
{
protected override IQueryable<CircleSearchItem> GetBaseQuery()
{
// Precompute LatestProductId per circle (by productId length, then value)
var latestPerCircle =
from vw in context.VoiceWorks.AsNoTracking()
group vw by vw.CircleId into g
let latest = g
.OrderByDescending(x => x.ProductId.Length)
.ThenByDescending(x => x.ProductId)
.Select(x => x.ProductId)
.FirstOrDefault()
select new { CircleId = g.Key, LatestProductId = latest };
// Aggregates per circle
var aggregates =
from vw in context.VoiceWorks.AsNoTracking()
group vw by vw.CircleId into g
select new
{
CircleId = g.Key,
Downloads = g.Sum(x => x.Downloads ?? 0),
Releases = g.Count(x => x.SalesDate != null),
Pending = g.Count(x => x.ExpectedDate != null),
FirstReleaseDate = g.Min(x => x.SalesDate),
LatestReleaseDate = g.Max(x => x.SalesDate)
};
// Join circles with aggregates and latest product id
var baseQuery =
from c in context.Circles.AsNoTracking()
join agg in aggregates on c.CircleId equals agg.CircleId into aggs
from a in aggs.DefaultIfEmpty()
join lp in latestPerCircle on c.CircleId equals lp.CircleId into lps
from l in lps.DefaultIfEmpty()
select new CircleSearchItem
{
CircleId = c.CircleId,
Name = c.Name,
MakerId = c.MakerId,
Favorite = c.Favorite,
Blacklisted = c.Blacklisted,
Spam = c.Spam,
Downloads = a != null ? a.Downloads : 0,
Releases = a != null ? a.Releases : 0,
Pending = a != null ? a.Pending : 0,
FirstReleaseDate = a != null ? a.FirstReleaseDate : null,
LatestReleaseDate = a != null ? a.LatestReleaseDate : null,
LatestProductId = l != null ? l.LatestProductId : null,
// these two get filled in during Select stage (below)
LatestVoiceWorkHasImage = null,
LatestVoiceWorkSalesDate = null
};
return baseQuery;
}
protected override IQueryable<CircleSearchItem> ApplyFilters(IQueryable<CircleSearchItem> query, CircleSearchCriteria criteria)
{
if (!string.IsNullOrWhiteSpace(criteria.Name))
{
var term = $"%{criteria.Name.Trim()}%";
query = query.Where(x =>
EF.Functions.Like(x.Name, term) ||
EF.Functions.Like(x.MakerId, term));
}
//if (criteria.Status is CircleStatus.NotBlacklisted)
// query = query.Where(x => !x.Blacklisted);
//else if (criteria.Status is CircleStatus.Favorited)
// query = query.Where(x => x.Favorite);
//else if (criteria.Status is CircleStatus.Blacklisted)
// query = query.Where(x => x.Blacklisted);
//else if (criteria.Status is CircleStatus.Spam)
// query = query.Where(x => x.Spam);
return query;
}
protected override Expression<Func<CircleSearchItem, object>> GetSortExpression(CircleSortField field) => field switch
{
//CircleSortField.MakerId => x => x.MakerId,
//CircleSortField.Downloads => x => x.Downloads,
//CircleSortField.Releases => x => x.Releases,
//CircleSortField.Pending => x => x.Pending,
//CircleSortField.FirstReleaseDate => x => x.FirstReleaseDate ?? DateTime.MinValue,
//CircleSortField.LatestReleaseDate => x => x.LatestReleaseDate ?? DateTime.MinValue,
CircleSortField.Favorite => x => x.Favorite,
CircleSortField.Blacklisted => x => x.Blacklisted,
CircleSortField.Spam => x => x.Spam,
_ => x => x.Name
};
protected override IOrderedQueryable<CircleSearchItem> GetDefaultSortExpression(IQueryable<CircleSearchItem> query)
=> query.OrderBy(x => x.Name).ThenBy(x => x.CircleId);
protected override IOrderedQueryable<CircleSearchItem> GetSelectQuery(IOrderedQueryable<CircleSearchItem> query)
{
// Join to VoiceWorks by LatestProductId to fill HasImage / SalesDate
var selected =
from item in query
join vw in context.VoiceWorks.AsNoTracking() on item.LatestProductId equals vw.ProductId into vws
from latest in vws.DefaultIfEmpty()
select new CircleSearchItem
{
CircleId = item.CircleId,
Name = item.Name,
MakerId = item.MakerId,
Favorite = item.Favorite,
Blacklisted = item.Blacklisted,
Spam = item.Spam,
Downloads = item.Downloads,
Releases = item.Releases,
Pending = item.Pending,
FirstReleaseDate = item.FirstReleaseDate,
LatestReleaseDate = item.LatestReleaseDate,
LatestProductId = item.LatestProductId,
LatestVoiceWorkHasImage = latest != null ? latest.HasImage : (bool?)null,
LatestVoiceWorkSalesDate = latest != null ? latest.SalesDate : null
};
// Preserve existing ordering; add stable tiebreaker
return selected.OrderBy(x => 0).ThenBy(x => x.Name).ThenBy(x => x.CircleId);
// NOTE: If your base class re-applies ordering after Select, you can just:
// return selected.OrderBy(x => x.Name).ThenBy(x => x.CircleId);
}
}

View File

@@ -0,0 +1,33 @@
using JSMR.Application.Circles.Queries.GetTags;
using Microsoft.EntityFrameworkCore;
namespace JSMR.Infrastructure.Data.Repositories.Circles;
public class CircleTagsProvider(AppDbContext context) : ICircleTagsProvider
{
public async Task<GetCircleTagsResponse> HandleAsync(GetCircleTagsRequest request, CancellationToken cancellationToken = default)
{
int? circleId = await CircleLookup.ResolveCircleIdAsync(context, request.CircleId, request.NameOrMakerId, cancellationToken);
if (circleId is null)
return new GetCircleTagsResponse([]);
IQueryable<CircleTagItem> query =
from voiceWork in context.VoiceWorks.AsNoTracking()
where voiceWork.CircleId == circleId.Value
join voiceWorkTag in context.VoiceWorkTags.AsNoTracking() on voiceWork.VoiceWorkId equals voiceWorkTag.VoiceWorkId
join tag in context.Tags.AsNoTracking() on voiceWorkTag.TagId equals tag.TagId
join englishTag in context.EnglishTags.AsNoTracking() on tag.TagId equals englishTag.TagId into eng
from e in eng.DefaultIfEmpty()
group new { tag, e } by new { tag.Name, English = e != null ? e.Name : null } into g
orderby g.Count() descending
select new CircleTagItem(
g.Key.Name,
g.Key.English,
g.Count());
CircleTagItem[] items = await query.ToArrayAsync(cancellationToken);
return new GetCircleTagsResponse(items);
}
}

View File

@@ -0,0 +1,49 @@
using JSMR.Application.Circles.Commands.UpdateCircleStatus;
using JSMR.Application.Circles.Contracts;
using JSMR.Application.Circles.Ports;
using JSMR.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace JSMR.Infrastructure.Data.Repositories.Circles;
public class CircleWriter(AppDbContext context) : ICircleWriter
{
public async Task<UpdateCircleStatusResponse> UpdateStatusAsync(UpdateCircleStatusRequest request, CancellationToken cancellationToken = default)
{
Circle circle = await GetCircleAsync(request.CircleId, cancellationToken);
switch (request.CircleStatus)
{
case CircleStatus.Neutral:
circle.Favorite = false;
circle.Blacklisted = false;
circle.Spam = false;
break;
case CircleStatus.Favorite:
circle.Favorite = true;
circle.Blacklisted = false;
circle.Spam = false;
break;
case CircleStatus.Blacklisted:
circle.Favorite = false;
circle.Blacklisted = true;
circle.Spam = false;
break;
case CircleStatus.Spam:
circle.Favorite = false;
circle.Blacklisted = false;
circle.Spam = true;
break;
}
await context.SaveChangesAsync(cancellationToken);
return new UpdateCircleStatusResponse(request.CircleId, request.CircleStatus);
}
private async Task<Circle> GetCircleAsync(int circleId, CancellationToken cancellationToken)
{
return await context.Circles.FirstOrDefaultAsync(circle => circle.CircleId == circleId, cancellationToken)
?? throw new KeyNotFoundException($"Circle {circleId} not found.");
}
}