using System.Threading.Channels; using ErsatzTV.Application.Playouts; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Errors; using ErsatzTV.Core.Scheduling; using ErsatzTV.Infrastructure.Data; using Microsoft.EntityFrameworkCore; using static ErsatzTV.Application.ProgramSchedules.Mapper; namespace ErsatzTV.Application.ProgramSchedules; public class AddProgramScheduleItemHandler : ProgramScheduleItemCommandBase, IRequestHandler> { private readonly ChannelWriter _channel; private readonly IDbContextFactory _dbContextFactory; public AddProgramScheduleItemHandler( IDbContextFactory dbContextFactory, ChannelWriter channel) { _dbContextFactory = dbContextFactory; _channel = channel; } public async Task> Handle( AddProgramScheduleItem request, CancellationToken cancellationToken) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); Option maybeProgramSchedule = await ProgramScheduleMustExist(dbContext, request.ProgramScheduleId, cancellationToken); return await maybeProgramSchedule.Match( Some: async programSchedule => { Validation validation = await Validate(dbContext, request, programSchedule); return await validation.Apply(ps => PersistItem(dbContext, request, ps, cancellationToken)); }, None: () => Task.FromResult>( new NotFoundError("[ProgramScheduleId] does not exist."))); } private async Task PersistItem( TvContext dbContext, AddProgramScheduleItem request, ProgramSchedule programSchedule, CancellationToken cancellationToken) { int nextIndex = programSchedule.Items.Select(i => i.Index).DefaultIfEmpty(0).Max() + 1; ProgramScheduleItem item = BuildItem(programSchedule, nextIndex, request); programSchedule.Items.Add(item); // bump the optimistic-concurrency token so this config edit rotates other clients' ETags (#253) programSchedule.Version++; await dbContext.SaveChangesAsync(cancellationToken); // refresh any playouts that use this schedule // post-commit side effect runs on CancellationToken.None so a late request cancellation // can't abort it after the commit landed (#254) foreach (Playout playout in programSchedule.Playouts) { await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), CancellationToken.None); } // reload with the full navigation graph before projecting: BuildItem creates the watermark/graphics // join rows with only their foreign-key ids set, so the tracked entities have null Watermark / // GraphicsElement navs that ProjectToViewModel dereferences (would 500 on POST — see #229). ProgramScheduleItem persisted = await dbContext.ProgramScheduleItems .Filter(psi => psi.Id == item.Id) .IncludeScheduleItemDetails() .SingleAsync(cancellationToken); return ProjectToViewModel(persisted); } private static async Task> Validate( TvContext dbContext, AddProgramScheduleItem request, ProgramSchedule programSchedule) { Validation validation = PlayoutModeMustBeValid(request, programSchedule) .Bind(programSchedule => CollectionTypeMustBeValid(request, programSchedule)); return await validation.ToEither().Match( Left: error => Task.FromResult>( Fail(error)), Right: async validProgramSchedule => { Either fillerResult = await FillerConfigurationMustBeValid( dbContext, request, validProgramSchedule); return fillerResult.ToValidation(); }); } }