diff --git a/ErsatzTV.Application/Scheduling/Commands/CreateTemplateHandler.cs b/ErsatzTV.Application/Scheduling/Commands/CreateTemplateHandler.cs index 944972cbe..6eeabe6a2 100644 --- a/ErsatzTV.Application/Scheduling/Commands/CreateTemplateHandler.cs +++ b/ErsatzTV.Application/Scheduling/Commands/CreateTemplateHandler.cs @@ -25,12 +25,28 @@ public class CreateTemplateHandler(IDbContextFactory dbContextFactory return Mapper.ProjectToViewModel(template); } - private static async Task> Validate(TvContext dbContext, CreateTemplate request) => - await ValidateTemplateName(dbContext, request).MapT(name => new Template + private static async Task> Validate(TvContext dbContext, CreateTemplate request) + { + Validation templateGroupValidation = await ValidateTemplateGroupExists(dbContext, request); + Validation nameValidation = await ValidateTemplateName(dbContext, request); + + return (templateGroupValidation, nameValidation).Apply((_, name) => new Template { TemplateGroupId = request.TemplateGroupId, Name = name }); + } + + private static async Task> ValidateTemplateGroupExists( + TvContext dbContext, + CreateTemplate request) + { + bool templateGroupExists = await dbContext.TemplateGroups.AnyAsync(tg => tg.Id == request.TemplateGroupId); + + return templateGroupExists + ? Success(Unit.Default) + : BaseError.New("Template group does not exist"); + } private static async Task> ValidateTemplateName( TvContext dbContext, diff --git a/ErsatzTV.Core/Api/Scheduling/TemplateGroupResponseModel.cs b/ErsatzTV.Core/Api/Scheduling/TemplateGroupResponseModel.cs new file mode 100644 index 000000000..dbb1e0bb9 --- /dev/null +++ b/ErsatzTV.Core/Api/Scheduling/TemplateGroupResponseModel.cs @@ -0,0 +1,4 @@ +#nullable enable +namespace ErsatzTV.Core.Api.Scheduling; + +public record TemplateGroupResponseModel(int Id, string Name, int TemplateCount); diff --git a/ErsatzTV.Core/Api/Scheduling/TemplateItemResponseModel.cs b/ErsatzTV.Core/Api/Scheduling/TemplateItemResponseModel.cs new file mode 100644 index 000000000..15605d7a9 --- /dev/null +++ b/ErsatzTV.Core/Api/Scheduling/TemplateItemResponseModel.cs @@ -0,0 +1,4 @@ +#nullable enable +namespace ErsatzTV.Core.Api.Scheduling; + +public record TemplateItemResponseModel(int BlockId, string BlockName, int BlockMinutes, TimeSpan StartTime); diff --git a/ErsatzTV.Core/Api/Scheduling/TemplateResponseModel.cs b/ErsatzTV.Core/Api/Scheduling/TemplateResponseModel.cs new file mode 100644 index 000000000..4f6dff4e2 --- /dev/null +++ b/ErsatzTV.Core/Api/Scheduling/TemplateResponseModel.cs @@ -0,0 +1,4 @@ +#nullable enable +namespace ErsatzTV.Core.Api.Scheduling; + +public record TemplateResponseModel(int Id, int TemplateGroupId, string GroupName, string Name); diff --git a/ErsatzTV.Core/Api/Scheduling/TemplateWithItemsResponseModel.cs b/ErsatzTV.Core/Api/Scheduling/TemplateWithItemsResponseModel.cs new file mode 100644 index 000000000..7ce4776cd --- /dev/null +++ b/ErsatzTV.Core/Api/Scheduling/TemplateWithItemsResponseModel.cs @@ -0,0 +1,9 @@ +#nullable enable +namespace ErsatzTV.Core.Api.Scheduling; + +public record TemplateWithItemsResponseModel( + int Id, + int TemplateGroupId, + string GroupName, + string Name, + List Items); diff --git a/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs b/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs index eea4d6858..7b711b9da 100644 --- a/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs +++ b/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs @@ -42,6 +42,7 @@ public class ApiControllerSecurityTests typeof(SessionController), typeof(SettingsController), typeof(SmartCollectionController), + typeof(TemplateController), typeof(TraktController), typeof(TroubleshootController), typeof(WatermarkController) diff --git a/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs b/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs index db7d23a6a..92f830a51 100644 --- a/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs +++ b/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs @@ -79,6 +79,20 @@ public class ApiErrorResponseMetadataTests [TestCase(typeof(BlockController), nameof(BlockController.Replace), StatusCodes.Status404NotFound)] [TestCase(typeof(BlockController), nameof(BlockController.Replace), StatusCodes.Status422UnprocessableEntity)] [TestCase(typeof(BlockController), nameof(BlockController.Preview), StatusCodes.Status404NotFound)] + [TestCase(typeof(BlockController), nameof(BlockController.Copy), StatusCodes.Status404NotFound)] + [TestCase(typeof(BlockController), nameof(BlockController.Copy), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(TemplateController), nameof(TemplateController.GetById), StatusCodes.Status404NotFound)] + [TestCase(typeof(TemplateController), nameof(TemplateController.CreateGroup), StatusCodes.Status404NotFound)] + [TestCase(typeof(TemplateController), nameof(TemplateController.CreateGroup), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(TemplateController), nameof(TemplateController.DeleteGroup), StatusCodes.Status404NotFound)] + [TestCase(typeof(TemplateController), nameof(TemplateController.Create), StatusCodes.Status404NotFound)] + [TestCase(typeof(TemplateController), nameof(TemplateController.Create), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(TemplateController), nameof(TemplateController.Delete), StatusCodes.Status404NotFound)] + [TestCase(typeof(TemplateController), nameof(TemplateController.GetItems), StatusCodes.Status404NotFound)] + [TestCase(typeof(TemplateController), nameof(TemplateController.Replace), StatusCodes.Status404NotFound)] + [TestCase(typeof(TemplateController), nameof(TemplateController.Replace), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(TemplateController), nameof(TemplateController.Copy), StatusCodes.Status404NotFound)] + [TestCase(typeof(TemplateController), nameof(TemplateController.Copy), StatusCodes.Status422UnprocessableEntity)] [TestCase(typeof(PlayoutController), nameof(PlayoutController.GetById), StatusCodes.Status404NotFound)] [TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status404NotFound)] [TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status422UnprocessableEntity)] diff --git a/ErsatzTV.Tests/Controllers/BlockControllerTests.cs b/ErsatzTV.Tests/Controllers/BlockControllerTests.cs index cc6723622..6ad2db0a8 100644 --- a/ErsatzTV.Tests/Controllers/BlockControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/BlockControllerTests.cs @@ -44,6 +44,7 @@ public class BlockControllerTests ShouldHaveActionRoute(nameof(BlockController.GetItems), "GET", "/api/blocks/{id:int}/items"); ShouldHaveActionRoute(nameof(BlockController.Replace), "PUT", "/api/blocks/{id:int}"); ShouldHaveActionRoute(nameof(BlockController.Preview), "POST", "/api/blocks/{id:int}/preview"); + ShouldHaveActionRoute(nameof(BlockController.Copy), "POST", "/api/blocks/{id:int}/copy"); } [Test] @@ -328,6 +329,57 @@ public class BlockControllerTests await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } + [Test] + public async Task Copy_Should_Return_201_And_Map_Request() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeBlock(4, 2, "Morning"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(MakeBlock(9, 3, "Morning Copy"))); + + IActionResult result = await _controller.Copy( + 4, + new CopyBlockRequest(3, "Morning Copy"), + CancellationToken.None); + + var created = result.ShouldBeOfType(); + created.Location.ShouldBe("/api/blocks/9"); + await _mediator.Received(1).Send( + Arg.Is(c => c.BlockId == 4 && c.NewBlockGroupId == 3 && c.NewBlockName == "Morning Copy"), + Arg.Any()); + } + + [Test] + public async Task Copy_Should_Return_404_When_Source_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.Copy( + 4, + new CopyBlockRequest(3, "Morning Copy"), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Copy_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeBlock(4, 2, "Morning"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("dupe"))); + + IActionResult result = await _controller.Copy( + 4, + new CopyBlockRequest(3, "Morning"), + CancellationToken.None); + + result.ShouldBeOfType(); + } + private static BlockViewModel MakeBlock(int id, int groupId, string name) => new(id, groupId, "Group", name, 30, BlockStopScheduling.AfterDurationEnd); diff --git a/ErsatzTV.Tests/Controllers/TemplateControllerTests.cs b/ErsatzTV.Tests/Controllers/TemplateControllerTests.cs new file mode 100644 index 000000000..a56f47017 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/TemplateControllerTests.cs @@ -0,0 +1,380 @@ +using System.Reflection; +using ErsatzTV.Application.Scheduling; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.Scheduling; +using LanguageExt; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class TemplateControllerTests +{ + private TemplateController _controller = null!; + private IMediator _mediator = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _controller = new TemplateController(_mediator); + } + + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Routes() + { + ShouldHaveActionRoute(nameof(TemplateController.GetGroups), "GET", "/api/templates/groups"); + ShouldHaveActionRoute(nameof(TemplateController.CreateGroup), "POST", "/api/templates/groups"); + ShouldHaveActionRoute(nameof(TemplateController.DeleteGroup), "DELETE", "/api/templates/groups/{id:int}"); + ShouldHaveActionRoute(nameof(TemplateController.GetAll), "GET", "/api/templates"); + ShouldHaveActionRoute(nameof(TemplateController.GetById), "GET", "/api/templates/{id:int}"); + ShouldHaveActionRoute(nameof(TemplateController.Create), "POST", "/api/templates"); + ShouldHaveActionRoute(nameof(TemplateController.Delete), "DELETE", "/api/templates/{id:int}"); + ShouldHaveActionRoute(nameof(TemplateController.GetItems), "GET", "/api/templates/{id:int}/items"); + ShouldHaveActionRoute(nameof(TemplateController.Replace), "PUT", "/api/templates/{id:int}"); + ShouldHaveActionRoute(nameof(TemplateController.Copy), "POST", "/api/templates/{id:int}/copy"); + } + + [Test] + public async Task CreateGroup_Should_Return_201_With_Location_And_Body() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(new TemplateGroupViewModel(5, "Prime Time", 0))); + + IActionResult result = await _controller.CreateGroup( + new CreateTemplateGroupRequest("Prime Time"), + CancellationToken.None); + + var created = result.ShouldBeOfType(); + created.Location.ShouldBe("/api/templates/groups/5"); + created.Value.ShouldBeOfType().Name.ShouldBe("Prime Time"); + await _mediator.Received(1).Send( + Arg.Is(c => c.Name == "Prime Time"), + Arg.Any()); + } + + [Test] + public async Task CreateGroup_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("bad"))); + + IActionResult result = await _controller.CreateGroup( + new CreateTemplateGroupRequest(string.Empty), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task DeleteGroup_Should_Return_204_When_Exists() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { new(3, "Prime Time", 0) }); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.DeleteGroup(3, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send( + Arg.Is(c => c.TemplateGroupId == 3), + Arg.Any()); + } + + [Test] + public async Task DeleteGroup_Should_Return_404_When_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List()); + + IActionResult result = await _controller.DeleteGroup(99, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task GetAll_Should_Map_ViewModels_To_Response_Models() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { MakeTemplate(1, 2, "Morning") }); + + List result = await _controller.GetAll(null, CancellationToken.None); + + result.Count.ShouldBe(1); + result[0].Id.ShouldBe(1); + result[0].TemplateGroupId.ShouldBe(2); + result[0].Name.ShouldBe("Morning"); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task GetAll_Should_Filter_By_TemplateGroupId_When_Provided() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { MakeTemplate(1, 2, "Morning") }); + + List result = await _controller.GetAll(2, CancellationToken.None); + + result.Count.ShouldBe(1); + await _mediator.Received(1).Send( + Arg.Is(c => c.TemplateGroupId == 2), + Arg.Any()); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Create_Should_Return_201_And_Map_Request() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(MakeTemplate(8, 2, "Morning"))); + + IActionResult result = await _controller.Create( + new CreateTemplateRequest(2, "Morning"), + CancellationToken.None); + + var created = result.ShouldBeOfType(); + created.Location.ShouldBe("/api/templates/8"); + await _mediator.Received(1).Send( + Arg.Is(c => c.TemplateGroupId == 2 && c.Name == "Morning"), + Arg.Any()); + } + + [Test] + public async Task Create_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("dupe"))); + + IActionResult result = await _controller.Create( + new CreateTemplateRequest(2, "Morning"), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task GetById_Should_Return_200_For_Some() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeTemplate(4, 2, "Morning"))); + + IActionResult result = await _controller.GetById(4, CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBeOfType().Id.ShouldBe(4); + } + + [Test] + public async Task GetById_Should_Return_404_For_None() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetById(4, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Delete_Should_Return_204_When_Exists() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeTemplate(4, 2, "Morning"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.Delete(4, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Delete_Should_Return_404_When_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.Delete(4, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task GetItems_Should_Return_200_Ordered_By_StartTime() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeTemplate(4, 2, "Morning"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List + { + MakeItem(20, "News", TimeSpan.FromHours(12), 30), + MakeItem(10, "Cartoons", TimeSpan.FromHours(6), 60) + }); + + IActionResult result = await _controller.GetItems(4, CancellationToken.None); + + var items = result.ShouldBeOfType().Value.ShouldBeOfType>(); + items.Count.ShouldBe(2); + items[0].BlockId.ShouldBe(10); + items[0].StartTime.ShouldBe(TimeSpan.FromHours(6)); + items[0].BlockMinutes.ShouldBe(60); + items[1].BlockId.ShouldBe(20); + } + + [Test] + public async Task GetItems_Should_Return_404_When_Template_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetItems(4, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Replace_Should_Return_200_With_Refreshed_Template_And_Map_Items() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeTemplate(4, 7, "Morning"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right>([])); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List + { + MakeItem(10, "Cartoons", TimeSpan.FromHours(6), 60) + }); + + IActionResult result = await _controller.Replace( + 4, + new ReplaceTemplateRequest( + "Morning", + [new TemplateItemRequest(10, TimeSpan.FromHours(6))]), + CancellationToken.None); + + var body = result.ShouldBeOfType().Value.ShouldBeOfType(); + body.Id.ShouldBe(4); + body.Items.Count.ShouldBe(1); + await _mediator.Received(1).Send( + Arg.Is(c => + c.TemplateId == 4 && + c.TemplateGroupId == 7 && + c.Name == "Morning" && + c.Items.Count == 1 && + c.Items[0].BlockId == 10 && + c.Items[0].StartTime == TimeSpan.FromHours(6)), + Arg.Any()); + } + + [Test] + public async Task Replace_Should_Return_404_When_Template_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.Replace( + 4, + new ReplaceTemplateRequest("Morning", []), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Replace_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeTemplate(4, 7, "Morning"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left>(BaseError.New("overlap"))); + + IActionResult result = await _controller.Replace( + 4, + new ReplaceTemplateRequest("Morning", []), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Copy_Should_Return_201_And_Map_Request() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeTemplate(4, 2, "Morning"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(MakeTemplate(9, 3, "Morning Copy"))); + + IActionResult result = await _controller.Copy( + 4, + new CopyTemplateRequest(3, "Morning Copy"), + CancellationToken.None); + + var created = result.ShouldBeOfType(); + created.Location.ShouldBe("/api/templates/9"); + await _mediator.Received(1).Send( + Arg.Is(c => c.TemplateId == 4 && c.NewTemplateGroupId == 3 && c.NewTemplateName == "Morning Copy"), + Arg.Any()); + } + + [Test] + public async Task Copy_Should_Return_404_When_Source_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.Copy( + 4, + new CopyTemplateRequest(3, "Morning Copy"), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Copy_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeTemplate(4, 2, "Morning"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("dupe"))); + + IActionResult result = await _controller.Copy( + 4, + new CopyTemplateRequest(3, "Morning"), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + private static TemplateViewModel MakeTemplate(int id, int groupId, string name) => + new(id, groupId, "Group", name); + + private static TemplateItemViewModel MakeItem(int blockId, string blockName, TimeSpan startTime, int minutes) + { + DateTime start = DateTime.Today.Add(startTime); + return new TemplateItemViewModel(blockId, blockName, start, start.AddMinutes(minutes)); + } + + private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) + { + MethodInfo action = typeof(TemplateController).GetMethod(actionName) + ?? throw new AssertionException($"Missing action {actionName}"); + + HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); + attribute.HttpMethods.ShouldContain(httpMethod); + attribute.Template.ShouldBe(route); + } +} diff --git a/ErsatzTV/Controllers/Api/BlockController.cs b/ErsatzTV/Controllers/Api/BlockController.cs index 571461d7f..62dd6f8a7 100644 --- a/ErsatzTV/Controllers/Api/BlockController.cs +++ b/ErsatzTV/Controllers/Api/BlockController.cs @@ -211,6 +211,31 @@ public class BlockController(IMediator mediator) : ControllerBase return new OkObjectResult(preview.Map(ProjectToResponseModel).ToList()); } + [HttpPost("/api/blocks/{id:int}/copy")] + [Tags("Blocks")] + [EndpointSummary("Copy a block")] + [EndpointDescription("Copies the block and its items into another (or the same) block group under a new name.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(BlockResponseModel), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Copy( + int id, + [Required] [FromBody] CopyBlockRequest request, + CancellationToken cancellationToken) + { + Option existing = await mediator.Send(new GetBlockById(id), cancellationToken); + if (existing.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + Either result = await mediator.Send(request.ToCommand(id), cancellationToken); + return result.ToCreatedResult( + vm => $"/api/blocks/{vm.Id}", + vm => ProjectToResponseModel(vm)); + } + private static BlockGroupResponseModel ProjectToResponseModel(BlockGroupViewModel vm) => new(vm.Id, vm.Name); diff --git a/ErsatzTV/Controllers/Api/Requests/CopyBlockRequest.cs b/ErsatzTV/Controllers/Api/Requests/CopyBlockRequest.cs new file mode 100644 index 000000000..5bcd2aa48 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/CopyBlockRequest.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Application.Scheduling; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record CopyBlockRequest(int BlockGroupId, string Name) +{ + public CopyBlock ToCommand(int blockId) => new(blockId, BlockGroupId, Name); +} diff --git a/ErsatzTV/Controllers/Api/Requests/CopyTemplateRequest.cs b/ErsatzTV/Controllers/Api/Requests/CopyTemplateRequest.cs new file mode 100644 index 000000000..e18d2d860 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/CopyTemplateRequest.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Application.Scheduling; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record CopyTemplateRequest(int TemplateGroupId, string Name) +{ + public CopyTemplate ToCommand(int templateId) => new(templateId, TemplateGroupId, Name); +} diff --git a/ErsatzTV/Controllers/Api/Requests/CreateTemplateGroupRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreateTemplateGroupRequest.cs new file mode 100644 index 000000000..27b1f3dca --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/CreateTemplateGroupRequest.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Application.Scheduling; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record CreateTemplateGroupRequest(string Name) +{ + public CreateTemplateGroup ToCommand() => new(Name); +} diff --git a/ErsatzTV/Controllers/Api/Requests/CreateTemplateRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreateTemplateRequest.cs new file mode 100644 index 000000000..bce33f6a3 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/CreateTemplateRequest.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Application.Scheduling; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record CreateTemplateRequest(int TemplateGroupId, string Name) +{ + public CreateTemplate ToCommand() => new(TemplateGroupId, Name); +} diff --git a/ErsatzTV/Controllers/Api/Requests/ReplaceTemplateRequest.cs b/ErsatzTV/Controllers/Api/Requests/ReplaceTemplateRequest.cs new file mode 100644 index 000000000..f2c5f20e0 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/ReplaceTemplateRequest.cs @@ -0,0 +1,13 @@ +using ErsatzTV.Application.Scheduling; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record ReplaceTemplateRequest(string Name, List Items) +{ + public ReplaceTemplateItems ToCommand(int templateGroupId, int templateId) => + new( + templateGroupId, + templateId, + Name, + (Items ?? []).Select(item => item.ToReplaceItem()).ToList()); +} diff --git a/ErsatzTV/Controllers/Api/Requests/TemplateItemRequest.cs b/ErsatzTV/Controllers/Api/Requests/TemplateItemRequest.cs new file mode 100644 index 000000000..20a0da2ae --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/TemplateItemRequest.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Application.Scheduling; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record TemplateItemRequest(int BlockId, TimeSpan StartTime) +{ + public ReplaceTemplateItem ToReplaceItem() => new(BlockId, StartTime); +} diff --git a/ErsatzTV/Controllers/Api/TemplateController.cs b/ErsatzTV/Controllers/Api/TemplateController.cs new file mode 100644 index 000000000..e02f87209 --- /dev/null +++ b/ErsatzTV/Controllers/Api/TemplateController.cs @@ -0,0 +1,235 @@ +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), StatusCodes.Status200OK)] + public async Task> GetGroups(CancellationToken cancellationToken) + { + List 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 CreateGroup( + [Required] [FromBody] CreateTemplateGroupRequest request, + CancellationToken cancellationToken) + { + Either 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 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/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/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/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/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 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/templates/{id:int}/items")] + [Tags("Templates")] + [EndpointSummary("Get template items")] + [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(); + } + + List 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).")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(TemplateWithItemsResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Replace( + int id, + [Required] [FromBody] ReplaceTemplateRequest request, + CancellationToken cancellationToken) + { + 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), cancellationToken); + + return await result.Match( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async _ => + { + Option refreshed = await mediator.Send(new GetTemplateById(id), cancellationToken); + List items = await mediator.Send(new GetTemplateItems(id), cancellationToken); + return refreshed.Match( + Some: vm => (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 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/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); +}