From 0fd4e762f4f2b91fe562149b27e12d7319b38b54 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Mon, 27 Jul 2026 08:43:53 +0300 Subject: [PATCH] Add restore functionality for template management and enhance related components Implemented a new endpoint for restoring templates to their last applied state, allowing users to revert changes made since the last application. Updated the ScheduleTemplate class to include a snapshot of the last applied state, enabling rollback capabilities. Enhanced the frontend with a restore button in the ChannelDetail component, providing users with a confirmation prompt before executing the restore action. Localization updates were made to support new UI strings in both English and Russian, improving the overall user experience in template management. --- .../Endpoints/TemplateEndpoints.cs | 15 + .../Planning/GridScheduleGenerator.cs | 4 +- .../GetChannelTemplateQueryHandler.cs | 1 + .../Restore/RestoreTemplateCommand.cs | 18 + .../Restore/RestoreTemplateCommandHandler.cs | 182 +++ .../Programming/Templates/TemplateDtos.cs | 2 + .../Programming/Templates/TemplateErrors.cs | 5 + .../Programming/Templates/TemplateSnapshot.cs | 119 ++ .../Programming/ScheduleTemplate.cs | 20 +- ...053636_TemplateAppliedSnapshot.Designer.cs | 1410 +++++++++++++++++ .../20260727053636_TemplateAppliedSnapshot.cs | 27 + .../Migrations/AppDbContextModelSnapshot.cs | 3 + .../ScheduleTemplateConfiguration.cs | 1 + .../Programming/RestoreTemplateTests.cs | 220 +++ docs/tv-scheduler-architecture.md | 15 + .../features/admin/channels/ChannelDetail.tsx | 48 +- frontend/src/features/admin/channels/api.ts | 11 + .../channels/components/SlotInspector.tsx | 242 ++- frontend/src/shared/api/types.ts | 5 + frontend/src/shared/lib/locales/en.ts | 6 + frontend/src/shared/lib/locales/ru.ts | 6 + 21 files changed, 2227 insertions(+), 133 deletions(-) create mode 100644 backend/src/TeleWave.Application/Programming/Templates/Restore/RestoreTemplateCommand.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/Restore/RestoreTemplateCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/TemplateSnapshot.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260727053636_TemplateAppliedSnapshot.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260727053636_TemplateAppliedSnapshot.cs create mode 100644 backend/tests/TeleWave.Application.Tests/Programming/RestoreTemplateTests.cs diff --git a/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs index 1e4e323..abcca91 100644 --- a/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs @@ -11,6 +11,7 @@ using TeleWave.Application.Programming.Templates.DeleteSlot; using TeleWave.Application.Programming.Templates.Generate; using TeleWave.Application.Programming.Templates.GetTemplate; using TeleWave.Application.Programming.Templates.Layers; +using TeleWave.Application.Programming.Templates.Restore; using TeleWave.Application.Programming.Templates.UpdateSlot; using TeleWave.Application.Programming.Templates.Validate; using TeleWave.Infrastructure.Identity; @@ -44,6 +45,10 @@ public static class TemplateEndpoints admin .MapPost("/channels/{channelId:guid}/template/apply", ApplyTemplate) .Produces(); + // Откат правок к состоянию, по которому идёт эфир. Ленту не трогает — она и так по нему. + admin + .MapPost("/channels/{channelId:guid}/template/restore", RestoreTemplate) + .Produces(); // Предпросмотр — тот же генератор, но без записи и без продвижения курсоров. admin .MapGet("/channels/{channelId:guid}/template/preview", PreviewTemplate) @@ -137,6 +142,16 @@ public static class TemplateEndpoints return result.ToHttpResult(); } + private static async Task RestoreTemplate( + Guid channelId, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new RestoreTemplateCommand(channelId), cancellationToken); + return result.ToHttpResult(); + } + private static async Task PreviewTemplate( Guid channelId, ISender sender, diff --git a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs index 74479e9..2f7eb64 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs @@ -161,7 +161,9 @@ public sealed class GridScheduleGenerator( await SaveCursorsAsync(result.Cursors, cancellationToken); - template.MarkApplied(); + // Снимок вместе с отметкой: применённое состояние — единственная корректная точка отката, + // и снимать его надо ровно тогда, когда по нему собрана лента. + template.MarkApplied(TemplateSnapshot.Capture(template).ToJson()); await dbContext.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); diff --git a/backend/src/TeleWave.Application/Programming/Templates/GetTemplate/GetChannelTemplateQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/GetTemplate/GetChannelTemplateQueryHandler.cs index 877a9a0..1f9d2bb 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/GetTemplate/GetChannelTemplateQueryHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/GetTemplate/GetChannelTemplateQueryHandler.cs @@ -93,6 +93,7 @@ public sealed class GetChannelTemplateQueryHandler(IAppDbContext dbContext) template.Revision, template.AppliedRevision, template.HasPendingChanges, + template.AppliedSnapshotJson is not null, channel.UtcOffsetMinutes, channel.DayStartTime, layers diff --git a/backend/src/TeleWave.Application/Programming/Templates/Restore/RestoreTemplateCommand.cs b/backend/src/TeleWave.Application/Programming/Templates/Restore/RestoreTemplateCommand.cs new file mode 100644 index 0000000..13d08d5 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Restore/RestoreTemplateCommand.cs @@ -0,0 +1,18 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Templates.Restore; + +/// +/// Откат правок сетки к тому состоянию, по которому идёт эфир. Возвращает шаблон к снимку, +/// сделанному при последнем применении, — правки слоёв, слотов и правил после него отменяются. +/// +public sealed record RestoreTemplateCommand(Guid ChannelId) + : ICommand>; + +/// Сколько слотов в сетке после отката. +/// +/// Ссылок, которые восстановить не удалось: группа или стык из снимка успели удалить. Такие слоты +/// возвращаются без них — иначе откат уронил бы вся операцию из-за одной удалённой группы. +/// +public sealed record RestoreTemplateResultDto(int Slots, int DroppedRefs); diff --git a/backend/src/TeleWave.Application/Programming/Templates/Restore/RestoreTemplateCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Restore/RestoreTemplateCommandHandler.cs new file mode 100644 index 0000000..599fa8f --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Restore/RestoreTemplateCommandHandler.cs @@ -0,0 +1,182 @@ +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.Restore; + +/// +/// Возвращает шаблон к снимку последнего применения. Слои и слоты правятся на месте по +/// идентификаторам из снимка: у пережившего откат слота сохраняется состояние (курсор сериала), +/// и после отката сериал не начинается заново. Слот, который успели удалить, вернётся новым — +/// его состояние ушло вместе с ним, и сделать с этим ничего нельзя. +/// +/// Эфир не трогается: он и так идёт по этому состоянию, а шаблон после отката снова ему равен. +/// +public sealed class RestoreTemplateCommandHandler(IAppDbContext dbContext) + : ICommandHandler> +{ + public async Task> Handle( + RestoreTemplateCommand command, + CancellationToken cancellationToken + ) + { + var template = await dbContext + .ScheduleTemplates.Include(t => t.Layers) + .ThenInclude(l => l.Slots) + .AsSplitQuery() + .FirstOrDefaultAsync(t => t.ChannelId == command.ChannelId, cancellationToken); + if (template is null) + return Result.Failure(ChannelErrors.TemplateNotFound); + + if (TemplateSnapshot.FromJson(template.AppliedSnapshotJson) is not { } snapshot) + return Result.Failure(TemplateErrors.NothingToRestore); + + var refs = await LoadAliveRefsAsync(snapshot, cancellationToken); + + // Слои, которых в снимке нет, — заведены после применения. Фоновый не трогаем: он есть + // в любом шаблоне и удалению не подлежит. + foreach (var layer in template.Layers.ToList()) + if (!layer.IsBackground && snapshot.Layers.All(l => l.Id != layer.Id)) + template.RemoveLayer(layer.Id); + + foreach (var layerSnapshot in snapshot.Layers) + RestoreLayer(template, layerSnapshot, refs); + + template.Rename(snapshot.Name); + template.SetFallbackGroup(refs.GroupOrNull(snapshot.FallbackGroupId)); + template.SetDefaultJunction(refs.JunctionOrNull(snapshot.DefaultJunctionId)); + template.SetRules(snapshot.RulesJson); + + // Шаблон снова равен эфирному состоянию — отметка «изменено» гаснет, снимок остаётся тем же. + template.MarkApplied(); + + return Result.Success( + new RestoreTemplateResultDto(template.Layers.Sum(l => l.Slots.Count), refs.Dropped) + ); + } + + private static void RestoreLayer( + ScheduleTemplate template, + LayerSnapshot snapshot, + AliveRefs refs + ) + { + var layer = + template.FindLayer(snapshot.Id) + ?? (snapshot.IsBackground ? template.Background : null) + ?? template.AddLayer(snapshot.Name, snapshot.Priority); + + layer.Update( + snapshot.Name, + snapshot.Priority, + snapshot.ApplicabilityJson, + snapshot.IsEnabled + ); + + foreach (var slot in layer.Slots.ToList()) + if (snapshot.Slots.All(s => s.Id != slot.Id)) + layer.RemoveSlot(slot.Id); + + foreach (var slotSnapshot in snapshot.Slots) + RestoreSlot(layer, slotSnapshot, refs); + } + + private static void RestoreSlot(GridLayer layer, SlotSnapshot snapshot, AliveRefs refs) + { + var slot = + layer.FindSlot(snapshot.Id) + ?? layer.AddSlot( + snapshot.Title, + snapshot.TargetStart, + snapshot.TargetDurationMinutes, + snapshot.Daypart, + snapshot.SlotKind, + snapshot.Weekday + ); + + slot.UpdateTiming( + snapshot.Weekday, + snapshot.TargetStart, + snapshot.TargetDurationMinutes, + snapshot.Daypart, + snapshot.IsAnchor, + snapshot.MaxDriftMinutes, + snapshot.SnapToMinutes + ); + slot.UpdateContent( + new SlotContent( + snapshot.Title, + snapshot.SlotKind, + refs.GroupOrNull(snapshot.GroupId), + snapshot.StrategyJson, + snapshot.RepeatSourceJson, + snapshot.BlockMode, + snapshot.BlockValue, + snapshot.OverflowPolicy, + refs.JunctionOrNull(snapshot.JunctionBetweenId), + refs.JunctionOrNull(snapshot.JunctionAfterId) + ) + ); + } + + /// + /// Какие группы и стыки из снимка ещё существуют. Ссылку на удалённое восстанавливать нельзя — + /// внешний ключ не даст сохранить, и откат упал бы целиком из-за одной прибранной группы. + /// + private async Task LoadAliveRefsAsync( + TemplateSnapshot snapshot, + CancellationToken cancellationToken + ) + { + var slots = snapshot.Layers.SelectMany(l => l.Slots).ToList(); + + var groupIds = slots + .Select(s => s.GroupId) + .Append(snapshot.FallbackGroupId) + .OfType() + .Distinct() + .ToList(); + var junctionIds = slots + .SelectMany(s => new[] { s.JunctionBetweenId, s.JunctionAfterId }) + .Append(snapshot.DefaultJunctionId) + .OfType() + .Distinct() + .ToList(); + + var groups = await dbContext + .Groups.AsNoTracking() + .Where(g => groupIds.Contains(g.Id)) + .Select(g => g.Id) + .ToListAsync(cancellationToken); + var junctions = await dbContext + .JunctionTemplates.AsNoTracking() + .Where(j => junctionIds.Contains(j.Id)) + .Select(j => j.Id) + .ToListAsync(cancellationToken); + + return new AliveRefs([.. groups], [.. junctions]); + } + + private sealed class AliveRefs(HashSet groups, HashSet junctions) + { + public int Dropped { get; private set; } + + public Guid? GroupOrNull(Guid? id) => Keep(id, groups); + + public Guid? JunctionOrNull(Guid? id) => Keep(id, junctions); + + private Guid? Keep(Guid? id, HashSet alive) + { + if (id is not { } value) + return null; + if (alive.Contains(value)) + return value; + + Dropped++; + return null; + } + } +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/TemplateDtos.cs b/backend/src/TeleWave.Application/Programming/Templates/TemplateDtos.cs index 82f464c..3137845 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/TemplateDtos.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/TemplateDtos.cs @@ -47,6 +47,8 @@ public sealed record ScheduleTemplateDto( int AppliedRevision, /// Есть ли правки правил, ещё не применённые к эфиру. bool HasPendingChanges, + /// Есть ли к чему откатываться — снимок появляется при первом применении. + bool CanRestore, /// Время канала — сетка задаётся в нём, а не в UTC. int UtcOffsetMinutes, TimeOnly DayStartTime, diff --git a/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs b/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs index d5eb82b..16ad3ac 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs @@ -59,6 +59,11 @@ public static class TemplateErrors "Врезке нужна группа, откуда брать ролики." ); + public static readonly Error NothingToRestore = Error.Validation( + "Templates.NothingToRestore", + "Откатывать не к чему: сетка ещё ни разу не применялась." + ); + public static readonly Error NoGroupsToGenerate = Error.Validation( "Templates.NoGroupsToGenerate", "Собирать сетку не из чего: нет ни одной группы с готовым медиа." diff --git a/backend/src/TeleWave.Application/Programming/Templates/TemplateSnapshot.cs b/backend/src/TeleWave.Application/Programming/Templates/TemplateSnapshot.cs new file mode 100644 index 0000000..cbd5a2d --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/TemplateSnapshot.cs @@ -0,0 +1,119 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Templates; + +/// Слот в снимке. Идентификатор сохраняется — по нему откат правит слот на месте. +public sealed record SlotSnapshot( + Guid Id, + int? Weekday, + TimeOnly TargetStart, + int TargetDurationMinutes, + string Title, + Daypart Daypart, + SlotKind SlotKind, + Guid? GroupId, + string? StrategyJson, + string? RepeatSourceJson, + SlotBlockMode BlockMode, + int BlockValue, + OverflowPolicy OverflowPolicy, + bool IsAnchor, + int MaxDriftMinutes, + int? SnapToMinutes, + Guid? JunctionBetweenId, + Guid? JunctionAfterId +); + +public sealed record LayerSnapshot( + Guid Id, + string Name, + int Priority, + string? ApplicabilityJson, + bool IsEnabled, + bool IsBackground, + IReadOnlyList Slots +); + +/// +/// Слепок шаблона: слои, слоты и настройки на момент применения. Снимается при каждом применении — +/// это ровно то, по чему идёт эфир, и потому корректная точка отката. +/// +/// Хранится JSON-строкой в самом шаблоне, а не отдельной таблицей: снимок читается только целиком +/// и только при откате, и своя таблица со слоями и слотами задвоила бы всю схему сетки — включая +/// внешние ключи на группы, которые к моменту отката могут быть уже удалены. +/// +public sealed record TemplateSnapshot( + string Name, + Guid? FallbackGroupId, + Guid? DefaultJunctionId, + string? RulesJson, + IReadOnlyList Layers +) +{ + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() }, + }; + + public static TemplateSnapshot Capture(ScheduleTemplate template) => + new( + template.Name, + template.FallbackGroupId, + template.DefaultJunctionId, + template.RulesJson, + [ + .. template.Layers.Select(layer => new LayerSnapshot( + layer.Id, + layer.Name, + layer.Priority, + layer.ApplicabilityJson, + layer.IsEnabled, + layer.IsBackground, + [ + .. layer.Slots.Select(slot => new SlotSnapshot( + slot.Id, + slot.Weekday, + slot.TargetStart, + slot.TargetDurationMinutes, + slot.Title, + slot.Daypart, + slot.SlotKind, + slot.GroupId, + slot.StrategyJson, + slot.RepeatSourceJson, + slot.BlockMode, + slot.BlockValue, + slot.OverflowPolicy, + slot.IsAnchor, + slot.MaxDriftMinutes, + slot.SnapToMinutes, + slot.JunctionBetweenId, + slot.JunctionAfterId + )), + ] + )), + ] + ); + + public string ToJson() => JsonSerializer.Serialize(this, Options); + + /// Разбирает снимок. Повреждённый JSON — как отсутствие снимка: откатывать не к чему. + public static TemplateSnapshot? FromJson(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + try + { + return JsonSerializer.Deserialize(json, Options); + } + catch (JsonException) + { + return null; + } + } +} diff --git a/backend/src/TeleWave.Domain/Programming/ScheduleTemplate.cs b/backend/src/TeleWave.Domain/Programming/ScheduleTemplate.cs index ec1c4b0..cf6325c 100644 --- a/backend/src/TeleWave.Domain/Programming/ScheduleTemplate.cs +++ b/backend/src/TeleWave.Domain/Programming/ScheduleTemplate.cs @@ -34,6 +34,14 @@ public class ScheduleTemplate /// Ревизия, по которой собрано текущее будущее расписание. public int AppliedRevision { get; private set; } + /// + /// Состояние шаблона на момент последнего применения (JSON) — то, по чему идёт эфир прямо + /// сейчас. Нужно, чтобы правки можно было откатить: слои и слоты правятся на месте, и без + /// снимка «как было» взять неоткуда. Домен его не разбирает — схема живёт в Application, как + /// у правил и стратегий. + /// + public string? AppliedSnapshotJson { get; private set; } + public DateTimeOffset CreatedAt { get; private set; } public IReadOnlyList Layers => _layers; @@ -80,8 +88,16 @@ public class ScheduleTemplate /// Отметить, что правила изменились — эфир пойдёт по старым до применения. public void MarkChanged() => Revision++; - /// Отметить, что хвост пересобран по текущей ревизии. - public void MarkApplied() => AppliedRevision = Revision; + /// + /// Отметить, что хвост пересобран по текущей ревизии. Снимок — состояние, по которому собран + /// эфир; он же станет точкой отката для последующих правок. + /// + public void MarkApplied(string? snapshotJson = null) + { + AppliedRevision = Revision; + if (!string.IsNullOrWhiteSpace(snapshotJson)) + AppliedSnapshotJson = snapshotJson; + } public GridLayer? FindLayer(Guid layerId) => _layers.FirstOrDefault(l => l.Id == layerId); diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260727053636_TemplateAppliedSnapshot.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260727053636_TemplateAppliedSnapshot.Designer.cs new file mode 100644 index 0000000..d33c141 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260727053636_TemplateAppliedSnapshot.Designer.cs @@ -0,0 +1,1410 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeleWave.Infrastructure.Persistence; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260727053636_TemplateAppliedSnapshot")] + partial class TemplateAppliedSnapshot + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromShowId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("ToShowId") + .HasColumnType("uuid"); + + b.Property("VariantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FromShowId", "ToShowId", "Signature"); + + b.ToTable("BumperAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccentColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AudioDurationSeconds") + .HasColumnType("double precision"); + + b.Property("AudioExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundColor2") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundImageId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("TextColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("BumperTemplate"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Line1") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Line2") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NextLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NowLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Trigger") + .HasColumnType("integer"); + + b.Property("Weight") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("BumperTemplateId", "Position"); + + b.ToTable("BumperTextVariants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AnalogFilterStrength") + .HasColumnType("double precision"); + + b.Property("BumperFont") + .HasColumnType("integer"); + + b.Property("BumperSelection") + .HasColumnType("integer"); + + b.Property("BumpersEnabled") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DayStartTime") + .HasColumnType("time without time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LogoCorner") + .HasColumnType("integer"); + + b.Property("LogoImageId") + .HasColumnType("uuid"); + + b.Property("LogoOpacity") + .HasColumnType("double precision"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("ShowClock") + .HasColumnType("boolean"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UtcOffsetMinutes") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Number") + .IsUnique() + .HasFilter("\"Number\" IS NOT NULL"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperVariantId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeIndex") + .HasColumnType("integer"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("SlotId") + .HasColumnType("uuid"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TraceJson") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "EndsAtUtc"); + + b.HasIndex("ChannelId", "StartsAtUtc"); + + b.HasIndex("ChannelId", "ShowId", "StartsAtUtc"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("TeleWave.Domain.Images.Image", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("Category", "CreatedAt"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Collections"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ShowId"); + + b.HasIndex("CollectionId", "Position"); + + b.HasIndex("CollectionId", "ShowId") + .IsUnique(); + + b.ToTable("CollectionItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("GenreId"); + + b.HasIndex("Value") + .IsUnique(); + + b.ToTable("GenreAliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Audience") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("FranchiseExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FranchiseName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MetadataExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MetadataProvider") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OriginalName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AirDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Overview") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StillImageId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("MediaAssetId"); + + b.HasIndex("ShowId", "Position"); + + b.ToTable("ShowEpisode"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.HasKey("ShowId", "GenreId"); + + b.HasIndex("GenreId"); + + b.ToTable("ShowGenres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AudioCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("OriginalExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProcessingDuration") + .HasColumnType("interval"); + + b.Property("ProcessingStartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RelativePath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SegmentCount") + .HasColumnType("integer"); + + b.Property("SegmentSeconds") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VideoCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("MediaAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicabilityJson") + .HasColumnType("jsonb"); + + b.Property("IsBackground") + .HasColumnType("boolean"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Priority"); + + b.ToTable("GridLayers"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("FilterJson") + .HasColumnType("jsonb"); + + b.Property("ItemCount") + .HasColumnType("integer"); + + b.Property("Mode") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StatsComputedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalDuration") + .HasColumnType("interval"); + + b.Property("UnitCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ElementId") + .HasColumnType("uuid"); + + b.Property("ElementKind") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Role") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ElementKind", "ElementId"); + + b.HasIndex("GroupId", "Position"); + + b.HasIndex("GroupId", "ElementKind", "ElementId") + .IsUnique(); + + b.ToTable("GroupItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AmountMode") + .HasColumnType("integer"); + + b.Property("AmountValue") + .HasColumnType("integer"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("ConditionsJson") + .HasColumnType("jsonb"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsRequired") + .HasColumnType("boolean"); + + b.Property("JunctionTemplateId") + .HasColumnType("uuid"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("JunctionTemplateId", "Position"); + + b.ToTable("JunctionElements"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.ToTable("JunctionTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AppliedRevision") + .HasColumnType("integer"); + + b.Property("AppliedSnapshotJson") + .HasColumnType("jsonb"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultJunctionId") + .HasColumnType("uuid"); + + b.Property("FallbackGroupId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("RulesJson") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.ToTable("ScheduleTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("Daypart") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsAnchor") + .HasColumnType("boolean"); + + b.Property("JunctionAfterId") + .HasColumnType("uuid"); + + b.Property("JunctionBetweenId") + .HasColumnType("uuid"); + + b.Property("LayerId") + .HasColumnType("uuid"); + + b.Property("MaxDriftMinutes") + .HasColumnType("integer"); + + b.Property("OverflowPolicy") + .HasColumnType("integer"); + + b.Property("RepeatSourceJson") + .HasColumnType("jsonb"); + + b.Property("SlotKind") + .HasColumnType("integer"); + + b.Property("SnapToMinutes") + .HasColumnType("integer"); + + b.Property("StrategyJson") + .HasColumnType("jsonb"); + + b.Property("TargetDurationMinutes") + .HasColumnType("integer"); + + b.Property("TargetStart") + .HasColumnType("time without time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Weekday") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("LayerId", "TargetStart"); + + b.ToTable("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.Property("SlotId") + .HasColumnType("uuid"); + + b.Property("CurrentElementId") + .HasColumnType("uuid"); + + b.Property("CurrentElementKind") + .HasColumnType("integer"); + + b.Property("NextUnitIndex") + .HasColumnType("integer"); + + b.HasKey("SlotId"); + + b.ToTable("SlotStates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("BumperTemplates") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany("Variants") + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.HasOne("TeleWave.Domain.Library.Collection", null) + .WithMany("Items") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany() + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany("Aliases") + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Genres") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.HasOne("TeleWave.Domain.Programming.ScheduleTemplate", null) + .WithMany("Layers") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany("Items") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.JunctionTemplate", null) + .WithMany("Elements") + .HasForeignKey("JunctionTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.GridLayer", null) + .WithMany("Slots") + .HasForeignKey("LayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.HasOne("TeleWave.Domain.Programming.Slot", null) + .WithOne() + .HasForeignKey("TeleWave.Domain.Programming.SlotState", "SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Navigation("Variants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Navigation("BumperTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Navigation("Aliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.Navigation("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b => + { + b.Navigation("Elements"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Navigation("Layers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260727053636_TemplateAppliedSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260727053636_TemplateAppliedSnapshot.cs new file mode 100644 index 0000000..efd044d --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260727053636_TemplateAppliedSnapshot.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class TemplateAppliedSnapshot : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AppliedSnapshotJson", + table: "ScheduleTemplates", + type: "jsonb", + nullable: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "AppliedSnapshotJson", table: "ScheduleTemplates"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index c6e8067..079078f 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -944,6 +944,9 @@ namespace TeleWave.Infrastructure.Migrations b.Property("AppliedRevision") .HasColumnType("integer"); + b.Property("AppliedSnapshotJson") + .HasColumnType("jsonb"); + b.Property("ChannelId") .HasColumnType("uuid"); diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ScheduleTemplateConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ScheduleTemplateConfiguration.cs index ee723a2..8b97cc8 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ScheduleTemplateConfiguration.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ScheduleTemplateConfiguration.cs @@ -11,6 +11,7 @@ public class ScheduleTemplateConfiguration : IEntityTypeConfiguration x.Name).IsRequired().HasMaxLength(256); builder.Property(x => x.RulesJson).HasColumnType("jsonb"); + builder.Property(x => x.AppliedSnapshotJson).HasColumnType("jsonb"); builder.HasIndex(x => x.ChannelId); builder diff --git a/backend/tests/TeleWave.Application.Tests/Programming/RestoreTemplateTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/RestoreTemplateTests.cs new file mode 100644 index 0000000..95c3e68 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Programming/RestoreTemplateTests.cs @@ -0,0 +1,220 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Programming.Templates; +using TeleWave.Application.Programming.Templates.Restore; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Programming; +using Xunit; + +namespace TeleWave.Application.Tests.Programming; + +/// +/// Откат правок сетки к применённому состоянию: что вернулось, что удалилось и что при этом +/// сохранило свой идентификатор — от него зависит, переживёт ли откат курсор сериала. +/// +public class RestoreTemplateTests +{ + private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + /// Канал с применённой сеткой: один слот и снятый по нему снимок. + private static async Task<(Guid ChannelId, Guid SlotId)> SeedAppliedAsync( + TestDb fixture, + Guid? groupId = null + ) + { + var channel = Channel.Create("Первый", "first", T0); + var template = ScheduleTemplate.Create(channel.Id, "Сетка"); + channel.SetTemplate(template.Id); + + var layer = template.AddLayer("Основная сетка", 10); + var slot = layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 120); + slot.UpdateContent( + new SlotContent( + "Вечернее кино", + SlotKind.Content, + groupId, + null, + null, + SlotBlockMode.FillSlot, + 1, + OverflowPolicy.ContinueNext + ) + ); + + // Применение снимает слепок — именно к нему потом откатываемся. + template.MarkApplied(TemplateSnapshot.Capture(template).ToJson()); + + await using var seed = fixture.New(); + seed.Channels.Add(channel); + seed.ScheduleTemplates.Add(template); + await seed.SaveChangesAsync(CancellationToken.None); + + return (channel.Id, slot.Id); + } + + private static async Task LoadAsync(TestDb fixture) + { + await using var db = fixture.New(); + return await db + .ScheduleTemplates.AsNoTracking() + .Include(t => t.Layers) + .ThenInclude(l => l.Slots) + .AsSplitQuery() + .FirstAsync(CancellationToken.None); + } + + private static async Task RestoreAsync(TestDb fixture, Guid channelId) + { + await using var db = fixture.New(); + var result = await new RestoreTemplateCommandHandler(db).Handle( + new RestoreTemplateCommand(channelId), + CancellationToken.None + ); + Assert.True(result.IsSuccess); + await db.SaveChangesAsync(CancellationToken.None); + return result.Value; + } + + [Fact] + public async Task RevertsEditedSlot_KeepingItsIdentity() + { + var fixture = new TestDb(); + var (channelId, slotId) = await SeedAppliedAsync(fixture); + + await using (var db = fixture.New()) + { + var template = await db + .ScheduleTemplates.Include(t => t.Layers) + .ThenInclude(l => l.Slots) + .FirstAsync(CancellationToken.None); + var slot = template.Layers.SelectMany(l => l.Slots).Single(); + slot.UpdateTiming(3, new TimeOnly(8, 0), 30, Daypart.Morning, false, 5, null); + template.MarkChanged(); + await db.SaveChangesAsync(CancellationToken.None); + } + + var result = await RestoreAsync(fixture, channelId); + + Assert.Equal(1, result.Slots); + var restored = (await LoadAsync(fixture)).Layers.SelectMany(l => l.Slots).Single(); + Assert.Equal(new TimeOnly(20, 0), restored.TargetStart); + Assert.Equal(120, restored.TargetDurationMinutes); + Assert.Null(restored.Weekday); + // Тот же слот, а не копия: с его идентификатором связано состояние (курсор сериала). + Assert.Equal(slotId, restored.Id); + } + + [Fact] + public async Task RemovesWhatAppearedAfterApply_AndBringsBackWhatWasDeleted() + { + var fixture = new TestDb(); + var (channelId, slotId) = await SeedAppliedAsync(fixture); + + await using (var db = fixture.New()) + { + var template = await db + .ScheduleTemplates.Include(t => t.Layers) + .ThenInclude(l => l.Slots) + .FirstAsync(CancellationToken.None); + var layer = template.Layers.Single(l => !l.IsBackground); + layer.RemoveSlot(slotId); + layer.AddSlot("Лишний", new TimeOnly(10, 0), 60); + template.AddLayer("Лишний слой", 20); + template.MarkChanged(); + await db.SaveChangesAsync(CancellationToken.None); + } + + await RestoreAsync(fixture, channelId); + + var restored = await LoadAsync(fixture); + var slot = Assert.Single(restored.Layers.SelectMany(l => l.Slots)); + Assert.Equal("Вечернее кино", slot.Title); + Assert.DoesNotContain(restored.Layers, l => l.Name == "Лишний слой"); + // Удалённый слот возвращается новым: его состояние ушло вместе с ним, вернуть нечего. + Assert.NotEqual(slotId, slot.Id); + } + + [Fact] + public async Task ClearsPendingFlag() + { + var fixture = new TestDb(); + var (channelId, _) = await SeedAppliedAsync(fixture); + + await using (var db = fixture.New()) + { + var template = await db.ScheduleTemplates.FirstAsync(CancellationToken.None); + template.SetRules("{\"maxBreakMinutesPerHour\":10}"); + template.MarkChanged(); + await db.SaveChangesAsync(CancellationToken.None); + Assert.True(template.HasPendingChanges); + } + + await RestoreAsync(fixture, channelId); + + var restored = await LoadAsync(fixture); + Assert.False(restored.HasPendingChanges); + Assert.Null(restored.RulesJson); + } + + [Fact] + public async Task DropsReferenceToGroupDeletedAfterApply() + { + var fixture = new TestDb(); + var group = Group.Create("Кино"); + Guid channelId; + + await using (var seed = fixture.New()) + { + seed.Groups.Add(group); + await seed.SaveChangesAsync(CancellationToken.None); + } + + (channelId, _) = await SeedAppliedAsync(fixture, group.Id); + + // Слот удалили, следом удалили и группу — ссылка в снимке осталась висеть. + await using (var db = fixture.New()) + { + var template = await db + .ScheduleTemplates.Include(t => t.Layers) + .ThenInclude(l => l.Slots) + .FirstAsync(CancellationToken.None); + var layer = template.Layers.Single(l => !l.IsBackground); + layer.RemoveSlot(layer.Slots[0].Id); + db.Groups.Remove(await db.Groups.FirstAsync(CancellationToken.None)); + template.MarkChanged(); + await db.SaveChangesAsync(CancellationToken.None); + } + + var result = await RestoreAsync(fixture, channelId); + + // Откат не падает из-за прибранной группы: слот возвращается без неё, а потеря сосчитана. + Assert.Equal(1, result.DroppedRefs); + var slot = (await LoadAsync(fixture)).Layers.SelectMany(l => l.Slots).Single(); + Assert.Null(slot.GroupId); + } + + [Fact] + public async Task Fails_WhenTemplateWasNeverApplied() + { + var fixture = new TestDb(); + var channel = Channel.Create("Первый", "first", T0); + var template = ScheduleTemplate.Create(channel.Id, "Сетка"); + channel.SetTemplate(template.Id); + + await using (var seed = fixture.New()) + { + seed.Channels.Add(channel); + seed.ScheduleTemplates.Add(template); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var result = await new RestoreTemplateCommandHandler(db).Handle( + new RestoreTemplateCommand(channel.Id), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(TemplateErrors.NothingToRestore, result.Error); + } +} diff --git a/docs/tv-scheduler-architecture.md b/docs/tv-scheduler-architecture.md index 7229e87..962ddaa 100644 --- a/docs/tv-scheduler-architecture.md +++ b/docs/tv-scheduler-architecture.md @@ -822,6 +822,21 @@ seed = hash(channelId, date, slotId, occurrenceInDay) Регулируется по силе, по умолчанию выключен: попадает точно в цель для канала «как в 96-м», но переборщить очень легко. +### 6.6.1. Откат правок + +У плашки «правила изменены» две кнопки: применить и **сбросить**. Слои и слоты правятся на месте, +поэтому «как было» взять неоткуда — при каждом применении шаблон снимается слепком +(`ScheduleTemplate.AppliedSnapshotJson`, схема `TemplateSnapshot` в Application). Это ровно то +состояние, по которому собрана лента, и потому единственная корректная точка отката. + +Откат правит слои и слоты **на месте по идентификаторам из снимка**: у пережившего откат слота +сохраняется `SlotState`, и сериал не начинается заново. Удалённый после применения слот вернётся +новым — его состояние ушло вместе с ним. Ссылка на группу или стык, удалённые после применения, +не восстанавливается (внешний ключ), слот возвращается без неё, потеря считается и показывается. + +Эфир при откате не трогается: он и так идёт по этому состоянию, а шаблон после отката снова ему +равен — `Revision` приравнивается к `AppliedRevision`, плашка гаснет. + ### 6.9. Автосборка сетки по профилю Собрать неделю руками — это несколько десятков слотов, и до первого эфира новый канал не доживает. diff --git a/frontend/src/features/admin/channels/ChannelDetail.tsx b/frontend/src/features/admin/channels/ChannelDetail.tsx index a6c8116..027d402 100644 --- a/frontend/src/features/admin/channels/ChannelDetail.tsx +++ b/frontend/src/features/admin/channels/ChannelDetail.tsx @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' -import { ChevronLeft, Send } from 'lucide-react' +import { ChevronLeft, Send, Undo2 } from 'lucide-react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { listAllMedia } from '@/features/admin/media/api' @@ -11,7 +11,13 @@ import { Badge } from '@/shared/ui/badge' import { Button } from '@/shared/ui/button' import { Card, CardContent } from '@/shared/ui/card' import { toast } from '@/shared/ui/toast-store' -import { applyChannelTemplate, getChannel, getChannelTemplate, getSchedule } from './api' +import { + applyChannelTemplate, + getChannel, + getChannelTemplate, + getSchedule, + restoreChannelTemplate, +} from './api' import { ApplyDialog } from './components/ApplyDialog' import { BumperCard } from './components/BumperCard' import { EntryTraceDialog } from './components/EntryTraceDialog' @@ -70,6 +76,18 @@ export function ChannelDetail({ channelId }: Readonly<{ channelId: string }>) { onError, }) + const restoreMutation = useMutation({ + mutationFn: () => restoreChannelTemplate(channelId), + onSuccess: (result) => { + toast.success(t('admin.channels.restored', { count: result.slots })) + // Ссылка на удалённую группу или стык вернуться не может — говорим об этом прямо. + if (result.droppedRefs > 0) + toast.error(t('admin.channels.restoreDropped', { count: result.droppedRefs })) + invalidate() + }, + onError, + }) + if (isLoading || !channel) return

{t('common.loading')}

return ( @@ -89,13 +107,31 @@ export function ChannelDetail({ channelId }: Readonly<{ channelId: string }>) { - {/* Правка правил эфира не двигает — применение отдельной кнопкой. */} + {/* Правка правил эфира не двигает — применение отдельной кнопкой. Рядом откат: правки + сетки правятся на месте, и без этой кнопки вернуть «как в эфире» было бы нечем. */} {template?.hasPendingChanges && (
{t('admin.channels.pendingChanges')} - +
+ {/* Кнопки отката нет, пока сетку ни разу не применяли: возвращаться некуда. */} + {template.canRestore && ( + + )} + +
)} diff --git a/frontend/src/features/admin/channels/api.ts b/frontend/src/features/admin/channels/api.ts index f25c7b7..165903a 100644 --- a/frontend/src/features/admin/channels/api.ts +++ b/frontend/src/features/admin/channels/api.ts @@ -20,6 +20,7 @@ import type { JunctionTemplateDto, LayerApplicability, PlanningRules, + RestoreTemplateResultDto, ScheduleDiffDto, ScheduleEntryDto, SchedulePreviewDto, @@ -84,6 +85,16 @@ export function applyChannelTemplate(channelId: string) { }) } +/** + * Откат правок сетки к состоянию, по которому идёт эфир: восстанавливает слои, слоты и правила + * из снимка последнего применения. Ленту не трогает — она и так по этому состоянию. + */ +export function restoreChannelTemplate(channelId: string) { + return apiRequest(`/admin/channels/${channelId}/template/restore`, { + method: 'POST', + }) +} + /** Проверки сетки по правилам — считаются по шаблону, без прогона генератора. */ export function getTemplateIssues(channelId: string) { return apiRequest(`/admin/channels/${channelId}/template/issues`) diff --git a/frontend/src/features/admin/channels/components/SlotInspector.tsx b/frontend/src/features/admin/channels/components/SlotInspector.tsx index 28843ba..768049c 100644 --- a/frontend/src/features/admin/channels/components/SlotInspector.tsx +++ b/frontend/src/features/admin/channels/components/SlotInspector.tsx @@ -120,37 +120,35 @@ export function SlotInspector({ // Отдельным окном, а не панелью под сеткой: форма длинная, и раньше она уезжала за нижний край, // а сетка — то, с чем её сверяют, — уходила из виду. !next && onClose()}> - + {draft.slot ? t('admin.channels.editSlot') : t('admin.channels.newSlot')} -
-
+
+
patch({ title: e.target.value })} />
-
-
- - patch({ targetStart: `${e.target.value}:00` })} - /> -
-
- - patch({ targetDurationMinutes: Number(e.target.value) })} - /> -
+
+ + patch({ targetStart: `${e.target.value}:00` })} + /> +
+
+ + patch({ targetDurationMinutes: Number(e.target.value) })} + />
@@ -171,49 +169,47 @@ export function SlotInspector({
-
-
- - -
-
- - -
+
+ + +
+
+ +
{isContent && ( @@ -280,33 +276,31 @@ export function SlotInspector({
)} -
-
- - -
- {body.blockMode !== 'FillSlot' && ( -
- - patch({ blockValue: Number(e.target.value) })} - /> -
- )} +
+ +
+ {body.blockMode !== 'FillSlot' && ( +
+ + patch({ blockValue: Number(e.target.value) })} + /> +
+ )}
@@ -327,7 +321,7 @@ export function SlotInspector({ )} {body.slotKind === 'Repeat' && body.repeatSource && ( -
+
)} -
+
-

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

+

+ {t('admin.channels.anchorHint')} +

{/* Стыки: внутри слота — между единицами, после слота — на переходе к следующему. */} -
-
- - -
-
- - -
+
+ + +
+
+ +
diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index d82437e..fc1146c 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -620,6 +620,8 @@ export type ScheduleTemplateDto = { appliedRevision: number /** Есть ли правки правил, не применённые к эфиру. */ hasPendingChanges: boolean + /** Есть ли к чему откатываться — снимок появляется при первом применении. */ + canRestore: boolean utcOffsetMinutes: number dayStartTime: string layers: GridLayerDto[] @@ -738,6 +740,9 @@ export type GridPlanDto = { export type GenerateGridResultDto = { created: number; removed: number } +/** Итог отката сетки: сколько слотов осталось и сколько ссылок восстановить не удалось. */ +export type RestoreTemplateResultDto = { slots: number; droppedRefs: number } + /** Проверки сетки по правилам, до генерации (см. 5.1). */ type TemplateIssueKind = | 'GroupEmpty' diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts index 1c6a7ac..94e44bc 100644 --- a/frontend/src/shared/lib/locales/en.ts +++ b/frontend/src/shared/lib/locales/en.ts @@ -628,6 +628,12 @@ export const en = { pickBumperTemplate: 'pick a block', minutesShort: ' min', pendingChanges: 'Rules changed — the air still follows the old ones.', + restore: 'Discard changes', + restoreConfirm: + 'Roll the grid back to what is on air? Everything changed since the last apply will be lost.', + restored: 'The grid is back to the on-air state: {{count}} slots.', + restoreDropped: + 'References not restored: {{count}} — the group or junction was deleted after the apply.', apply: 'Apply', applied: 'Air rebuilt, entries: {{count}}', weekdays: { diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts index 505ee94..be08386 100644 --- a/frontend/src/shared/lib/locales/ru.ts +++ b/frontend/src/shared/lib/locales/ru.ts @@ -633,6 +633,12 @@ export const ru = { pickBumperTemplate: 'выберите блок', minutesShort: ' мин', pendingChanges: 'Правила изменены — эфир идёт по старым.', + restore: 'Сбросить изменения', + restoreConfirm: + 'Вернуть сетку к тому состоянию, по которому идёт эфир? Всё, что изменено после последнего применения, будет потеряно.', + restored: 'Сетка возвращена к эфирной: слотов — {{count}}.', + restoreDropped: + 'Ссылок не восстановлено: {{count}} — группа или стык были удалены после применения.', apply: 'Применить', applied: 'Эфир пересобран, записей: {{count}}', weekdays: {