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>
Adds TemplateController mirroring BlockController: groups CRUD, template
CRUD, item replace (PUT full-replace via ReplaceTemplateItems), and a
copy endpoint. Also wires the missing POST /api/blocks/{id}/copy since
CopyBlock's CQRS shape is directly analogous to CopyTemplate.
Fixes CreateTemplateHandler to validate the target template group exists
before insert (previously an unhandled FK violation -> 500), matching the
CreateBlockHandler fix (1e34e00e) for the same class of bug.
New DTOs in ErsatzTV.Core/Api/Scheduling; TimeSpan start times serialize
the same way as PlayoutResponseModel.DailyRebuildTime.
#144 S2 (#162)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds BlockController (block groups + blocks CRUD, items GET, full replace,
non-persisting preview) mirroring ScheduleController, plus four scheduling
search picker endpoints on SearchController (collections, television shows,
television seasons, smart collections). Response DTOs in ErsatzTV.Core/Api/
Scheduling; request DTOs with ToCommand index auto-assignment.
#144 S1 (#162)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>