Refactor .gitignore to streamline ignored files and enhance clarity. Update CLAUDE.md to improve unit test instructions and add coverage reporting details. Revise README.md for better project overview and deployment instructions. Refactor ChannelEndpoints and StreamingEndpoints to utilize SegmentFiles for file resolution, improving code maintainability. Remove unused JunctionHandlers and update DependencyInjection for cleaner service registration. Enhance media processing services for better job handling and error management. Update frontend API types for consistency and clarity.
build / backend (push) Successful in 1m28s
build / frontend (push) Failing after 31s
tests / backend-tests (push) Canceled after 0s
sonar / analyze (push) Successful in 4m39s

This commit is contained in:
Leonid Pershin
2026-07-26 20:43:38 +03:00
parent f36dbfa9cb
commit 205672b77d
77 changed files with 3292 additions and 3102 deletions
@@ -0,0 +1,110 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>
/// Восстанавливает <see cref="BumperRenderSpec"/> по кэш-строке заставки: планировщик сохранил только
/// ссылки (канал/блок/подблок/пара шоу), а рендеру нужны названия шоу и абсолютные пути к звуку,
/// постеру и фону. Вынесено из фонового рендерера: чтение и сборка — работа слоя приложения,
/// воркер лишь крутит ffmpeg.
/// </summary>
public sealed class BumperSpecLoader(
IAppDbContext dbContext,
IBumperTemplateStorage bumperStorage,
IImageStore imageStore,
IOptions<BumperOptions> bumperOptions,
IOptions<StreamingOptions> streamingOptions
)
{
private readonly BumperOptions _bumper = bumperOptions.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
/// <summary>Спецификация заставки для ассета, либо null если восстановить её уже нельзя.</summary>
public async Task<BumperRenderSpec?> LoadAsync(
Guid assetId,
CancellationToken cancellationToken
)
{
var cache = await dbContext
.BumperAssets.AsNoTracking()
.Where(b => b.MediaAssetId == assetId)
.OrderByDescending(b => b.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (cache is null)
return null;
var channel = await dbContext
.Channels.AsNoTracking()
.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
.FirstOrDefaultAsync(c => c.Id == cache.ChannelId, cancellationToken);
var template = channel?.BumperTemplates.FirstOrDefault(t => t.Id == cache.TemplateId);
var variant = template?.Variants.FirstOrDefault(v => v.Id == cache.VariantId);
if (channel is null || template is null || variant is null)
return null;
var names = await dbContext
.Shows.AsNoTracking()
.Where(s => s.Id == cache.FromShowId || s.Id == cache.ToShowId)
.Select(s => new { s.Id, s.Name })
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
// Постер шоу-получателя как фон — только для «Сейчас/Далее».
string? posterPath = null;
if (variant.Kind == BumperTextKind.NowNext)
posterPath = await ResolveShowPosterAsync(cache.ToShowId, cancellationToken);
var bgPath = await ResolveImagePathAsync(template.BackgroundImageId, cancellationToken);
var aligned = BumperDuration.Aligned(
BumperDuration.TemplateSeconds(template),
_segmentSeconds
);
return BumperSpecFactory.Build(
_bumper,
channel.BumperFont,
template,
variant,
aligned,
names.GetValueOrDefault(cache.FromShowId, "…"),
names.GetValueOrDefault(cache.ToShowId, "…"),
bumperStorage.AudioPath(template.Id, template.AudioExtension),
posterPath,
bgPath
);
}
private async Task<string?> ResolveShowPosterAsync(
Guid showId,
CancellationToken cancellationToken
)
{
var posterImageId = await dbContext
.Shows.AsNoTracking()
.Where(s => s.Id == showId && s.PosterImageId != null)
.Select(s => s.PosterImageId)
.FirstOrDefaultAsync(cancellationToken);
return await ResolveImagePathAsync(posterImageId, cancellationToken);
}
private async Task<string?> ResolveImagePathAsync(
Guid? imageId,
CancellationToken cancellationToken
)
{
if (imageId is not { } id)
return null;
var ext = await dbContext
.Images.AsNoTracking()
.Where(i => i.Id == id)
.Select(i => i.FileExtension)
.FirstOrDefaultAsync(cancellationToken);
return ext is null ? null : imageStore.ResolvePath(id, ext);
}
}
@@ -3,7 +3,12 @@ using FluentValidation;
using LiteCqrs.Behaviors;
using LiteCqrs.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Common.Behaviors;
using TeleWave.Application.Library.Genres;
using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Programming.Planning;
using TeleWave.Application.Programming.Templates;
namespace TeleWave.Application;
@@ -26,6 +31,19 @@ public static class DependencyInjection
RegisterClosedGeneric(services, assembly, typeof(IValidator<>));
// Сервисы самого слоя приложения — не хендлеры, а общие для них помощники. Регистрируются
// здесь, а не в Infrastructure: тот слой не должен знать внутреннего устройства Application.
services.AddScoped<BumperSpecLoader>();
services.AddScoped<GenreMatcher>();
services.AddScoped<GroupElementResolver>();
services.AddScoped<GroupStatsService>();
services.AddScoped<GroupMembershipCleaner>();
services.AddScoped<SlotWriter>();
services.AddScoped<GroupExpander>();
services.AddScoped<BumperResolver>();
services.AddScoped<PostCheckRunner>();
services.AddScoped<GridScheduleGenerator>();
return services;
}
@@ -16,11 +16,6 @@ public static class GroupErrors
"Шоу или коллекция не найдены."
);
public static readonly Error ElementAlreadyAdded = Error.Conflict(
"Groups.ElementAlreadyAdded",
"Этот элемент уже входит в группу."
);
public static readonly Error FilterNotSet = Error.Validation(
"Groups.FilterNotSet",
"У группы не задано правило набора."
@@ -0,0 +1,27 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.Junctions;
public sealed class AddJunctionElementCommandHandler(IAppDbContext dbContext)
: ICommandHandler<AddJunctionElementCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
AddJunctionElementCommand command,
CancellationToken cancellationToken
)
{
var junction = await JunctionLoader.LoadAsync(
dbContext,
command.JunctionId,
cancellationToken
);
if (junction is null)
return Result.Failure<Guid>(TemplateErrors.JunctionNotFound);
var element = junction.AddElement(command.Kind);
await JunctionLoader.MarkTemplateChangedAsync(dbContext, junction, cancellationToken);
return Result.Success(element.Id);
}
}
@@ -0,0 +1,25 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Junctions;
public sealed class CreateJunctionCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CreateJunctionCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
CreateJunctionCommand command,
CancellationToken cancellationToken
)
{
if (!await dbContext.Channels.AnyAsync(c => c.Id == command.ChannelId, cancellationToken))
return Result.Failure<Guid>(ChannelErrors.NotFound);
var junction = JunctionTemplate.Create(command.ChannelId, command.Name);
dbContext.JunctionTemplates.Add(junction);
return Result.Success(junction.Id);
}
}
@@ -0,0 +1,39 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.Junctions;
public sealed class DeleteJunctionCommandHandler(IAppDbContext dbContext)
: ICommandHandler<DeleteJunctionCommand, Result>
{
public async Task<Result> Handle(
DeleteJunctionCommand command,
CancellationToken cancellationToken
)
{
var junction = await JunctionLoader.LoadAsync(
dbContext,
command.JunctionId,
cancellationToken
);
if (junction is null)
return Result.Failure(TemplateErrors.JunctionNotFound);
// Слот, ссылающийся на удалённый стык, молча остался бы без врезок — проверяем заранее.
var used = await dbContext.Slots.AnyAsync(
s => s.JunctionBetweenId == junction.Id || s.JunctionAfterId == junction.Id,
cancellationToken
);
if (used)
return Result.Failure(TemplateErrors.JunctionInUse);
dbContext.JunctionTemplates.Remove(junction);
return await JunctionLoader.MarkTemplateChangedAsync(
dbContext,
junction,
cancellationToken
);
}
}
@@ -1,297 +0,0 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Junctions;
public sealed class ListJunctionsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListJunctionsQuery, IReadOnlyList<JunctionTemplateDto>>
{
public async Task<IReadOnlyList<JunctionTemplateDto>> Handle(
ListJunctionsQuery query,
CancellationToken cancellationToken
)
{
var junctions = await dbContext
.JunctionTemplates.AsNoTracking()
.Include(j => j.Elements)
.Where(j => j.ChannelId == query.ChannelId)
.OrderBy(j => j.Name)
.ToListAsync(cancellationToken);
// Имена групп и блоков заставок резолвим одним проходом — редактор показывает их сразу.
var groupIds = junctions
.SelectMany(j => j.Elements)
.Select(e => e.GroupId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
var groupNames = await dbContext
.Groups.AsNoTracking()
.Where(g => groupIds.Contains(g.Id))
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
var bumperNames = await dbContext
.Channels.AsNoTracking()
.Where(c => c.Id == query.ChannelId)
.SelectMany(c => c.BumperTemplates)
.ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
return junctions
.Select(j => new JunctionTemplateDto(
j.Id,
j.Name,
j.Elements.OrderBy(e => e.Position)
.Select(e => new JunctionElementDto(
e.Id,
e.Position,
e.Kind,
e.GroupId,
e.GroupId is { } gid && groupNames.TryGetValue(gid, out var gname)
? gname
: null,
e.BumperTemplateId,
e.BumperTemplateId is { } bid && bumperNames.TryGetValue(bid, out var bname)
? bname
: null,
e.AmountMode,
e.AmountValue,
e.IsRequired,
JunctionConditions.FromJson(e.ConditionsJson)
))
.ToList()
))
.ToList();
}
}
public sealed class CreateJunctionCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CreateJunctionCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
CreateJunctionCommand command,
CancellationToken cancellationToken
)
{
if (!await dbContext.Channels.AnyAsync(c => c.Id == command.ChannelId, cancellationToken))
return Result.Failure<Guid>(ChannelErrors.NotFound);
var junction = JunctionTemplate.Create(command.ChannelId, command.Name);
dbContext.JunctionTemplates.Add(junction);
return Result.Success(junction.Id);
}
}
public sealed class RenameJunctionCommandHandler(IAppDbContext dbContext)
: ICommandHandler<RenameJunctionCommand, Result>
{
public async Task<Result> Handle(
RenameJunctionCommand command,
CancellationToken cancellationToken
)
{
var junction = await JunctionLoader.LoadAsync(
dbContext,
command.JunctionId,
cancellationToken
);
if (junction is null)
return Result.Failure(TemplateErrors.JunctionNotFound);
junction.Rename(command.Name);
return await MarkTemplateChangedAsync(dbContext, junction, cancellationToken);
}
/// <summary>Правка стыка — тоже правка правил эфира: шаблон канала помечается изменённым.</summary>
internal static async Task<Result> MarkTemplateChangedAsync(
IAppDbContext dbContext,
JunctionTemplate junction,
CancellationToken cancellationToken
)
{
var template = await dbContext.ScheduleTemplates.FirstOrDefaultAsync(
t => t.ChannelId == junction.ChannelId,
cancellationToken
);
template?.MarkChanged();
return Result.Success();
}
}
public sealed class DeleteJunctionCommandHandler(IAppDbContext dbContext)
: ICommandHandler<DeleteJunctionCommand, Result>
{
public async Task<Result> Handle(
DeleteJunctionCommand command,
CancellationToken cancellationToken
)
{
var junction = await JunctionLoader.LoadAsync(
dbContext,
command.JunctionId,
cancellationToken
);
if (junction is null)
return Result.Failure(TemplateErrors.JunctionNotFound);
// Слот, ссылающийся на удалённый стык, молча остался бы без врезок — проверяем заранее.
var used = await dbContext.Slots.AnyAsync(
s => s.JunctionBetweenId == junction.Id || s.JunctionAfterId == junction.Id,
cancellationToken
);
if (used)
return Result.Failure(TemplateErrors.JunctionInUse);
dbContext.JunctionTemplates.Remove(junction);
return await RenameJunctionCommandHandler.MarkTemplateChangedAsync(
dbContext,
junction,
cancellationToken
);
}
}
public sealed class AddJunctionElementCommandHandler(IAppDbContext dbContext)
: ICommandHandler<AddJunctionElementCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
AddJunctionElementCommand command,
CancellationToken cancellationToken
)
{
var junction = await JunctionLoader.LoadAsync(
dbContext,
command.JunctionId,
cancellationToken
);
if (junction is null)
return Result.Failure<Guid>(TemplateErrors.JunctionNotFound);
var element = junction.AddElement(command.Kind);
await RenameJunctionCommandHandler.MarkTemplateChangedAsync(
dbContext,
junction,
cancellationToken
);
return Result.Success(element.Id);
}
}
public sealed class UpdateJunctionElementCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateJunctionElementCommand, Result>
{
public async Task<Result> Handle(
UpdateJunctionElementCommand command,
CancellationToken cancellationToken
)
{
var junction = await JunctionLoader.LoadAsync(
dbContext,
command.JunctionId,
cancellationToken
);
var element = junction?.FindElement(command.ElementId);
if (junction is null || element is null)
return Result.Failure(TemplateErrors.JunctionElementNotFound);
var input = command.Input;
if (input.Kind == JunctionElementKind.Bumper)
{
var known = await dbContext
.Channels.Where(c => c.Id == junction.ChannelId)
.SelectMany(c => c.BumperTemplates)
.AnyAsync(t => t.Id == input.BumperTemplateId, cancellationToken);
if (!known)
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
}
else
{
if (input.GroupId is not { } groupId)
return Result.Failure(TemplateErrors.JunctionGroupRequired);
if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken))
return Result.Failure(TemplateErrors.GroupNotFound);
}
element.Update(
input.Kind,
input.GroupId,
input.BumperTemplateId,
input.AmountMode,
input.AmountValue,
input.IsRequired,
input.Conditions?.ToJson()
);
return await RenameJunctionCommandHandler.MarkTemplateChangedAsync(
dbContext,
junction,
cancellationToken
);
}
}
public sealed class RemoveJunctionElementCommandHandler(IAppDbContext dbContext)
: ICommandHandler<RemoveJunctionElementCommand, Result>
{
public async Task<Result> Handle(
RemoveJunctionElementCommand command,
CancellationToken cancellationToken
)
{
var junction = await JunctionLoader.LoadAsync(
dbContext,
command.JunctionId,
cancellationToken
);
if (junction is null || !junction.RemoveElement(command.ElementId))
return Result.Failure(TemplateErrors.JunctionElementNotFound);
return await RenameJunctionCommandHandler.MarkTemplateChangedAsync(
dbContext,
junction,
cancellationToken
);
}
}
public sealed class ReorderJunctionCommandHandler(IAppDbContext dbContext)
: ICommandHandler<ReorderJunctionCommand, Result>
{
public async Task<Result> Handle(
ReorderJunctionCommand command,
CancellationToken cancellationToken
)
{
var junction = await JunctionLoader.LoadAsync(
dbContext,
command.JunctionId,
cancellationToken
);
if (junction is null)
return Result.Failure(TemplateErrors.JunctionNotFound);
junction.Reorder(command.ElementIdsInOrder);
return await RenameJunctionCommandHandler.MarkTemplateChangedAsync(
dbContext,
junction,
cancellationToken
);
}
}
internal static class JunctionLoader
{
public static Task<JunctionTemplate?> LoadAsync(
IAppDbContext dbContext,
Guid junctionId,
CancellationToken cancellationToken
) =>
dbContext
.JunctionTemplates.Include(j => j.Elements)
.FirstOrDefaultAsync(j => j.Id == junctionId, cancellationToken);
}
@@ -0,0 +1,34 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Junctions;
/// <summary>Общее для всех команд стыка: загрузка шаблона и отметка правил эфира изменёнными.</summary>
internal static class JunctionLoader
{
public static Task<JunctionTemplate?> LoadAsync(
IAppDbContext dbContext,
Guid junctionId,
CancellationToken cancellationToken
) =>
dbContext
.JunctionTemplates.Include(j => j.Elements)
.FirstOrDefaultAsync(j => j.Id == junctionId, cancellationToken);
/// <summary>Правка стыка — тоже правка правил эфира: шаблон канала помечается изменённым.</summary>
public static async Task<Result> MarkTemplateChangedAsync(
IAppDbContext dbContext,
JunctionTemplate junction,
CancellationToken cancellationToken
)
{
var template = await dbContext.ScheduleTemplates.FirstOrDefaultAsync(
t => t.ChannelId == junction.ChannelId,
cancellationToken
);
template?.MarkChanged();
return Result.Success();
}
}
@@ -0,0 +1,67 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Programming.Templates.Junctions;
public sealed class ListJunctionsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListJunctionsQuery, IReadOnlyList<JunctionTemplateDto>>
{
public async Task<IReadOnlyList<JunctionTemplateDto>> Handle(
ListJunctionsQuery query,
CancellationToken cancellationToken
)
{
var junctions = await dbContext
.JunctionTemplates.AsNoTracking()
.Include(j => j.Elements)
.Where(j => j.ChannelId == query.ChannelId)
.OrderBy(j => j.Name)
.ToListAsync(cancellationToken);
// Имена групп и блоков заставок резолвим одним проходом — редактор показывает их сразу.
var groupIds = junctions
.SelectMany(j => j.Elements)
.Select(e => e.GroupId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
var groupNames = await dbContext
.Groups.AsNoTracking()
.Where(g => groupIds.Contains(g.Id))
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
var bumperNames = await dbContext
.Channels.AsNoTracking()
.Where(c => c.Id == query.ChannelId)
.SelectMany(c => c.BumperTemplates)
.ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
return junctions
.Select(j => new JunctionTemplateDto(
j.Id,
j.Name,
j.Elements.OrderBy(e => e.Position)
.Select(e => new JunctionElementDto(
e.Id,
e.Position,
e.Kind,
e.GroupId,
e.GroupId is { } gid && groupNames.TryGetValue(gid, out var gname)
? gname
: null,
e.BumperTemplateId,
e.BumperTemplateId is { } bid && bumperNames.TryGetValue(bid, out var bname)
? bname
: null,
e.AmountMode,
e.AmountValue,
e.IsRequired,
JunctionConditions.FromJson(e.ConditionsJson)
))
.ToList()
))
.ToList();
}
}
@@ -0,0 +1,29 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.Junctions;
public sealed class RemoveJunctionElementCommandHandler(IAppDbContext dbContext)
: ICommandHandler<RemoveJunctionElementCommand, Result>
{
public async Task<Result> Handle(
RemoveJunctionElementCommand command,
CancellationToken cancellationToken
)
{
var junction = await JunctionLoader.LoadAsync(
dbContext,
command.JunctionId,
cancellationToken
);
if (junction is null || !junction.RemoveElement(command.ElementId))
return Result.Failure(TemplateErrors.JunctionElementNotFound);
return await JunctionLoader.MarkTemplateChangedAsync(
dbContext,
junction,
cancellationToken
);
}
}
@@ -0,0 +1,30 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.Junctions;
public sealed class RenameJunctionCommandHandler(IAppDbContext dbContext)
: ICommandHandler<RenameJunctionCommand, Result>
{
public async Task<Result> Handle(
RenameJunctionCommand command,
CancellationToken cancellationToken
)
{
var junction = await JunctionLoader.LoadAsync(
dbContext,
command.JunctionId,
cancellationToken
);
if (junction is null)
return Result.Failure(TemplateErrors.JunctionNotFound);
junction.Rename(command.Name);
return await JunctionLoader.MarkTemplateChangedAsync(
dbContext,
junction,
cancellationToken
);
}
}
@@ -0,0 +1,30 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.Junctions;
public sealed class ReorderJunctionCommandHandler(IAppDbContext dbContext)
: ICommandHandler<ReorderJunctionCommand, Result>
{
public async Task<Result> Handle(
ReorderJunctionCommand command,
CancellationToken cancellationToken
)
{
var junction = await JunctionLoader.LoadAsync(
dbContext,
command.JunctionId,
cancellationToken
);
if (junction is null)
return Result.Failure(TemplateErrors.JunctionNotFound);
junction.Reorder(command.ElementIdsInOrder);
return await JunctionLoader.MarkTemplateChangedAsync(
dbContext,
junction,
cancellationToken
);
}
}
@@ -0,0 +1,62 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Junctions;
public sealed class UpdateJunctionElementCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateJunctionElementCommand, Result>
{
public async Task<Result> Handle(
UpdateJunctionElementCommand command,
CancellationToken cancellationToken
)
{
var junction = await JunctionLoader.LoadAsync(
dbContext,
command.JunctionId,
cancellationToken
);
var element = junction?.FindElement(command.ElementId);
if (junction is null || element is null)
return Result.Failure(TemplateErrors.JunctionElementNotFound);
var input = command.Input;
if (input.Kind == JunctionElementKind.Bumper)
{
var known = await dbContext
.Channels.Where(c => c.Id == junction.ChannelId)
.SelectMany(c => c.BumperTemplates)
.AnyAsync(t => t.Id == input.BumperTemplateId, cancellationToken);
if (!known)
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
}
else
{
if (input.GroupId is not { } groupId)
return Result.Failure(TemplateErrors.JunctionGroupRequired);
if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken))
return Result.Failure(TemplateErrors.GroupNotFound);
}
element.Update(
input.Kind,
input.GroupId,
input.BumperTemplateId,
input.AmountMode,
input.AmountValue,
input.IsRequired,
input.Conditions?.ToJson()
);
return await JunctionLoader.MarkTemplateChangedAsync(
dbContext,
junction,
cancellationToken
);
}
}
@@ -281,9 +281,13 @@ public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
.Select(i => new { i.CollectionId, i.ShowId })
.ToListAsync(cancellationToken);
// Список id собираем до запроса: проекция по материализованной коллекции внутри дерева
// выражений заставляет EF пересобирать её на каждый вызов.
var neededShowIds = showIds.Concat(partsByCollection.Select(p => p.ShowId)).Distinct().ToList();
var audiences = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id) || partsByCollection.Select(p => p.ShowId).Contains(s.Id))
.Where(s => neededShowIds.Contains(s.Id))
.Select(s => new { s.Id, s.Audience })
.ToDictionaryAsync(s => s.Id, s => s.Audience, cancellationToken);