diff --git a/ErsatzTV.Application/Scheduling/Commands/CreateDecoTemplateHandler.cs b/ErsatzTV.Application/Scheduling/Commands/CreateDecoTemplateHandler.cs index 9a684e3bb..226140590 100644 --- a/ErsatzTV.Application/Scheduling/Commands/CreateDecoTemplateHandler.cs +++ b/ErsatzTV.Application/Scheduling/Commands/CreateDecoTemplateHandler.cs @@ -27,12 +27,30 @@ public class CreateDecoTemplateHandler(IDbContextFactory dbContextFac private static async Task> Validate( TvContext dbContext, - CreateDecoTemplate request) => - await ValidateDecoTemplateName(dbContext, request).MapT(name => new DecoTemplate + CreateDecoTemplate request) + { + Validation decoTemplateGroupValidation = + await ValidateDecoTemplateGroupExists(dbContext, request); + Validation nameValidation = await ValidateDecoTemplateName(dbContext, request); + + return (decoTemplateGroupValidation, nameValidation).Apply((_, name) => new DecoTemplate { DecoTemplateGroupId = request.DecoTemplateGroupId, Name = name }); + } + + private static async Task> ValidateDecoTemplateGroupExists( + TvContext dbContext, + CreateDecoTemplate request) + { + bool decoTemplateGroupExists = + await dbContext.DecoTemplateGroups.AnyAsync(dtg => dtg.Id == request.DecoTemplateGroupId); + + return decoTemplateGroupExists + ? Success(Unit.Default) + : BaseError.New("Deco template group does not exist"); + } private static async Task> ValidateDecoTemplateName( TvContext dbContext, diff --git a/ErsatzTV.Application/Scheduling/Commands/ReplaceDecoTemplateItemsHandler.cs b/ErsatzTV.Application/Scheduling/Commands/ReplaceDecoTemplateItemsHandler.cs index c3711e09c..7d67f1364 100644 --- a/ErsatzTV.Application/Scheduling/Commands/ReplaceDecoTemplateItemsHandler.cs +++ b/ErsatzTV.Application/Scheduling/Commands/ReplaceDecoTemplateItemsHandler.cs @@ -29,11 +29,7 @@ public class ReplaceDecoTemplateItemsHandler(IDbContextFactory dbCont dbContext.RemoveRange(decoTemplate.Items); - // drop items that are invalid - decoTemplate.Items = request.Items - .Map(i => BuildItem(decoTemplate, i)) - .Filter(i => i.StartTime < i.EndTime || i.EndTime == TimeSpan.Zero) - .ToList(); + decoTemplate.Items = request.Items.Map(i => BuildItem(decoTemplate, i)).ToList(); await dbContext.SaveChangesAsync(cancellationToken); @@ -66,7 +62,71 @@ public class ReplaceDecoTemplateItemsHandler(IDbContextFactory dbCont ReplaceDecoTemplateItems request, CancellationToken cancellationToken) => DecoTemplateMustExist(dbContext, request.DecoTemplateId, cancellationToken) - .BindT(decoTemplate => DecoTemplateNameMustBeValid(dbContext, decoTemplate, request)); + .BindT(decoTemplate => DecoTemplateNameMustBeValid(dbContext, decoTemplate, request)) + .BindT(decoTemplate => DecoTemplateItemsMustBeValid(dbContext, decoTemplate, request)); + + // Hardening (deliberate deviation from the original handler): rather than silently filtering out + // invalid items (unknown DecoId, StartTime >= EndTime, or overlapping ranges) - the same silent-drop + // and silent-overlap bug class fixed for ReplaceTemplateItemsHandler in #144 S2 - reject the whole + // request with a 422 so the caller knows exactly what is wrong. EndTime == TimeSpan.Zero means + // "end of day" (24:00) and is treated as such for both the ordering and overlap checks. + private static async Task> DecoTemplateItemsMustBeValid( + TvContext dbContext, + DecoTemplate decoTemplate, + ReplaceDecoTemplateItems request) + { + var allDecoIds = request.Items.Map(i => i.DecoId).Distinct().ToList(); + + Dictionary allDecos = await dbContext.Decos + .AsNoTracking() + .Filter(d => allDecoIds.Contains(d.Id)) + .ToListAsync() + .Map(list => list.ToDictionary(d => d.Id, d => d)); + + var missingDecoIds = allDecoIds.Filter(id => !allDecos.ContainsKey(id)).ToList(); + if (missingDecoIds.Count > 0) + { + return BaseError.New($"[DecoId] {missingDecoIds.Head()} does not exist."); + } + + var itemsWithEffectiveEnd = request.Items + .Map(i => new DecoTemplateItemRange( + i.DecoId, + i.StartTime, + i.EndTime, + i.EndTime == TimeSpan.Zero ? TimeSpan.FromHours(24) : i.EndTime)) + .ToList(); + + foreach (DecoTemplateItemRange item in itemsWithEffectiveEnd) + { + if (item.StartTime >= item.EffectiveEndTime) + { + return BaseError.New( + $"Deco from {item.StartTime} to {item.EndTime} must start before it ends"); + } + } + + foreach (DecoTemplateItemRange item in itemsWithEffectiveEnd) + { + foreach (DecoTemplateItemRange otherItem in itemsWithEffectiveEnd) + { + if (item == otherItem) + { + continue; + } + + if (item.StartTime < otherItem.EffectiveEndTime && otherItem.StartTime < item.EffectiveEndTime) + { + return BaseError.New( + $"Deco from {item.StartTime} to {item.EndTime} intersects deco from {otherItem.StartTime} to {otherItem.EndTime}"); + } + } + } + + return decoTemplate; + } + + private sealed record DecoTemplateItemRange(int DecoId, TimeSpan StartTime, TimeSpan EndTime, TimeSpan EffectiveEndTime); private static Task> DecoTemplateMustExist( TvContext dbContext, diff --git a/ErsatzTV.Core/Api/Scheduling/DecoTemplateGroupResponseModel.cs b/ErsatzTV.Core/Api/Scheduling/DecoTemplateGroupResponseModel.cs new file mode 100644 index 000000000..4e1cbd538 --- /dev/null +++ b/ErsatzTV.Core/Api/Scheduling/DecoTemplateGroupResponseModel.cs @@ -0,0 +1,4 @@ +#nullable enable +namespace ErsatzTV.Core.Api.Scheduling; + +public record DecoTemplateGroupResponseModel(int Id, string Name, int DecoTemplateCount); diff --git a/ErsatzTV.Core/Api/Scheduling/DecoTemplateItemResponseModel.cs b/ErsatzTV.Core/Api/Scheduling/DecoTemplateItemResponseModel.cs new file mode 100644 index 000000000..f77805923 --- /dev/null +++ b/ErsatzTV.Core/Api/Scheduling/DecoTemplateItemResponseModel.cs @@ -0,0 +1,4 @@ +#nullable enable +namespace ErsatzTV.Core.Api.Scheduling; + +public record DecoTemplateItemResponseModel(int DecoId, string DecoName, TimeSpan StartTime, TimeSpan EndTime); diff --git a/ErsatzTV.Core/Api/Scheduling/DecoTemplateResponseModel.cs b/ErsatzTV.Core/Api/Scheduling/DecoTemplateResponseModel.cs new file mode 100644 index 000000000..4f34ed4b4 --- /dev/null +++ b/ErsatzTV.Core/Api/Scheduling/DecoTemplateResponseModel.cs @@ -0,0 +1,4 @@ +#nullable enable +namespace ErsatzTV.Core.Api.Scheduling; + +public record DecoTemplateResponseModel(int Id, int DecoTemplateGroupId, string GroupName, string Name); diff --git a/ErsatzTV.Core/Api/Scheduling/DecoTemplateWithItemsResponseModel.cs b/ErsatzTV.Core/Api/Scheduling/DecoTemplateWithItemsResponseModel.cs new file mode 100644 index 000000000..5ac4be597 --- /dev/null +++ b/ErsatzTV.Core/Api/Scheduling/DecoTemplateWithItemsResponseModel.cs @@ -0,0 +1,9 @@ +#nullable enable +namespace ErsatzTV.Core.Api.Scheduling; + +public record DecoTemplateWithItemsResponseModel( + int Id, + int DecoTemplateGroupId, + string GroupName, + string Name, + List Items); diff --git a/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs b/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs index f7bc4b93c..f5c1cfa03 100644 --- a/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs +++ b/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs @@ -29,6 +29,7 @@ public class ApiControllerSecurityTests typeof(ChannelController), typeof(CollectionController), typeof(DecoController), + typeof(DecoTemplateController), typeof(FFmpegProfileController), typeof(FillerPresetController), typeof(LibrariesController), diff --git a/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs b/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs index 025b0f7e2..d0d94433d 100644 --- a/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs +++ b/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs @@ -102,6 +102,16 @@ public class ApiErrorResponseMetadataTests [TestCase(typeof(DecoController), nameof(DecoController.Delete), StatusCodes.Status404NotFound)] [TestCase(typeof(DecoController), nameof(DecoController.Replace), StatusCodes.Status404NotFound)] [TestCase(typeof(DecoController), nameof(DecoController.Replace), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.GetById), StatusCodes.Status404NotFound)] + [TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.CreateGroup), StatusCodes.Status404NotFound)] + [TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.CreateGroup), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.DeleteGroup), StatusCodes.Status404NotFound)] + [TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.Create), StatusCodes.Status404NotFound)] + [TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.Create), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.Delete), StatusCodes.Status404NotFound)] + [TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.GetItems), StatusCodes.Status404NotFound)] + [TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.Replace), StatusCodes.Status404NotFound)] + [TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.Replace), StatusCodes.Status422UnprocessableEntity)] [TestCase(typeof(PlayoutController), nameof(PlayoutController.UpdateDefaultDeco), StatusCodes.Status404NotFound)] [TestCase(typeof(PlayoutController), nameof(PlayoutController.UpdateDefaultDeco), StatusCodes.Status422UnprocessableEntity)] [TestCase(typeof(PlayoutController), nameof(PlayoutController.GetById), StatusCodes.Status404NotFound)] diff --git a/ErsatzTV.Tests/Controllers/DecoTemplateControllerTests.cs b/ErsatzTV.Tests/Controllers/DecoTemplateControllerTests.cs new file mode 100644 index 000000000..6ebfe6754 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/DecoTemplateControllerTests.cs @@ -0,0 +1,348 @@ +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 DecoTemplateControllerTests +{ + private DecoTemplateController _controller = null!; + private IMediator _mediator = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _controller = new DecoTemplateController(_mediator); + } + + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Routes() + { + ShouldHaveActionRoute(nameof(DecoTemplateController.GetGroups), "GET", "/api/deco-templates/groups"); + ShouldHaveActionRoute(nameof(DecoTemplateController.CreateGroup), "POST", "/api/deco-templates/groups"); + ShouldHaveActionRoute( + nameof(DecoTemplateController.DeleteGroup), + "DELETE", + "/api/deco-templates/groups/{id:int}"); + ShouldHaveActionRoute(nameof(DecoTemplateController.GetAll), "GET", "/api/deco-templates"); + ShouldHaveActionRoute(nameof(DecoTemplateController.GetById), "GET", "/api/deco-templates/{id:int}"); + ShouldHaveActionRoute(nameof(DecoTemplateController.Create), "POST", "/api/deco-templates"); + ShouldHaveActionRoute(nameof(DecoTemplateController.Delete), "DELETE", "/api/deco-templates/{id:int}"); + ShouldHaveActionRoute(nameof(DecoTemplateController.GetItems), "GET", "/api/deco-templates/{id:int}/items"); + ShouldHaveActionRoute(nameof(DecoTemplateController.Replace), "PUT", "/api/deco-templates/{id:int}"); + } + + [Test] + public async Task CreateGroup_Should_Return_201_With_Location_And_Body() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(new DecoTemplateGroupViewModel(5, "Weekday", 0))); + + IActionResult result = await _controller.CreateGroup( + new CreateDecoTemplateGroupRequest("Weekday"), + CancellationToken.None); + + var created = result.ShouldBeOfType(); + created.Location.ShouldBe("/api/deco-templates/groups/5"); + created.Value.ShouldBeOfType().Name.ShouldBe("Weekday"); + await _mediator.Received(1).Send( + Arg.Is(c => c.Name == "Weekday"), + 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 CreateDecoTemplateGroupRequest(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, "Weekday", 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.DecoTemplateGroupId == 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_Flatten_DecoTemplates_Across_Groups() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { new(2, "Weekday", 1) }); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { MakeDecoTemplate(1, 2, "Morning") }); + + List result = await _controller.GetAll(CancellationToken.None); + + result.Count.ShouldBe(1); + result[0].Id.ShouldBe(1); + result[0].DecoTemplateGroupId.ShouldBe(2); + result[0].Name.ShouldBe("Morning"); + await _mediator.Received(1).Send( + Arg.Is(c => c.DecoTemplateGroupId == 2), + Arg.Any()); + } + + [Test] + public async Task GetById_Should_Return_200_For_Some() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeDecoTemplate(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 Create_Should_Return_201_And_Map_Request() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(MakeDecoTemplate(8, 2, "Morning"))); + + IActionResult result = await _controller.Create( + new CreateDecoTemplateRequest(2, "Morning"), + CancellationToken.None); + + var created = result.ShouldBeOfType(); + created.Location.ShouldBe("/api/deco-templates/8"); + await _mediator.Received(1).Send( + Arg.Is(c => c.DecoTemplateGroupId == 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 CreateDecoTemplateRequest(2, "Morning"), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Delete_Should_Return_204_When_Exists() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeDecoTemplate(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(MakeDecoTemplate(4, 2, "Morning"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List + { + MakeItem(20, "News Bumper", TimeSpan.FromHours(12), TimeSpan.FromHours(13)), + MakeItem(10, "Morning Bumper", TimeSpan.FromHours(6), TimeSpan.FromHours(7)) + }); + + IActionResult result = await _controller.GetItems(4, CancellationToken.None); + + var items = result.ShouldBeOfType().Value + .ShouldBeOfType>(); + items.Count.ShouldBe(2); + items[0].DecoId.ShouldBe(10); + items[0].StartTime.ShouldBe(TimeSpan.FromHours(6)); + items[0].EndTime.ShouldBe(TimeSpan.FromHours(7)); + items[1].DecoId.ShouldBe(20); + } + + [Test] + public async Task GetItems_Should_Return_End_Of_Day_As_Midnight() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeDecoTemplate(4, 2, "Morning"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List + { + MakeItem(10, "Overnight Bumper", TimeSpan.FromHours(22), TimeSpan.Zero) + }); + + IActionResult result = await _controller.GetItems(4, CancellationToken.None); + + var items = result.ShouldBeOfType().Value + .ShouldBeOfType>(); + items[0].StartTime.ShouldBe(TimeSpan.FromHours(22)); + items[0].EndTime.ShouldBe(TimeSpan.Zero); + } + + [Test] + public async Task GetItems_Should_Return_404_When_DecoTemplate_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_DecoTemplate_And_Map_Items() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeDecoTemplate(4, 7, "Morning"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right>([])); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List + { + MakeItem(10, "Morning Bumper", TimeSpan.FromHours(6), TimeSpan.FromHours(7)) + }); + + IActionResult result = await _controller.Replace( + 4, + new ReplaceDecoTemplateRequest( + "Morning", + [new DecoTemplateItemRequest(10, TimeSpan.FromHours(6), TimeSpan.FromHours(7))]), + 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.DecoTemplateId == 4 && + c.DecoTemplateGroupId == 7 && + c.Name == "Morning" && + c.Items.Count == 1 && + c.Items[0].DecoId == 10 && + c.Items[0].StartTime == TimeSpan.FromHours(6) && + c.Items[0].EndTime == TimeSpan.FromHours(7)), + Arg.Any()); + } + + [Test] + public async Task Replace_Should_Return_404_When_DecoTemplate_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.Replace( + 4, + new ReplaceDecoTemplateRequest("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(MakeDecoTemplate(4, 7, "Morning"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left>(BaseError.New("overlap"))); + + IActionResult result = await _controller.Replace( + 4, + new ReplaceDecoTemplateRequest("Morning", []), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + private static DecoTemplateViewModel MakeDecoTemplate(int id, int groupId, string name) => + new(id, groupId, "Group", name); + + private static DecoTemplateItemViewModel MakeItem(int decoId, string decoName, TimeSpan startTime, TimeSpan endTime) + { + DateTime start = DateTime.Today.Add(startTime); + DateTime end = DateTime.Today.Add(endTime); + if (start > end) + { + end = end.AddDays(1); + } + + return new DecoTemplateItemViewModel(decoId, decoName, start, end); + } + + private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) + { + MethodInfo action = typeof(DecoTemplateController).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/DecoTemplateController.cs b/ErsatzTV/Controllers/Api/DecoTemplateController.cs new file mode 100644 index 000000000..82a316239 --- /dev/null +++ b/ErsatzTV/Controllers/Api/DecoTemplateController.cs @@ -0,0 +1,221 @@ +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/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/deco-templates/groups")] + [Tags("DecoTemplates")] + [EndpointSummary("Create a deco template group")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(DecoTemplateGroupResponseModel), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [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/deco-templates/groups/{vm.Id}", + vm => ProjectToResponseModel(vm)); + } + + [HttpDelete("/api/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/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/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/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/deco-templates/{vm.Id}", + vm => ProjectToResponseModel(vm)); + } + + [HttpDelete("/api/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/deco-templates/{id:int}/items")] + [Tags("DecoTemplates")] + [EndpointSummary("Get deco template items")] + [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(); + } + + List items = await mediator.Send(new GetDecoTemplateItems(id), cancellationToken); + return new OkObjectResult( + items.OrderBy(i => i.StartTime.TimeOfDay).Map(ProjectToResponseModel).ToList()); + } + + [HttpPut("/api/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.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(DecoTemplateWithItemsResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Replace( + int id, + [Required] [FromBody] ReplaceDecoTemplateRequest request, + CancellationToken cancellationToken) + { + 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), cancellationToken); + + return await result.Match( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async _ => + { + Option refreshed = + await mediator.Send(new GetDecoTemplateById(id), cancellationToken); + List items = + await mediator.Send(new GetDecoTemplateItems(id), cancellationToken); + return refreshed.Match( + Some: vm => (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); +} diff --git a/ErsatzTV/Controllers/Api/Requests/CreateDecoTemplateGroupRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreateDecoTemplateGroupRequest.cs new file mode 100644 index 000000000..75e1c15cb --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/CreateDecoTemplateGroupRequest.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Application.Scheduling; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record CreateDecoTemplateGroupRequest(string Name) +{ + public CreateDecoTemplateGroup ToCommand() => new(Name); +} diff --git a/ErsatzTV/Controllers/Api/Requests/CreateDecoTemplateRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreateDecoTemplateRequest.cs new file mode 100644 index 000000000..e0d3afabd --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/CreateDecoTemplateRequest.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Application.Scheduling; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record CreateDecoTemplateRequest(int DecoTemplateGroupId, string Name) +{ + public CreateDecoTemplate ToCommand() => new(DecoTemplateGroupId, Name); +} diff --git a/ErsatzTV/Controllers/Api/Requests/DecoTemplateItemRequest.cs b/ErsatzTV/Controllers/Api/Requests/DecoTemplateItemRequest.cs new file mode 100644 index 000000000..451081071 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/DecoTemplateItemRequest.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Application.Scheduling; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record DecoTemplateItemRequest(int DecoId, TimeSpan StartTime, TimeSpan EndTime) +{ + public ReplaceDecoTemplateItem ToReplaceItem() => new(DecoId, StartTime, EndTime); +} diff --git a/ErsatzTV/Controllers/Api/Requests/ReplaceDecoTemplateRequest.cs b/ErsatzTV/Controllers/Api/Requests/ReplaceDecoTemplateRequest.cs new file mode 100644 index 000000000..e649a6a1e --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/ReplaceDecoTemplateRequest.cs @@ -0,0 +1,13 @@ +using ErsatzTV.Application.Scheduling; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record ReplaceDecoTemplateRequest(string Name, List Items) +{ + public ReplaceDecoTemplateItems ToCommand(int decoTemplateGroupId, int decoTemplateId) => + new( + decoTemplateId, + decoTemplateGroupId, + Name, + (Items ?? []).Select(item => item.ToReplaceItem()).ToList()); +} diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 58437257c..0def3b884 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -3247,6 +3247,597 @@ } } }, + "/api/deco-templates/groups": { + "get": { + "tags": [ + "DecoTemplates" + ], + "summary": "Get all deco template groups", + "operationId": "GetDecoTemplateGroups", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DecoTemplateGroupResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DecoTemplateGroupResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DecoTemplateGroupResponseModel" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "DecoTemplates" + ], + "summary": "Create a deco template group", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/CreateDecoTemplateGroupRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDecoTemplateGroupRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateDecoTemplateGroupRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateDecoTemplateGroupRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/DecoTemplateGroupResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/DecoTemplateGroupResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/DecoTemplateGroupResponseModel" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/deco-templates/groups/{id}": { + "delete": { + "tags": [ + "DecoTemplates" + ], + "summary": "Delete a deco template group", + "description": "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.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/deco-templates": { + "get": { + "tags": [ + "DecoTemplates" + ], + "summary": "Get all deco templates", + "description": "Returns every deco template as a flat list, ordered by group name then deco template name.", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DecoTemplateResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DecoTemplateResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DecoTemplateResponseModel" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "DecoTemplates" + ], + "summary": "Create a deco template", + "description": "Creates an empty deco template in the given deco template group.", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/CreateDecoTemplateRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDecoTemplateRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateDecoTemplateRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateDecoTemplateRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/DecoTemplateResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/DecoTemplateResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/DecoTemplateResponseModel" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/deco-templates/{id}": { + "get": { + "tags": [ + "DecoTemplates" + ], + "summary": "Get a deco template by id", + "operationId": "GetDecoTemplateById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/DecoTemplateResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/DecoTemplateResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/DecoTemplateResponseModel" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "delete": { + "tags": [ + "DecoTemplates" + ], + "summary": "Delete a deco template", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "put": { + "tags": [ + "DecoTemplates" + ], + "summary": "Replace a deco template and its items", + "description": "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.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/ReplaceDecoTemplateRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceDecoTemplateRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceDecoTemplateRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ReplaceDecoTemplateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/DecoTemplateWithItemsResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/DecoTemplateWithItemsResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/DecoTemplateWithItemsResponseModel" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/deco-templates/{id}/items": { + "get": { + "tags": [ + "DecoTemplates" + ], + "summary": "Get deco template items", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DecoTemplateItemResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DecoTemplateItemResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DecoTemplateItemResponseModel" + } + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/ffmpeg/profiles": { "get": { "tags": [ @@ -11876,6 +12467,39 @@ } } }, + "CreateDecoTemplateGroupRequest": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "type": [ + "null", + "string" + ] + } + } + }, + "CreateDecoTemplateRequest": { + "required": [ + "decoTemplateGroupId", + "name" + ], + "type": "object", + "properties": { + "decoTemplateGroupId": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": [ + "null", + "string" + ] + } + } + }, "CreateFFmpegProfileRequest": { "required": [ "name", @@ -12724,6 +13348,132 @@ } } }, + "DecoTemplateGroupResponseModel": { + "required": [ + "id", + "name", + "decoTemplateCount" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + }, + "decoTemplateCount": { + "type": "integer", + "format": "int32" + } + } + }, + "DecoTemplateItemRequest": { + "required": [ + "decoId", + "startTime", + "endTime" + ], + "type": "object", + "properties": { + "decoId": { + "type": "integer", + "format": "int32" + }, + "startTime": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": "string" + }, + "endTime": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": "string" + } + } + }, + "DecoTemplateItemResponseModel": { + "required": [ + "decoId", + "decoName", + "startTime", + "endTime" + ], + "type": "object", + "properties": { + "decoId": { + "type": "integer", + "format": "int32" + }, + "decoName": { + "type": "string" + }, + "startTime": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": "string" + }, + "endTime": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": "string" + } + } + }, + "DecoTemplateResponseModel": { + "required": [ + "id", + "decoTemplateGroupId", + "groupName", + "name" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "decoTemplateGroupId": { + "type": "integer", + "format": "int32" + }, + "groupName": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "DecoTemplateWithItemsResponseModel": { + "required": [ + "id", + "decoTemplateGroupId", + "groupName", + "name", + "items" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "decoTemplateGroupId": { + "type": "integer", + "format": "int32" + }, + "groupName": { + "type": "string" + }, + "name": { + "type": "string" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DecoTemplateItemResponseModel" + } + } + } + }, "DeleteMediaItemsRequest": { "required": [ "ids" @@ -14815,6 +15565,30 @@ } } }, + "ReplaceDecoTemplateRequest": { + "required": [ + "name", + "items" + ], + "type": "object", + "properties": { + "name": { + "type": [ + "null", + "string" + ] + }, + "items": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/DecoTemplateItemRequest" + } + } + } + }, "ReplaceScheduleItemsRequest": { "required": [ "items" @@ -16927,6 +17701,9 @@ { "name": "Decos" }, + { + "name": "DecoTemplates" + }, { "name": "FFmpeg Profiles" },