Files
ersatztv/ErsatzTV.Application/Scheduling/Mapper.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

211 lines
9.7 KiB
C#

using ErsatzTV.Application.Tree;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
namespace ErsatzTV.Application.Scheduling;
internal static class Mapper
{
internal static TreeViewModel ProjectToViewModel(List<DecoTemplateGroup> decoTemplateGroups) =>
new(
decoTemplateGroups.OrderBy(dtg => dtg.Name).Map(dtg => new TreeGroupViewModel(
dtg.Id,
dtg.Name,
dtg.DecoTemplates.OrderBy(dt => dt.Name).Map(dt => new TreeItemViewModel(dt.Id, dt.Name)).ToList()))
.ToList());
internal static TreeViewModel ProjectToViewModel(List<DecoGroup> decoGroups) =>
new(
decoGroups.OrderBy(dg => dg.Name).Map(dg => new TreeGroupViewModel(
dg.Id,
dg.Name,
dg.Decos.OrderBy(d => d.Name).Map(d => new TreeItemViewModel(d.Id, d.Name)).ToList())).ToList());
internal static TreeViewModel ProjectToViewModel(List<TemplateGroup> templateGroups) =>
new(
templateGroups.OrderBy(tg => tg.Name).Map(tg => new TreeGroupViewModel(
tg.Id,
tg.Name,
tg.Templates.OrderBy(t => t.Name).Map(t => new TreeItemViewModel(t.Id, t.Name)).ToList())).ToList());
internal static BlockGroupViewModel ProjectToViewModel(BlockGroup blockGroup) =>
new(blockGroup.Id, blockGroup.Name);
internal static BlockViewModel ProjectToViewModel(Block block) =>
new(block.Id, block.BlockGroupId, block.BlockGroup.Name, block.Name, block.Minutes, block.StopScheduling, block.Version);
internal static BlockItemViewModel ProjectToViewModel(BlockItem blockItem) =>
new(
blockItem.Id,
blockItem.Index,
blockItem.CollectionType,
blockItem.Collection is not null ? MediaCollections.Mapper.ProjectToViewModel(blockItem.Collection) : null,
blockItem.MultiCollection is not null
? MediaCollections.Mapper.ProjectToViewModel(blockItem.MultiCollection)
: null,
blockItem.SmartCollection is not null
? MediaCollections.Mapper.ProjectToViewModel(blockItem.SmartCollection)
: null,
blockItem.MediaItem switch
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
Season season => MediaItems.Mapper.ProjectToViewModel(season),
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
_ => null
},
blockItem.SearchTitle,
blockItem.SearchQuery,
blockItem.PlaybackOrder,
blockItem.IncludeInProgramGuide,
blockItem.DisableWatermarks,
blockItem.BlockItemWatermarks.Map(wm => Watermarks.Mapper.ProjectToViewModel(wm.Watermark)).ToList(),
blockItem.BlockItemGraphicsElements.Map(ge => Graphics.Mapper.ProjectToViewModel(ge.GraphicsElement)).ToList());
internal static TemplateGroupViewModel ProjectToViewModel(TemplateGroup templateGroup) =>
new(templateGroup.Id, templateGroup.Name, templateGroup.Templates.Count);
internal static TemplateViewModel ProjectToViewModel(Template template) =>
new(template.Id, template.TemplateGroupId, template.TemplateGroup.Name, template.Name, template.Version);
internal static TemplateItemViewModel ProjectToViewModel(TemplateItem templateItem)
{
DateTime startTime = DateTime.Today.Add(templateItem.StartTime);
DateTime endTime = startTime.AddMinutes(templateItem.Block.Minutes);
return new TemplateItemViewModel(templateItem.BlockId, templateItem.Block.Name, startTime, endTime);
}
internal static DecoGroupViewModel ProjectToViewModel(DecoGroup decoGroup) =>
new(decoGroup.Id, decoGroup.Name, decoGroup.Decos.Count);
internal static DecoViewModel ProjectToViewModel(Deco deco) =>
new(
deco.Id,
deco.DecoGroupId,
deco.DecoGroup.Name,
deco.Name,
deco.WatermarkMode,
deco.DecoWatermarks.Map(wm => Watermarks.Mapper.ProjectToViewModel(wm.Watermark)).ToList(),
deco.UseWatermarkDuringFiller,
deco.GraphicsElementsMode,
deco.DecoGraphicsElements.Map(ge => Graphics.Mapper.ProjectToViewModel(ge.GraphicsElement)).ToList(),
deco.UseGraphicsElementsDuringFiller,
deco.BreakContentMode,
deco.BreakContent.Map(ProjectToViewModel).ToList(),
deco.DefaultFillerMode,
deco.DefaultFillerCollectionType,
deco.DefaultFillerCollection is not null
? MediaCollections.Mapper.ProjectToViewModel(deco.DefaultFillerCollection)
: null,
deco.DefaultFillerMediaItem switch
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
Season season => MediaItems.Mapper.ProjectToViewModel(season),
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
_ => null
},
deco.DefaultFillerMultiCollection is not null
? MediaCollections.Mapper.ProjectToViewModel(deco.DefaultFillerMultiCollection)
: null,
deco.DefaultFillerSmartCollection is not null
? MediaCollections.Mapper.ProjectToViewModel(deco.DefaultFillerSmartCollection)
: null,
deco.DefaultFillerTrimToFit,
deco.DeadAirFallbackMode,
deco.DeadAirFallbackCollectionType,
deco.DeadAirFallbackCollection is not null
? MediaCollections.Mapper.ProjectToViewModel(deco.DeadAirFallbackCollection)
: null,
deco.DeadAirFallbackMediaItem switch
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
Season season => MediaItems.Mapper.ProjectToViewModel(season),
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
_ => null
},
deco.DeadAirFallbackMultiCollection is not null
? MediaCollections.Mapper.ProjectToViewModel(deco.DeadAirFallbackMultiCollection)
: null,
deco.DeadAirFallbackSmartCollection is not null
? MediaCollections.Mapper.ProjectToViewModel(deco.DeadAirFallbackSmartCollection)
: null);
internal static DecoBreakContentViewModel ProjectToViewModel(DecoBreakContent decoBreakContent) =>
new(
decoBreakContent.Id,
decoBreakContent.CollectionType,
decoBreakContent.Collection is not null
? MediaCollections.Mapper.ProjectToViewModel(decoBreakContent.Collection)
: null,
decoBreakContent.MediaItem switch
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
Season season => MediaItems.Mapper.ProjectToViewModel(season),
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
_ => null
},
decoBreakContent.MultiCollection is not null
? MediaCollections.Mapper.ProjectToViewModel(decoBreakContent.MultiCollection)
: null,
decoBreakContent.SmartCollection is not null
? MediaCollections.Mapper.ProjectToViewModel(decoBreakContent.SmartCollection)
: null,
decoBreakContent.Playlist is not null
? MediaCollections.Mapper.ProjectToViewModel(decoBreakContent.Playlist)
: null,
decoBreakContent.Placement);
internal static DecoTemplateGroupViewModel ProjectToViewModel(DecoTemplateGroup decoTemplateGroup) =>
new(decoTemplateGroup.Id, decoTemplateGroup.Name, decoTemplateGroup.DecoTemplates.Count);
internal static DecoTemplateViewModel ProjectToViewModel(DecoTemplate decoTemplate)
{
if (decoTemplate is null)
{
return null;
}
return new DecoTemplateViewModel(
decoTemplate.Id,
decoTemplate.DecoTemplateGroupId,
decoTemplate.DecoTemplateGroup.Name,
decoTemplate.Name,
decoTemplate.Version);
}
internal static DecoTemplateItemViewModel ProjectToViewModel(DecoTemplateItem decoTemplateItem)
{
DateTime startTime = DateTime.Today.Add(decoTemplateItem.StartTime);
DateTime endTime = DateTime.Today.Add(decoTemplateItem.EndTime);
if (startTime > endTime)
{
endTime = endTime.AddDays(1);
}
return new DecoTemplateItemViewModel(decoTemplateItem.DecoId, decoTemplateItem.Deco.Name, startTime, endTime);
}
internal static PlayoutTemplateViewModel ProjectToViewModel(PlayoutTemplate playoutTemplate) =>
new(
playoutTemplate.Id,
ProjectToViewModel(playoutTemplate.Template),
ProjectToViewModel(playoutTemplate.DecoTemplate),
playoutTemplate.Index,
playoutTemplate.DaysOfWeek,
playoutTemplate.DaysOfMonth,
playoutTemplate.MonthsOfYear,
playoutTemplate.LimitToDateRange,
playoutTemplate.StartMonth,
playoutTemplate.StartDay,
playoutTemplate.StartYear,
playoutTemplate.EndMonth,
playoutTemplate.EndDay,
playoutTemplate.EndYear);
internal static PlayoutItemPreviewViewModel ProjectToViewModel(PlayoutItem playoutItem) =>
new(
Playouts.Mapper.GetDisplayTitle(playoutItem.MediaItem, playoutItem.ChapterTitle),
playoutItem.StartOffset.TimeOfDay,
playoutItem.FinishOffset.TimeOfDay,
playoutItem.GetDisplayDuration());
}