Files
ersatztv/ErsatzTV/Controllers/Api/TemplateController.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

262 lines
12 KiB
C#

using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Scheduling;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class TemplateController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/templates/groups", Name = "GetTemplateGroups")]
[Tags("Templates")]
[EndpointSummary("Get all template groups")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<TemplateGroupResponseModel>), StatusCodes.Status200OK)]
public async Task<List<TemplateGroupResponseModel>> GetGroups(CancellationToken cancellationToken)
{
List<TemplateGroupViewModel> groups = await mediator.Send(new GetAllTemplateGroups(), cancellationToken);
return groups.Map(ProjectToResponseModel).ToList();
}
[HttpPost("/api/templates/groups")]
[Tags("Templates")]
[EndpointSummary("Create a template group")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(TemplateGroupResponseModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> CreateGroup(
[Required] [FromBody] CreateTemplateGroupRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, TemplateGroupViewModel> result =
await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToCreatedResult(
vm => $"/api/templates/groups/{vm.Id}",
vm => ProjectToResponseModel(vm));
}
[HttpDelete("/api/templates/groups/{id:int}")]
[Tags("Templates")]
[EndpointSummary("Delete a template group")]
[EndpointDescription(
"Deletes the template group. The database cascade removes every template (and its items) in the group.")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteGroup(int id, CancellationToken cancellationToken)
{
List<TemplateGroupViewModel> groups = await mediator.Send(new GetAllTemplateGroups(), cancellationToken);
if (groups.All(g => g.Id != id))
{
return ApiResults.NotFoundProblem();
}
Option<BaseError> result = await mediator.Send(new DeleteTemplateGroup(id), cancellationToken);
return result.Match<IActionResult>(
Some: error => error.ToErrorResult(),
None: () => new NoContentResult());
}
[HttpGet("/api/templates")]
[Tags("Templates")]
[EndpointSummary("Get all templates")]
[EndpointDescription(
"Returns every template. Pass templateGroupId to filter to the templates in a single template group.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<TemplateResponseModel>), StatusCodes.Status200OK)]
public async Task<List<TemplateResponseModel>> GetAll(
[FromQuery] int? templateGroupId,
CancellationToken cancellationToken)
{
List<TemplateViewModel> templates = templateGroupId is { } groupId
? await mediator.Send(new GetTemplatesByTemplateGroupId(groupId), cancellationToken)
: await mediator.Send(new GetAllTemplates(), cancellationToken);
return templates.Map(ProjectToResponseModel).ToList();
}
[HttpGet("/api/templates/{id:int}", Name = "GetTemplateById")]
[Tags("Templates")]
[EndpointSummary("Get a template by id")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(TemplateResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<TemplateViewModel> result = await mediator.Send(new GetTemplateById(id), cancellationToken);
return result.Map(ProjectToResponseModel).ToGetResult();
}
[HttpPost("/api/templates")]
[Tags("Templates")]
[EndpointSummary("Create a template")]
[EndpointDescription("Creates an empty template in the given template group.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(TemplateResponseModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Create(
[Required] [FromBody] CreateTemplateRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, TemplateViewModel> result = await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToCreatedResult(
vm => $"/api/templates/{vm.Id}",
vm => ProjectToResponseModel(vm));
}
[HttpDelete("/api/templates/{id:int}")]
[Tags("Templates")]
[EndpointSummary("Delete a template")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
Option<TemplateViewModel> existing = await mediator.Send(new GetTemplateById(id), cancellationToken);
if (existing.IsNone)
{
return ApiResults.NotFoundProblem();
}
Option<BaseError> result = await mediator.Send(new DeleteTemplate(id), cancellationToken);
return result.Match<IActionResult>(
Some: error => error.ToErrorResult(),
None: () => new NoContentResult());
}
[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)]
public async Task<IActionResult> GetItems(int id, CancellationToken cancellationToken)
{
Option<TemplateViewModel> template = await mediator.Send(new GetTemplateById(id), cancellationToken);
if (template.IsNone)
{
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());
}
[HttpPut("/api/templates/{id:int}")]
[Tags("Templates")]
[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). " +
"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)
{
return ApiResults.NotFoundProblem();
}
int templateGroupId = maybeTemplate.Map(t => t.TemplateGroupId).IfNone(0);
Either<BaseError, List<TemplateItemViewModel>> result =
await mediator.Send(request.ToCommand(templateGroupId, id, ifMatch.ExpectedVersion), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async items =>
{
Option<TemplateViewModel> refreshed = await mediator.Send(new GetTemplateById(id), cancellationToken);
return refreshed.Match(
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());
});
}
[HttpPost("/api/templates/{id:int}/copy")]
[Tags("Templates")]
[EndpointSummary("Copy a template")]
[EndpointDescription("Copies the template and its items into another (or the same) template group under a new name.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(TemplateResponseModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Copy(
int id,
[Required] [FromBody] CopyTemplateRequest request,
CancellationToken cancellationToken)
{
Option<TemplateViewModel> existing = await mediator.Send(new GetTemplateById(id), cancellationToken);
if (existing.IsNone)
{
return ApiResults.NotFoundProblem();
}
Either<BaseError, TemplateViewModel> result =
await mediator.Send(request.ToCommand(id), cancellationToken);
return result.ToCreatedResult(
vm => $"/api/templates/{vm.Id}",
vm => ProjectToResponseModel(vm));
}
private static TemplateGroupResponseModel ProjectToResponseModel(TemplateGroupViewModel vm) =>
new(vm.Id, vm.Name, vm.TemplateCount);
private static TemplateResponseModel ProjectToResponseModel(TemplateViewModel vm) =>
new(vm.Id, vm.TemplateGroupId, vm.GroupName, vm.Name);
private static TemplateWithItemsResponseModel ProjectToWithItemsResponseModel(
TemplateViewModel vm,
List<TemplateItemViewModel> items) =>
new(
vm.Id,
vm.TemplateGroupId,
vm.GroupName,
vm.Name,
items.OrderBy(i => i.StartTime.TimeOfDay).Map(ProjectToResponseModel).ToList());
private static TemplateItemResponseModel ProjectToResponseModel(TemplateItemViewModel vm) =>
new(vm.BlockId, vm.BlockName, (int)(vm.EndTime - vm.StartTime).TotalMinutes, vm.StartTime.TimeOfDay);
}