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>
This commit is contained in:
2026-07-11 18:36:35 +02:00
co-authored by Claude Opus 4.8
parent 2de091ea4f
commit 611924c0ee
22 changed files with 926 additions and 120 deletions
@@ -143,6 +143,9 @@ public class DecoTemplateController(IMediator mediator) : ControllerBase
[HttpGet("/api/deco-templates/{id:int}/items")]
[Tags("DecoTemplates")]
[EndpointSummary("Get deco template items")]
[EndpointDescription(
"Returns the deco template's items and a strong ETag of the deco template's version. Pass that ETag " +
"back as If-Match on the replace (PUT) to detect a concurrent edit (issue #253).")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<DecoTemplateItemResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
@@ -154,6 +157,9 @@ public class DecoTemplateController(IMediator mediator) : ControllerBase
return ApiResults.NotFoundProblem();
}
// The items GET returns children, not the root, so read the deco template's version for the ETag.
ConcurrencyHeaders.SetETag(Response, decoTemplate.Map(t => t.Version).IfNone(0));
List<DecoTemplateItemViewModel> items = await mediator.Send(new GetDecoTemplateItems(id), cancellationToken);
return new OkObjectResult(
items.OrderBy(i => i.StartTime.TimeOfDay).Map(ProjectToResponseModel).ToList());
@@ -164,16 +170,32 @@ public class DecoTemplateController(IMediator mediator) : ControllerBase
[EndpointSummary("Replace a deco template and its items")]
[EndpointDescription(
"Replaces the deco template's name and its full item list. Each item assigns a deco to a start/end time " +
"of day; an end time of 00:00:00 means the item runs to the end of the day. Items must not overlap.")]
"of day; an end time of 00:00:00 means the item runs to the end of the day. Items must not overlap. " +
"Send the ETag from the items GET as If-Match to reject a stale overwrite with 412 (issue #253); " +
"a successful response carries the new ETag.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(DecoTemplateWithItemsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Replace(
int id,
[Required] [FromBody] ReplaceDecoTemplateRequest request,
CancellationToken cancellationToken)
{
IfMatchCondition ifMatch = ConcurrencyHeaders.ParseIfMatch(Request);
if (ifMatch.Kind is IfMatchKind.Malformed)
{
return new BadRequestObjectResult(
new ProblemDetails
{
Status = StatusCodes.Status400BadRequest,
Title = "Invalid If-Match header",
Detail = "If-Match must be a strong ETag of the resource version (e.g. \"3\") or \"*\"."
});
}
Option<DecoTemplateViewModel> maybeDecoTemplate =
await mediator.Send(new GetDecoTemplateById(id), cancellationToken);
if (maybeDecoTemplate.IsNone)
@@ -184,18 +206,23 @@ public class DecoTemplateController(IMediator mediator) : ControllerBase
int decoTemplateGroupId = maybeDecoTemplate.Map(t => t.DecoTemplateGroupId).IfNone(0);
Either<BaseError, List<DecoTemplateItemViewModel>> result =
await mediator.Send(request.ToCommand(decoTemplateGroupId, id), cancellationToken);
await mediator.Send(
request.ToCommand(decoTemplateGroupId, id, ifMatch.ExpectedVersion),
cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
Right: async items =>
{
Option<DecoTemplateViewModel> refreshed =
await mediator.Send(new GetDecoTemplateById(id), cancellationToken);
List<DecoTemplateItemViewModel> items =
await mediator.Send(new GetDecoTemplateItems(id), cancellationToken);
return refreshed.Match(
Some: vm => (IActionResult)new OkObjectResult(ProjectToWithItemsResponseModel(vm, items)),
Some: vm =>
{
// Return the new ETag so a same-tab second save doesn't 412 against its own write.
ConcurrencyHeaders.SetETag(Response, vm.Version);
return (IActionResult)new OkObjectResult(ProjectToWithItemsResponseModel(vm, items));
},
None: () => ApiResults.NotFoundProblem());
});
}
@@ -4,10 +4,14 @@ namespace ErsatzTV.Controllers.Api.Requests;
public record ReplaceDecoTemplateRequest(string Name, List<DecoTemplateItemRequest> Items)
{
public ReplaceDecoTemplateItems ToCommand(int decoTemplateGroupId, int decoTemplateId) =>
public ReplaceDecoTemplateItems ToCommand(
int decoTemplateGroupId,
int decoTemplateId,
Option<int> expectedVersion = default) =>
new(
decoTemplateId,
decoTemplateGroupId,
Name,
(Items ?? []).Select(item => item.ToReplaceItem()).ToList());
(Items ?? []).Select(item => item.ToReplaceItem()).ToList(),
expectedVersion);
}
@@ -4,10 +4,11 @@ namespace ErsatzTV.Controllers.Api.Requests;
public record ReplaceTemplateRequest(string Name, List<TemplateItemRequest> Items)
{
public ReplaceTemplateItems ToCommand(int templateGroupId, int templateId) =>
public ReplaceTemplateItems ToCommand(int templateGroupId, int templateId, Option<int> expectedVersion = default) =>
new(
templateGroupId,
templateId,
Name,
(Items ?? []).Select(item => item.ToReplaceItem()).ToList());
(Items ?? []).Select(item => item.ToReplaceItem()).ToList(),
expectedVersion);
}
+31 -5
View File
@@ -134,6 +134,9 @@ public class TemplateController(IMediator mediator) : ControllerBase
[HttpGet("/api/templates/{id:int}/items")]
[Tags("Templates")]
[EndpointSummary("Get template items")]
[EndpointDescription(
"Returns the template's items and a strong ETag of the template's version. Pass that ETag back as " +
"If-Match on the replace (PUT) to detect a concurrent edit (issue #253).")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<TemplateItemResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
@@ -145,6 +148,9 @@ public class TemplateController(IMediator mediator) : ControllerBase
return ApiResults.NotFoundProblem();
}
// The items GET returns children, not the root, so read the template's version for the ETag.
ConcurrencyHeaders.SetETag(Response, template.Map(t => t.Version).IfNone(0));
List<TemplateItemViewModel> items = await mediator.Send(new GetTemplateItems(id), cancellationToken);
return new OkObjectResult(
items.OrderBy(i => i.StartTime.TimeOfDay).Map(ProjectToResponseModel).ToList());
@@ -155,16 +161,32 @@ public class TemplateController(IMediator mediator) : ControllerBase
[EndpointSummary("Replace a template and its items")]
[EndpointDescription(
"Replaces the template's name and its full item list. Each item assigns a block to a start time of day; " +
"items must not overlap (an item's end time is its start time plus the assigned block's duration).")]
"items must not overlap (an item's end time is its start time plus the assigned block's duration). " +
"Send the ETag from the items GET as If-Match to reject a stale overwrite with 412 (issue #253); " +
"a successful response carries the new ETag.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(TemplateWithItemsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Replace(
int id,
[Required] [FromBody] ReplaceTemplateRequest request,
CancellationToken cancellationToken)
{
IfMatchCondition ifMatch = ConcurrencyHeaders.ParseIfMatch(Request);
if (ifMatch.Kind is IfMatchKind.Malformed)
{
return new BadRequestObjectResult(
new ProblemDetails
{
Status = StatusCodes.Status400BadRequest,
Title = "Invalid If-Match header",
Detail = "If-Match must be a strong ETag of the resource version (e.g. \"3\") or \"*\"."
});
}
Option<TemplateViewModel> maybeTemplate = await mediator.Send(new GetTemplateById(id), cancellationToken);
if (maybeTemplate.IsNone)
{
@@ -174,16 +196,20 @@ public class TemplateController(IMediator mediator) : ControllerBase
int templateGroupId = maybeTemplate.Map(t => t.TemplateGroupId).IfNone(0);
Either<BaseError, List<TemplateItemViewModel>> result =
await mediator.Send(request.ToCommand(templateGroupId, id), cancellationToken);
await mediator.Send(request.ToCommand(templateGroupId, id, ifMatch.ExpectedVersion), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
Right: async items =>
{
Option<TemplateViewModel> refreshed = await mediator.Send(new GetTemplateById(id), cancellationToken);
List<TemplateItemViewModel> items = await mediator.Send(new GetTemplateItems(id), cancellationToken);
return refreshed.Match(
Some: vm => (IActionResult)new OkObjectResult(ProjectToWithItemsResponseModel(vm, items)),
Some: vm =>
{
// Return the new ETag so a same-tab second save doesn't 412 against its own write.
ConcurrencyHeaders.SetETag(Response, vm.Version);
return (IActionResult)new OkObjectResult(ProjectToWithItemsResponseModel(vm, items));
},
None: () => ApiResults.NotFoundProblem());
});
}