diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperErrors.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperErrors.cs index 08994d7..1ce2e1a 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperErrors.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperErrors.cs @@ -20,10 +20,14 @@ public static class BumperErrors "Нельзя удалить последний подблок — нужен хотя бы один." ); - public static readonly Error TemplateInUse = Error.Conflict( - "Bumpers.TemplateInUse", - "Блок заставки используется во врезках стыков." - ); + /// Блок общий: держат его врезки конкретных стыков, их и перечисляем. + public static Error TemplateInUse(string where) => + Error.Conflict( + "Bumpers.TemplateInUse", + where.Length > 0 + ? $"Блок заставки используется во врезках стыков ({where}) — уберите его оттуда." + : "Блок заставки используется во врезках стыков." + ); public static readonly Error VariantInUse = Error.Conflict( "Bumpers.VariantInUse", diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/DeleteBumperTemplateCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/DeleteBumperTemplateCommandHandler.cs index 0e4feb0..35205cf 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/DeleteBumperTemplateCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/DeleteBumperTemplateCommandHandler.cs @@ -29,7 +29,22 @@ public sealed class DeleteBumperTemplateCommandHandler( cancellationToken ); if (used) - return Result.Failure(BumperErrors.TemplateInUse); + { + // Имя стыка — по связи врезки с ним; факт использования проверен отдельно и раньше. + var junctions = await ( + from element in dbContext.JunctionElements + join junction in dbContext.JunctionTemplates + on element.JunctionTemplateId equals junction.Id + where element.BumperTemplateId == template.Id + select junction.Name + ) + .Distinct() + .ToListAsync(cancellationToken); + + return Result.Failure( + BumperErrors.TemplateInUse(UsageText.Part("стыки", junctions) ?? string.Empty) + ); + } dbContext.BumperTemplates.Remove(template); await storage.DeleteAudioAsync(template.Id, cancellationToken); diff --git a/backend/src/TeleWave.Application/Common/Models/UsageText.cs b/backend/src/TeleWave.Application/Common/Models/UsageText.cs new file mode 100644 index 0000000..ee45d86 --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Models/UsageText.cs @@ -0,0 +1,28 @@ +namespace TeleWave.Application.Common.Models; + +/// +/// Склейка ответа на вопрос «где это используется» для текста ошибки. Отказ вида «объект занят» +/// без адреса заставляет обходить все экраны подряд, а перечисление держателей превращает ошибку +/// в инструкцию: видно, что именно отвязать. +/// +public static class UsageText +{ + /// Сколько имён показываем: длинный список в тосте всё равно не читается. + private const int MaxNames = 5; + + /// Раздел перечисления: «слоты каналов: Первый, Второй». Пустой список — пропускается. + public static string? Part(string what, IReadOnlyCollection names) + { + if (names.Count == 0) + return null; + + var shown = string.Join(", ", names.Take(MaxNames)); + return names.Count > MaxNames + ? $"{what}: {shown} и ещё {names.Count - MaxNames}" + : $"{what}: {shown}"; + } + + /// Собирает разделы в одну строку; пустые отбрасываются. + public static string Join(params string?[] parts) => + string.Join("; ", parts.Where(p => !string.IsNullOrWhiteSpace(p))); +} diff --git a/backend/src/TeleWave.Application/Media/Delete/DeleteMediaAssetCommandHandler.cs b/backend/src/TeleWave.Application/Media/Delete/DeleteMediaAssetCommandHandler.cs index 18b29fb..e3c5c4c 100644 --- a/backend/src/TeleWave.Application/Media/Delete/DeleteMediaAssetCommandHandler.cs +++ b/backend/src/TeleWave.Application/Media/Delete/DeleteMediaAssetCommandHandler.cs @@ -32,8 +32,11 @@ public sealed class DeleteMediaAssetCommandHandler(IAppDbContext dbContext, IMed c => c.FillerAssetId == asset.Id, cancellationToken ); + if (inSchedule || isFiller) - return Result.Failure(MediaErrors.InUse); + return Result.Failure( + MediaErrors.InUse(await UsedByAsync(asset.Id, cancellationToken)) + ); // Сначала коммитим удаление в БД, потом чистим файлы (best-effort): orphan-файл на диске // безопаснее, чем строка БД, ссылающаяся на уже удалённые артефакты. @@ -45,4 +48,27 @@ public sealed class DeleteMediaAssetCommandHandler(IAppDbContext dbContext, IMed return Result.Success(); } + + /// Чем занят ассет: расписанием каких каналов и где он выбран заполнителем. + private async Task UsedByAsync(Guid assetId, CancellationToken cancellationToken) + { + var inSchedule = await ( + from entry in dbContext.ScheduleEntries + join channel in dbContext.Channels on entry.ChannelId equals channel.Id + where entry.MediaAssetId == assetId + select channel.Name + ) + .Distinct() + .ToListAsync(cancellationToken); + + var asFiller = await dbContext + .Channels.Where(c => c.FillerAssetId == assetId) + .Select(c => c.Name) + .ToListAsync(cancellationToken); + + return UsageText.Join( + UsageText.Part("расписание каналов", inSchedule), + UsageText.Part("заполнитель каналов", asFiller) + ); + } } diff --git a/backend/src/TeleWave.Application/Media/MediaErrors.cs b/backend/src/TeleWave.Application/Media/MediaErrors.cs index 89e39a9..bbda962 100644 --- a/backend/src/TeleWave.Application/Media/MediaErrors.cs +++ b/backend/src/TeleWave.Application/Media/MediaErrors.cs @@ -34,10 +34,14 @@ public static class MediaErrors "Исходный файл не найден в хранилище." ); - public static readonly Error InUse = Error.Conflict( - "Media.InUse", - "Ассет занят: он стоит в расписании канала или выбран заполнителем эфира." - ); + /// Чем именно занят ассет: расписанием какого канала или ролью заполнителя. + public static Error InUse(string where) => + Error.Conflict( + "Media.InUse", + where.Length > 0 + ? $"Ассет занят ({where}) — удалить его нельзя." + : "Ассет занят: он стоит в расписании канала или выбран заполнителем эфира." + ); public static readonly Error DuplicateFileName = Error.Conflict( "Media.DuplicateFileName", diff --git a/backend/src/TeleWave.Application/Programming/Groups/DeleteGroup/DeleteGroupCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Groups/DeleteGroup/DeleteGroupCommandHandler.cs index 0810d0c..c118e90 100644 --- a/backend/src/TeleWave.Application/Programming/Groups/DeleteGroup/DeleteGroupCommandHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Groups/DeleteGroup/DeleteGroupCommandHandler.cs @@ -34,10 +34,55 @@ public sealed class DeleteGroupCommandHandler(IAppDbContext dbContext) t => t.FallbackGroupId == group.Id, cancellationToken ); + + // Сам факт занятости и адрес — разные вопросы: адрес собирается по цепочке слой → шаблон → + // канал, и если она неполна, имени не будет, а запрещать удаление всё равно надо. if (used) - return Result.Failure(GroupErrors.InUse); + return Result.Failure( + GroupErrors.InUse(await UsedByAsync(group.Id, cancellationToken)) + ); dbContext.Groups.Remove(group); return Result.Success(); } + + /// Кто держит группу: слоты каналов, стыки и аварийная группа шаблона. + private async Task UsedByAsync(Guid groupId, CancellationToken cancellationToken) + { + var slots = await ( + from slot in dbContext.Slots + join layer in dbContext.GridLayers on slot.LayerId equals layer.Id + join template in dbContext.ScheduleTemplates on layer.TemplateId equals template.Id + join channel in dbContext.Channels on template.ChannelId equals channel.Id + where slot.GroupId == groupId + select channel.Name + ) + .Distinct() + .ToListAsync(cancellationToken); + + var junctions = await ( + from element in dbContext.JunctionElements + join junction in dbContext.JunctionTemplates + on element.JunctionTemplateId equals junction.Id + where element.GroupId == groupId + select junction.Name + ) + .Distinct() + .ToListAsync(cancellationToken); + + var fallback = await ( + from template in dbContext.ScheduleTemplates + join channel in dbContext.Channels on template.ChannelId equals channel.Id + where template.FallbackGroupId == groupId + select channel.Name + ) + .Distinct() + .ToListAsync(cancellationToken); + + return UsageText.Join( + UsageText.Part("слоты каналов", slots), + UsageText.Part("врезки стыков", junctions), + UsageText.Part("аварийная группа каналов", fallback) + ); + } } diff --git a/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs b/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs index f248158..384b7f4 100644 --- a/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs +++ b/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs @@ -16,10 +16,19 @@ public static class GroupErrors "Шоу или коллекция не найдены." ); - public static readonly Error InUse = Error.Conflict( - "Groups.InUse", - "Группа используется в сетке: на неё ссылается слот, врезка стыка или запасная группа шаблона." - ); + /// + /// Где именно занята группа: без адреса «используется в сетке» отправляет обходить все каналы. + /// Пустой — связи неполны (слой без шаблона, шаблон без канала), + /// и тогда честнее общий текст, чем пустые скобки. + /// + public static Error InUse(string where) => + Error.Conflict( + "Groups.InUse", + where.Length > 0 + ? $"Группа используется в сетке ({where}) — сначала отвяжите её там." + : "Группа используется в сетке: на неё ссылается слот, врезка стыка " + + "или запасная группа шаблона." + ); public static readonly Error FilterNotSet = Error.Validation( "Groups.FilterNotSet", diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/DeleteJunctionCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/DeleteJunctionCommandHandler.cs index d988061..4ce6ce0 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Junctions/DeleteJunctionCommandHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/DeleteJunctionCommandHandler.cs @@ -30,10 +30,44 @@ public sealed class DeleteJunctionCommandHandler(IAppDbContext dbContext) t => t.DefaultJunctionId == junction.Id, cancellationToken ); + + // Факт и адрес — разные вопросы: имя канала берётся по цепочке связей, и её неполнота + // не должна превращать запрет в разрешение. if (usedBySlot || usedByDefault) - return Result.Failure(TemplateErrors.JunctionInUse); + return Result.Failure( + TemplateErrors.JunctionInUse(await UsedByAsync(junction.Id, cancellationToken)) + ); dbContext.JunctionTemplates.Remove(junction); return Result.Success(); } + + /// Кто держит стык: слоты каналов и каналы, где он выбран стыком по умолчанию. + private async Task UsedByAsync(Guid junctionId, CancellationToken cancellationToken) + { + var slots = await ( + from slot in dbContext.Slots + join layer in dbContext.GridLayers on slot.LayerId equals layer.Id + join template in dbContext.ScheduleTemplates on layer.TemplateId equals template.Id + join channel in dbContext.Channels on template.ChannelId equals channel.Id + where slot.JunctionBetweenId == junctionId || slot.JunctionAfterId == junctionId + select channel.Name + ) + .Distinct() + .ToListAsync(cancellationToken); + + var byDefault = await ( + from template in dbContext.ScheduleTemplates + join channel in dbContext.Channels on template.ChannelId equals channel.Id + where template.DefaultJunctionId == junctionId + select channel.Name + ) + .Distinct() + .ToListAsync(cancellationToken); + + return UsageText.Join( + UsageText.Part("слоты каналов", slots), + UsageText.Part("стык по умолчанию у каналов", byDefault) + ); + } } diff --git a/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs b/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs index 16ad3ac..ebf0b13 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs @@ -49,10 +49,14 @@ public static class TemplateErrors "Врезка не найдена." ); - public static readonly Error JunctionInUse = Error.Conflict( - "Templates.JunctionInUse", - "Стык используется слотами — сначала отвяжите его." - ); + /// Стык общий, поэтому держать его может слот чужого канала — называем какого. + public static Error JunctionInUse(string where) => + Error.Conflict( + "Templates.JunctionInUse", + where.Length > 0 + ? $"Стык используется ({where}) — сначала отвяжите его там." + : "Стык используется слотами — сначала отвяжите его." + ); public static readonly Error JunctionGroupRequired = Error.Validation( "Templates.JunctionGroupRequired", diff --git a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperTemplateHandlersTests.cs b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperTemplateHandlersTests.cs index 71ac6fa..bbd7566 100644 --- a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperTemplateHandlersTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperTemplateHandlersTests.cs @@ -259,7 +259,7 @@ public class BumperTemplateHandlersTests new DeleteBumperTemplateCommand(template.Id), CancellationToken.None ); - Assert.Equal(BumperErrors.TemplateInUse, used.Error); + Assert.Equal(BumperErrors.TemplateInUse(string.Empty).Code, used.Error.Code); } await using (var db = fixture.New()) diff --git a/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs b/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs index d6dc7c8..a70f5a7 100644 --- a/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs @@ -155,7 +155,7 @@ public class DeleteGuardsTests ); Assert.False(result.IsSuccess); - Assert.Equal(MediaErrors.InUse, result.Error); + Assert.Equal(MediaErrors.InUse(string.Empty).Code, result.Error.Code); // Файлы не тронуты: до чистки хранилища дело не дошло. await storage .DidNotReceiveWithAnyArgs() @@ -184,7 +184,7 @@ public class DeleteGuardsTests ).Handle(new DeleteMediaAssetCommand(asset.Id), CancellationToken.None); Assert.False(result.IsSuccess); - Assert.Equal(MediaErrors.InUse, result.Error); + Assert.Equal(MediaErrors.InUse(string.Empty).Code, result.Error.Code); } [Fact] @@ -222,7 +222,7 @@ public class DeleteGuardsTests ); Assert.False(result.IsSuccess); - Assert.Equal(GroupErrors.InUse, result.Error); + Assert.Equal(GroupErrors.InUse(string.Empty).Code, result.Error.Code); } /// Запасная группа шаблона внешнего ключа не имеет — её пришлось бы ловить отдельно. @@ -248,7 +248,38 @@ public class DeleteGuardsTests ); Assert.False(result.IsSuccess); - Assert.Equal(GroupErrors.InUse, result.Error); + Assert.Equal(GroupErrors.InUse(string.Empty).Code, result.Error.Code); + } + + /// + /// Отказ должен называть адрес: «используется в сетке» без имени канала отправляет обходить + /// их все руками. Связи полные — значит и канал в тексте есть. + /// + [Fact] + public async Task DeleteGroup_UsedByChannel_NamesIt() + { + var fixture = new TestDb(); + var group = Group.Create("G"); + var channel = Channel.Create("Первый", $"first-{Guid.NewGuid():N}", T0); + var template = ScheduleTemplate.Create(channel.Id, "Сетка"); + template.SetFallbackGroup(group.Id); + + await using (var seed = fixture.New()) + { + seed.Groups.Add(group); + seed.Channels.Add(channel); + seed.ScheduleTemplates.Add(template); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var result = await new DeleteGroupCommandHandler(db).Handle( + new DeleteGroupCommand(group.Id), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Contains("Первый", result.Error.Message, StringComparison.Ordinal); } [Fact] diff --git a/backend/tests/TeleWave.Application.Tests/Programming/JunctionHandlersTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/JunctionHandlersTests.cs index 617a99f..37ed9b5 100644 --- a/backend/tests/TeleWave.Application.Tests/Programming/JunctionHandlersTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Programming/JunctionHandlersTests.cs @@ -156,7 +156,7 @@ public class JunctionHandlersTests new DeleteJunctionCommand(junctionId), CancellationToken.None ); - Assert.Equal(TemplateErrors.JunctionInUse, used.Error); + Assert.Equal(TemplateErrors.JunctionInUse(string.Empty).Code, used.Error.Code); } // Снимаем ссылку слота, но оставляем стыком по умолчанию — удалять всё равно нельзя. @@ -189,7 +189,7 @@ public class JunctionHandlersTests new DeleteJunctionCommand(junctionId), CancellationToken.None ); - Assert.Equal(TemplateErrors.JunctionInUse, asDefault.Error); + Assert.Equal(TemplateErrors.JunctionInUse(string.Empty).Code, asDefault.Error.Code); } }