Merge remote-tracking branch 'origin/main' into feat/235-async-contract
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m30s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m51s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

# Conflicts:
#	docs/decisions.md
This commit is contained in:
2026-07-11 18:11:34 +02:00
52 changed files with 15580 additions and 50 deletions
+30 -4
View File
@@ -128,6 +128,9 @@ public class BlockController(IMediator mediator) : ControllerBase
[HttpGet("/api/blocks/{id:int}/items")]
[Tags("Blocks")]
[EndpointSummary("Get block items")]
[EndpointDescription(
"Returns the block's items and a strong ETag of the block'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<BlockItemResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
@@ -139,6 +142,9 @@ public class BlockController(IMediator mediator) : ControllerBase
return ApiResults.NotFoundProblem();
}
// The items GET returns children, not the root, so read the block's version for the ETag.
ConcurrencyHeaders.SetETag(Response, block.Map(b => b.Version).IfNone(0));
List<BlockItemViewModel> items = await mediator.Send(new GetBlockItems(id), cancellationToken);
return new OkObjectResult(items.OrderBy(i => i.Index).Map(ProjectToResponseModel).ToList());
}
@@ -148,16 +154,32 @@ public class BlockController(IMediator mediator) : ControllerBase
[EndpointSummary("Replace a block and its items")]
[EndpointDescription(
"Replaces the block's name/minutes/stop-scheduling and its full item list. Item indexes are assigned " +
"from the array order. Minutes must be greater than zero, divisible by 5, and at most 24 hours.")]
"from the array order. Minutes must be greater than zero, divisible by 5, and at most 24 hours. " +
"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(BlockWithItemsResponseModel), 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] ReplaceBlockRequest 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<BlockViewModel> maybeBlock = await mediator.Send(new GetBlockById(id), cancellationToken);
if (maybeBlock.IsNone)
{
@@ -167,7 +189,7 @@ public class BlockController(IMediator mediator) : ControllerBase
int groupId = maybeBlock.Map(b => b.GroupId).IfNone(0);
Either<BaseError, Unit> result =
await mediator.Send(request.ToCommand(groupId, id), cancellationToken);
await mediator.Send(request.ToCommand(groupId, id, ifMatch.ExpectedVersion), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
@@ -176,8 +198,12 @@ public class BlockController(IMediator mediator) : ControllerBase
Option<BlockViewModel> refreshed = await mediator.Send(new GetBlockById(id), cancellationToken);
List<BlockItemViewModel> items = await mediator.Send(new GetBlockItems(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());
});
}
@@ -9,12 +9,13 @@ public record ReplaceBlockRequest(
BlockStopScheduling StopScheduling,
List<BlockItemRequest> Items)
{
public ReplaceBlockItems ToCommand(int blockGroupId, int blockId) =>
public ReplaceBlockItems ToCommand(int blockGroupId, int blockId, Option<int> expectedVersion = default) =>
new(
blockGroupId,
blockId,
Name,
Minutes,
StopScheduling,
(Items ?? []).Select((item, index) => item.ToReplaceItem(index)).ToList());
(Items ?? []).Select((item, index) => item.ToReplaceItem(index)).ToList(),
expectedVersion);
}