Files
ersatztv/ErsatzTV.Application/Scheduling/Mapper.cs
T
timothyandClaude Opus 4.8 94ebf34ccd
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m25s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
feat(api): optimistic-concurrency contract for replace-all PUTs — PR1 infra + Block reference (#253)
Adds the shared optimistic-concurrency contract so a stale second tab can no longer
silently overwrite a fresher edit. PR1 lands the infra + the Block reference aggregate;
PRs 2–4 fan the same recipe across the other 8 roots (design: #253#issuecomment-8472).

Contract
- `IVersionedAggregate` (`int Version`) on all 9 replace-all roots (ProgramSchedule,
  Block, Template, DecoTemplate, Playlist, Collection, Playout, MultiCollection,
  RerunCollection), EF-mapped `.IsConcurrencyToken()`; one dual-provider migration
  `AddAggregateVersions` (nullable:false, default 0).
- Strong `ETag` of `Version` on the aggregate GET; `If-Match` on the PUT; mismatch →
  412 (distinct from the §3a 409 build-lock guard). Successful PUT returns the new ETag.
- `PreconditionFailedError : BaseError` → 412 in `ApiResults.ToErrorResult`;
  `ConcurrencyHeaders.ParseIfMatch/SetETag`; malformed If-Match → 400; `*`/absent =
  Phase-1 force-write.

Block reference wiring
- Handler: standalone `Either` via `CheckVersion` AFTER validation (never through
  `Apply`, which Join()-flattens the subtype to 422), unconditional `Version++`,
  `SaveChangesWithConcurrencyGuard` backstop (DbUpdateConcurrencyException → 412).
- `BlockViewModel.Version` (header-only, not echoed in the body); controller sets the
  ETag on GET items and on the successful PUT.
- SPA: `client.requestWithMeta` seam; `blocks.getBlockItemsWithMeta` + `replaceBlock`
  If-Match/ETag round-trip; `BlockEditor` holds the ETag, sends If-Match, and on 412
  opens a blocking "changed elsewhere — reload" dialog.

Tests
- Handler contract tests: stale-If-Match → 412 (no mutation), matching/absent → success
  + bump, no-op save still bumps, and a two-context racing save → 412; proven
  non-vacuous (drop `.IsConcurrencyToken()` → the race test fails).
- Controller tests: malformed If-Match → 400, If-Match threaded to the command, ETag on
  GET/PUT, 412 passthrough. SPA: requestWithMeta ETag, replaceBlock If-Match, 412 dialog.

Docs: api-conventions §7a, spa-conventions §4a, domain-model glossary, decisions log.

Refs #253
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:51:59 +02:00

210 lines
9.6 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);
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);
}
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());
}