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/v1/templates/groups", Name = "GetTemplateGroups")] [Tags("Templates")] [EndpointSummary("Get all template groups")] [EndpointGroupName("general")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] public async Task> GetGroups(CancellationToken cancellationToken) { List groups = await mediator.Send(new GetAllTemplateGroups(), cancellationToken); return groups.Map(ProjectToResponseModel).ToList(); } [HttpPost("/api/v1/templates/groups")] [Tags("Templates")] [EndpointSummary("Create a template group")] [EndpointGroupName("general")] [ProducesResponseType(typeof(TemplateGroupResponseModel), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task CreateGroup( [Required][FromBody] CreateTemplateGroupRequest request, CancellationToken cancellationToken) { Either result = await mediator.Send(request.ToCommand(), cancellationToken); return result.ToCreatedResult( vm => $"/api/v1/templates/groups/{vm.Id}", vm => ProjectToResponseModel(vm)); } [HttpDelete("/api/v1/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 DeleteGroup(int id, CancellationToken cancellationToken) { List groups = await mediator.Send(new GetAllTemplateGroups(), cancellationToken); if (groups.All(g => g.Id != id)) { return ApiResults.NotFoundProblem(); } Option result = await mediator.Send(new DeleteTemplateGroup(id), cancellationToken); return result.Match( Some: error => error.ToErrorResult(), None: () => new NoContentResult()); } [HttpGet("/api/v1/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), StatusCodes.Status200OK)] public async Task> GetAll( [FromQuery] int? templateGroupId, CancellationToken cancellationToken) { List templates = templateGroupId is { } groupId ? await mediator.Send(new GetTemplatesByTemplateGroupId(groupId), cancellationToken) : await mediator.Send(new GetAllTemplates(), cancellationToken); return templates.Map(ProjectToResponseModel).ToList(); } [HttpGet("/api/v1/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 GetById(int id, CancellationToken cancellationToken) { Option result = await mediator.Send(new GetTemplateById(id), cancellationToken); return result.Map(ProjectToResponseModel).ToGetResult(); } [HttpPost("/api/v1/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 Create( [Required][FromBody] CreateTemplateRequest request, CancellationToken cancellationToken) { Either result = await mediator.Send(request.ToCommand(), cancellationToken); return result.ToCreatedResult( vm => $"/api/v1/templates/{vm.Id}", vm => ProjectToResponseModel(vm)); } [HttpDelete("/api/v1/templates/{id:int}")] [Tags("Templates")] [EndpointSummary("Delete a 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 GetTemplateById(id), cancellationToken); if (existing.IsNone) { return ApiResults.NotFoundProblem(); } Option result = await mediator.Send(new DeleteTemplate(id), cancellationToken); return result.Match( Some: error => error.ToErrorResult(), None: () => new NoContentResult()); } [HttpGet("/api/v1/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), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetItems(int id, CancellationToken cancellationToken) { Option 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 items = await mediator.Send(new GetTemplateItems(id), cancellationToken); return new OkObjectResult( items.OrderBy(i => i.StartTime.TimeOfDay).Map(ProjectToResponseModel).ToList()); } [HttpPut("/api/v1/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 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 maybeTemplate = await mediator.Send(new GetTemplateById(id), cancellationToken); if (maybeTemplate.IsNone) { return ApiResults.NotFoundProblem(); } int templateGroupId = maybeTemplate.Map(t => t.TemplateGroupId).IfNone(0); Either> result = await mediator.Send(request.ToCommand(templateGroupId, 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). Returning the // handler's item snapshot alongside a separately re-queried version could pair stale items // with a newer ETag — a client would then silently overwrite the interleaving write. Option refreshed = await mediator.Send(new GetTemplateById(id), cancellationToken); List items = await mediator.Send(new GetTemplateItems(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/v1/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 Copy( int id, [Required][FromBody] CopyTemplateRequest request, CancellationToken cancellationToken) { Option existing = await mediator.Send(new GetTemplateById(id), cancellationToken); if (existing.IsNone) { return ApiResults.NotFoundProblem(); } Either result = await mediator.Send(request.ToCommand(id), cancellationToken); return result.ToCreatedResult( vm => $"/api/v1/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 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); }