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.
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.CreateSlot;
|
||||
|
||||
public sealed record CreateSlotCommand(Guid LayerId, SlotInput Input) : ICommand<Result<Guid>>;
|
||||
|
||||
public sealed class CreateSlotCommandValidator : AbstractValidator<CreateSlotCommand>
|
||||
{
|
||||
public CreateSlotCommandValidator() =>
|
||||
RuleFor(x => x.Input).NotNull().SetValidator(new SlotInputValidator());
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.CreateSlot;
|
||||
|
||||
public sealed class CreateSlotCommandHandler(SlotWriter writer)
|
||||
: ICommandHandler<CreateSlotCommand, Result<Guid>>
|
||||
{
|
||||
public async Task<Result<Guid>> Handle(
|
||||
CreateSlotCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var template = await writer.LoadTemplateByLayerAsync(command.LayerId, cancellationToken);
|
||||
var layer = template?.FindLayer(command.LayerId);
|
||||
if (template is null || layer is null)
|
||||
return Result.Failure<Guid>(TemplateErrors.LayerNotFound);
|
||||
|
||||
var before = layer.Slots.Select(s => s.Id).ToHashSet();
|
||||
|
||||
var applied = await writer.ApplyAsync(layer, null, command.Input, cancellationToken);
|
||||
if (!applied.IsSuccess)
|
||||
return Result.Failure<Guid>(applied.Error);
|
||||
|
||||
// Правка правил эфир не двигает — только помечает шаблон изменённым (см. применение по кнопке).
|
||||
template.MarkChanged();
|
||||
|
||||
var created = layer.Slots.First(s => !before.Contains(s.Id));
|
||||
return Result.Success(created.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.DeleteSlot;
|
||||
|
||||
public sealed record DeleteSlotCommand(Guid SlotId) : ICommand<Result>;
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.DeleteSlot;
|
||||
|
||||
public sealed class DeleteSlotCommandHandler(SlotWriter writer)
|
||||
: ICommandHandler<DeleteSlotCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(DeleteSlotCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var template = await writer.LoadTemplateBySlotAsync(command.SlotId, cancellationToken);
|
||||
var layer = template?.FindLayerOfSlot(command.SlotId);
|
||||
if (template is null || layer is null || !layer.RemoveSlot(command.SlotId))
|
||||
return Result.Failure(TemplateErrors.SlotNotFound);
|
||||
|
||||
template.MarkChanged();
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.GetTemplate;
|
||||
|
||||
public sealed record GetChannelTemplateQuery(Guid ChannelId) : IQuery<Result<ScheduleTemplateDto>>;
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.GetTemplate;
|
||||
|
||||
public sealed class GetChannelTemplateQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<GetChannelTemplateQuery, Result<ScheduleTemplateDto>>
|
||||
{
|
||||
public async Task<Result<ScheduleTemplateDto>> Handle(
|
||||
GetChannelTemplateQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext
|
||||
.Channels.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure<ScheduleTemplateDto>(ChannelErrors.NotFound);
|
||||
|
||||
var template = await dbContext
|
||||
.ScheduleTemplates.AsNoTracking()
|
||||
.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken);
|
||||
if (template is null)
|
||||
return Result.Failure<ScheduleTemplateDto>(ChannelErrors.TemplateNotFound);
|
||||
|
||||
// Имена групп резолвим одним запросом: инспектор слота показывает их сразу, без второго обхода.
|
||||
var groupIds = template
|
||||
.Layers.SelectMany(l => l.Slots)
|
||||
.Select(s => s.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 layers = template
|
||||
.Layers.OrderByDescending(l => l.Priority)
|
||||
.Select(layer => new GridLayerDto(
|
||||
layer.Id,
|
||||
layer.Name,
|
||||
layer.Priority,
|
||||
layer.IsEnabled,
|
||||
layer.IsBackground,
|
||||
LayerApplicability.FromJson(layer.ApplicabilityJson),
|
||||
layer
|
||||
.Slots.OrderBy(s => s.Weekday ?? -1)
|
||||
.ThenBy(s => s.TargetStart)
|
||||
.Select(slot => new SlotDto(
|
||||
slot.Id,
|
||||
slot.LayerId,
|
||||
slot.Weekday,
|
||||
slot.TargetStart,
|
||||
slot.TargetDurationMinutes,
|
||||
slot.Title,
|
||||
slot.Daypart,
|
||||
slot.SlotKind,
|
||||
slot.GroupId,
|
||||
slot.GroupId is { } gid && groupNames.TryGetValue(gid, out var gname)
|
||||
? gname
|
||||
: null,
|
||||
SlotStrategy.FromJson(slot.StrategyJson),
|
||||
RepeatSource.FromJson(slot.RepeatSourceJson),
|
||||
slot.BlockMode,
|
||||
slot.BlockValue,
|
||||
slot.OverflowPolicy,
|
||||
slot.IsAnchor,
|
||||
slot.MaxDriftMinutes,
|
||||
slot.SnapToMinutes,
|
||||
slot.JunctionBetweenId,
|
||||
slot.JunctionAfterId
|
||||
))
|
||||
.ToList()
|
||||
))
|
||||
.ToList();
|
||||
|
||||
return Result.Success(
|
||||
new ScheduleTemplateDto(
|
||||
template.Id,
|
||||
template.ChannelId,
|
||||
template.Name,
|
||||
template.FallbackGroupId,
|
||||
template.DefaultJunctionId,
|
||||
template.Revision,
|
||||
template.AppliedRevision,
|
||||
template.HasPendingChanges,
|
||||
channel.UtcOffsetMinutes,
|
||||
channel.DayStartTime,
|
||||
layers
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates;
|
||||
|
||||
/// <summary>
|
||||
/// Условия показа врезки. Хранятся структурно, а не выражением: парсер выражений, его валидация
|
||||
/// и отдельный UI обошлись бы дорого, а покрывают ровно те же несколько реальных случаев.
|
||||
/// </summary>
|
||||
public sealed record JunctionConditions(
|
||||
/// <summary>Ставить только при смене шоу, а не между сериями одного.</summary>
|
||||
bool OnlyOnElementChange = false,
|
||||
/// <summary>Не ставить чаще, чем раз в N минут (0 — без ограничения).</summary>
|
||||
int MinMinutesBetween = 0
|
||||
)
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
public string ToJson() => JsonSerializer.Serialize(this, Options);
|
||||
|
||||
public static JunctionConditions? FromJson(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<JunctionConditions>(json, Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using FluentValidation;
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Junctions;
|
||||
|
||||
public sealed record ListJunctionsQuery(Guid ChannelId)
|
||||
: IQuery<IReadOnlyList<JunctionTemplateDto>>;
|
||||
|
||||
public sealed record CreateJunctionCommand(Guid ChannelId, string Name) : ICommand<Result<Guid>>;
|
||||
|
||||
public sealed record RenameJunctionCommand(Guid JunctionId, string Name) : ICommand<Result>;
|
||||
|
||||
public sealed record DeleteJunctionCommand(Guid JunctionId) : ICommand<Result>;
|
||||
|
||||
public sealed record AddJunctionElementCommand(Guid JunctionId, JunctionElementKind Kind)
|
||||
: ICommand<Result<Guid>>;
|
||||
|
||||
/// <summary>Полный набор настроек врезки — то же тело для правки любого элемента стыка.</summary>
|
||||
public sealed record JunctionElementInput(
|
||||
JunctionElementKind Kind,
|
||||
Guid? GroupId,
|
||||
Guid? BumperTemplateId,
|
||||
JunctionAmountMode AmountMode,
|
||||
int AmountValue,
|
||||
bool IsRequired,
|
||||
JunctionConditions? Conditions
|
||||
);
|
||||
|
||||
public sealed record UpdateJunctionElementCommand(
|
||||
Guid JunctionId,
|
||||
Guid ElementId,
|
||||
JunctionElementInput Input
|
||||
) : ICommand<Result>;
|
||||
|
||||
public sealed record RemoveJunctionElementCommand(Guid JunctionId, Guid ElementId)
|
||||
: ICommand<Result>;
|
||||
|
||||
public sealed record ReorderJunctionCommand(Guid JunctionId, IReadOnlyList<Guid> ElementIdsInOrder)
|
||||
: ICommand<Result>;
|
||||
|
||||
public sealed class CreateJunctionCommandValidator : AbstractValidator<CreateJunctionCommand>
|
||||
{
|
||||
public CreateJunctionCommandValidator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
|
||||
}
|
||||
|
||||
public sealed class RenameJunctionCommandValidator : AbstractValidator<RenameJunctionCommand>
|
||||
{
|
||||
public RenameJunctionCommandValidator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
|
||||
}
|
||||
|
||||
public sealed class UpdateJunctionElementCommandValidator
|
||||
: AbstractValidator<UpdateJunctionElementCommand>
|
||||
{
|
||||
public UpdateJunctionElementCommandValidator()
|
||||
{
|
||||
// Верхняя граница — сутки: врезка длиннее вещательного дня бессмысленна.
|
||||
RuleFor(x => x.Input.AmountValue).InclusiveBetween(1, 24 * 60);
|
||||
RuleFor(x => x.Input.Conditions!.MinMinutesBetween)
|
||||
.InclusiveBetween(0, 24 * 60)
|
||||
.When(x => x.Input.Conditions is not null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Junctions;
|
||||
|
||||
public sealed record JunctionElementDto(
|
||||
Guid Id,
|
||||
int Position,
|
||||
JunctionElementKind Kind,
|
||||
Guid? GroupId,
|
||||
string? GroupName,
|
||||
Guid? BumperTemplateId,
|
||||
string? BumperTemplateName,
|
||||
JunctionAmountMode AmountMode,
|
||||
int AmountValue,
|
||||
bool IsRequired,
|
||||
JunctionConditions? Conditions
|
||||
);
|
||||
|
||||
public sealed record JunctionTemplateDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
IReadOnlyList<JunctionElementDto> Elements
|
||||
);
|
||||
@@ -0,0 +1,297 @@
|
||||
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,87 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates;
|
||||
|
||||
/// <summary>Разовый диапазон дат.</summary>
|
||||
public sealed record DateRange(DateOnly From, DateOnly To);
|
||||
|
||||
/// <summary>Ежегодно повторяющийся период — «перед Новым годом» задаётся один раз, а не каждый год.</summary>
|
||||
public sealed record AnnualRange(int FromMonth, int FromDay, int ToMonth, int ToDay);
|
||||
|
||||
/// <summary>
|
||||
/// Когда действует слой сетки. Пустая применимость — слой действует всегда. Заполненные разделы
|
||||
/// объединяются по ИЛИ: слой применим, если дата подходит хотя бы под одно из условий.
|
||||
/// </summary>
|
||||
public sealed record LayerApplicability(
|
||||
/// <summary>Дни недели вещательных суток (0=Вс..6=Сб).</summary>
|
||||
IReadOnlyList<int>? Weekdays = null,
|
||||
IReadOnlyList<DateRange>? DateRanges = null,
|
||||
IReadOnlyList<AnnualRange>? AnnualRanges = null,
|
||||
IReadOnlyList<DateOnly>? SpecificDates = null
|
||||
)
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
public bool IsEmpty =>
|
||||
Weekdays is null or { Count: 0 }
|
||||
&& DateRanges is null or { Count: 0 }
|
||||
&& AnnualRanges is null or { Count: 0 }
|
||||
&& SpecificDates is null or { Count: 0 };
|
||||
|
||||
public string ToJson() => JsonSerializer.Serialize(this, Options);
|
||||
|
||||
public static LayerApplicability? FromJson(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<LayerApplicability>(json, Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Действует ли слой в эту дату (дата вещательных суток, не календарная — ночь после полуночи
|
||||
/// принадлежит предыдущему дню).
|
||||
/// </summary>
|
||||
public bool Covers(DateOnly date)
|
||||
{
|
||||
if (IsEmpty)
|
||||
return true;
|
||||
|
||||
if (Weekdays is { Count: > 0 } weekdays && weekdays.Contains((int)date.DayOfWeek))
|
||||
return true;
|
||||
|
||||
if (SpecificDates is { Count: > 0 } dates && dates.Contains(date))
|
||||
return true;
|
||||
|
||||
if (DateRanges is { Count: > 0 } ranges && ranges.Any(r => date >= r.From && date <= r.To))
|
||||
return true;
|
||||
|
||||
return AnnualRanges is { Count: > 0 } annual && annual.Any(r => CoversAnnual(r, date));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Попадает ли дата в ежегодный период. Период может пересекать Новый год («20 декабря —
|
||||
/// 8 января»), поэтому сравнение идёт по паре (месяц, день), а не по датам.
|
||||
/// </summary>
|
||||
private static bool CoversAnnual(AnnualRange range, DateOnly date)
|
||||
{
|
||||
var value = date.Month * 100 + date.Day;
|
||||
var from = range.FromMonth * 100 + range.FromDay;
|
||||
var to = range.ToMonth * 100 + range.ToDay;
|
||||
|
||||
return from <= to ? value >= from && value <= to : value >= from || value <= to;
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Layers;
|
||||
|
||||
public sealed class CreateLayerCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<CreateLayerCommand, Result<Guid>>
|
||||
{
|
||||
public async Task<Result<Guid>> Handle(
|
||||
CreateLayerCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var template = await LayerLoader.ByTemplateAsync(
|
||||
dbContext,
|
||||
command.TemplateId,
|
||||
cancellationToken
|
||||
);
|
||||
if (template is null)
|
||||
return Result.Failure<Guid>(TemplateErrors.NotFound);
|
||||
|
||||
var layer = template.AddLayer(command.Name, command.Priority);
|
||||
template.MarkChanged();
|
||||
return Result.Success(layer.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpdateLayerCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdateLayerCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(UpdateLayerCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var template = await LayerLoader.ByLayerAsync(dbContext, command.LayerId, cancellationToken);
|
||||
var layer = template?.FindLayer(command.LayerId);
|
||||
if (template is null || layer is null)
|
||||
return Result.Failure(TemplateErrors.LayerNotFound);
|
||||
|
||||
layer.Update(
|
||||
command.Name,
|
||||
command.Priority,
|
||||
command.Applicability?.ToJson(),
|
||||
command.IsEnabled
|
||||
);
|
||||
template.MarkChanged();
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DeleteLayerCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<DeleteLayerCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(DeleteLayerCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var template = await LayerLoader.ByLayerAsync(dbContext, command.LayerId, cancellationToken);
|
||||
var layer = template?.FindLayer(command.LayerId);
|
||||
if (template is null || layer is null)
|
||||
return Result.Failure(TemplateErrors.LayerNotFound);
|
||||
|
||||
if (layer.IsBackground)
|
||||
return Result.Failure(TemplateErrors.BackgroundLayerCannotBeDeleted);
|
||||
|
||||
template.RemoveLayer(command.LayerId);
|
||||
template.MarkChanged();
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpdateTemplateCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdateTemplateCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
UpdateTemplateCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var template = await dbContext.ScheduleTemplates.FirstOrDefaultAsync(
|
||||
t => t.Id == command.TemplateId,
|
||||
cancellationToken
|
||||
);
|
||||
if (template is null)
|
||||
return Result.Failure(TemplateErrors.NotFound);
|
||||
|
||||
if (
|
||||
command.FallbackGroupId is { } groupId
|
||||
&& !await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken)
|
||||
)
|
||||
return Result.Failure(TemplateErrors.GroupNotFound);
|
||||
|
||||
template.Rename(command.Name);
|
||||
template.SetFallbackGroup(command.FallbackGroupId);
|
||||
template.SetDefaultJunction(command.DefaultJunctionId);
|
||||
template.MarkChanged();
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Загрузка шаблона со слоями и слотами: любая правка слоя меняет ревизию всего шаблона.</summary>
|
||||
internal static class LayerLoader
|
||||
{
|
||||
public static Task<ScheduleTemplate?> ByTemplateAsync(
|
||||
IAppDbContext dbContext,
|
||||
Guid templateId,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
dbContext
|
||||
.ScheduleTemplates.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.FirstOrDefaultAsync(t => t.Id == templateId, cancellationToken);
|
||||
|
||||
public static Task<ScheduleTemplate?> ByLayerAsync(
|
||||
IAppDbContext dbContext,
|
||||
Guid layerId,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
dbContext
|
||||
.ScheduleTemplates.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.FirstOrDefaultAsync(t => t.Layers.Any(l => l.Id == layerId), cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using FluentValidation;
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Layers;
|
||||
|
||||
public sealed record CreateLayerCommand(Guid TemplateId, string Name, int Priority)
|
||||
: ICommand<Result<Guid>>;
|
||||
|
||||
public sealed record UpdateLayerCommand(
|
||||
Guid LayerId,
|
||||
string Name,
|
||||
int Priority,
|
||||
LayerApplicability? Applicability,
|
||||
bool IsEnabled
|
||||
) : ICommand<Result>;
|
||||
|
||||
public sealed record DeleteLayerCommand(Guid LayerId) : ICommand<Result>;
|
||||
|
||||
/// <summary>Имя шаблона и аварийная группа, играющая, когда пуст даже фоновый слой.</summary>
|
||||
public sealed record UpdateTemplateCommand(
|
||||
Guid TemplateId,
|
||||
string Name,
|
||||
Guid? FallbackGroupId,
|
||||
Guid? DefaultJunctionId
|
||||
) : ICommand<Result>;
|
||||
|
||||
public sealed class CreateLayerCommandValidator : AbstractValidator<CreateLayerCommand>
|
||||
{
|
||||
public CreateLayerCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
|
||||
RuleFor(x => x.Priority).InclusiveBetween(1, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpdateLayerCommandValidator : AbstractValidator<UpdateLayerCommand>
|
||||
{
|
||||
public UpdateLayerCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
|
||||
RuleFor(x => x.Priority).InclusiveBetween(1, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpdateTemplateCommandValidator : AbstractValidator<UpdateTemplateCommand>
|
||||
{
|
||||
public UpdateTemplateCommandValidator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates;
|
||||
|
||||
/// <summary>
|
||||
/// Откуда слот-повтор берёт содержимое: точка в уже записанной ленте того же канала. «Вечерний фильм
|
||||
/// утром в субботу» — это <c>daysAgo = 1, time = 20:00</c>.
|
||||
/// </summary>
|
||||
public sealed record RepeatSource(int DaysAgo, TimeOnly Time, int DurationMinutes)
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
public string ToJson() => JsonSerializer.Serialize(this, Options);
|
||||
|
||||
public static RepeatSource? FromJson(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<RepeatSource>(json, Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using FluentValidation;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates;
|
||||
|
||||
/// <summary>Полный набор настроек слота — общий для создания и правки.</summary>
|
||||
public sealed record SlotInput(
|
||||
string Title,
|
||||
int? Weekday,
|
||||
TimeOnly TargetStart,
|
||||
int TargetDurationMinutes,
|
||||
Daypart Daypart,
|
||||
SlotKind SlotKind,
|
||||
Guid? GroupId,
|
||||
SlotStrategy? Strategy,
|
||||
RepeatSource? RepeatSource,
|
||||
SlotBlockMode BlockMode,
|
||||
int BlockValue,
|
||||
OverflowPolicy OverflowPolicy,
|
||||
bool IsAnchor,
|
||||
int MaxDriftMinutes,
|
||||
int? SnapToMinutes,
|
||||
/// <summary>Стык между единицами внутри блока.</summary>
|
||||
Guid? JunctionBetweenId = null,
|
||||
/// <summary>Стык в конце блока (null — берётся стык шаблона по умолчанию).</summary>
|
||||
Guid? JunctionAfterId = null
|
||||
);
|
||||
|
||||
public sealed class SlotInputValidator : AbstractValidator<SlotInput>
|
||||
{
|
||||
/// <summary>Округлять старт можно только до значений, которые читаются как «круглое время».</summary>
|
||||
private static readonly int[] AllowedSnap = [5, 10, 15, 30];
|
||||
|
||||
public SlotInputValidator()
|
||||
{
|
||||
RuleFor(x => x.Title).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.Weekday).InclusiveBetween(0, 6).When(x => x.Weekday is not null);
|
||||
|
||||
// Сутки — верхняя граница осмысленного слота: длиннее он всё равно перекроет сам себя.
|
||||
RuleFor(x => x.TargetDurationMinutes).InclusiveBetween(1, 24 * 60);
|
||||
RuleFor(x => x.BlockValue).GreaterThan(0);
|
||||
RuleFor(x => x.MaxDriftMinutes).InclusiveBetween(0, 12 * 60);
|
||||
|
||||
RuleFor(x => x.SnapToMinutes)
|
||||
.Must(value => value is null || AllowedSnap.Contains(value.Value))
|
||||
.WithMessage("Округление старта допустимо до 5, 10, 15 или 30 минут.");
|
||||
|
||||
RuleFor(x => x.Strategy!.CooldownDays)
|
||||
.InclusiveBetween(0, 3650)
|
||||
.When(x => x.Strategy is not null);
|
||||
|
||||
RuleFor(x => x.RepeatSource!.DaysAgo)
|
||||
.InclusiveBetween(1, 365)
|
||||
.When(x => x.RepeatSource is not null);
|
||||
RuleFor(x => x.RepeatSource!.DurationMinutes)
|
||||
.InclusiveBetween(1, 24 * 60)
|
||||
.When(x => x.RepeatSource is not null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates;
|
||||
|
||||
/// <summary>Как слот выбирает элемент группы.</summary>
|
||||
public enum SlotStrategyType
|
||||
{
|
||||
/// <summary>Элементы группы по порядку позиций.</summary>
|
||||
Sequential = 0,
|
||||
|
||||
/// <summary>Случайный элемент, не показанный последние N дней.</summary>
|
||||
RandomWithCooldown = 1,
|
||||
|
||||
/// <summary>Всегда один и тот же элемент.</summary>
|
||||
Fixed = 2,
|
||||
}
|
||||
|
||||
/// <summary>Что делать, когда остывание отсекло всех кандидатов.</summary>
|
||||
public enum CooldownFallback
|
||||
{
|
||||
/// <summary>Взять элемент, не игравший дольше всех.</summary>
|
||||
OldestFirst = 0,
|
||||
|
||||
/// <summary>Проигнорировать остывание на этот выход.</summary>
|
||||
IgnoreCooldown = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Стратегия выбора элемента внутри слота. Отвечает только за выбор элемента: единицы внутри элемента
|
||||
/// всегда идут по порядку от курсора — сериал не должен прыгать по сериям.
|
||||
///
|
||||
/// Размер блока живёт на слоте отдельно и ортогонален стратегии: любую можно скомбинировать
|
||||
/// с «одна единица», «четыре подряд» или «пока не наберётся два часа».
|
||||
/// </summary>
|
||||
public sealed record SlotStrategy(
|
||||
SlotStrategyType Type = SlotStrategyType.Sequential,
|
||||
/// <summary>Начинать группу заново, дойдя до конца (иначе слот останавливается).</summary>
|
||||
bool RestartOnEnd = true,
|
||||
/// <summary>Дней остывания для <see cref="SlotStrategyType.RandomWithCooldown"/>.</summary>
|
||||
int CooldownDays = 0,
|
||||
CooldownFallback Fallback = CooldownFallback.OldestFirst,
|
||||
/// <summary>Закреплённый элемент для <see cref="SlotStrategyType.Fixed"/>.</summary>
|
||||
Guid? FixedElementId = 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 SlotStrategy? FromJson(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<SlotStrategy>(json, Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates;
|
||||
|
||||
/// <summary>
|
||||
/// Общая часть создания и правки слота: проверки, которые нельзя доверить валидатору, потому что
|
||||
/// им нужны соседние слоты и справочник групп.
|
||||
/// </summary>
|
||||
public sealed class SlotWriter(IAppDbContext dbContext)
|
||||
{
|
||||
/// <summary>
|
||||
/// Проверяет вход и применяет его к слоту. <paramref name="slot"/> = null — проверка перед
|
||||
/// созданием.
|
||||
/// </summary>
|
||||
public async Task<Result> ApplyAsync(
|
||||
GridLayer layer,
|
||||
Slot? slot,
|
||||
SlotInput input,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
// Перекрытие внутри слоя запрещено: два слота на одну минуту сделали бы выбор неоднозначным.
|
||||
if (
|
||||
layer.HasOverlap(
|
||||
input.Weekday,
|
||||
input.TargetStart,
|
||||
input.TargetDurationMinutes,
|
||||
slot?.Id
|
||||
)
|
||||
)
|
||||
return Result.Failure(TemplateErrors.SlotsOverlap);
|
||||
|
||||
if (input.SlotKind == SlotKind.Content)
|
||||
{
|
||||
if (input.GroupId is not { } groupId)
|
||||
return Result.Failure(TemplateErrors.GroupRequired);
|
||||
|
||||
if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken))
|
||||
return Result.Failure(TemplateErrors.GroupNotFound);
|
||||
}
|
||||
|
||||
if (input.SlotKind == SlotKind.Repeat && input.RepeatSource is null)
|
||||
return Result.Failure(TemplateErrors.RepeatSourceRequired);
|
||||
|
||||
var target =
|
||||
slot
|
||||
?? layer.AddSlot(
|
||||
input.Title,
|
||||
input.TargetStart,
|
||||
input.TargetDurationMinutes,
|
||||
input.Daypart,
|
||||
input.SlotKind,
|
||||
input.Weekday
|
||||
);
|
||||
|
||||
target.UpdateTiming(
|
||||
input.Weekday,
|
||||
input.TargetStart,
|
||||
input.TargetDurationMinutes,
|
||||
input.Daypart,
|
||||
input.IsAnchor,
|
||||
input.MaxDriftMinutes,
|
||||
input.SnapToMinutes
|
||||
);
|
||||
target.UpdateContent(
|
||||
input.Title,
|
||||
input.SlotKind,
|
||||
input.GroupId,
|
||||
input.Strategy?.ToJson(),
|
||||
input.RepeatSource?.ToJson(),
|
||||
input.BlockMode,
|
||||
input.BlockValue,
|
||||
input.OverflowPolicy,
|
||||
input.JunctionBetweenId,
|
||||
input.JunctionAfterId
|
||||
);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
/// <summary>Загружает шаблон целиком по слою — правка слота меняет ревизию всего шаблона.</summary>
|
||||
public Task<ScheduleTemplate?> LoadTemplateByLayerAsync(
|
||||
Guid layerId,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
dbContext
|
||||
.ScheduleTemplates.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.FirstOrDefaultAsync(t => t.Layers.Any(l => l.Id == layerId), cancellationToken);
|
||||
|
||||
/// <summary>Загружает шаблон по слоту.</summary>
|
||||
public Task<ScheduleTemplate?> LoadTemplateBySlotAsync(
|
||||
Guid slotId,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
dbContext
|
||||
.ScheduleTemplates.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.FirstOrDefaultAsync(
|
||||
t => t.Layers.Any(l => l.Slots.Any(s => s.Id == slotId)),
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates;
|
||||
|
||||
public sealed record SlotDto(
|
||||
Guid Id,
|
||||
Guid LayerId,
|
||||
int? Weekday,
|
||||
TimeOnly TargetStart,
|
||||
int TargetDurationMinutes,
|
||||
string Title,
|
||||
Daypart Daypart,
|
||||
SlotKind SlotKind,
|
||||
Guid? GroupId,
|
||||
string? GroupName,
|
||||
SlotStrategy? Strategy,
|
||||
RepeatSource? RepeatSource,
|
||||
SlotBlockMode BlockMode,
|
||||
int BlockValue,
|
||||
OverflowPolicy OverflowPolicy,
|
||||
bool IsAnchor,
|
||||
int MaxDriftMinutes,
|
||||
int? SnapToMinutes,
|
||||
Guid? JunctionBetweenId,
|
||||
Guid? JunctionAfterId
|
||||
);
|
||||
|
||||
public sealed record GridLayerDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
int Priority,
|
||||
bool IsEnabled,
|
||||
bool IsBackground,
|
||||
LayerApplicability? Applicability,
|
||||
IReadOnlyList<SlotDto> Slots
|
||||
);
|
||||
|
||||
public sealed record ScheduleTemplateDto(
|
||||
Guid Id,
|
||||
Guid ChannelId,
|
||||
string Name,
|
||||
Guid? FallbackGroupId,
|
||||
Guid? DefaultJunctionId,
|
||||
int Revision,
|
||||
int AppliedRevision,
|
||||
/// <summary>Есть ли правки правил, ещё не применённые к эфиру.</summary>
|
||||
bool HasPendingChanges,
|
||||
/// <summary>Время канала — сетка задаётся в нём, а не в UTC.</summary>
|
||||
int UtcOffsetMinutes,
|
||||
TimeOnly DayStartTime,
|
||||
IReadOnlyList<GridLayerDto> Layers
|
||||
);
|
||||
@@ -0,0 +1,66 @@
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates;
|
||||
|
||||
public static class TemplateErrors
|
||||
{
|
||||
public static readonly Error NotFound = Error.NotFound(
|
||||
"Templates.NotFound",
|
||||
"Шаблон сетки не найден."
|
||||
);
|
||||
|
||||
public static readonly Error LayerNotFound = Error.NotFound(
|
||||
"Templates.LayerNotFound",
|
||||
"Слой сетки не найден."
|
||||
);
|
||||
|
||||
public static readonly Error SlotNotFound = Error.NotFound(
|
||||
"Templates.SlotNotFound",
|
||||
"Слот не найден."
|
||||
);
|
||||
|
||||
public static readonly Error BackgroundLayerCannotBeDeleted = Error.Validation(
|
||||
"Templates.BackgroundLayerCannotBeDeleted",
|
||||
"Фоновый слой удалить нельзя — без него в сетке появятся дыры."
|
||||
);
|
||||
|
||||
public static readonly Error SlotsOverlap = Error.Conflict(
|
||||
"Templates.SlotsOverlap",
|
||||
"Слоты одного слоя пересекаются по времени."
|
||||
);
|
||||
|
||||
public static readonly Error GroupRequired = Error.Validation(
|
||||
"Templates.GroupRequired",
|
||||
"Слоту с контентом нужна группа."
|
||||
);
|
||||
|
||||
public static readonly Error GroupNotFound = Error.NotFound(
|
||||
"Templates.GroupNotFound",
|
||||
"Группа не найдена."
|
||||
);
|
||||
|
||||
public static readonly Error JunctionNotFound = Error.NotFound(
|
||||
"Templates.JunctionNotFound",
|
||||
"Шаблон стыка не найден."
|
||||
);
|
||||
|
||||
public static readonly Error JunctionElementNotFound = Error.NotFound(
|
||||
"Templates.JunctionElementNotFound",
|
||||
"Врезка не найдена."
|
||||
);
|
||||
|
||||
public static readonly Error JunctionInUse = Error.Conflict(
|
||||
"Templates.JunctionInUse",
|
||||
"Стык используется слотами — сначала отвяжите его."
|
||||
);
|
||||
|
||||
public static readonly Error JunctionGroupRequired = Error.Validation(
|
||||
"Templates.JunctionGroupRequired",
|
||||
"Врезке нужна группа, откуда брать ролики."
|
||||
);
|
||||
|
||||
public static readonly Error RepeatSourceRequired = Error.Validation(
|
||||
"Templates.RepeatSourceRequired",
|
||||
"Слоту-повтору нужно указать, что повторять."
|
||||
);
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.UpdateSlot;
|
||||
|
||||
public sealed record UpdateSlotCommand(Guid SlotId, SlotInput Input) : ICommand<Result>;
|
||||
|
||||
public sealed class UpdateSlotCommandValidator : AbstractValidator<UpdateSlotCommand>
|
||||
{
|
||||
public UpdateSlotCommandValidator() =>
|
||||
RuleFor(x => x.Input).NotNull().SetValidator(new SlotInputValidator());
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.UpdateSlot;
|
||||
|
||||
public sealed class UpdateSlotCommandHandler(SlotWriter writer)
|
||||
: ICommandHandler<UpdateSlotCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(UpdateSlotCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var template = await writer.LoadTemplateBySlotAsync(command.SlotId, cancellationToken);
|
||||
var layer = template?.FindLayerOfSlot(command.SlotId);
|
||||
var slot = layer?.FindSlot(command.SlotId);
|
||||
if (template is null || layer is null || slot is null)
|
||||
return Result.Failure(TemplateErrors.SlotNotFound);
|
||||
|
||||
var applied = await writer.ApplyAsync(layer, slot, command.Input, cancellationToken);
|
||||
if (!applied.IsSuccess)
|
||||
return applied;
|
||||
|
||||
template.MarkChanged();
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user