Files
ersatztv/ErsatzTV.Application/Scheduling/Commands/ReplacePlayoutTemplateItemsHandler.cs
T
timothyandClaude Opus 5 528383cf3a
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
fix(880): an absent recurrence array means unrestricted, an explicit [] is rejected (#892)
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>
2026-08-30 09:29:02 +00:00

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);
}
}
}