Update scheduling parameters and refactor channel endpoints: extend HorizonDays to 7 and RetentionDays to 90 in appsettings.json. Consolidate channel-related endpoint logic by removing obsolete files and enhancing the ShowEndpoints with audience and genre management capabilities. Improve error handling and streamline command handlers for channel operations.
build / backend (push) Successful in 7m40s
build / frontend (push) Failing after 39s
tests / backend-tests (push) Successful in 6m9s

This commit is contained in:
Leonid Pershin
2026-07-26 13:32:13 +03:00
parent c4ef954dea
commit 66040a8841
272 changed files with 27944 additions and 8699 deletions
@@ -0,0 +1,15 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups.AddGroupElements;
public sealed record GroupElementRef(GroupElementKind ElementKind, Guid ElementId);
/// <summary>
/// Добавляет элементы в конец группы. Массовая — так же добавляется и найденное правилом набора.
/// Уже входящие в группу молча пропускаются: при добавлении полусотни позиций падать из-за одного
/// повтора бессмысленно. Возвращает число реально добавленных.
/// </summary>
public sealed record AddGroupElementsCommand(Guid GroupId, IReadOnlyList<GroupElementRef> Elements)
: ICommand<Result<int>>;
@@ -0,0 +1,57 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups.AddGroupElements;
public sealed class AddGroupElementsCommandHandler(
IAppDbContext dbContext,
GroupStatsService stats
) : ICommandHandler<AddGroupElementsCommand, Result<int>>
{
public async Task<Result<int>> Handle(
AddGroupElementsCommand command,
CancellationToken cancellationToken
)
{
var group = await dbContext
.Groups.Include(g => g.Items)
.FirstOrDefaultAsync(g => g.Id == command.GroupId, cancellationToken);
if (group is null)
return Result.Failure<int>(GroupErrors.NotFound);
var requested = command.Elements.Distinct().ToList();
// Ссылки полиморфные, внешнего ключа нет — проверяем существование сами, иначе в группе
// осядут позиции, которые никогда не развернутся в контент.
var showIds = requested
.Where(e => e.ElementKind == GroupElementKind.Show)
.Select(e => e.ElementId)
.ToList();
var collectionIds = requested
.Where(e => e.ElementKind == GroupElementKind.Collection)
.Select(e => e.ElementId)
.ToList();
var knownShows = await dbContext
.Shows.Where(s => showIds.Contains(s.Id))
.Select(s => s.Id)
.ToListAsync(cancellationToken);
var knownCollections = await dbContext
.Collections.Where(c => collectionIds.Contains(c.Id))
.Select(c => c.Id)
.ToListAsync(cancellationToken);
if (knownShows.Count != showIds.Count || knownCollections.Count != collectionIds.Count)
return Result.Failure<int>(GroupErrors.ElementNotFound);
var added = requested.Count(element =>
group.AddElement(element.ElementKind, element.ElementId) is not null
);
await stats.RecomputeAsync(group, cancellationToken);
return Result.Success(added);
}
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.CreateGroup;
public sealed record CreateGroupCommand(string Name, string? Description = null)
: ICommand<Result<Guid>>;
@@ -0,0 +1,17 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups.CreateGroup;
public sealed class CreateGroupCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CreateGroupCommand, Result<Guid>>
{
public Task<Result<Guid>> Handle(CreateGroupCommand command, CancellationToken cancellationToken)
{
var group = Group.Create(command.Name, command.Description);
dbContext.Groups.Add(group);
return Task.FromResult(Result.Success(group.Id));
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace TeleWave.Application.Programming.Groups.CreateGroup;
public sealed class CreateGroupCommandValidator : AbstractValidator<CreateGroupCommand>
{
public CreateGroupCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.Description).MaximumLength(2048);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.DeleteGroup;
public sealed record DeleteGroupCommand(Guid GroupId) : ICommand<Result>;
@@ -0,0 +1,24 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.DeleteGroup;
public sealed class DeleteGroupCommandHandler(IAppDbContext dbContext)
: ICommandHandler<DeleteGroupCommand, Result>
{
public async Task<Result> Handle(DeleteGroupCommand command, CancellationToken cancellationToken)
{
var group = await dbContext.Groups.FirstOrDefaultAsync(
g => g.Id == command.GroupId,
cancellationToken
);
if (group is null)
return Result.Failure(GroupErrors.NotFound);
// TODO(срез 1C): запретить удаление, пока на группу ссылается слот сетки.
dbContext.Groups.Remove(group);
return Result.Success();
}
}
@@ -0,0 +1,11 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.FindGroupCandidates;
/// <summary>
/// Подбирает позиции по правилу набора. <paramref name="Filter"/> = null — берём правило, сохранённое
/// у группы; передача фильтра явно нужна редактору, где его крутят до сохранения.
/// </summary>
public sealed record FindGroupCandidatesQuery(Guid GroupId, GroupFilter? Filter = null)
: IQuery<Result<IReadOnlyList<GroupCandidateDto>>>;
@@ -0,0 +1,139 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups.FindGroupCandidates;
public sealed class FindGroupCandidatesQueryHandler(
IAppDbContext dbContext,
GroupElementResolver resolver
) : IQueryHandler<FindGroupCandidatesQuery, Result<IReadOnlyList<GroupCandidateDto>>>
{
public async Task<Result<IReadOnlyList<GroupCandidateDto>>> Handle(
FindGroupCandidatesQuery query,
CancellationToken cancellationToken
)
{
var group = await dbContext
.Groups.AsNoTracking()
.Include(g => g.Items)
.FirstOrDefaultAsync(g => g.Id == query.GroupId, cancellationToken);
if (group is null)
return Result.Failure<IReadOnlyList<GroupCandidateDto>>(GroupErrors.NotFound);
var filter = query.Filter ?? GroupFilter.FromJson(group.FilterJson);
if (filter is null)
return Result.Failure<IReadOnlyList<GroupCandidateDto>>(GroupErrors.FilterNotSet);
var wantsShows =
filter.ElementKinds is null or { Count: 0 }
|| filter.ElementKinds.Contains(GroupElementKind.Show);
var wantsCollections =
filter.ElementKinds is null or { Count: 0 }
|| filter.ElementKinds.Contains(GroupElementKind.Collection);
var matchingShowIds = await MatchShowIdsAsync(filter, cancellationToken);
var elements = new List<(GroupElementKind Kind, Guid Id)>();
if (wantsShows)
elements.AddRange(matchingShowIds.Select(id => (GroupElementKind.Show, id)));
if (wantsCollections)
{
// Коллекция подходит, только если подходят все её части: иначе во «взрослую ночь» через
// франшизу просочилось бы детское, а в «боевики» — комедия из той же серии фильмов.
var collections = await dbContext
.Collections.AsNoTracking()
.Select(c => new { c.Id, ShowIds = c.Items.Select(i => i.ShowId).ToList() })
.ToListAsync(cancellationToken);
var matching = matchingShowIds.ToHashSet();
elements.AddRange(
collections
.Where(c => c.ShowIds.Count > 0 && c.ShowIds.All(matching.Contains))
.Select(c => (GroupElementKind.Collection, c.Id))
);
}
var info = await resolver.ResolveAsync(elements, cancellationToken);
var inGroup = group
.Items.Select(i => (i.ElementKind, i.ElementId))
.ToHashSet();
var result = new List<GroupCandidateDto>();
foreach (var (kind, id) in elements)
{
if (!info.TryGetValue((kind, id), out var element))
continue;
if (!MatchesUnitDuration(filter, element))
continue;
result.Add(
new GroupCandidateDto(
kind,
id,
element.Name,
element.UnitCount,
element.ShowKind,
element.Audience,
element.Year,
element.PosterImageId,
inGroup.Contains((kind, id))
)
);
}
return Result.Success<IReadOnlyList<GroupCandidateDto>>(
result.OrderBy(c => c.ElementName, StringComparer.CurrentCultureIgnoreCase).ToList()
);
}
private async Task<List<Guid>> MatchShowIdsAsync(
GroupFilter filter,
CancellationToken cancellationToken
)
{
var shows = dbContext.Shows.AsNoTracking();
if (filter.ShowKinds is { Count: > 0 } kinds)
shows = shows.Where(s => kinds.Contains(s.Kind));
// Категории упорядочены по возрастанию строгости, поэтому «не строже» — обычное сравнение.
if (filter.MaxAudience is { } maxAudience)
shows = shows.Where(s => s.Audience <= maxAudience);
if (filter.YearMin is { } yearMin)
shows = shows.Where(s => s.Year != null && s.Year >= yearMin);
if (filter.YearMax is { } yearMax)
shows = shows.Where(s => s.Year != null && s.Year <= yearMax);
// Жанры — «любой из»: шоу редко подходит под все перечисленные сразу.
if (filter.GenreIds is { Count: > 0 } genreIds)
shows = shows.Where(s => s.Genres.Any(g => genreIds.Contains(g.GenreId)));
return await shows.Select(s => s.Id).ToListAsync(cancellationToken);
}
/// <summary>
/// Длительность проверяется по средней единице, а не по сумме: у сериала в фильтре «серии
/// по 20–25 минут» осмысленна именно длина серии. Элементы без готовых ассетов длительности
/// не имеют — их этот фильтр не отбрасывает, иначе из группы выпадало бы всё, что ещё
/// обрабатывается.
/// </summary>
private static bool MatchesUnitDuration(GroupFilter filter, GroupElementInfo element)
{
if (filter.UnitMinutesMin is null && filter.UnitMinutesMax is null)
return true;
if (element.UnitCount == 0 || element.TotalDuration <= TimeSpan.Zero)
return true;
var averageMinutes = element.TotalDuration.TotalMinutes / element.UnitCount;
if (filter.UnitMinutesMin is { } min && averageMinutes < min)
return false;
if (filter.UnitMinutesMax is { } max && averageMinutes > max)
return false;
return true;
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.GetGroup;
public sealed record GetGroupQuery(Guid Id) : IQuery<Result<GroupDto>>;
@@ -0,0 +1,65 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.GetGroup;
public sealed class GetGroupQueryHandler(IAppDbContext dbContext, GroupElementResolver resolver)
: IQueryHandler<GetGroupQuery, Result<GroupDto>>
{
public async Task<Result<GroupDto>> Handle(
GetGroupQuery query,
CancellationToken cancellationToken
)
{
var group = await dbContext
.Groups.AsNoTracking()
.Include(g => g.Items)
.FirstOrDefaultAsync(g => g.Id == query.Id, cancellationToken);
if (group is null)
return Result.Failure<GroupDto>(GroupErrors.NotFound);
var info = await resolver.ResolveAsync(
group.Items.Select(i => (i.ElementKind, i.ElementId)),
cancellationToken
);
var items = group
.Items.OrderBy(i => i.Position)
.Select(i =>
{
info.TryGetValue((i.ElementKind, i.ElementId), out var element);
return new GroupItemDto(
i.Id,
i.ElementKind,
i.ElementId,
// Элемент мог исчезнуть из библиотеки между чисткой и чтением — показываем прочерк,
// а не роняем весь экран группы.
element?.Name ?? "—",
i.Weight,
i.Position,
element?.UnitCount ?? 0,
element?.ShowKind,
element?.Audience,
element?.Year,
element?.PosterImageId
);
})
.ToList();
return Result.Success(
new GroupDto(
group.Id,
group.Name,
group.Description,
GroupFilter.FromJson(group.FilterJson),
group.ItemCount,
group.UnitCount,
group.TotalDuration.TotalSeconds,
group.StatsComputedAt,
items
)
);
}
}
@@ -0,0 +1,60 @@
using TeleWave.Domain.Library;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups;
/// <summary>Группа в списке — со сводкой, без состава.</summary>
public sealed record GroupSummaryDto(
Guid Id,
string Name,
string? Description,
int ItemCount,
int UnitCount,
double TotalDurationSeconds,
bool HasFilter,
DateTimeOffset? StatsComputedAt,
DateTimeOffset CreatedAt
);
/// <summary>
/// Позиция группы с данными элемента. <paramref name="UnitCount"/> — сколько единиц воспроизведения
/// она даёт: у фильма одна, у сериала и коллекции больше.
/// </summary>
public sealed record GroupItemDto(
Guid Id,
GroupElementKind ElementKind,
Guid ElementId,
string ElementName,
int Weight,
int Position,
int UnitCount,
ShowKind? ShowKind,
ShowAudience? Audience,
int? Year,
Guid? PosterImageId
);
public sealed record GroupDto(
Guid Id,
string Name,
string? Description,
GroupFilter? Filter,
int ItemCount,
int UnitCount,
double TotalDurationSeconds,
DateTimeOffset? StatsComputedAt,
IReadOnlyList<GroupItemDto> Items
);
/// <summary>Кандидат, найденный правилом набора: ещё не в группе, но подходит под фильтр.</summary>
public sealed record GroupCandidateDto(
GroupElementKind ElementKind,
Guid ElementId,
string ElementName,
int UnitCount,
ShowKind? ShowKind,
ShowAudience? Audience,
int? Year,
Guid? PosterImageId,
bool AlreadyInGroup
);
@@ -0,0 +1,137 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups;
/// <summary>Сводка по элементу группы: как показать и сколько эфира он даёт.</summary>
public sealed record GroupElementInfo(
string Name,
int UnitCount,
TimeSpan TotalDuration,
ShowKind? ShowKind,
ShowAudience? Audience,
int? Year,
Guid? PosterImageId
);
/// <summary>
/// Разворачивает ссылки группы (шоу либо коллекция) в данные для отображения и статистики.
/// Единица воспроизведения — серия: у фильма она одна, у сериала их столько же, сколько серий,
/// у коллекции — сумма по всем её частям.
///
/// Длительность считается только по готовым ассетам: в группе может лежать шоу, чьё медиа ещё
/// обрабатывается, и включать его в «118 часов» значило бы обещать эфир, которого пока нет.
/// </summary>
public sealed class GroupElementResolver(IAppDbContext dbContext)
{
public async Task<IReadOnlyDictionary<(GroupElementKind Kind, Guid Id), GroupElementInfo>> ResolveAsync(
IEnumerable<(GroupElementKind Kind, Guid Id)> elements,
CancellationToken cancellationToken
)
{
var requested = elements.Distinct().ToList();
if (requested.Count == 0)
return new Dictionary<(GroupElementKind, Guid), GroupElementInfo>();
var collectionIds = requested
.Where(e => e.Kind == GroupElementKind.Collection)
.Select(e => e.Id)
.ToList();
var collections = await dbContext
.Collections.AsNoTracking()
.Where(c => collectionIds.Contains(c.Id))
.Select(c => new
{
c.Id,
c.Name,
c.PosterImageId,
ShowIds = c.Items.OrderBy(i => i.Position).Select(i => i.ShowId).ToList(),
})
.ToListAsync(cancellationToken);
var showIds = requested
.Where(e => e.Kind == GroupElementKind.Show)
.Select(e => e.Id)
.Concat(collections.SelectMany(c => c.ShowIds))
.Distinct()
.ToList();
var shows = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new
{
s.Id,
s.Name,
s.Kind,
s.Audience,
s.Year,
s.PosterImageId,
AssetIds = s.Episodes.Select(e => e.MediaAssetId).ToList(),
})
.ToDictionaryAsync(s => s.Id, cancellationToken);
var assetIds = shows.Values.SelectMany(s => s.AssetIds).Distinct().ToList();
var durations = await dbContext
.MediaAssets.AsNoTracking()
.Where(a =>
assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready && a.Duration != null
)
.Select(a => new { a.Id, a.Duration })
.ToDictionaryAsync(a => a.Id, a => a.Duration!.Value, cancellationToken);
TimeSpan DurationOf(IEnumerable<Guid> ids) =>
ids.Aggregate(
TimeSpan.Zero,
(sum, id) => durations.TryGetValue(id, out var d) ? sum + d : sum
);
var result = new Dictionary<(GroupElementKind, Guid), GroupElementInfo>();
foreach (var (kind, id) in requested)
{
if (kind == GroupElementKind.Show)
{
if (!shows.TryGetValue(id, out var show))
continue;
result[(kind, id)] = new GroupElementInfo(
show.Name,
show.AssetIds.Count,
DurationOf(show.AssetIds),
show.Kind,
show.Audience,
show.Year,
show.PosterImageId
);
continue;
}
var collection = collections.FirstOrDefault(c => c.Id == id);
if (collection is null)
continue;
var parts = collection
.ShowIds.Select(showId => shows.TryGetValue(showId, out var s) ? s : null)
.Where(s => s is not null)
.ToList();
var partAssets = parts.SelectMany(p => p!.AssetIds).ToList();
result[(kind, id)] = new GroupElementInfo(
collection.Name,
partAssets.Count,
DurationOf(partAssets),
null,
// Категория коллекции — самая строгая среди частей: по ней отбирают в детское время.
parts.Count == 0 ? null : parts.Max(p => p!.Audience),
parts.Count == 0 ? null : parts.Min(p => p!.Year),
collection.PosterImageId
);
}
return result;
}
}
@@ -0,0 +1,28 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups;
public static class GroupErrors
{
public static readonly Error NotFound = Error.NotFound("Groups.NotFound", "Группа не найдена.");
public static readonly Error ItemNotFound = Error.NotFound(
"Groups.ItemNotFound",
"Позиция в группе не найдена."
);
public static readonly Error ElementNotFound = Error.NotFound(
"Groups.ElementNotFound",
"Шоу или коллекция не найдены."
);
public static readonly Error ElementAlreadyAdded = Error.Conflict(
"Groups.ElementAlreadyAdded",
"Этот элемент уже входит в группу."
);
public static readonly Error FilterNotSet = Error.Validation(
"Groups.FilterNotSet",
"У группы не задано правило набора."
);
}
@@ -0,0 +1,48 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using TeleWave.Domain.Library;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups;
/// <summary>
/// Правило быстрого набора состава группы. Это не запрос, выполняемый при генерации: фильтр находит
/// подходящие позиции, а добавляет их в группу отдельная команда. Пустые поля не ограничивают.
/// </summary>
public sealed record GroupFilter(
IReadOnlyList<GroupElementKind>? ElementKinds = null,
IReadOnlyList<ShowKind>? ShowKinds = null,
IReadOnlyList<Guid>? GenreIds = null,
ShowAudience? MaxAudience = null,
int? YearMin = null,
int? YearMax = null,
int? UnitMinutesMin = null,
int? UnitMinutesMax = null
)
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
};
public string ToJson() => JsonSerializer.Serialize(this, Options);
/// <summary>Разбирает сохранённое правило. Повреждённый JSON трактуется как отсутствие фильтра:
/// группа продолжает работать своим явным составом, а админ переустановит правило.</summary>
public static GroupFilter? FromJson(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize<GroupFilter>(json, Options);
}
catch (JsonException)
{
return null;
}
}
}
@@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups;
/// <summary>
/// Снимает элемент со всех групп при его удалении из библиотеки. Нужен потому, что ссылка группы
/// полиморфна (шоу либо коллекция) и внешнего ключа под неё нет — каскад БД тут не сработает,
/// а висячая позиция при генерации развернулась бы в пустоту.
///
/// Затронутым группам пересчитывается статистика: иначе в инспекторе слота осталось бы обещание
/// эфира, которого больше нет.
/// </summary>
public sealed class GroupMembershipCleaner(IAppDbContext dbContext, GroupStatsService stats)
{
public async Task RemoveElementAsync(
GroupElementKind kind,
Guid elementId,
CancellationToken cancellationToken
)
{
var affectedGroupIds = await dbContext
.GroupItems.Where(i => i.ElementKind == kind && i.ElementId == elementId)
.Select(i => i.GroupId)
.Distinct()
.ToListAsync(cancellationToken);
if (affectedGroupIds.Count == 0)
return;
var groups = await dbContext
.Groups.Include(g => g.Items)
.Where(g => affectedGroupIds.Contains(g.Id))
.ToListAsync(cancellationToken);
foreach (var group in groups)
{
group.RemoveElement(kind, elementId);
await stats.RecomputeAsync(group, cancellationToken);
}
}
}
@@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups;
/// <summary>
/// Пересчёт кэша статистики группы. Вызывается при каждой правке состава: цифры «342 позиции ·
/// 118 ч» видны прямо в инспекторе слота, и устаревшая статистика там хуже, чем её отсутствие.
/// </summary>
public sealed class GroupStatsService(IAppDbContext dbContext, GroupElementResolver resolver)
{
public async Task RecomputeAsync(Group group, CancellationToken cancellationToken)
{
var info = await resolver.ResolveAsync(
group.Items.Select(i => (i.ElementKind, i.ElementId)),
cancellationToken
);
var unitCount = 0;
var totalDuration = TimeSpan.Zero;
foreach (var item in group.Items)
{
if (!info.TryGetValue((item.ElementKind, item.ElementId), out var element))
continue;
unitCount += element.UnitCount;
totalDuration += element.TotalDuration;
}
group.UpdateStats(group.Items.Count, unitCount, totalDuration, DateTimeOffset.UtcNow);
}
/// <summary>Пересчитать по идентификатору, загрузив состав. Возвращает false, если группы нет.</summary>
public async Task<bool> RecomputeAsync(Guid groupId, CancellationToken cancellationToken)
{
var group = await dbContext
.Groups.Include(g => g.Items)
.FirstOrDefaultAsync(g => g.Id == groupId, cancellationToken);
if (group is null)
return false;
await RecomputeAsync(group, cancellationToken);
return true;
}
}
@@ -0,0 +1,5 @@
using LiteCqrs;
namespace TeleWave.Application.Programming.Groups.ListGroups;
public sealed record ListGroupsQuery : IQuery<IReadOnlyList<GroupSummaryDto>>;
@@ -0,0 +1,32 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Programming.Groups.ListGroups;
public sealed class ListGroupsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListGroupsQuery, IReadOnlyList<GroupSummaryDto>>
{
public async Task<IReadOnlyList<GroupSummaryDto>> Handle(
ListGroupsQuery query,
CancellationToken cancellationToken
)
{
// Список читает кэш статистики, не пересчитывая его: пересчёт идёт при правке состава.
return await dbContext
.Groups.AsNoTracking()
.OrderBy(g => g.Name)
.Select(g => new GroupSummaryDto(
g.Id,
g.Name,
g.Description,
g.ItemCount,
g.UnitCount,
g.TotalDuration.TotalSeconds,
g.FilterJson != null,
g.StatsComputedAt,
g.CreatedAt
))
.ToListAsync(cancellationToken);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.RemoveGroupItem;
public sealed record RemoveGroupItemCommand(Guid GroupId, Guid ItemId) : ICommand<Result>;
@@ -0,0 +1,28 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.RemoveGroupItem;
public sealed class RemoveGroupItemCommandHandler(IAppDbContext dbContext, GroupStatsService stats)
: ICommandHandler<RemoveGroupItemCommand, Result>
{
public async Task<Result> Handle(
RemoveGroupItemCommand command,
CancellationToken cancellationToken
)
{
var group = await dbContext
.Groups.Include(g => g.Items)
.FirstOrDefaultAsync(g => g.Id == command.GroupId, cancellationToken);
if (group is null)
return Result.Failure(GroupErrors.NotFound);
if (!group.RemoveItem(command.ItemId))
return Result.Failure(GroupErrors.ItemNotFound);
await stats.RecomputeAsync(group, cancellationToken);
return Result.Success();
}
}
@@ -0,0 +1,9 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.ReorderGroup;
/// <summary>Порядок позиций (важен для последовательных стратегий). Не упомянутые остаются после
/// перечисленных, сохраняя относительный порядок.</summary>
public sealed record ReorderGroupCommand(Guid GroupId, IReadOnlyList<Guid> ItemIdsInOrder)
: ICommand<Result>;
@@ -0,0 +1,25 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.ReorderGroup;
public sealed class ReorderGroupCommandHandler(IAppDbContext dbContext)
: ICommandHandler<ReorderGroupCommand, Result>
{
public async Task<Result> Handle(
ReorderGroupCommand command,
CancellationToken cancellationToken
)
{
var group = await dbContext
.Groups.Include(g => g.Items)
.FirstOrDefaultAsync(g => g.Id == command.GroupId, cancellationToken);
if (group is null)
return Result.Failure(GroupErrors.NotFound);
group.Reorder(command.ItemIdsInOrder);
return Result.Success();
}
}
@@ -0,0 +1,8 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.SetGroupItemWeight;
/// <summary>Вес позиции при случайном выборе (0 — исключить, не удаляя из группы).</summary>
public sealed record SetGroupItemWeightCommand(Guid GroupId, Guid ItemId, int Weight)
: ICommand<Result>;
@@ -0,0 +1,27 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.SetGroupItemWeight;
public sealed class SetGroupItemWeightCommandHandler(IAppDbContext dbContext)
: ICommandHandler<SetGroupItemWeightCommand, Result>
{
public async Task<Result> Handle(
SetGroupItemWeightCommand command,
CancellationToken cancellationToken
)
{
var group = await dbContext
.Groups.Include(g => g.Items)
.FirstOrDefaultAsync(g => g.Id == command.GroupId, cancellationToken);
if (group is null)
return Result.Failure(GroupErrors.NotFound);
// Вес не влияет на объём контента — статистику пересчитывать незачем.
return group.SetWeight(command.ItemId, command.Weight)
? Result.Success()
: Result.Failure(GroupErrors.ItemNotFound);
}
}
@@ -0,0 +1,13 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.UpdateGroup;
/// <summary>Имя, описание и правило набора. <paramref name="Filter"/> = null снимает правило;
/// состав группы при этом не меняется.</summary>
public sealed record UpdateGroupCommand(
Guid GroupId,
string Name,
string? Description,
GroupFilter? Filter
) : ICommand<Result>;
@@ -0,0 +1,24 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.UpdateGroup;
public sealed class UpdateGroupCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateGroupCommand, Result>
{
public async Task<Result> Handle(UpdateGroupCommand command, CancellationToken cancellationToken)
{
var group = await dbContext.Groups.FirstOrDefaultAsync(
g => g.Id == command.GroupId,
cancellationToken
);
if (group is null)
return Result.Failure(GroupErrors.NotFound);
group.Rename(command.Name, command.Description);
group.SetFilter(command.Filter?.ToJson());
return Result.Success();
}
}
@@ -0,0 +1,23 @@
using FluentValidation;
namespace TeleWave.Application.Programming.Groups.UpdateGroup;
public sealed class UpdateGroupCommandValidator : AbstractValidator<UpdateGroupCommand>
{
public UpdateGroupCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.Description).MaximumLength(2048);
When(
x => x.Filter is not null,
() =>
{
RuleFor(x => x.Filter!.YearMin).InclusiveBetween(1870, 2200).When(x => x.Filter!.YearMin is not null);
RuleFor(x => x.Filter!.YearMax).InclusiveBetween(1870, 2200).When(x => x.Filter!.YearMax is not null);
RuleFor(x => x.Filter!.UnitMinutesMin).GreaterThanOrEqualTo(0).When(x => x.Filter!.UnitMinutesMin is not null);
RuleFor(x => x.Filter!.UnitMinutesMax).GreaterThanOrEqualTo(0).When(x => x.Filter!.UnitMinutesMax is not null);
}
);
}
}