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 DecoTemplateController(IMediator mediator) : ControllerBase { [HttpGet("/api/v1/deco-templates/groups", Name = "GetDecoTemplateGroups")] [Tags("DecoTemplates")] [EndpointSummary("Get all deco template groups")] [EndpointGroupName("general")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] public async Task> GetGroups(CancellationToken cancellationToken) { List groups = await mediator.Send(new GetAllDecoTemplateGroups(), cancellationToken); return groups.Map(ProjectToResponseModel).ToList(); } [HttpPost("/api/v1/deco-templates/groups")] [Tags("DecoTemplates")] [EndpointSummary("Create a deco template group")] [EndpointGroupName("general")] [ProducesResponseType(typeof(DecoTemplateGroupResponseModel), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task CreateGroup( [Required][FromBody] CreateDecoTemplateGroupRequest request, CancellationToken cancellationToken) { Either result = await mediator.Send(request.ToCommand(), cancellationToken); return result.ToCreatedResult( vm => $"/api/v1/deco-templates/groups/{vm.Id}", vm => ProjectToResponseModel(vm)); } [HttpDelete("/api/v1/deco-templates/groups/{id:int}")] [Tags("DecoTemplates")] [EndpointSummary("Delete a deco template group")] [EndpointDescription( "Deletes the deco template group. The database cascade removes every deco template (and its items) in " + "the group; any playout template that used a removed deco template has that reference cleared.")] [EndpointGroupName("general")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task DeleteGroup(int id, CancellationToken cancellationToken) { List groups = await mediator.Send(new GetAllDecoTemplateGroups(), cancellationToken); if (groups.All(g => g.Id != id)) { return ApiResults.NotFoundProblem(); } Option result = await mediator.Send(new DeleteDecoTemplateGroup(id), cancellationToken); return result.Match( Some: error => error.ToErrorResult(), None: () => new NoContentResult()); } [HttpGet("/api/v1/deco-templates")] [Tags("DecoTemplates")] [EndpointSummary("Get all deco templates")] [EndpointDescription( "Returns every deco template as a flat list, ordered by group name then deco template name.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] public async Task> GetAll(CancellationToken cancellationToken) { List groups = await mediator.Send(new GetAllDecoTemplateGroups(), cancellationToken); var result = new List(); foreach (DecoTemplateGroupViewModel group in groups) { List decoTemplates = await mediator.Send(new GetDecoTemplatesByDecoTemplateGroupId(group.Id), cancellationToken); result.AddRange(decoTemplates.Map(ProjectToResponseModel)); } return result; } [HttpGet("/api/v1/deco-templates/{id:int}", Name = "GetDecoTemplateById")] [Tags("DecoTemplates")] [EndpointSummary("Get a deco template by id")] [EndpointGroupName("general")] [ProducesResponseType(typeof(DecoTemplateResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetById(int id, CancellationToken cancellationToken) { Option result = await mediator.Send(new GetDecoTemplateById(id), cancellationToken); return result.Map(ProjectToResponseModel).ToGetResult(); } [HttpPost("/api/v1/deco-templates")] [Tags("DecoTemplates")] [EndpointSummary("Create a deco template")] [EndpointDescription("Creates an empty deco template in the given deco template group.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(DecoTemplateResponseModel), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Create( [Required][FromBody] CreateDecoTemplateRequest request, CancellationToken cancellationToken) { Either result = await mediator.Send(request.ToCommand(), cancellationToken); return result.ToCreatedResult( vm => $"/api/v1/deco-templates/{vm.Id}", vm => ProjectToResponseModel(vm)); } [HttpDelete("/api/v1/deco-templates/{id:int}")] [Tags("DecoTemplates")] [EndpointSummary("Delete a deco template")] [EndpointGroupName("general")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task Delete(int id, CancellationToken cancellationToken) { Option existing = await mediator.Send(new GetDecoTemplateById(id), cancellationToken); if (existing.IsNone) { return ApiResults.NotFoundProblem(); } Option result = await mediator.Send(new DeleteDecoTemplate(id), cancellationToken); return result.Match( Some: error => error.ToErrorResult(), None: () => new NoContentResult()); } [HttpGet("/api/v1/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), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetItems(int id, CancellationToken cancellationToken) { Option decoTemplate = await mediator.Send(new GetDecoTemplateById(id), cancellationToken); if (decoTemplate.IsNone) { 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 items = await mediator.Send(new GetDecoTemplateItems(id), cancellationToken); return new OkObjectResult( items.OrderBy(i => i.StartTime.TimeOfDay).Map(ProjectToResponseModel).ToList()); } [HttpPut("/api/v1/deco-templates/{id:int}")] [Tags("DecoTemplates")] [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. " + "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 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 maybeDecoTemplate = await mediator.Send(new GetDecoTemplateById(id), cancellationToken); if (maybeDecoTemplate.IsNone) { return ApiResults.NotFoundProblem(); } int decoTemplateGroupId = maybeDecoTemplate.Map(t => t.DecoTemplateGroupId).IfNone(0); Either> result = await mediator.Send( request.ToCommand(decoTemplateGroupId, id, ifMatch.ExpectedVersions), cancellationToken); return await result.Match( Left: error => Task.FromResult(error.ToErrorResult()), Right: async _ => { // Reload the root (version) FIRST, then the items, so the emitted ETag is never newer than // the returned items (issue #253 fail-safe ordering; matches BlockController). Option refreshed = await mediator.Send(new GetDecoTemplateById(id), cancellationToken); List items = await mediator.Send(new GetDecoTemplateItems(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()); }); } private static DecoTemplateGroupResponseModel ProjectToResponseModel(DecoTemplateGroupViewModel vm) => new(vm.Id, vm.Name, vm.DecoTemplateCount); private static DecoTemplateResponseModel ProjectToResponseModel(DecoTemplateViewModel vm) => new(vm.Id, vm.DecoTemplateGroupId, vm.GroupName, vm.Name); private static DecoTemplateWithItemsResponseModel ProjectToWithItemsResponseModel( DecoTemplateViewModel vm, List items) => new( vm.Id, vm.DecoTemplateGroupId, vm.GroupName, vm.Name, items.OrderBy(i => i.StartTime.TimeOfDay).Map(ProjectToResponseModel).ToList()); private static DecoTemplateItemResponseModel ProjectToResponseModel(DecoTemplateItemViewModel vm) => new(vm.DecoId, vm.DecoName, vm.StartTime.TimeOfDay, vm.EndTime.TimeOfDay); }