feat(api): flat schedule-item DTO + discovery endpoints (#126 #207 #212)

Task A (#126): new non-polymorphic ScheduleItemResponseModel /
ScheduleItemsResponseModel in Core/Api/Scheduling, plus shared
NamedIdResponseModel. ScheduleItemResponseMapper flattens the
One/Flood/Multiple/Duration VM hierarchy. ScheduleController GET/POST/PUT
items now return the flat DTOs.

Task B: GET /api/languages (LanguagesController + LanguageCodeResponseModel);
GET /api/channels/music-video-credits-templates and
GET /api/channels/stream-selectors; FillerKind added to
FillerPresetResponseModel with optional ?fillerKind= filter on
GET /api/filler-presets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 23:40:47 +02:00
co-authored by Claude Fable 5
parent 8b77d5e739
commit d7782dccac
15 changed files with 311 additions and 21 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ namespace ErsatzTV.Application.Filler;
internal static class Mapper internal static class Mapper
{ {
internal static FillerPresetResponseModel ProjectToResponseModel(FillerPreset fillerPreset) => internal static FillerPresetResponseModel ProjectToResponseModel(FillerPreset fillerPreset) =>
new(fillerPreset.Id, fillerPreset.Name); new(fillerPreset.Id, fillerPreset.Name, fillerPreset.FillerKind);
internal static FillerPresetFullResponseModel ProjectToFullResponseModel(FillerPreset fillerPreset) => internal static FillerPresetFullResponseModel ProjectToFullResponseModel(FillerPreset fillerPreset) =>
new( new(
@@ -1,5 +1,6 @@
using ErsatzTV.Core.Api.Filler; using ErsatzTV.Core.Api.Filler;
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Application.Filler; namespace ErsatzTV.Application.Filler;
public record GetAllFillerPresetsForApi : IRequest<List<FillerPresetResponseModel>>; public record GetAllFillerPresetsForApi(FillerKind? FillerKind = null) : IRequest<List<FillerPresetResponseModel>>;
@@ -14,9 +14,13 @@ public class GetAllFillerPresetsForApiHandler(IDbContextFactory<TvContext> dbCon
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<FillerPreset> fillerPresets = await dbContext.FillerPresets IQueryable<FillerPreset> query = dbContext.FillerPresets.AsNoTracking();
.AsNoTracking() if (request.FillerKind is { } fillerKind)
.ToListAsync(cancellationToken); {
query = query.Where(fp => fp.FillerKind == fillerKind);
}
List<FillerPreset> fillerPresets = await query.ToListAsync(cancellationToken);
return fillerPresets.Map(ProjectToResponseModel).ToList(); return fillerPresets.Map(ProjectToResponseModel).ToList();
} }
} }
@@ -0,0 +1,109 @@
using System.Collections.Generic;
using System.Linq;
using ErsatzTV.Core.Api;
using ErsatzTV.Core.Api.Scheduling;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.ProgramSchedules;
/// <summary>
/// Maps the polymorphic <see cref="ProgramScheduleItemViewModel" /> hierarchy to the flat,
/// fully-described <see cref="ScheduleItemResponseModel" /> API DTO (issue #126). Subtype-specific
/// fields (Multiple's mode/count, Duration's playoutDuration/tailMode/discardToFillAttempts) are
/// read by pattern-matching the concrete VM type; they are null for other subtypes.
/// </summary>
public static class ScheduleItemResponseMapper
{
public static ScheduleItemResponseModel ProjectToResponseModel(ProgramScheduleItemViewModel item)
{
MultipleMode? multipleMode = null;
string multipleCount = null;
TimeSpan? playoutDuration = null;
TailMode? tailMode = null;
int? discardToFillAttempts = null;
switch (item)
{
case ProgramScheduleItemMultipleViewModel multiple:
multipleMode = multiple.MultipleMode;
multipleCount = multiple.Count;
break;
case ProgramScheduleItemDurationViewModel duration:
playoutDuration = duration.PlayoutDuration;
tailMode = duration.TailMode;
discardToFillAttempts = duration.DiscardToFillAttempts;
break;
}
List<int> watermarkIds = item.Watermarks?.Map(w => w.Id).ToList() ?? new List<int>();
List<int> graphicsElementIds = item.GraphicsElements?.Map(g => g.Id).ToList() ?? new List<int>();
List<NamedIdResponseModel> watermarks =
item.Watermarks?.Map(w => new NamedIdResponseModel(w.Id, w.Name)).ToList()
?? new List<NamedIdResponseModel>();
List<NamedIdResponseModel> graphicsElements =
item.GraphicsElements?.Map(g => new NamedIdResponseModel(g.Id, g.Name)).ToList()
?? new List<NamedIdResponseModel>();
return new ScheduleItemResponseModel(
item.Id,
item.Index,
item.StartType,
item.StartTime,
item.FixedStartTimeBehavior,
item.PlayoutMode,
item.CollectionType,
item.Collection?.Id,
item.MultiCollection?.Id,
item.SmartCollection?.Id,
item.RerunCollection?.Id,
item.MediaItem?.MediaItemId,
item.Playlist?.Id,
item.SearchTitle,
item.SearchQuery,
item.PlaybackOrder,
item.MarathonGroupBy,
item.MarathonShuffleGroups,
item.MarathonShuffleItems,
item.MarathonBatchSize,
item.FillWithGroupMode,
multipleMode,
multipleCount,
playoutDuration,
tailMode,
discardToFillAttempts,
item.CustomTitle,
item.GuideMode,
item.PreRollFiller?.Id,
item.MidRollFiller?.Id,
item.PostRollFiller?.Id,
item.TailFiller?.Id,
item.FallbackFiller?.Id,
watermarkIds,
graphicsElementIds,
item.PreferredAudioLanguageCode,
item.PreferredAudioTitle,
item.PreferredSubtitleLanguageCode,
item.SubtitleMode,
item.Collection?.Name,
item.MultiCollection?.Name,
item.SmartCollection?.Name,
item.RerunCollection?.Name,
item.Playlist?.Name,
item.Playlist?.PlaylistGroupId,
item.MediaItem?.Name,
item.PreRollFiller?.Name,
item.MidRollFiller?.Name,
item.PostRollFiller?.Name,
item.TailFiller?.Name,
item.FallbackFiller?.Name,
watermarks,
graphicsElements,
item.Name,
item.DurationEstimate);
}
public static ScheduleItemsResponseModel ProjectToResponseModel(ProgramScheduleItemsWithDurationViewModel vm) =>
new(
vm.Items.Map(ProjectToResponseModel).ToList(),
vm.TotalDurationEstimate);
}
@@ -1,3 +1,5 @@
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Core.Api.Filler; namespace ErsatzTV.Core.Api.Filler;
public record FillerPresetResponseModel(int Id, string Name); public record FillerPresetResponseModel(int Id, string Name, FillerKind FillerKind);
@@ -0,0 +1,3 @@
namespace ErsatzTV.Core.Api.MediaItems;
public record LanguageCodeResponseModel(string Code, string EnglishName);
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Api;
/// <summary>
/// A lightweight {id, name} pair used when an API response needs to embed a reference to a
/// named entity (e.g. a schedule item's watermarks or graphics elements) without projecting
/// the full entity DTO.
/// </summary>
public record NamedIdResponseModel(int Id, string Name);
@@ -0,0 +1,70 @@
#nullable enable
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Core.Api.Scheduling;
/// <summary>
/// A flat, non-polymorphic projection of a program schedule item (issue #126). Unlike the
/// Application-layer <c>ProgramScheduleItemViewModel</c> hierarchy (One/Flood/Multiple/Duration
/// subtypes), every subtype-specific field is a nullable member here so the OpenAPI schema fully
/// describes the shape. The mutation fields are named 1:1 with
/// <c>ScheduleItemRequest</c> so a GET can be mapped losslessly back to a PUT/POST request.
/// </summary>
public record ScheduleItemResponseModel(
int Id,
int Index,
StartType StartType,
TimeSpan? StartTime,
FixedStartTimeBehavior? FixedStartTimeBehavior,
PlayoutMode PlayoutMode,
CollectionType CollectionType,
int? CollectionId,
int? MultiCollectionId,
int? SmartCollectionId,
int? RerunCollectionId,
int? MediaItemId,
int? PlaylistId,
string? SearchTitle,
string? SearchQuery,
PlaybackOrder PlaybackOrder,
MarathonGroupBy MarathonGroupBy,
bool MarathonShuffleGroups,
bool MarathonShuffleItems,
int? MarathonBatchSize,
FillWithGroupMode FillWithGroupMode,
MultipleMode? MultipleMode,
string? MultipleCount,
TimeSpan? PlayoutDuration,
TailMode? TailMode,
int? DiscardToFillAttempts,
string? CustomTitle,
GuideMode GuideMode,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
int? TailFillerId,
int? FallbackFillerId,
List<int> WatermarkIds,
List<int> GraphicsElementIds,
string? PreferredAudioLanguageCode,
string? PreferredAudioTitle,
string? PreferredSubtitleLanguageCode,
ChannelSubtitleMode? SubtitleMode,
string? CollectionName,
string? MultiCollectionName,
string? SmartCollectionName,
string? RerunCollectionName,
string? PlaylistName,
int? PlaylistGroupId,
string? MediaItemName,
string? PreRollFillerName,
string? MidRollFillerName,
string? PostRollFillerName,
string? TailFillerName,
string? FallbackFillerName,
List<NamedIdResponseModel> Watermarks,
List<NamedIdResponseModel> GraphicsElements,
string? Name,
TimeSpan? DurationEstimate);
@@ -0,0 +1,12 @@
#nullable enable
using System.Collections.Generic;
namespace ErsatzTV.Core.Api.Scheduling;
/// <summary>
/// GET envelope for a schedule's items. Carries the flat <see cref="ScheduleItemResponseModel" />
/// list plus a best-effort total runtime estimate (null when unbounded/unknown).
/// </summary>
public record ScheduleItemsResponseModel(
List<ScheduleItemResponseModel> Items,
TimeSpan? TotalDurationEstimate);
@@ -32,8 +32,24 @@ public class FillerPresetHandlerTests
await handler.Handle(new GetAllFillerPresetsForApi(), CancellationToken.None); await handler.Handle(new GetAllFillerPresetsForApi(), CancellationToken.None);
result.Count.ShouldBe(2); result.Count.ShouldBe(2);
result.ShouldContain(new FillerPresetResponseModel(1, "Intro")); result.ShouldContain(new FillerPresetResponseModel(1, "Intro", FillerKind.PreRoll));
result.ShouldContain(new FillerPresetResponseModel(2, "Outro")); result.ShouldContain(new FillerPresetResponseModel(2, "Outro", FillerKind.PreRoll));
}
[Test]
public async Task GetAllFillerPresetsForApi_Should_Filter_By_FillerKind()
{
await SeedPreset(1, "Intro", FillerKind.PreRoll);
await SeedPreset(2, "Mid", FillerKind.MidRoll);
await SeedPreset(3, "Outro", FillerKind.PostRoll);
var handler = new GetAllFillerPresetsForApiHandler(_db.Factory);
List<FillerPresetResponseModel> result =
await handler.Handle(new GetAllFillerPresetsForApi(FillerKind.MidRoll), CancellationToken.None);
result.Count.ShouldBe(1);
result.ShouldContain(new FillerPresetResponseModel(2, "Mid", FillerKind.MidRoll));
} }
[Test] [Test]
@@ -47,14 +63,14 @@ public class FillerPresetHandlerTests
result.ShouldBeEmpty(); result.ShouldBeEmpty();
} }
private async Task SeedPreset(int id, string name) private async Task SeedPreset(int id, string name, FillerKind fillerKind = FillerKind.PreRoll)
{ {
await using TvContext context = _db.CreateContext(); await using TvContext context = _db.CreateContext();
context.FillerPresets.Add(new FillerPreset context.FillerPresets.Add(new FillerPreset
{ {
Id = id, Id = id,
Name = name, Name = name,
FillerKind = FillerKind.PreRoll, FillerKind = fillerKind,
FillerMode = FillerMode.Duration, FillerMode = FillerMode.Duration,
CollectionType = CollectionType.Collection CollectionType = CollectionType.Collection
}); });
@@ -63,17 +63,30 @@ public class FillerPresetControllerTests
{ {
List<FillerPresetResponseModel> models = List<FillerPresetResponseModel> models =
[ [
new FillerPresetResponseModel(1, "Intro"), new FillerPresetResponseModel(1, "Intro", FillerKind.PreRoll),
new FillerPresetResponseModel(2, "Outro") new FillerPresetResponseModel(2, "Outro", FillerKind.PostRoll)
]; ];
_mediator.Send(Arg.Any<GetAllFillerPresetsForApi>(), Arg.Any<CancellationToken>()) _mediator.Send(Arg.Any<GetAllFillerPresetsForApi>(), Arg.Any<CancellationToken>())
.Returns(models); .Returns(models);
List<FillerPresetResponseModel> result = await _controller.GetAll(CancellationToken.None); List<FillerPresetResponseModel> result = await _controller.GetAll(null, CancellationToken.None);
result.ShouldBe(models); result.ShouldBe(models);
} }
[Test]
public async Task GetAll_Should_Forward_FillerKind_Filter()
{
_mediator.Send(Arg.Any<GetAllFillerPresetsForApi>(), Arg.Any<CancellationToken>())
.Returns([]);
await _controller.GetAll(FillerKind.MidRoll, CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<GetAllFillerPresetsForApi>(q => q.FillerKind == FillerKind.MidRoll),
Arg.Any<CancellationToken>());
}
[Test] [Test]
public async Task GetById_Should_Return_200_For_Some() public async Task GetById_Should_Return_200_For_Some()
{ {
@@ -3,6 +3,7 @@ using System.Threading.Channels;
using ErsatzTV.Application; using ErsatzTV.Application;
using ErsatzTV.Application.Channels; using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Playouts; using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Templates;
using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels; using ErsatzTV.Core.Api.Channels;
@@ -43,6 +44,22 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
await mediator.Send(new GetChannelGuideData(start, end), cancellationToken); await mediator.Send(new GetChannelGuideData(start, end), cancellationToken);
[HttpGet("/api/channels/music-video-credits-templates", Name = "GetMusicVideoCreditsTemplates")]
[Tags("Channels")]
[EndpointSummary("Get available music video credits template names")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<string>), StatusCodes.Status200OK)]
public async Task<List<string>> GetMusicVideoCreditsTemplates(CancellationToken cancellationToken) =>
await mediator.Send(new GetMusicVideoCreditTemplates(), cancellationToken);
[HttpGet("/api/channels/stream-selectors", Name = "GetChannelStreamSelectors")]
[Tags("Channels")]
[EndpointSummary("Get available channel stream selector names")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<string>), StatusCodes.Status200OK)]
public async Task<List<string>> GetStreamSelectors(CancellationToken cancellationToken) =>
await mediator.Send(new GetChannelStreamSelectors(), cancellationToken);
[HttpGet("/api/channels/{id:int}", Name = "GetChannelById")] [HttpGet("/api/channels/{id:int}", Name = "GetChannelById")]
[Tags("Channels")] [Tags("Channels")]
[EndpointSummary("Get a channel by id")] [EndpointSummary("Get a channel by id")]
@@ -3,6 +3,7 @@ using ErsatzTV.Application.Filler;
using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Api.Filler; using ErsatzTV.Core.Api.Filler;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Extensions; using ErsatzTV.Extensions;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
@@ -16,10 +17,13 @@ public class FillerPresetController(IMediator mediator) : ControllerBase
[HttpGet("/api/filler-presets", Name = "GetFillerPresets")] [HttpGet("/api/filler-presets", Name = "GetFillerPresets")]
[Tags("Filler Presets")] [Tags("Filler Presets")]
[EndpointSummary("Get all filler presets")] [EndpointSummary("Get all filler presets")]
[EndpointDescription("Optionally filter to a single filler kind via the fillerKind query parameter.")]
[EndpointGroupName("general")] [EndpointGroupName("general")]
[ProducesResponseType(typeof(List<FillerPresetResponseModel>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(List<FillerPresetResponseModel>), StatusCodes.Status200OK)]
public async Task<List<FillerPresetResponseModel>> GetAll(CancellationToken cancellationToken) => public async Task<List<FillerPresetResponseModel>> GetAll(
await mediator.Send(new GetAllFillerPresetsForApi(), cancellationToken); [FromQuery] FillerKind? fillerKind,
CancellationToken cancellationToken) =>
await mediator.Send(new GetAllFillerPresetsForApi(fillerKind), cancellationToken);
[HttpGet("/api/filler-presets/{id:int}", Name = "GetFillerPresetById")] [HttpGet("/api/filler-presets/{id:int}", Name = "GetFillerPresetById")]
[Tags("Filler Presets")] [Tags("Filler Presets")]
@@ -0,0 +1,25 @@
using System.Linq;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Api.MediaItems;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class LanguagesController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/languages", Name = "GetLanguages")]
[Tags("Languages")]
[EndpointSummary("Get all available language codes")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<LanguageCodeResponseModel>), StatusCodes.Status200OK)]
public async Task<List<LanguageCodeResponseModel>> GetLanguages(CancellationToken cancellationToken)
{
List<LanguageCodeViewModel> languages = await mediator.Send(new GetAllLanguageCodes(), cancellationToken);
return languages
.Select(l => new LanguageCodeResponseModel(l.ThreeLetterISOLanguageName, l.EnglishName))
.ToList();
}
}
+12 -6
View File
@@ -1,7 +1,9 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq;
using ErsatzTV.Application.ProgramSchedules; using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Api.Scheduling;
using ErsatzTV.Extensions; using ErsatzTV.Extensions;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
@@ -104,7 +106,7 @@ public class ScheduleController(IMediator mediator) : ControllerBase
"nullable durationEstimate and the envelope carries a nullable totalDurationEstimate. Estimates are " + "nullable durationEstimate and the envelope carries a nullable totalDurationEstimate. Estimates are " +
"derived from referenced collection/media runtimes and are null when unbounded or unknown.")] "derived from referenced collection/media runtimes and are null when unbounded or unknown.")]
[EndpointGroupName("general")] [EndpointGroupName("general")]
[ProducesResponseType(typeof(ProgramScheduleItemsWithDurationViewModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ScheduleItemsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetItems(int id, CancellationToken cancellationToken) public async Task<IActionResult> GetItems(int id, CancellationToken cancellationToken)
{ {
@@ -116,14 +118,14 @@ public class ScheduleController(IMediator mediator) : ControllerBase
ProgramScheduleItemsWithDurationViewModel items = ProgramScheduleItemsWithDurationViewModel items =
await mediator.Send(new GetProgramScheduleItemsWithDurations(id), cancellationToken); await mediator.Send(new GetProgramScheduleItemsWithDurations(id), cancellationToken);
return new OkObjectResult(items); return new OkObjectResult(ScheduleItemResponseMapper.ProjectToResponseModel(items));
} }
[HttpPost("/api/schedules/{id:int}/items")] [HttpPost("/api/schedules/{id:int}/items")]
[Tags("Schedules")] [Tags("Schedules")]
[EndpointSummary("Add a schedule item")] [EndpointSummary("Add a schedule item")]
[EndpointGroupName("general")] [EndpointGroupName("general")]
[ProducesResponseType(typeof(ProgramScheduleItemViewModel), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ScheduleItemResponseModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> AddItem( public async Task<IActionResult> AddItem(
@@ -133,14 +135,16 @@ public class ScheduleController(IMediator mediator) : ControllerBase
{ {
Either<BaseError, ProgramScheduleItemViewModel> result = Either<BaseError, ProgramScheduleItemViewModel> result =
await mediator.Send(request.ToAddCommand(id), cancellationToken); await mediator.Send(request.ToAddCommand(id), cancellationToken);
return result.ToCreatedResult(item => $"/api/schedules/{id}/items/{item.Id}", item => item); return result.ToCreatedResult(
item => $"/api/schedules/{id}/items/{item.Id}",
item => ScheduleItemResponseMapper.ProjectToResponseModel(item));
} }
[HttpPut("/api/schedules/{id:int}/items")] [HttpPut("/api/schedules/{id:int}/items")]
[Tags("Schedules")] [Tags("Schedules")]
[EndpointSummary("Replace schedule items")] [EndpointSummary("Replace schedule items")]
[EndpointGroupName("general")] [EndpointGroupName("general")]
[ProducesResponseType(typeof(IEnumerable<ProgramScheduleItemViewModel>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(List<ScheduleItemResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> ReplaceItems( public async Task<IActionResult> ReplaceItems(
@@ -150,7 +154,9 @@ public class ScheduleController(IMediator mediator) : ControllerBase
{ {
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> result = Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> result =
await mediator.Send(request.ToCommand(id), cancellationToken); await mediator.Send(request.ToCommand(id), cancellationToken);
return result.ToUpdatedResult(); return result
.Map(items => items.Select(ScheduleItemResponseMapper.ProjectToResponseModel).ToList())
.ToUpdatedResult();
} }
[HttpDelete("/api/schedules/{id:int}/items/{itemId:int}")] [HttpDelete("/api/schedules/{id:int}/items/{itemId:int}")]