diff --git a/backend/src/TeleWave.Api/Endpoints/AdminUserEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/AdminUserEndpoints.cs index 639b051..edc78b5 100644 --- a/backend/src/TeleWave.Api/Endpoints/AdminUserEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/AdminUserEndpoints.cs @@ -5,6 +5,7 @@ using TeleWave.Application.Admin.Users.CreateUser; using TeleWave.Application.Admin.Users.DeleteUser; using TeleWave.Application.Admin.Users.GetUser; using TeleWave.Application.Admin.Users.ListUsers; +using TeleWave.Application.Admin.Users.ResetPassword; using TeleWave.Application.Admin.Users.UnblockUser; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; @@ -29,6 +30,9 @@ public static class AdminUserEndpoints admin .MapPost("/{id:guid}/unblock", UnblockUser) .Produces(StatusCodes.Status204NoContent); + admin + .MapPost("/{id:guid}/password", ResetPassword) + .Produces(StatusCodes.Status204NoContent); admin.MapDelete("/{id:guid}", DeleteUser).Produces(StatusCodes.Status204NoContent); return app; @@ -96,6 +100,20 @@ public static class AdminUserEndpoints return result.ToHttpResult(); } + private static async Task ResetPassword( + Guid id, + ResetPasswordBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new ResetUserPasswordCommand(id, body.NewPassword), + cancellationToken + ); + return result.ToHttpResult(); + } + private static async Task DeleteUser( Guid id, ISender sender, @@ -108,3 +126,5 @@ public static class AdminUserEndpoints } public sealed record CreateUserBody(string UserName, string Password, Guid RoleId); + +public sealed record ResetPasswordBody(string NewPassword); diff --git a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs index 668feaa..5b84a3d 100644 --- a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs @@ -538,8 +538,12 @@ public static class ChannelEndpoints new CreateProgrammingOverrideCommand( id, body.Mode, + body.Recurrence, body.StartsAtUtc, body.EndsAtUtc, + body.DayOfWeek, + body.StartMinute, + body.EndMinute, body.Shows ), cancellationToken @@ -659,7 +663,11 @@ internal static class BumperFiles public sealed record CreateOverrideBody( OverrideMode Mode, - DateTimeOffset StartsAtUtc, - DateTimeOffset EndsAtUtc, + OverrideRecurrence Recurrence, + DateTimeOffset? StartsAtUtc, + DateTimeOffset? EndsAtUtc, + int? DayOfWeek, + int? StartMinute, + int? EndMinute, IReadOnlyList Shows ); diff --git a/backend/src/TeleWave.Application/Admin/Users/ResetPassword/ResetUserPasswordCommand.cs b/backend/src/TeleWave.Application/Admin/Users/ResetPassword/ResetUserPasswordCommand.cs new file mode 100644 index 0000000..54db876 --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Users/ResetPassword/ResetUserPasswordCommand.cs @@ -0,0 +1,7 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Admin.Users.ResetPassword; + +/// Сброс пароля пользователя администратором (без текущего пароля). +public sealed record ResetUserPasswordCommand(Guid UserId, string NewPassword) : ICommand; diff --git a/backend/src/TeleWave.Application/Admin/Users/ResetPassword/ResetUserPasswordCommandHandler.cs b/backend/src/TeleWave.Application/Admin/Users/ResetPassword/ResetUserPasswordCommandHandler.cs new file mode 100644 index 0000000..94a6758 --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Users/ResetPassword/ResetUserPasswordCommandHandler.cs @@ -0,0 +1,14 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Admin.Users.ResetPassword; + +public sealed class ResetUserPasswordCommandHandler(IIdentityService identityService) + : ICommandHandler +{ + public Task Handle( + ResetUserPasswordCommand command, + CancellationToken cancellationToken + ) => identityService.ResetPasswordAsync(command.UserId, command.NewPassword, cancellationToken); +} diff --git a/backend/src/TeleWave.Application/Admin/Users/ResetPassword/ResetUserPasswordCommandValidator.cs b/backend/src/TeleWave.Application/Admin/Users/ResetPassword/ResetUserPasswordCommandValidator.cs new file mode 100644 index 0000000..266fe6b --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Users/ResetPassword/ResetUserPasswordCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace TeleWave.Application.Admin.Users.ResetPassword; + +public sealed class ResetUserPasswordCommandValidator : AbstractValidator +{ + public ResetUserPasswordCommandValidator() + { + // Длина — здесь; сложность (цифра/заглавная) проверяют валидаторы ASP.NET Identity при сбросе. + RuleFor(x => x.UserId).NotEmpty(); + RuleFor(x => x.NewPassword).NotEmpty().MinimumLength(8); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs b/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs index a3b3b4a..2a7c627 100644 --- a/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs +++ b/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs @@ -27,8 +27,12 @@ public sealed record OverrideShowDto(Guid ShowId, string ShowName, int Weight); public sealed record ProgrammingOverrideDto( Guid Id, OverrideMode Mode, - DateTimeOffset StartsAtUtc, - DateTimeOffset EndsAtUtc, + OverrideRecurrence Recurrence, + DateTimeOffset? StartsAtUtc, + DateTimeOffset? EndsAtUtc, + int? DayOfWeek, + int? StartMinute, + int? EndMinute, IReadOnlyList Shows ); diff --git a/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommand.cs b/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommand.cs index 9155a84..fb0b975 100644 --- a/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommand.cs +++ b/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommand.cs @@ -7,7 +7,11 @@ namespace TeleWave.Application.Broadcast.CreateOverride; public sealed record CreateProgrammingOverrideCommand( Guid ChannelId, OverrideMode Mode, - DateTimeOffset StartsAtUtc, - DateTimeOffset EndsAtUtc, + OverrideRecurrence Recurrence, + DateTimeOffset? StartsAtUtc, + DateTimeOffset? EndsAtUtc, + int? DayOfWeek, + int? StartMinute, + int? EndMinute, IReadOnlyList Shows ) : ICommand>; diff --git a/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommandHandler.cs index fc3a0f2..5490a8a 100644 --- a/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommandHandler.cs @@ -2,6 +2,7 @@ using LiteCqrs; using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; +using TeleWave.Domain.Broadcast; namespace TeleWave.Application.Broadcast.CreateOverride; @@ -13,8 +14,24 @@ public sealed class CreateProgrammingOverrideCommandHandler(IAppDbContext dbCont CancellationToken cancellationToken ) { - if (command.EndsAtUtc <= command.StartsAtUtc) + var weekly = command.Recurrence == OverrideRecurrence.Weekly; + if (weekly) + { + if ( + command.DayOfWeek is not (>= 0 and <= 6) + || command.StartMinute is not { } sm + || command.EndMinute is not { } em + || em <= sm + || sm < 0 + || em > 1440 + ) + return Result.Failure(ChannelErrors.InvalidOverrideWindow); + } + else if (command.StartsAtUtc is not { } start || command.EndsAtUtc is not { } end || end <= start) + { return Result.Failure(ChannelErrors.InvalidOverrideWindow); + } + if (command.Shows.Count == 0) return Result.Failure(ChannelErrors.OverrideNeedsShow); @@ -33,7 +50,14 @@ public sealed class CreateProgrammingOverrideCommandHandler(IAppDbContext dbCont if (existingCount != showIds.Count) return Result.Failure(ChannelErrors.ShowNotFound); - var ovr = channel.AddOverride(command.Mode, command.StartsAtUtc, command.EndsAtUtc); + var ovr = weekly + ? channel.AddWeeklyOverride( + command.Mode, + command.DayOfWeek!.Value, + command.StartMinute!.Value, + command.EndMinute!.Value + ) + : channel.AddOverride(command.Mode, command.StartsAtUtc!.Value, command.EndsAtUtc!.Value); foreach (var show in command.Shows) ovr.AddShow(show.ShowId, show.Weight); diff --git a/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs index 9cec779..c4f777c 100644 --- a/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs @@ -104,12 +104,18 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext) .ToList(); var overrides = channel.Overrides - .OrderBy(o => o.StartsAtUtc) + .OrderBy(o => o.Recurrence) + .ThenBy(o => o.StartsAtUtc) + .ThenBy(o => o.DayOfWeek) .Select(o => new ProgrammingOverrideDto( o.Id, o.Mode, + o.Recurrence, o.StartsAtUtc, o.EndsAtUtc, + o.DayOfWeek, + o.StartMinute, + o.EndMinute, o.Shows .Select(s => new OverrideShowDto(s.ShowId, ShowName(s.ShowId), s.Weight)) .ToList() diff --git a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs index 657227f..99d6972 100644 --- a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs +++ b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs @@ -562,10 +562,14 @@ public sealed class ScheduleGenerator( var overrides = channel.Overrides .Select(o => new PlannerOverride( + o.Mode, + o.Shows.Select(s => new PlannerOverrideShow(s.ShowId, s.Weight)).ToList(), + o.Recurrence, o.StartsAtUtc, o.EndsAtUtc, - o.Mode, - o.Shows.Select(s => new PlannerOverrideShow(s.ShowId, s.Weight)).ToList() + o.DayOfWeek, + o.StartMinute, + o.EndMinute )) .ToList(); diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IIdentityService.cs b/backend/src/TeleWave.Application/Common/Interfaces/IIdentityService.cs index 1cc9037..323d49c 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IIdentityService.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IIdentityService.cs @@ -36,6 +36,13 @@ public interface IIdentityService CancellationToken cancellationToken ); + /// Сброс пароля администратором — без текущего пароля (для чужого аккаунта). + Task ResetPasswordAsync( + Guid userId, + string newPassword, + CancellationToken cancellationToken + ); + Task ChangeUserNameAsync( Guid userId, string newUserName, diff --git a/backend/src/TeleWave.Domain/Broadcast/Channel.cs b/backend/src/TeleWave.Domain/Broadcast/Channel.cs index ca173d0..af6a967 100644 --- a/backend/src/TeleWave.Domain/Broadcast/Channel.cs +++ b/backend/src/TeleWave.Domain/Broadcast/Channel.cs @@ -198,7 +198,20 @@ public class Channel DateTimeOffset endsAtUtc ) { - var ovr = ProgrammingOverride.Create(Id, mode, startsAtUtc, endsAtUtc); + var ovr = ProgrammingOverride.CreateOneTime(Id, mode, startsAtUtc, endsAtUtc); + _overrides.Add(ovr); + return ovr; + } + + /// Еженедельный override: день недели (0=Вс..6=Сб) + окно минут суток (UTC). + public ProgrammingOverride AddWeeklyOverride( + OverrideMode mode, + int dayOfWeek, + int startMinute, + int endMinute + ) + { + var ovr = ProgrammingOverride.CreateWeekly(Id, mode, dayOfWeek, startMinute, endMinute); _overrides.Add(ovr); return ovr; } diff --git a/backend/src/TeleWave.Domain/Broadcast/OverrideRecurrence.cs b/backend/src/TeleWave.Domain/Broadcast/OverrideRecurrence.cs new file mode 100644 index 0000000..5310823 --- /dev/null +++ b/backend/src/TeleWave.Domain/Broadcast/OverrideRecurrence.cs @@ -0,0 +1,11 @@ +namespace TeleWave.Domain.Broadcast; + +/// Как повторяется override программирования канала. +public enum OverrideRecurrence +{ + /// Разовое окно [StartsAtUtc, EndsAtUtc). + OneTime, + + /// Еженедельно в заданный день недели на окне часов суток (UTC). + Weekly, +} diff --git a/backend/src/TeleWave.Domain/Broadcast/ProgrammingOverride.cs b/backend/src/TeleWave.Domain/Broadcast/ProgrammingOverride.cs index cc6856d..f23ab33 100644 --- a/backend/src/TeleWave.Domain/Broadcast/ProgrammingOverride.cs +++ b/backend/src/TeleWave.Domain/Broadcast/ProgrammingOverride.cs @@ -1,9 +1,12 @@ namespace TeleWave.Domain.Broadcast; /// -/// Временный override программирования канала на окне [, -/// ). Марафон = с одним шоу и большим -/// временным блоком. Пересекающийся с генерируемым временем override заменяет базовую ротацию. +/// Временный override программирования канала. Разовый () — +/// на окне [, ). Еженедельный +/// () — каждую неделю в на окне минут +/// суток [, ) в UTC. Марафон = обычно +/// с одним шоу; пересекающийся с генерируемым временем override +/// заменяет базовую ротацию. /// public class ProgrammingOverride { @@ -12,14 +15,23 @@ public class ProgrammingOverride public Guid Id { get; private set; } public Guid ChannelId { get; private set; } public OverrideMode Mode { get; private set; } - public DateTimeOffset StartsAtUtc { get; private set; } - public DateTimeOffset EndsAtUtc { get; private set; } + + public OverrideRecurrence Recurrence { get; private set; } + + // ── OneTime ── + public DateTimeOffset? StartsAtUtc { get; private set; } + public DateTimeOffset? EndsAtUtc { get; private set; } + + // ── Weekly ── (день недели 0=Вс..6=Сб как System.DayOfWeek/JS getDay; минуты суток 0..1440, UTC) + public int? DayOfWeek { get; private set; } + public int? StartMinute { get; private set; } + public int? EndMinute { get; private set; } public IReadOnlyList Shows => _shows; private ProgrammingOverride() { } - internal static ProgrammingOverride Create( + internal static ProgrammingOverride CreateOneTime( Guid channelId, OverrideMode mode, DateTimeOffset startsAtUtc, @@ -30,10 +42,29 @@ public class ProgrammingOverride Id = Guid.NewGuid(), ChannelId = channelId, Mode = mode, + Recurrence = OverrideRecurrence.OneTime, StartsAtUtc = startsAtUtc, EndsAtUtc = endsAtUtc, }; + internal static ProgrammingOverride CreateWeekly( + Guid channelId, + OverrideMode mode, + int dayOfWeek, + int startMinute, + int endMinute + ) => + new() + { + Id = Guid.NewGuid(), + ChannelId = channelId, + Mode = mode, + Recurrence = OverrideRecurrence.Weekly, + DayOfWeek = dayOfWeek, + StartMinute = startMinute, + EndMinute = endMinute, + }; + public OverrideShow AddShow(Guid showId, int weight) { var entry = OverrideShow.Create(Id, showId, weight); @@ -41,5 +72,18 @@ public class ProgrammingOverride return entry; } - public bool Covers(DateTimeOffset moment) => moment >= StartsAtUtc && moment < EndsAtUtc; + /// Действует ли override в этот момент (по типу повторения). + public bool Covers(DateTimeOffset moment) + { + if (Recurrence == OverrideRecurrence.Weekly) + { + var utc = moment.UtcDateTime; + var minuteOfDay = utc.Hour * 60 + utc.Minute; + return (int)utc.DayOfWeek == DayOfWeek + && minuteOfDay >= StartMinute + && minuteOfDay < EndMinute; + } + + return moment >= StartsAtUtc && moment < EndsAtUtc; + } } diff --git a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs index 47142a7..4146674 100644 --- a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs +++ b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs @@ -205,7 +205,7 @@ public static class SchedulePlanner IReadOnlyDictionary byShowId ) { - var ovr = input.Overrides.FirstOrDefault(o => moment >= o.StartsAtUtc && moment < o.EndsAtUtc); + var ovr = input.Overrides.FirstOrDefault(o => o.Covers(moment)); if (ovr is not null) { var overridden = new List<(PlannerShow, int)>(); diff --git a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs index 5dd88b8..fbcf739 100644 --- a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs +++ b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs @@ -19,13 +19,36 @@ public sealed record PlannerHourWindow(int StartHour, int EndHour) public bool Contains(int hour) => hour >= StartHour && hour < EndHour; } -/// Override в терминах планировщика: окно + режим + шоу с весами. +/// +/// Override в терминах планировщика: режим + шоу с весами + правило действия (разовое окно либо +/// еженедельно по дню недели на окне минут суток UTC). +/// public sealed record PlannerOverride( - DateTimeOffset StartsAtUtc, - DateTimeOffset EndsAtUtc, OverrideMode Mode, - IReadOnlyList Shows -); + IReadOnlyList Shows, + OverrideRecurrence Recurrence = OverrideRecurrence.OneTime, + DateTimeOffset? StartsAtUtc = null, + DateTimeOffset? EndsAtUtc = null, + int? DayOfWeek = null, + int? StartMinute = null, + int? EndMinute = null +) +{ + /// Действует ли override в этот момент. + public bool Covers(DateTimeOffset moment) + { + if (Recurrence == OverrideRecurrence.Weekly) + { + var utc = moment.UtcDateTime; + var minuteOfDay = utc.Hour * 60 + utc.Minute; + return (int)utc.DayOfWeek == DayOfWeek + && minuteOfDay >= StartMinute + && minuteOfDay < EndMinute; + } + + return moment >= StartsAtUtc && moment < EndsAtUtc; + } +} public sealed record PlannerOverrideShow(Guid ShowId, int Weight); diff --git a/backend/src/TeleWave.Infrastructure/Identity/IdentityService.cs b/backend/src/TeleWave.Infrastructure/Identity/IdentityService.cs index 9dedabe..46b9633 100644 --- a/backend/src/TeleWave.Infrastructure/Identity/IdentityService.cs +++ b/backend/src/TeleWave.Infrastructure/Identity/IdentityService.cs @@ -99,6 +99,29 @@ internal sealed class IdentityService( ); } + public async Task ResetPasswordAsync( + Guid userId, + string newPassword, + CancellationToken cancellationToken + ) + { + var user = await userManager.FindByIdAsync(userId.ToString()); + if (user is null) + return Result.Failure(UserErrors.NotFound); + + // Админ меняет чужой пароль — без текущего: сбрасываем через одноразовый токен сброса. + var token = await userManager.GeneratePasswordResetTokenAsync(user); + var result = await userManager.ResetPasswordAsync(user, token, newPassword); + return result.Succeeded + ? Result.Success() + : Result.Failure( + Error.Validation( + "Auth.ResetPasswordFailed", + string.Join("; ", result.Errors.Select(e => e.Description)) + ) + ); + } + public async Task ChangeUserNameAsync( Guid userId, string newUserName, diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260725164501_WeeklyProgrammingOverrides.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725164501_WeeklyProgrammingOverrides.Designer.cs new file mode 100644 index 0000000..da47063 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725164501_WeeklyProgrammingOverrides.Designer.cs @@ -0,0 +1,1008 @@ +// +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("20260725164501_WeeklyProgrammingOverrides")] + partial class WeeklyProgrammingOverrides + { + /// + 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("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("ToShowId") + .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("AdInsertion") + .HasColumnType("integer"); + + b.Property("AdsPerBreak") + .HasColumnType("integer"); + + b.Property("BumperEpisodeChangeChance") + .HasColumnType("double precision"); + + b.Property("BumperFont") + .HasColumnType("integer"); + + b.Property("BumperMinIntervalMinutes") + .HasColumnType("integer"); + + b.Property("BumperSelection") + .HasColumnType("integer"); + + b.Property("BumperShowChangeChance") + .HasColumnType("double precision"); + + b.Property("BumpersEnabled") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NextAdIndex") + .HasColumnType("integer"); + + b.Property("NextBumperIndex") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("ChannelAd"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("NextEpisodeIndex") + .HasColumnType("integer"); + + b.Property("PreferredWeightMultiplier") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "ShowId"); + + b.ToTable("ChannelShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelShowId") + .HasColumnType("uuid"); + + b.Property("EndHour") + .HasColumnType("integer"); + + b.Property("StartHour") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelShowId"); + + b.ToTable("ChannelShowHour"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ProgrammingOverrideId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProgrammingOverrideId"); + + b.ToTable("OverrideShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("DayOfWeek") + .HasColumnType("integer"); + + b.Property("EndMinute") + .HasColumnType("integer"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("Recurrence") + .HasColumnType("integer"); + + b.Property("StartMinute") + .HasColumnType("integer"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "StartsAtUtc", "EndsAtUtc"); + + b.ToTable("ProgrammingOverride"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperVariantId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .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("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "EndsAtUtc"); + + b.HasIndex("ChannelId", "ShowId"); + + b.HasIndex("ChannelId", "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.Show", 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("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.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("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.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.Broadcast.ChannelAd", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Ads") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Shows") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b => + { + b.HasOne("TeleWave.Domain.Broadcast.ChannelShow", null) + .WithMany("PreferredHours") + .HasForeignKey("ChannelShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null) + .WithMany("Shows") + .HasForeignKey("ProgrammingOverrideId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Overrides") + .HasForeignKey("ChannelId") + .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.Broadcast.BumperTemplate", b => + { + b.Navigation("Variants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Navigation("Ads"); + + b.Navigation("BumperTemplates"); + + b.Navigation("Overrides"); + + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Navigation("PreferredHours"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260725164501_WeeklyProgrammingOverrides.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725164501_WeeklyProgrammingOverrides.cs new file mode 100644 index 0000000..2304584 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725164501_WeeklyProgrammingOverrides.cs @@ -0,0 +1,96 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class WeeklyProgrammingOverrides : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "StartsAtUtc", + table: "ProgrammingOverride", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn( + name: "EndsAtUtc", + table: "ProgrammingOverride", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone"); + + migrationBuilder.AddColumn( + name: "DayOfWeek", + table: "ProgrammingOverride", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "EndMinute", + table: "ProgrammingOverride", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "Recurrence", + table: "ProgrammingOverride", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "StartMinute", + table: "ProgrammingOverride", + type: "integer", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DayOfWeek", + table: "ProgrammingOverride"); + + migrationBuilder.DropColumn( + name: "EndMinute", + table: "ProgrammingOverride"); + + migrationBuilder.DropColumn( + name: "Recurrence", + table: "ProgrammingOverride"); + + migrationBuilder.DropColumn( + name: "StartMinute", + table: "ProgrammingOverride"); + + migrationBuilder.AlterColumn( + name: "StartsAtUtc", + table: "ProgrammingOverride", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "EndsAtUtc", + table: "ProgrammingOverride", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone", + oldNullable: true); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 3afb010..5ceb3e6 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -478,13 +478,25 @@ namespace TeleWave.Infrastructure.Migrations b.Property("ChannelId") .HasColumnType("uuid"); - b.Property("EndsAtUtc") + b.Property("DayOfWeek") + .HasColumnType("integer"); + + b.Property("EndMinute") + .HasColumnType("integer"); + + b.Property("EndsAtUtc") .HasColumnType("timestamp with time zone"); b.Property("Mode") .HasColumnType("integer"); - b.Property("StartsAtUtc") + b.Property("Recurrence") + .HasColumnType("integer"); + + b.Property("StartMinute") + .HasColumnType("integer"); + + b.Property("StartsAtUtc") .HasColumnType("timestamp with time zone"); b.HasKey("Id"); diff --git a/backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs b/backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs index afaede4..4afdb5f 100644 --- a/backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs +++ b/backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs @@ -208,10 +208,10 @@ public class SchedulePlannerTests Overrides: [ new PlannerOverride( - Start, - Start.AddHours(1), OverrideMode.Exclusive, - [new PlannerOverrideShow(b.ShowId, 1)] + [new PlannerOverrideShow(b.ShowId, 1)], + StartsAtUtc: Start, + EndsAtUtc: Start.AddHours(1) ), ], StartTime: Start, @@ -479,6 +479,63 @@ public class SchedulePlannerTests Assert.All(bumpers, e => Assert.Equal(t1, e.BumperTemplateId)); } + [Fact] + public void WeeklyOverride_AppliesOnMatchingDayAndTime() + { + // Start = 2026-01-01 (четверг, DayOfWeek=4), полночь. Еженедельный override на четверг 00:00–01:00. + var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); + var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); + var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20)); + + var input = BaseInput([a, b], durations, Start.AddSeconds(1)) with + { + Overrides = + [ + new PlannerOverride( + OverrideMode.Exclusive, + [new PlannerOverrideShow(b.ShowId, 1)], + OverrideRecurrence.Weekly, + DayOfWeek: (int)Start.UtcDateTime.DayOfWeek, + StartMinute: 0, + EndMinute: 60 + ), + ], + }; + + var result = SchedulePlanner.Plan(input, new FixedRandom(0)); + + Assert.Equal(b.ShowId, result.Entries[0].ShowId); + } + + [Fact] + public void WeeklyOverride_IgnoredOnOtherDay() + { + // Override на другой день недели → базовая ротация (FixedRandom(0) берёт первое шоу — a). + var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); + var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); + var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20)); + var otherDay = ((int)Start.UtcDateTime.DayOfWeek + 1) % 7; + + var input = BaseInput([a, b], durations, Start.AddSeconds(1)) with + { + Overrides = + [ + new PlannerOverride( + OverrideMode.Exclusive, + [new PlannerOverrideShow(b.ShowId, 1)], + OverrideRecurrence.Weekly, + DayOfWeek: otherDay, + StartMinute: 0, + EndMinute: 1440 + ), + ], + }; + + var result = SchedulePlanner.Plan(input, new FixedRandom(0)); + + Assert.Equal(a.ShowId, result.Entries[0].ShowId); + } + [Fact] public void PreferredHours_BoostsWeight_InsideWindow() { diff --git a/frontend/src/features/admin/channels/ChannelDetail.tsx b/frontend/src/features/admin/channels/ChannelDetail.tsx index ce3f31a..621f370 100644 --- a/frontend/src/features/admin/channels/ChannelDetail.tsx +++ b/frontend/src/features/admin/channels/ChannelDetail.tsx @@ -20,6 +20,7 @@ import type { ChannelShowDto, HourWindow, OverrideMode, + OverrideRecurrence, ScheduleEntryDto, } from '@/shared/api/types' import { Badge } from '@/shared/ui/badge' @@ -57,7 +58,8 @@ import { uploadBumperTemplateAudio, } from './api' -function formatTime(iso: string) { +function formatTime(iso: string | null) { + if (!iso) return '—' return new Date(iso).toLocaleString([], { day: '2-digit', month: '2-digit', @@ -66,6 +68,14 @@ function formatTime(iso: string) { }) } +/** Минуты суток → «HH:MM». */ +function formatMinute(minute: number | null) { + if (minute == null) return '—' + const h = Math.floor(minute / 60) + const m = minute % 60 + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}` +} + export function ChannelDetail({ channelId }: { channelId: string }) { const { t } = useTranslation() const queryClient = useQueryClient() @@ -212,8 +222,10 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
  • {t(`admin.channels.modes.${o.mode}`)}{' '} - {formatTime(o.startsAtUtc)} – {formatTime(o.endsAtUtc)} ·{' '} - {o.shows.map((s) => s.showName).join(', ')} + {o.recurrence === 'Weekly' + ? `${t(`admin.channels.weekdays.${o.dayOfWeek}`)} ${formatMinute(o.startMinute)}–${formatMinute(o.endMinute)}` + : `${formatTime(o.startsAtUtc)} – ${formatTime(o.endsAtUtc)}`}{' '} + · {o.shows.map((s) => s.showName).join(', ')} deleteOverride(channelId, o.id).then(invalidate).catch(onError)} @@ -1472,32 +1484,72 @@ function OverrideForm({ }) { const { t } = useTranslation() const [mode, setMode] = useState('Exclusive') + const [recurrence, setRecurrence] = useState('OneTime') const [showId, setShowId] = useState('') const [weight, setWeight] = useState(1) const [start, setStart] = useState('') const [end, setEnd] = useState('') + // Weekly: день недели (0=Вс..6=Сб) + окна времени суток «HH:MM». + const [dayOfWeek, setDayOfWeek] = useState(6) + const [startTime, setStartTime] = useState('') + const [endTime, setEndTime] = useState('') + + const toMinutes = (hhmm: string) => { + const [h, m] = hhmm.split(':').map(Number) + return h * 60 + m + } + const weekly = recurrence === 'Weekly' const create = useMutation({ mutationFn: () => - createOverride(channelId, { - mode, - startsAtUtc: new Date(start).toISOString(), - endsAtUtc: new Date(end).toISOString(), - shows: [{ showId, weight }], - }), + createOverride( + channelId, + weekly + ? { + mode, + recurrence, + dayOfWeek, + startMinute: toMinutes(startTime), + endMinute: toMinutes(endTime), + shows: [{ showId, weight }], + } + : { + mode, + recurrence, + startsAtUtc: new Date(start).toISOString(), + endsAtUtc: new Date(end).toISOString(), + shows: [{ showId, weight }], + }, + ), onSuccess: () => { setShowId('') setStart('') setEnd('') + setStartTime('') + setEndTime('') onCreated() }, onError, }) - const valid = showId && start && end && new Date(end) > new Date(start) + const valid = weekly + ? showId && startTime && endTime && toMinutes(endTime) > toMinutes(startTime) + : showId && start && end && new Date(end) > new Date(start) return (
    +
    + + +
    setStart(e.target.value)} className="w-52" /> -
    -
    - - setEnd(e.target.value)} className="w-52" /> -
    + {weekly ? ( + <> +
    + + +
    +
    + + setStartTime(e.target.value)} className="w-32" /> +
    +
    + + setEndTime(e.target.value)} className="w-32" /> +
    + + ) : ( + <> +
    + + setStart(e.target.value)} className="w-60" /> +
    +
    + + setEnd(e.target.value)} className="w-60" /> +
    + + )} diff --git a/frontend/src/features/admin/channels/api.ts b/frontend/src/features/admin/channels/api.ts index c47d2c7..2962970 100644 --- a/frontend/src/features/admin/channels/api.ts +++ b/frontend/src/features/admin/channels/api.ts @@ -10,6 +10,7 @@ import type { CreatedIdResponse, HourWindow, OverrideMode, + OverrideRecurrence, ScheduleEntryDto, } from '@/shared/api/types' @@ -217,8 +218,12 @@ export function bumperPreviewPlaylistUrl(id: string, templateId: string, variant export type OverrideBody = { mode: OverrideMode - startsAtUtc: string - endsAtUtc: string + recurrence: OverrideRecurrence + startsAtUtc?: string | null + endsAtUtc?: string | null + dayOfWeek?: number | null + startMinute?: number | null + endMinute?: number | null shows: { showId: string; weight: number }[] } diff --git a/frontend/src/features/admin/media/MediaPanel.tsx b/frontend/src/features/admin/media/MediaPanel.tsx index 3e7c90d..7eb8119 100644 --- a/frontend/src/features/admin/media/MediaPanel.tsx +++ b/frontend/src/features/admin/media/MediaPanel.tsx @@ -6,6 +6,7 @@ import { HttpError } from '@/shared/api/client' import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types' import { Badge, type BadgeProps } from '@/shared/ui/badge' import { Button } from '@/shared/ui/button' +import { Pager } from '@/shared/ui/pager' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { toast } from '@/shared/ui/toast-store' import { deleteMedia, listMedia } from './api' @@ -46,12 +47,13 @@ export function MediaPanel() { const fileInput = useRef(null) const fileInputShow = useRef(null) const [filter, setFilter] = useState('active') + const [page, setPage] = useState(1) const [filesForShow, setFilesForShow] = useState(null) const enqueue = useUploadStore((s) => s.enqueue) const { data, isLoading } = useQuery({ - queryKey: ['admin', 'media', filter], - queryFn: () => listMedia({ page: 1, pageSize: PAGE_SIZE, statuses: filterStatuses[filter] }), + queryKey: ['admin', 'media', filter, page], + queryFn: () => listMedia({ page, pageSize: PAGE_SIZE, statuses: filterStatuses[filter] }), // Пока что-то обрабатывается — обновляем чаще, чтобы статус ехал в UI. refetchInterval: (query) => query.state.data?.items.some((a) => a.status === 'Processing' || a.status === 'Pending') @@ -70,7 +72,13 @@ export function MediaPanel() {

    {t('admin.media.title')}

    - { + setPage(1) + setFilter(v as MediaFilter) + }} + > @@ -158,6 +166,12 @@ export function MediaPanel() {
    + +
    ) } diff --git a/frontend/src/features/admin/shows/ShowsPanel.tsx b/frontend/src/features/admin/shows/ShowsPanel.tsx index 1db58cf..8dbd8f4 100644 --- a/frontend/src/features/admin/shows/ShowsPanel.tsx +++ b/frontend/src/features/admin/shows/ShowsPanel.tsx @@ -1,24 +1,42 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { HttpError } from '@/shared/api/client' import type { ShowKind } from '@/shared/api/types' import { Badge } from '@/shared/ui/badge' import { Button } from '@/shared/ui/button' import { Input } from '@/shared/ui/input' +import { Pager } from '@/shared/ui/pager' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { toast } from '@/shared/ui/toast-store' import { createShow, deleteShow, listShows } from './api' +const PAGE_SIZE = 20 + export function ShowsPanel() { const { t } = useTranslation() const queryClient = useQueryClient() const [name, setName] = useState('') const [originalName, setOriginalName] = useState('') const [kind, setKind] = useState('Series') + const [query, setQuery] = useState('') + const [page, setPage] = useState(1) const { data, isLoading } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows }) + + // Список шоу обычно умещается в одну загрузку — фильтруем и листаем на клиенте (пикеры берут всё). + const filtered = useMemo(() => { + const q = query.trim().toLowerCase() + const all = data ?? [] + if (!q) return all + return all.filter( + (s) => + s.name.toLowerCase().includes(q) || (s.originalName ?? '').toLowerCase().includes(q), + ) + }, [data, query]) + const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)) + const pageItems = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE) const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'shows'] }) const onError = (error: unknown) => toast.error(error instanceof HttpError ? error.detail : t('common.error')) @@ -70,6 +88,16 @@ export function ShowsPanel() { + { + setPage(1) + setQuery(e.target.value) + }} + /> +
    @@ -89,7 +117,7 @@ export function ShowsPanel() { )} - {data?.map((show) => ( + {pageItems.map((show) => (
    + + ) } diff --git a/frontend/src/features/admin/users/UsersPanel.tsx b/frontend/src/features/admin/users/UsersPanel.tsx index 02d91cb..9f3cb64 100644 --- a/frontend/src/features/admin/users/UsersPanel.tsx +++ b/frontend/src/features/admin/users/UsersPanel.tsx @@ -5,13 +5,21 @@ import { HttpError } from '@/shared/api/client' import { Badge } from '@/shared/ui/badge' import { Button } from '@/shared/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/shared/ui/dialog' import { Input } from '@/shared/ui/input' import { Label } from '@/shared/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { toast } from '@/shared/ui/toast-store' +import type { UserSummaryDto } from '@/shared/api/types' import { changeUserRole } from '@/features/admin/roles/api' import { listRoles } from '@/features/admin/roles/api' -import { blockUser, createUser, deleteUser, listUsers, unblockUser } from './api' +import { blockUser, createUser, deleteUser, listUsers, resetUserPassword, unblockUser } from './api' const PAGE_SIZE = 20 @@ -24,6 +32,7 @@ export function UsersPanel() { const [newUserName, setNewUserName] = useState('') const [newPassword, setNewPassword] = useState('') const [newRoleId, setNewRoleId] = useState('') + const [resetTarget, setResetTarget] = useState(null) const { data: roles } = useQuery({ queryKey: ['admin', 'roles'], queryFn: listRoles }) const { data, isLoading } = useQuery({ @@ -214,6 +223,9 @@ export function UsersPanel() { {t('admin.users.block')} )} + @@ -238,6 +250,68 @@ export function UsersPanel() { )} + + {resetTarget && ( + setResetTarget(null)} + onError={onError} + /> + )} ) } + +function ResetPasswordDialog({ + user, + onClose, + onError, +}: { + user: UserSummaryDto + onClose: () => void + onError: (e: unknown) => void +}) { + const { t } = useTranslation() + const [password, setPassword] = useState('') + + const reset = useMutation({ + mutationFn: () => resetUserPassword(user.id, password), + onSuccess: () => { + toast.success(t('admin.users.passwordReset')) + onClose() + }, + onError, + }) + + return ( + !open && onClose()}> + + + {t('admin.users.resetPasswordFor', { name: user.userName })} + +
    + + setPassword(e.target.value)} + /> +

    {t('admin.users.passwordHint')}

    +
    + + + + +
    +
    + ) +} diff --git a/frontend/src/features/admin/users/api.ts b/frontend/src/features/admin/users/api.ts index 101c02f..9a4d1fb 100644 --- a/frontend/src/features/admin/users/api.ts +++ b/frontend/src/features/admin/users/api.ts @@ -36,3 +36,10 @@ export function unblockUser(id: string) { export function deleteUser(id: string) { return apiRequest(`/admin/users/${id}`, { method: 'DELETE' }) } + +export function resetUserPassword(id: string, newPassword: string) { + return apiRequest(`/admin/users/${id}/password`, { + method: 'POST', + body: { newPassword }, + }) +} diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index 63b809e..29b7307 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -206,11 +206,18 @@ export type ChannelAdDto = { export type OverrideShowDto = { showId: string; showName: string; weight: number } +export type OverrideRecurrence = 'OneTime' | 'Weekly' + export type ProgrammingOverrideDto = { id: string mode: OverrideMode - startsAtUtc: string - endsAtUtc: string + recurrence: OverrideRecurrence + startsAtUtc: string | null + endsAtUtc: string | null + /** Weekly: день недели 0=Вс..6=Сб; окно минут суток (UTC). */ + dayOfWeek: number | null + startMinute: number | null + endMinute: number | null shows: OverrideShowDto[] } diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index a3af50e..412339d 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -27,6 +27,8 @@ const resources = { confirm: 'Подтвердить', search: 'Поиск', actions: 'Действия', + prevPage: 'Предыдущая страница', + nextPage: 'Следующая страница', yes: 'Да', no: 'Нет', }, @@ -105,6 +107,10 @@ const resources = { active: 'Активен', block: 'Заблокировать', unblock: 'Разблокировать', + resetPassword: 'Пароль', + resetPasswordFor: 'Сменить пароль: {{name}}', + newPassword: 'Новый пароль', + passwordReset: 'Пароль изменён', filterAll: 'Все роли', createTitle: 'Создать пользователя', password: 'Пароль', @@ -288,6 +294,19 @@ const resources = { noAds: 'Пул рекламы пуст', overrides: 'Марафоны / override', modes: { Exclusive: 'Эксклюзив', Boost: 'Буст' }, + overrideRecurrence: 'Повтор', + recurrenceOneTime: 'Разово', + recurrenceWeekly: 'Еженедельно', + weekday: 'День недели', + weekdays: { + 0: 'Вс', + 1: 'Пн', + 2: 'Вт', + 3: 'Ср', + 4: 'Чт', + 5: 'Пт', + 6: 'Сб', + }, from: 'С', to: 'По', noOverrides: 'Override не заданы', @@ -368,6 +387,8 @@ const resources = { confirm: 'Confirm', search: 'Search', actions: 'Actions', + prevPage: 'Previous page', + nextPage: 'Next page', yes: 'Yes', no: 'No', }, @@ -446,6 +467,10 @@ const resources = { active: 'Active', block: 'Block', unblock: 'Unblock', + resetPassword: 'Password', + resetPasswordFor: 'Reset password: {{name}}', + newPassword: 'New password', + passwordReset: 'Password changed', filterAll: 'All roles', createTitle: 'Create user', password: 'Password', @@ -629,6 +654,19 @@ const resources = { noAds: 'Ad pool is empty', overrides: 'Marathons / overrides', modes: { Exclusive: 'Exclusive', Boost: 'Boost' }, + overrideRecurrence: 'Repeat', + recurrenceOneTime: 'One-time', + recurrenceWeekly: 'Weekly', + weekday: 'Weekday', + weekdays: { + 0: 'Sun', + 1: 'Mon', + 2: 'Tue', + 3: 'Wed', + 4: 'Thu', + 5: 'Fri', + 6: 'Sat', + }, from: 'From', to: 'To', noOverrides: 'No overrides set', diff --git a/frontend/src/shared/ui/pager.tsx b/frontend/src/shared/ui/pager.tsx new file mode 100644 index 0000000..871ba7d --- /dev/null +++ b/frontend/src/shared/ui/pager.tsx @@ -0,0 +1,42 @@ +import { useTranslation } from 'react-i18next' +import { Button } from './button' + +/** Простой пейджер «‹ N / M ›». Ничего не рисует, если страница одна. */ +export function Pager({ + page, + totalPages, + onChange, +}: { + page: number + totalPages: number + onChange: (page: number) => void +}) { + const { t } = useTranslation() + if (totalPages <= 1) return null + + return ( +
    + + + {page} / {totalPages} + + +
    + ) +}