From 9c865e1faaec9df675e8f31add240b9950fc9208 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 26 Jul 2026 20:10:47 +0300 Subject: [PATCH] Add CreateTemplate endpoint and related functionality for channels without grids Implement a new API endpoint to create a template for channels lacking a grid, enhancing the template management capabilities. Update frontend components to support the creation of templates directly from the channel detail view, including user feedback and translations for improved clarity. Add integration tests to ensure the new functionality works as expected. --- .../Endpoints/TemplateEndpoints.cs | 23 ++++++++++ .../CreateChannelTemplateCommand.cs | 11 +++++ .../CreateChannelTemplateCommandHandler.cs | 43 +++++++++++++++++++ .../TemplateOperationsIntegrationTests.cs | 39 +++++++++++++++++ .../features/admin/channels/ChannelDetail.tsx | 34 +++++++++++---- frontend/src/features/admin/channels/api.ts | 5 +++ frontend/src/shared/lib/i18n.ts | 6 +++ 7 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 backend/src/TeleWave.Application/Programming/Templates/CreateTemplate/CreateChannelTemplateCommand.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/CreateTemplate/CreateChannelTemplateCommandHandler.cs diff --git a/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs index 50512f4..eb62ea2 100644 --- a/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs @@ -6,6 +6,7 @@ using TeleWave.Application.Programming.Planning.Preview; using TeleWave.Application.Programming.Templates; using TeleWave.Application.Programming.Templates.CopyTemplate; using TeleWave.Application.Programming.Templates.CreateSlot; +using TeleWave.Application.Programming.Templates.CreateTemplate; using TeleWave.Application.Programming.Templates.DeleteSlot; using TeleWave.Application.Programming.Templates.GetTemplate; using TeleWave.Application.Programming.Templates.Layers; @@ -30,6 +31,10 @@ public static class TemplateEndpoints admin .MapGet("/channels/{channelId:guid}/template", GetTemplate) .Produces(); + // Завести сетку каналу, у которого её нет (напр. пережившему снос старой ротации). + admin + .MapPost("/channels/{channelId:guid}/template", CreateTemplate) + .Produces(StatusCodes.Status201Created); admin .MapPut("/templates/{templateId:guid}", UpdateTemplate) .Produces(StatusCodes.Status204NoContent); @@ -81,6 +86,24 @@ public static class TemplateEndpoints return result.ToHttpResult(); } + private static async Task CreateTemplate( + Guid channelId, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new CreateChannelTemplateCommand(channelId), + cancellationToken + ); + return result.IsSuccess + ? Results.Created( + $"/api/admin/channels/{channelId}/template", + new CreatedIdResponse(result.Value) + ) + : result.ToHttpResult(); + } + private static async Task ApplyTemplate( Guid channelId, ISender sender, diff --git a/backend/src/TeleWave.Application/Programming/Templates/CreateTemplate/CreateChannelTemplateCommand.cs b/backend/src/TeleWave.Application/Programming/Templates/CreateTemplate/CreateChannelTemplateCommand.cs new file mode 100644 index 0000000..e9356d1 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/CreateTemplate/CreateChannelTemplateCommand.cs @@ -0,0 +1,11 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Templates.CreateTemplate; + +/// +/// Заводит каналу сетку, если её нет. Новые каналы получают шаблон при создании, но каналы, +/// пережившие снос старой ротации, остались без него — и чинить это пересозданием канала было бы +/// перебором. +/// +public sealed record CreateChannelTemplateCommand(Guid ChannelId) : ICommand>; diff --git a/backend/src/TeleWave.Application/Programming/Templates/CreateTemplate/CreateChannelTemplateCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/CreateTemplate/CreateChannelTemplateCommandHandler.cs new file mode 100644 index 0000000..4bd8b70 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/CreateTemplate/CreateChannelTemplateCommandHandler.cs @@ -0,0 +1,43 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Broadcast; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Templates.CreateTemplate; + +public sealed class CreateChannelTemplateCommandHandler(IAppDbContext dbContext) + : ICommandHandler> +{ + public async Task> Handle( + CreateChannelTemplateCommand command, + CancellationToken cancellationToken + ) + { + var channel = await dbContext.Channels.FirstOrDefaultAsync( + c => c.Id == command.ChannelId, + cancellationToken + ); + if (channel is null) + return Result.Failure(ChannelErrors.NotFound); + + // Шаблон мог остаться от прошлой жизни канала, потеряв ссылку на себя, — тогда просто + // возвращаем его, а не заводим второй: одна сетка на канал. + var existing = await dbContext + .ScheduleTemplates.FirstOrDefaultAsync( + t => t.ChannelId == channel.Id, + cancellationToken + ); + if (existing is not null) + { + channel.SetTemplate(existing.Id); + return Result.Success(existing.Id); + } + + var template = ScheduleTemplate.Create(channel.Id, channel.Name); + dbContext.ScheduleTemplates.Add(template); + channel.SetTemplate(template.Id); + return Result.Success(template.Id); + } +} diff --git a/backend/tests/TeleWave.Integration.Tests/TemplateOperationsIntegrationTests.cs b/backend/tests/TeleWave.Integration.Tests/TemplateOperationsIntegrationTests.cs index 56ff4eb..68b6304 100644 --- a/backend/tests/TeleWave.Integration.Tests/TemplateOperationsIntegrationTests.cs +++ b/backend/tests/TeleWave.Integration.Tests/TemplateOperationsIntegrationTests.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using TeleWave.Application.Programming.Templates; using TeleWave.Application.Programming.Templates.CopyTemplate; +using TeleWave.Application.Programming.Templates.CreateTemplate; using TeleWave.Application.Programming.Templates.Validate; using TeleWave.Domain.Broadcast; using TeleWave.Domain.Programming; @@ -91,6 +92,44 @@ public sealed class TemplateOperationsIntegrationTests(PostgresFixture fixture) Assert.Contains(result.Value, i => i.Kind == TemplateIssueKind.GridGap); } + [SkippableFact] + public async Task CreateTemplate_ForChannelWithoutGrid_LinksItAndIsIdempotent() + { + Skip.IfNot(fixture.Available, "Docker недоступен"); + + // Канал из старой ротации: сетки нет, ссылки на неё тоже. + await using var seedDb = fixture.CreateContext(); + var suffix = Guid.NewGuid().ToString("N")[..8]; + var channel = Channel.Create($"Без сетки {suffix}", $"nogrid-{suffix}", DateTimeOffset.UtcNow); + seedDb.Channels.Add(channel); + await seedDb.SaveChangesAsync(); + + await using var db = fixture.CreateContext(); + var created = await new CreateChannelTemplateCommandHandler(db).Handle( + new CreateChannelTemplateCommand(channel.Id), + default + ); + Assert.True(created.IsSuccess); + await db.SaveChangesAsync(); + + await using var again = fixture.CreateContext(); + var second = await new CreateChannelTemplateCommandHandler(again).Handle( + new CreateChannelTemplateCommand(channel.Id), + default + ); + await again.SaveChangesAsync(); + + // Повторный вызов возвращает ту же сетку: одна на канал, второй не появляется. + Assert.True(second.IsSuccess); + Assert.Equal(created.Value, second.Value); + + await using var verify = fixture.CreateContext(); + var template = verify.ScheduleTemplates.Single(t => t.ChannelId == channel.Id); + Assert.Equal(template.Id, verify.Channels.Single(c => c.Id == channel.Id).TemplateId); + // Фоновый слой заводится сразу — без него первую же дыру в сетке нечем закрыть. + Assert.Single(verify.GridLayers.Where(l => l.TemplateId == template.Id && l.IsBackground)); + } + /// Два канала: у источника слой со слотом, группой и стыком; у приёмника — пустая сетка. private static async Task<(Guid Source, Guid Target, Guid GroupId)> SeedPairAsync(AppDbContext db) { diff --git a/frontend/src/features/admin/channels/ChannelDetail.tsx b/frontend/src/features/admin/channels/ChannelDetail.tsx index d2bf967..d07edf6 100644 --- a/frontend/src/features/admin/channels/ChannelDetail.tsx +++ b/frontend/src/features/admin/channels/ChannelDetail.tsx @@ -15,6 +15,7 @@ import { toast } from '@/shared/ui/toast-store' import { applyChannelTemplate, copyTemplateTo, + createChannelTemplate, createLayer, createSlot, deleteLayer, @@ -41,8 +42,8 @@ import { ViewerCard } from './components/ViewerCard' import { SlotInspector, type SlotDraft } from './components/SlotInspector' import { toTime } from './lib/format' -/** Вкладки экрана канала — в порядке частоты правки. */ -const TABS = ['grid', 'rules', 'junctions', 'bumpers', 'viewer', 'settings', 'air'] as const +/** Вкладки экрана канала: настройки первыми — с них канал и начинается. */ +const TABS = ['settings', 'grid', 'rules', 'junctions', 'bumpers', 'viewer', 'air'] as const type ChannelTab = (typeof TABS)[number] export function ChannelDetail({ channelId }: { channelId: string }) { @@ -58,7 +59,7 @@ export function ChannelDetail({ channelId }: { channelId: string }) { const [applyOpen, setApplyOpen] = useState(false) const [traceEntryId, setTraceEntryId] = useState(null) const [copyToChannel, setCopyToChannel] = useState('') - const [tab, setTab] = useState('grid') + const [tab, setTab] = useState('settings') const { data: channel, isLoading } = useQuery({ queryKey: ['admin', 'channels', channelId], @@ -154,6 +155,13 @@ export function ChannelDetail({ channelId }: { channelId: string }) { onError, }) + // Канал без сетки — наследство старой ротации: заводим шаблон на месте, а не пересоздаём канал. + const createTemplateMutation = useMutation({ + mutationFn: () => createChannelTemplate(channelId), + onSuccess: invalidate, + onError, + }) + const copyTemplateMutation = useMutation({ mutationFn: (targetChannelId: string) => copyTemplateTo(channelId, targetChannelId), onSuccess: (result) => { @@ -283,11 +291,21 @@ export function ChannelDetail({ channelId }: { channelId: string }) { {/* Шаблон мог не загрузиться — раньше вкладка сетки просто оказывалась пустой. */} {tab === 'grid' && !template && ( -

- {templateError instanceof HttpError - ? templateError.detail - : t('admin.channels.noTemplate')} -

+
+

+ {templateError instanceof HttpError + ? templateError.detail + : t('admin.channels.noTemplate')} +

+ +

{t('admin.channels.createTemplateHint')}

+
)} {tab === 'grid' && template && ( diff --git a/frontend/src/features/admin/channels/api.ts b/frontend/src/features/admin/channels/api.ts index c9db52c..e8da80c 100644 --- a/frontend/src/features/admin/channels/api.ts +++ b/frontend/src/features/admin/channels/api.ts @@ -67,6 +67,11 @@ export function getChannelTemplate(channelId: string) { return apiRequest(`/admin/channels/${channelId}/template`) } +/** Заводит каналу сетку, если её нет: у каналов из старой ротации шаблона может не быть. */ +export function createChannelTemplate(channelId: string) { + return apiRequest(`/admin/channels/${channelId}/template`, { method: 'POST' }) +} + /** Применяет правила к эфиру: пересобирает будущий хвост. Правка слотов эфир не двигает. */ export function applyChannelTemplate(channelId: string) { return apiRequest(`/admin/channels/${channelId}/template/apply`, { diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index 1f4926b..5e8c66e 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -398,6 +398,9 @@ const resources = { air: 'Эфир', }, noTemplate: 'Сетка канала не загрузилась', + createTemplate: 'Создать сетку', + createTemplateHint: + 'Появится пустая сетка с фоновым слоем — дальше добавляйте слои и слоты.', rules: 'Правила отбора', rulesHint: 'Жёсткие фильтры: отсекают неподходящее до жребия. Как и правка сетки, эфир не двигают — нужно применить.', @@ -1100,6 +1103,9 @@ const resources = { air: 'On air', }, noTemplate: 'The channel grid failed to load', + createTemplate: 'Create the grid', + createTemplateHint: + 'An empty grid with a background layer appears — then add layers and slots.', rules: 'Candidate rules', rulesHint: 'Hard filters: they cut out what is not allowed before the draw. Like grid edits, they do not move the air — apply to take effect.',