Files
ersatztv/ErsatzTV.Application/Scheduling/Commands/ReplaceTemplateItemsHandler.cs
T
timothyandClaude Opus 4.8 611924c0ee feat(#253 PR2): optimistic-concurrency contract for Template and DecoTemplate
Wire the frozen ETag/If-Match/412 recipe (Block reference implementation)
onto the Template and DecoTemplate aggregates:

- ReplaceTemplateItems / ReplaceDecoTemplateItems commands gain
  Option<int> ExpectedVersion; ToCommand() on the request DTOs threads it
  through from If-Match.
- Handlers introduce the version check as a standalone Either after
  validation (never via Apply), bump Version unconditionally before
  saving, and persist through SaveChangesWithConcurrencyGuard so a losing
  writer maps to 412 instead of 500. DecoTemplate's post-commit playout
  Reset enqueue now only runs after a successful save.
- TemplateViewModel / DecoTemplateViewModel carry Version (header-only,
  not echoed in the response body), populated in Mapper.
- TemplateController / DecoTemplateController: GET items emits a strong
  ETag of the root's version; PUT parses If-Match (400 on malformed),
  threads the expected version into the command, and returns the new
  ETag from the refreshed root on success. Both PUT actions now use the
  handler's returned item list directly instead of re-querying items.
- SPA: templates.ts / decoTemplates.ts gain getXItemsWithMeta and an
  If-Match-aware replaceX; TemplateEditor / DecoTemplateEditor hold the
  ETag in a ref, read items-with-meta first on load, and open a
  "changed elsewhere" ConfirmDialog on a 412 instead of navigating away.

Tests: new ReplaceTemplateItemsHandlerConcurrencyTests /
ReplaceDecoTemplateItemsHandlerConcurrencyTests mirror the Block
concurrency contract tests (stale/matching/absent If-Match, no-op bump,
racing-save 412, non-vacuous backstop). TemplateControllerTests /
DecoTemplateControllerTests gain ETag/If-Match/412 coverage.
TemplatesScreen.test.tsx / DecoTemplatesScreen.test.tsx gain a 412
conflict-dialog test mirroring BlocksScreen's.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 18:36:35 +02:00

175 lines
7.2 KiB
C#

using ErsatzTV.Core;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Scheduling;
public class ReplaceTemplateItemsHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<ReplaceTemplateItems, Either<BaseError, List<TemplateItemViewModel>>>
{
public async Task<Either<BaseError, List<TemplateItemViewModel>>> Handle(
ReplaceTemplateItems request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Template> validation = await Validate(dbContext, request, cancellationToken);
// Introduce the optimistic-concurrency check as a standalone Either AFTER the validation
// pipeline (never via Apply) so a PreconditionFailedError survives to a 412 and is not
// flattened to a generic 422 by Join() (issue #253 / api-conventions §7a).
Either<BaseError, Template> validated = LanguageExtensions.ToEither(validation)
.Bind(template => template.CheckVersion(request.ExpectedVersion));
return await validated.Match(
Right: template => Persist(dbContext, request, template, cancellationToken),
Left: error => Task.FromResult<Either<BaseError, List<TemplateItemViewModel>>>(error));
}
private static async Task<Either<BaseError, List<TemplateItemViewModel>>> Persist(
TvContext dbContext,
ReplaceTemplateItems request,
Template template,
CancellationToken cancellationToken)
{
template.Name = request.Name;
template.DateUpdated = DateTime.UtcNow;
dbContext.RemoveRange(template.Items);
template.Items = request.Items.Map(i => BuildItem(template, i)).ToList();
// Unconditional bump: EF emits the root UPDATE only when a scalar actually differs, so a
// same-value/no-op save would otherwise write no root row and neither fire the concurrency
// token nor rotate other clients' ETags. Bumping guarantees both on every save (issue #253).
template.Version++;
// TODO: refresh any playouts that use this schedule
// foreach (Playout playout in programSchedule.Playouts)
// {
// await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh));
// }
// Save through the guard so an EF concurrency failure (a racing writer won between our load
// and save) maps to 412 rather than surfacing as a 500.
Either<BaseError, Unit> saved = await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken);
return await saved.Match(
Right: async _ =>
{
await dbContext.Entry(template)
.Collection(t => t.Items)
.Query()
.Include(i => i.Block)
.LoadAsync(cancellationToken);
return Right<BaseError, List<TemplateItemViewModel>>(
template.Items.Map(Mapper.ProjectToViewModel).ToList());
},
Left: error => Task.FromResult(Left<BaseError, List<TemplateItemViewModel>>(error)));
}
private static TemplateItem BuildItem(Template template, ReplaceTemplateItem item) =>
new()
{
TemplateId = template.Id,
BlockId = item.BlockId,
StartTime = item.StartTime
};
private static Task<Validation<BaseError, Template>> Validate(
TvContext dbContext,
ReplaceTemplateItems request,
CancellationToken cancellationToken) =>
TemplateMustExist(dbContext, request.TemplateId, cancellationToken)
.BindT(template => TemplateItemsMustBeValid(dbContext, template, request))
.BindT(template => ValidateTemplateName(dbContext, template, request));
private static async Task<Validation<BaseError, Template>> TemplateItemsMustBeValid(
TvContext dbContext,
Template template,
ReplaceTemplateItems request)
{
var allBlockIds = request.Items.Map(i => i.BlockId).Distinct().ToList();
Dictionary<int, Block> allBlocks = await dbContext.Blocks
.AsNoTracking()
.Filter(b => allBlockIds.Contains(b.Id))
.ToListAsync()
.Map(list => list.ToDictionary(b => b.Id, b => b));
var missingBlockIds = allBlockIds.Filter(id => !allBlocks.ContainsKey(id)).ToList();
if (missingBlockIds.Count > 0)
{
return BaseError.New($"[BlockId] {missingBlockIds.Head()} does not exist.");
}
var allTemplateItems = request.Items.Map(i =>
{
Block block = allBlocks[i.BlockId];
var endTime = i.StartTime + TimeSpan.FromMinutes(block.Minutes);
return new BlockTemplateItem(i.BlockId, i.StartTime, endTime);
})
.ToList();
foreach (BlockTemplateItem item in allTemplateItems)
{
if (item.EndTime > TimeSpan.FromHours(24))
{
return BaseError.New(
$"Block from {item.StartTime} to {item.EndTime} crosses midnight, which is not supported");
}
}
foreach (BlockTemplateItem item in allTemplateItems)
{
foreach (BlockTemplateItem otherItem in allTemplateItems)
{
if (item == otherItem)
{
continue;
}
if (item.StartTime < otherItem.EndTime && otherItem.StartTime < item.EndTime)
{
return BaseError.New(
$"Block from {item.StartTime} to {item.EndTime} intersects block from {otherItem.StartTime} to {otherItem.EndTime}");
}
}
}
return template;
}
private static Task<Validation<BaseError, Template>> TemplateMustExist(
TvContext dbContext,
int templateId,
CancellationToken cancellationToken) =>
dbContext.Templates
.Include(b => b.Items)
.SelectOneAsync(b => b.Id, b => b.Id == templateId, cancellationToken)
.Map(o => o.ToValidation<BaseError>("[TemplateId] does not exist."));
private static async Task<Validation<BaseError, Template>> ValidateTemplateName(
TvContext dbContext,
Template template,
ReplaceTemplateItems request)
{
if (request.Name.Length > 50)
{
return BaseError.New($"Template name \"{request.Name}\" is invalid");
}
Option<Template> maybeExisting = await dbContext.Templates
.AsNoTracking()
.FirstOrDefaultAsync(d =>
d.Id != request.TemplateId && d.TemplateGroupId == request.TemplateGroupId && d.Name == request.Name)
.Map(Optional);
return maybeExisting.IsSome
? BaseError.New($"A template named \"{request.Name}\" already exists in that template group")
: Success<BaseError, Template>(template);
}
private sealed record BlockTemplateItem(int BlockId, TimeSpan StartTime, TimeSpan EndTime);
}