Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 10m54s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 7m34s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m59s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 1m27s
The three recurrence arrays are read CONJUNCTIVELY by AlternateScheduleSelector.GetScheduleForDate, so an empty set matches no date. `?? []` on an omitted array therefore returned HTTP 200 while storing an alternate-schedule or template item that could never apply, silently -- while the read side (#823) already read a NULL column as the All*() sets. Absent and explicitly-empty are two different requests and get two answers: ABSENT (missing, or explicit null) normalizes to AlternateScheduleSelector.All*(), the same symbols the read side substitutes; EXPLICIT [] is rejected with a 422 naming the consequence, via RecurrenceSetBounds called from both replace handlers. The rejection lives in the handlers, not the controller, because api.ffmpeg-profile-numeric-bounds' "accept an UNCHANGED bad value" rule binds hardest here: both PUT paths are whole-list replaces, so rejecting a pre-existing empty set would make every OTHER item in the list uneditable. That comparison needs the stored row. The validated set is derived from `incoming`, so the highest-Index catch-all -- whose recurrence the handler discards -- is excluded by construction. Verified: full ErsatzTV.Tests suite green; three mutation proofs with disjoint reddened sets; live-E2E against a real instance confirmed an OMITTED property round-trips as unrestricted (the Newtonsoft missing-property chain unit tests cannot reach), an explicit [] returns the 422, and [] on the catch-all is accepted. Cross-family cold review BLOCKED the first implementation with 3 findings, all real and all fixed; re-review returned MERGEABLE. Follow-up #894 filed: the SPA can still build the empty state the server rejects. fixes #880 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
146 lines
6.4 KiB
C#
146 lines
6.4 KiB
C#
using ErsatzTV.Core;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Domain.Scheduling;
|
|
using ErsatzTV.Infrastructure.Data;
|
|
using ErsatzTV.Infrastructure.Extensions;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace ErsatzTV.Application.Scheduling;
|
|
|
|
public class ReplacePlayoutTemplateItemsHandler(
|
|
IDbContextFactory<TvContext> dbContextFactory,
|
|
ILogger<ReplacePlayoutTemplateItemsHandler> logger)
|
|
: IRequestHandler<ReplacePlayoutTemplateItems, Option<BaseError>>
|
|
{
|
|
public async Task<Option<BaseError>> Handle(
|
|
ReplacePlayoutTemplateItems request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
|
|
Option<Playout> maybePlayout = await dbContext.Playouts
|
|
.Include(p => p.ProgramSchedule)
|
|
.Include(p => p.Templates)
|
|
.ThenInclude(t => t.Template)
|
|
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId, cancellationToken);
|
|
|
|
foreach (Playout playout in maybePlayout)
|
|
{
|
|
// Optimistic-concurrency pre-check (issue #253 §7a): reject a stale If-Match with a
|
|
// PreconditionFailedError (→ 412) before any mutation. Returned directly so it escapes the
|
|
// catch(Exception) below as a 412 rather than being reshaped into a bare 422 (§9/H1).
|
|
Either<BaseError, Playout> versionCheck = playout.CheckVersion(request.ExpectedVersions);
|
|
if (versionCheck.IsLeft)
|
|
{
|
|
return versionCheck.Match<Option<BaseError>>(
|
|
Left: error => error,
|
|
Right: _ => Option<BaseError>.None);
|
|
}
|
|
|
|
PlayoutTemplate[] existing = playout.Templates.ToArray();
|
|
|
|
List<ReplacePlayoutTemplate> incoming = request.Items;
|
|
|
|
// Same rule as the alternate-schedule path (#880), over ALL items: unlike that one, every
|
|
// template item's recurrence IS stored, so there is no catch-all to exclude here.
|
|
foreach (ReplacePlayoutTemplate item in incoming)
|
|
{
|
|
PlayoutTemplate stored = existing.FirstOrDefault(e => e.Id == item.Id);
|
|
Option<BaseError> recurrenceError = RecurrenceSetBounds.Validate(
|
|
item.DaysOfWeek,
|
|
item.DaysOfMonth,
|
|
item.MonthsOfYear,
|
|
stored?.DaysOfWeek,
|
|
stored?.DaysOfMonth,
|
|
stored?.MonthsOfYear);
|
|
|
|
if (recurrenceError.IsSome)
|
|
{
|
|
return recurrenceError;
|
|
}
|
|
}
|
|
|
|
var toAdd = incoming.Filter(x => existing.All(e => e.Id != x.Id)).ToList();
|
|
var toRemove = existing.Filter(e => incoming.All(m => m.Id != e.Id)).ToList();
|
|
var toUpdate = incoming.Except(toAdd).ToList();
|
|
|
|
foreach (PlayoutTemplate remove in toRemove)
|
|
{
|
|
playout.Templates.Remove(remove);
|
|
}
|
|
|
|
DateTime now = DateTime.UtcNow;
|
|
|
|
foreach (ReplacePlayoutTemplate add in toAdd)
|
|
{
|
|
playout.Templates.Add(
|
|
new PlayoutTemplate
|
|
{
|
|
PlayoutId = playout.Id,
|
|
Index = add.Index,
|
|
TemplateId = add.TemplateId,
|
|
DecoTemplateId = add.DecoTemplateId,
|
|
DaysOfWeek = add.DaysOfWeek,
|
|
DaysOfMonth = add.DaysOfMonth,
|
|
MonthsOfYear = add.MonthsOfYear,
|
|
LimitToDateRange = add.LimitToDateRange,
|
|
StartMonth = add.StartMonth,
|
|
StartDay = add.StartDay,
|
|
StartYear = add.StartYear,
|
|
EndMonth = add.EndMonth,
|
|
EndDay = add.EndDay,
|
|
EndYear = add.EndYear,
|
|
DateUpdated = now
|
|
});
|
|
}
|
|
|
|
foreach (ReplacePlayoutTemplate update in toUpdate)
|
|
{
|
|
foreach (PlayoutTemplate ex in existing.Filter(x => x.Id == update.Id))
|
|
{
|
|
ex.Index = update.Index;
|
|
ex.TemplateId = update.TemplateId;
|
|
ex.DecoTemplateId = update.DecoTemplateId;
|
|
ex.DaysOfWeek = update.DaysOfWeek;
|
|
ex.DaysOfMonth = update.DaysOfMonth;
|
|
ex.MonthsOfYear = update.MonthsOfYear;
|
|
ex.LimitToDateRange = update.LimitToDateRange;
|
|
ex.StartMonth = update.StartMonth;
|
|
ex.StartDay = update.StartDay;
|
|
ex.StartYear = update.StartYear;
|
|
ex.EndMonth = update.EndMonth;
|
|
ex.EndDay = update.EndDay;
|
|
ex.EndYear = update.EndYear;
|
|
ex.DateUpdated = now;
|
|
}
|
|
}
|
|
|
|
// Unconditional bump (issue #253 §7a / M1): #8 writes no root scalar at all (only the
|
|
// Templates child collection), so without this the root UPDATE never fires and the token
|
|
// never rotates. Bump then save through the guard, which maps a racing
|
|
// DbUpdateConcurrencyException to PreconditionFailedError (→ 412) as a return value.
|
|
playout.Version++;
|
|
|
|
Either<BaseError, Unit> saveResult =
|
|
await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken);
|
|
if (saveResult.IsLeft)
|
|
{
|
|
return saveResult.Match<Option<BaseError>>(
|
|
Left: error => error,
|
|
Right: _ => Option<BaseError>.None);
|
|
}
|
|
}
|
|
|
|
return Option<BaseError>.None;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Error saving playout template items");
|
|
return BaseError.New(ex.Message);
|
|
}
|
|
}
|
|
}
|