Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m9s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m46s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Blazor disabled per-playout Reset/Erase/Delete/Edit while a BuildPlayout was
in flight (EntityLocker.IsPlayoutLocked); the REST API had no equivalent, so a
client could race an in-flight build with a destructive ExecuteDelete and leave
a half-built playout. After Blazor removal this safety invariant would vanish
entirely (adversarial-reviewer#18 removal gate).
Server:
- Add public ApiResults.ConflictProblem(title, detail) (409, mirrors NotFoundProblem).
- Inject IEntityLocker into PlayoutController; guard every id-keyed mutation
(PUT {id}, PUT .../deco, PUT .../alternate-schedules, PUT .../templates,
POST .../erase-items, POST .../erase-items-and-history, DELETE {id}) → 409
when IsPlayoutLocked(id); add [ProducesResponseType(...409)] to each.
- Guard ChannelController.ResetPlayout the same way after resolving the id.
- reset-all stays 202 (ResetAllPlayoutsHandler already skips locked playouts).
- Stamp IsLocked onto PlayoutListItemResponseModel from IsPlayoutLocked.
SPA:
- Disable Reset/Erase/Erase-and-history/Delete for a locked row + show a
"Building…" Badge; on a 409 surface the error and refresh the list.
Tests: controller-level 409 guard tests (delete/erase/PUT/deco/channel-reset)
+ IsLocked projection test; new OpenAPI contract + metadata 409 rows.
Docs: api-conventions §3a, blazor-route-parity playouts verdict, decisions.md.
Regenerated v1.json + web types.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
68 lines
3.0 KiB
C#
68 lines
3.0 KiB
C#
using System.Diagnostics.CodeAnalysis;
|
|
using ErsatzTV.Core;
|
|
using ErsatzTV.Core.Errors;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
|
|
namespace ErsatzTV.Extensions;
|
|
|
|
/// <summary>
|
|
/// REST status-code mapping helpers for the JSON write/read API (slice #2a).
|
|
/// These are additive and must not change the behavior of the existing
|
|
/// <see cref="EitherToActionResult" /> / <see cref="OptionToActionResult" /> helpers,
|
|
/// which IptvController and other read endpoints depend on.
|
|
/// </summary>
|
|
[SuppressMessage("ReSharper", "VSTHRD003")]
|
|
public static class ApiResults
|
|
{
|
|
/// <summary>Maps a failure to 404 when it is a <see cref="NotFoundError" />, otherwise 422.</summary>
|
|
public static IActionResult ToErrorResult(this BaseError error) =>
|
|
error is NotFoundError
|
|
? new NotFoundObjectResult(CreateProblemDetails(404, "Resource not found", error.Value))
|
|
: new UnprocessableEntityObjectResult(CreateProblemDetails(422, "Validation failed", error.Value));
|
|
|
|
/// <summary>Right: 201 Created with a Location header and body; Left: 404 (NotFound) or 422.</summary>
|
|
public static IActionResult ToCreatedResult<TR>(
|
|
this Either<BaseError, TR> either,
|
|
Func<TR, string> location,
|
|
Func<TR, object> body) =>
|
|
either.Match(
|
|
Left: error => error.ToErrorResult(),
|
|
Right: value => new CreatedResult(location(value), body(value)));
|
|
|
|
/// <summary>Right: 200 with body; Left: 404 (NotFound) or 422.</summary>
|
|
public static IActionResult ToUpdatedResult<TR>(this Either<BaseError, TR> either) =>
|
|
either.Match(
|
|
Left: error => error.ToErrorResult(),
|
|
Right: value => (IActionResult)new OkObjectResult(value));
|
|
|
|
/// <summary>Right(Unit): 204 No Content; Left: 404 (NotFound) or 422.</summary>
|
|
public static IActionResult ToDeletedResult(this Either<BaseError, Unit> either) =>
|
|
either.Match(
|
|
Left: error => error.ToErrorResult(),
|
|
Right: _ => (IActionResult)new NoContentResult());
|
|
|
|
/// <summary>Some: 200 with body; None: 404.</summary>
|
|
public static IActionResult ToGetResult<T>(this Option<T> option) =>
|
|
option.Match(
|
|
Some: value => (IActionResult)new OkObjectResult(value),
|
|
None: () => NotFoundProblem());
|
|
|
|
public static IActionResult NotFoundProblem(string detail = "Resource not found") =>
|
|
new NotFoundObjectResult(CreateProblemDetails(404, "Resource not found", detail));
|
|
|
|
/// <summary>
|
|
/// 409 <see cref="ProblemDetails" /> directly — for a mutation that races a background operation
|
|
/// holding a lock (e.g. a playout build in flight). Mirrors <see cref="NotFoundProblem" />.
|
|
/// </summary>
|
|
public static IActionResult ConflictProblem(string title, string detail) =>
|
|
new ConflictObjectResult(CreateProblemDetails(409, title, detail));
|
|
|
|
private static ProblemDetails CreateProblemDetails(int status, string title, string detail) =>
|
|
new()
|
|
{
|
|
Status = status,
|
|
Title = title,
|
|
Detail = detail
|
|
};
|
|
}
|