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.',