From 4cca23cb35c65440ec5a2e10281f82d3646afba1 Mon Sep 17 00:00:00 2001 From: Ricardo Carneiro Date: Tue, 4 Mar 2025 19:06:01 -0300 Subject: [PATCH] feat: channels com mongodb --- BaseDomain/BaseDomain.csproj | 2 +- BaseDomain/Results/Error.cs | 16 +- BaseDomain/Results/Result.cs | 2 +- MVCPostall.sln | 6 + .../Application/ChannelApplicationService.cs | 26 ++ Postall.Domain/Dtos/ChannelResponse.cs | 44 ++++ .../{Responses => }/FacebookTokenResponse.cs | 0 Postall.Domain/Dtos/VideoItemResponse.cs | 23 ++ Postall.Domain/Entities/ChannelData.cs | 67 +++++ Postall.Domain/IChannelRepository.cs | 92 +++++++ Postall.Domain/Postall.Domain.csproj | 7 +- .../Services/ChannelVideoService.cs | 171 ++++++++++++ .../Services/Contracts/IChannelService.cs | 23 ++ .../Contracts/IChannelYoutubeService.cs | 17 ++ .../{ => Contracts}/IFacebookServices.cs | 2 +- .../Services/Contracts/IVideoService.cs | 20 ++ .../Extensions/ServiceRepositoryExtensions.cs | 23 ++ .../Postall.Infra.MongoDB.csproj | 19 ++ .../Repositories/ChannelRepository.cs | 244 ++++++++++++++++++ .../Settings/MongoDbSetting.cs | 18 ++ .../Extensions/ServiceCollectionExtensions.cs | 23 ++ Postall.Infra/Postall.Infra.csproj | 1 + .../Services/ChannelYoutubeService.cs | 96 +++++++ .../Services/FacebookTokenService.cs | 2 +- Postall.Infra/Services/YouTubeServiceVideo.cs | 227 ++++++++++++++++ Postall/Controllers/ChannelsController.cs | 98 +++++++ Postall/Controllers/LoginController.cs | 101 ++++---- Postall/Controllers/OtherLoginsController.cs | 2 +- Postall/Controllers/VideosController.cs | 75 ++++++ Postall/Models/VideoViewModel.cs | 21 ++ Postall/Postall.csproj | 1 + Postall/Program.cs | 12 +- Postall/Properties/launchSettings.json | 2 +- Postall/Views/Channels/Add.cshtml | 167 ++++++++++++ Postall/Views/Channels/Index.cshtml | 93 +++++++ Postall/Views/Shared/_Layout.cshtml | 66 ++++- Postall/Views/Videos/Index.cshtml | 169 ++++++++++++ .../Views/Videos/_ChannelVideosPartial.cshtml | 51 ++++ Postall/appsettings.Development.json | 5 + Postall/appsettings.json | 8 + Postall/wwwroot/css/videos.css | 82 ++++++ 41 files changed, 2057 insertions(+), 67 deletions(-) create mode 100644 Postall.Domain/Application/ChannelApplicationService.cs create mode 100644 Postall.Domain/Dtos/ChannelResponse.cs rename Postall.Domain/Dtos/{Responses => }/FacebookTokenResponse.cs (100%) create mode 100644 Postall.Domain/Dtos/VideoItemResponse.cs create mode 100644 Postall.Domain/Entities/ChannelData.cs create mode 100644 Postall.Domain/IChannelRepository.cs create mode 100644 Postall.Domain/Services/ChannelVideoService.cs create mode 100644 Postall.Domain/Services/Contracts/IChannelService.cs create mode 100644 Postall.Domain/Services/Contracts/IChannelYoutubeService.cs rename Postall.Domain/Services/{ => Contracts}/IFacebookServices.cs (87%) create mode 100644 Postall.Domain/Services/Contracts/IVideoService.cs create mode 100644 Postall.Infra.MongoDB/Extensions/ServiceRepositoryExtensions.cs create mode 100644 Postall.Infra.MongoDB/Postall.Infra.MongoDB.csproj create mode 100644 Postall.Infra.MongoDB/Repositories/ChannelRepository.cs create mode 100644 Postall.Infra.MongoDB/Settings/MongoDbSetting.cs create mode 100644 Postall.Infra/Extensions/ServiceCollectionExtensions.cs create mode 100644 Postall.Infra/Services/ChannelYoutubeService.cs create mode 100644 Postall.Infra/Services/YouTubeServiceVideo.cs create mode 100644 Postall/Controllers/ChannelsController.cs create mode 100644 Postall/Controllers/VideosController.cs create mode 100644 Postall/Models/VideoViewModel.cs create mode 100644 Postall/Views/Channels/Add.cshtml create mode 100644 Postall/Views/Channels/Index.cshtml create mode 100644 Postall/Views/Videos/Index.cshtml create mode 100644 Postall/Views/Videos/_ChannelVideosPartial.cshtml create mode 100644 Postall/wwwroot/css/videos.css diff --git a/BaseDomain/BaseDomain.csproj b/BaseDomain/BaseDomain.csproj index d53549b..303c8ba 100644 --- a/BaseDomain/BaseDomain.csproj +++ b/BaseDomain/BaseDomain.csproj @@ -1,4 +1,4 @@ - + net7.0 diff --git a/BaseDomain/Results/Error.cs b/BaseDomain/Results/Error.cs index 6466204..bc0ed80 100644 --- a/BaseDomain/Results/Error.cs +++ b/BaseDomain/Results/Error.cs @@ -6,16 +6,16 @@ using System.Threading.Tasks; namespace BaseDomain.Results { + public enum ErrorTypeEnum + { + None = 0, //Erro vazio (para result) + Failure = 1, //Quando for uma falha que eu queira retornar no lugar de uma Exception + Validation = 2, //Quando for um problema de validação + Others = 3 //Inesperado ou sem categoria + } + public class Error { - public enum ErrorTypeEnum - { - None = 0, //Erro vazio (para result) - Failure = 1, //Quando for uma falha que eu queira retornar no lugar de uma Exception - Validation = 2, //Quando for um problema de validação - Others = 3 //Inesperado ou sem categoria - } - public Error(ErrorTypeEnum error, string message, string description="") { ErrorType = error; diff --git a/BaseDomain/Results/Result.cs b/BaseDomain/Results/Result.cs index f6b11c5..22fe714 100644 --- a/BaseDomain/Results/Result.cs +++ b/BaseDomain/Results/Result.cs @@ -10,7 +10,7 @@ namespace BaseDomain.Results { protected Result(bool isSuccess, Error error) { - if (isSuccess && error != Error.None) + if (isSuccess && error.ErrorType != ErrorTypeEnum.None) { throw new InvalidOperationException(); } diff --git a/MVCPostall.sln b/MVCPostall.sln index 891e3e2..a68800a 100644 --- a/MVCPostall.sln +++ b/MVCPostall.sln @@ -11,6 +11,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Postall.Domain", "Postall.D EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BaseDomain", "BaseDomain\BaseDomain.csproj", "{72324CE0-8009-4876-9CF9-9F2BA8288B87}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Postall.Infra.MongoDB", "Postall.Infra.MongoDB\Postall.Infra.MongoDB.csproj", "{2EFC97F7-84D3-4582-9202-B03B401D9E9F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -33,6 +35,10 @@ Global {72324CE0-8009-4876-9CF9-9F2BA8288B87}.Debug|Any CPU.Build.0 = Debug|Any CPU {72324CE0-8009-4876-9CF9-9F2BA8288B87}.Release|Any CPU.ActiveCfg = Release|Any CPU {72324CE0-8009-4876-9CF9-9F2BA8288B87}.Release|Any CPU.Build.0 = Release|Any CPU + {2EFC97F7-84D3-4582-9202-B03B401D9E9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2EFC97F7-84D3-4582-9202-B03B401D9E9F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2EFC97F7-84D3-4582-9202-B03B401D9E9F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2EFC97F7-84D3-4582-9202-B03B401D9E9F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Postall.Domain/Application/ChannelApplicationService.cs b/Postall.Domain/Application/ChannelApplicationService.cs new file mode 100644 index 0000000..b297ba0 --- /dev/null +++ b/Postall.Domain/Application/ChannelApplicationService.cs @@ -0,0 +1,26 @@ +using BaseDomain.Results; +using Postall.Domain.Dtos; +using Postall.Domain.Services.Contracts; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Postall.Domain.Application +{ + public class ChannelApplicationService + { + private readonly IChannelService _channelService; + + public ChannelApplicationService(IChannelService channelService) + { + _channelService = channelService; + } + + public async Task>> GetUserChannelsAsync() + { + return await _channelService.GetUserChannelsAsync(); + } + } +} diff --git a/Postall.Domain/Dtos/ChannelResponse.cs b/Postall.Domain/Dtos/ChannelResponse.cs new file mode 100644 index 0000000..6f98e6f --- /dev/null +++ b/Postall.Domain/Dtos/ChannelResponse.cs @@ -0,0 +1,44 @@ +using Postall.Domain.Entities; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Postall.Domain.Dtos +{ + public class ChannelResponse + { + public string Id { get; set; } + public string UserId { get; set; } + public string YoutubeId { get; set; } + public string Title { get; set; } + public string Description { get; set; } + public string ThumbnailUrl { get; set; } + public DateTime PublishedAt { get; set; } + public ulong SubscriberCount { get; set; } + public ulong VideoCount { get; set; } + + // Propriedade para seleção de canais na interface + public bool IsSelected { get; set; } + + // URL do canal no YouTube + public string ChannelUrl => $"https://www.youtube.com/channel/{Id}"; + + public ChannelData ToChannelData() + { + return new ChannelData + { + Id = Id, + UserId = UserId, + YoutubeId = YoutubeId, + Title = Title, + Description = Description, + ThumbnailUrl = ThumbnailUrl, + PublishedAt = PublishedAt, + SubscriberCount = SubscriberCount, + VideoCount = VideoCount + }; + } + } +} diff --git a/Postall.Domain/Dtos/Responses/FacebookTokenResponse.cs b/Postall.Domain/Dtos/FacebookTokenResponse.cs similarity index 100% rename from Postall.Domain/Dtos/Responses/FacebookTokenResponse.cs rename to Postall.Domain/Dtos/FacebookTokenResponse.cs diff --git a/Postall.Domain/Dtos/VideoItemResponse.cs b/Postall.Domain/Dtos/VideoItemResponse.cs new file mode 100644 index 0000000..e01b7df --- /dev/null +++ b/Postall.Domain/Dtos/VideoItemResponse.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Postall.Infra.Dtos +{ + public class VideoItemResponse + { + public string Id { get; set; } + public string Title { get; set; } + public string Description { get; set; } + public string ThumbnailUrl { get; set; } + public DateTime PublishedAt { get; set; } + public bool IsSelected { get; set; } + + // Propriedades adicionais que podem ser úteis no futuro + public string ChannelTitle { get; set; } + public string ChannelId { get; set; } + public string VideoUrl => $"https://www.youtube.com/watch?v={Id}"; + } +} diff --git a/Postall.Domain/Entities/ChannelData.cs b/Postall.Domain/Entities/ChannelData.cs new file mode 100644 index 0000000..3bd1f73 --- /dev/null +++ b/Postall.Domain/Entities/ChannelData.cs @@ -0,0 +1,67 @@ +using MongoDB.Bson.Serialization.Attributes; +using MongoDB.Bson; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Postall.Domain.Dtos; + +namespace Postall.Domain.Entities +{ + /// + /// Modelo de dados para armazenamento do canal no MongoDB + /// + public class ChannelData + { + [BsonId] + [BsonRepresentation(BsonType.String)] + public string Id { get; set; } + + [BsonElement("userId")] + public string UserId { get; set; } + + [BsonElement("channelId")] + public string ChannelId { get; set; } + + [BsonElement("youtubeId")] + public string YoutubeId { get; set; } + + [BsonElement("title")] + public string Title { get; set; } + + [BsonElement("description")] + public string Description { get; set; } + + [BsonElement("thumbnailUrl")] + public string ThumbnailUrl { get; set; } + + [BsonElement("publishedAt")] + public DateTime PublishedAt { get; set; } + + [BsonElement("subscriberCount")] + public ulong SubscriberCount { get; set; } + + [BsonElement("videoCount")] + public ulong VideoCount { get; set; } + + [BsonElement("isSelected")] + public bool IsSelected { get; set; } + + [BsonIgnore] + public string ChannelUrl => $"https://www.youtube.com/channel/{YoutubeId}"; + + public ChannelResponse ToChannelResponse() => new ChannelResponse + { + Id = Id, + UserId = UserId, + YoutubeId = YoutubeId, + Title = Title, + Description = Description, + ThumbnailUrl = ThumbnailUrl, + PublishedAt = PublishedAt, + SubscriberCount = SubscriberCount, + VideoCount = VideoCount + }; + } +} diff --git a/Postall.Domain/IChannelRepository.cs b/Postall.Domain/IChannelRepository.cs new file mode 100644 index 0000000..07b9cd4 --- /dev/null +++ b/Postall.Domain/IChannelRepository.cs @@ -0,0 +1,92 @@ +using Postall.Domain.Dtos; +using Postall.Domain.Entities; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Postall.Domain +{ + /// + /// Interface para repositório de canais do YouTube no MongoDB + /// + public interface IChannelRepository + { + /// + /// Obtém todos os canais + /// + Task> GetAllAsync(); + + /// + /// Obtém um canal pelo ID do MongoDB + /// + Task GetByIdAsync(string id); + + + /// + /// Obtém os canais pelo ID do usuario do MongoDB + /// + Task> GetByUserIdAsync(string userId); + + /// + /// Obtém os canais pelo ID do usuario e o channelId + /// + Task GetByUserIdAndChannelIdAsync(string userId, string channelId); + + /// + /// Obtém um canal pelo ID do YouTube + /// + Task GetByYoutubeIdAsync(string youtubeId); + + /// + /// Adiciona um novo canal + /// + Task AddAsync(ChannelData ChannelData); + + /// + /// Adiciona vários canais de uma vez + /// + Task> AddManyAsync(IEnumerable channels); + + /// + /// Atualiza um canal existente + /// + Task UpdateAsync(ChannelData ChannelData); + + /// + /// Remove um canal pelo ID do MongoDB + /// + Task DeleteAsync(string id); + + /// + /// Remove um canal pelo ID do YouTube + /// + Task DeleteByYoutubeIdAsync(string youtubeId); + + /// + /// Busca canais com base em um termo de pesquisa no título ou descrição + /// + Task> SearchAsync(string searchTerm); + + /// + /// Obtém canais selecionados (IsSelected = true) + /// + Task> GetSelectedAsync(); + + /// + /// Marca ou desmarca um canal como selecionado + /// + Task SetSelectedStatusAsync(string id, bool isSelected); + + /// + /// Converte ChannelResponse para ChannelData + /// + ChannelData ConvertFromResponse(ChannelResponse channelResponse); + + /// + /// Converte ChannelData para ChannelResponse + /// + ChannelResponse ConvertToResponse(ChannelData ChannelData); + } +} diff --git a/Postall.Domain/Postall.Domain.csproj b/Postall.Domain/Postall.Domain.csproj index 7c79f16..3f38de6 100644 --- a/Postall.Domain/Postall.Domain.csproj +++ b/Postall.Domain/Postall.Domain.csproj @@ -7,6 +7,7 @@ + @@ -17,7 +18,11 @@ - + + + + + diff --git a/Postall.Domain/Services/ChannelVideoService.cs b/Postall.Domain/Services/ChannelVideoService.cs new file mode 100644 index 0000000..6dc77d8 --- /dev/null +++ b/Postall.Domain/Services/ChannelVideoService.cs @@ -0,0 +1,171 @@ +using BaseDomain.Results; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Postall.Domain; +using Postall.Domain.Dtos; +using Postall.Domain.Entities; +using Postall.Domain.Services.Contracts; +using System.Security.Claims; + +namespace Postall.Infra.Services +{ + public class ChannelVideoService: IChannelService + { + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IChannelRepository _channelRepository; + private readonly IChannelYoutubeService _channelYoutubeService; + private readonly IConfiguration _configuration; + private readonly string _apiKey; + + public ChannelVideoService( + IHttpContextAccessor httpContextAccessor, + IChannelRepository channelRepository, + IChannelYoutubeService channelYoutubeService + ) + { + _httpContextAccessor = httpContextAccessor; + this._channelRepository = channelRepository; + this._channelYoutubeService = channelYoutubeService; + } + + /// + /// Obtém o ID do usuário atual a partir das claims + /// + private string GetCurrentUserId() + { + var user = _httpContextAccessor.HttpContext.User; + var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + + if (string.IsNullOrEmpty(userId)) + { + var email = user.FindFirst(ClaimTypes.Email)?.Value; + if (!string.IsNullOrEmpty(email)) + { + userId = Convert.ToBase64String( + System.Text.Encoding.UTF8.GetBytes(email) + ).Replace("/", "_").Replace("+", "-").Replace("=", ""); + } + } + + return userId; + } + + /// + /// Obtém a lista de canais que o usuário tem permissão para listar + /// + public async Task>> GetUserChannelsAsync() + { + var userId = GetCurrentUserId(); + + if (string.IsNullOrEmpty(userId)) + return new List(); + + var channelList = await _channelRepository.GetByUserIdAsync(userId); + + if (!channelList.Any()) + { + var defaultChannels = new List + { + new ChannelData { Id = "UC_x5XG1OV2P6uZZ5FSM9Ttw", Title = "Google Developers" }, + new ChannelData { Id = "UCkw4JCwteGrDHIsyIIKo4tQ", Title = "Google" } + }; + + foreach (var channel in defaultChannels) + { + var channelDetails = await this.GetChannelDetailsAsync(channel.Id); + if (channelDetails.IsSuccess) + { + channelList.Add(channelDetails.Value.ToChannelData()); + } + else + { + Console.WriteLine($"Erro ao buscar detalhes do canal {channel.Id}: {channelDetails.Error.Description}"); + channelList.Add(new ChannelData + { + Id = channel.Id, + Title = channel.Title, + ThumbnailUrl = "/images/default-channel-thumb.jpg" // Imagem padrão + }); + } + } + } + + return channelList.Select(c => c.ToChannelResponse()).ToList(); + } + + /// + /// Adiciona um canal à lista do usuário + /// + public async Task> AddChannelAsync(string channelId) + { + if (string.IsNullOrEmpty(channelId)) + return false; + + var userId = GetCurrentUserId(); + + if (string.IsNullOrEmpty(userId)) + return false; + + var channelItem = await _channelRepository.GetByUserIdAndChannelIdAsync(userId, channelId); + + try + { + if (channelItem != null) + return false; + + var channelDetails = await GetChannelDetailsAsync(channelId); + + await _channelRepository.AddAsync(channelDetails.Value.ToChannelData()); + + return true; + } + catch (Exception ex) + { + Console.WriteLine($"Erro ao adicionar canal {channelId}: {ex.Message}"); + return false; + } + } + + /// + /// Remove um canal da lista do usuário + /// + public async Task> RemoveChannel(string channelId) + { + if (string.IsNullOrEmpty(channelId)) + return false; + + var userId = GetCurrentUserId(); + + var channelItem = await _channelRepository.GetByUserIdAndChannelIdAsync(userId, channelId); + + if (channelItem == null) + return false; + + await _channelRepository.DeleteAsync(channelId); + + return true; + } + + /// + /// Busca os detalhes de um canal na API do YouTube + /// + public async Task> GetChannelDetailsAsync(string channelId) + { + var channel = await _channelYoutubeService.GetChannelDetailsAsync(channelId); + return channel; + } + + /// + /// Busca um canal pelo nome ou ID + /// + public async Task>> SearchChannelsAsync(string query, int maxResults = 5) + { + if (string.IsNullOrEmpty(query) || query.Length < 3) + return new List(); + + var results = await _channelYoutubeService.SearchChannelsAsync(query, maxResults); + + return results; + } + } +} diff --git a/Postall.Domain/Services/Contracts/IChannelService.cs b/Postall.Domain/Services/Contracts/IChannelService.cs new file mode 100644 index 0000000..747d8ad --- /dev/null +++ b/Postall.Domain/Services/Contracts/IChannelService.cs @@ -0,0 +1,23 @@ +using BaseDomain.Results; +using Postall.Domain.Dtos; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Postall.Domain.Services.Contracts +{ + public interface IChannelService + { + Task>> GetUserChannelsAsync(); + + Task> AddChannelAsync(string channelId); + + Task> RemoveChannel(string channelId); + + Task>> SearchChannelsAsync(string query, int maxResults = 5); + + Task> GetChannelDetailsAsync(string channelId); + } +} diff --git a/Postall.Domain/Services/Contracts/IChannelYoutubeService.cs b/Postall.Domain/Services/Contracts/IChannelYoutubeService.cs new file mode 100644 index 0000000..b299238 --- /dev/null +++ b/Postall.Domain/Services/Contracts/IChannelYoutubeService.cs @@ -0,0 +1,17 @@ +using BaseDomain.Results; +using Postall.Domain.Dtos; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Postall.Domain.Services.Contracts +{ + public interface IChannelYoutubeService + { + Task>> SearchChannelsAsync(string query, int maxResults = 5); + + Task> GetChannelDetailsAsync(string channelId); + } +} diff --git a/Postall.Domain/Services/IFacebookServices.cs b/Postall.Domain/Services/Contracts/IFacebookServices.cs similarity index 87% rename from Postall.Domain/Services/IFacebookServices.cs rename to Postall.Domain/Services/Contracts/IFacebookServices.cs index ed06869..9121f1c 100644 --- a/Postall.Domain/Services/IFacebookServices.cs +++ b/Postall.Domain/Services/Contracts/IFacebookServices.cs @@ -4,7 +4,7 @@ using System.Linq; using System.Text; using System.Threading.Tasks; -namespace Postall.Domain.Services +namespace Postall.Domain.Services.Contracts { public interface IFacebookServices { diff --git a/Postall.Domain/Services/Contracts/IVideoService.cs b/Postall.Domain/Services/Contracts/IVideoService.cs new file mode 100644 index 0000000..543595b --- /dev/null +++ b/Postall.Domain/Services/Contracts/IVideoService.cs @@ -0,0 +1,20 @@ +using Postall.Infra.Dtos; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Postall.Domain.Services.Contracts +{ + public interface IVideoService + { + Task> GetUserChannelVideosAsync(int maxResults = 10); + + Task> GetChannelVideosAsync(string channelId, int maxResults = 10); + + Task SaveVideoForUserAsync(string videoId); + + Task RemoveVideoForUserAsync(string videoId); + } +} diff --git a/Postall.Infra.MongoDB/Extensions/ServiceRepositoryExtensions.cs b/Postall.Infra.MongoDB/Extensions/ServiceRepositoryExtensions.cs new file mode 100644 index 0000000..f2b8390 --- /dev/null +++ b/Postall.Infra.MongoDB/Extensions/ServiceRepositoryExtensions.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.DependencyInjection; +using Postall.Domain; +using Postall.Domain.Services.Contracts; +using Postall.Infra.MongoDB.Repositories; +using Postall.Infra.Services; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Postall.Infra.MongoDB.Extensions +{ + public static class ServiceRepositoryExtensions + { + public static IServiceCollection AddRepositories(this IServiceCollection services) + { + services.AddScoped(); + + return services; + } + } +} diff --git a/Postall.Infra.MongoDB/Postall.Infra.MongoDB.csproj b/Postall.Infra.MongoDB/Postall.Infra.MongoDB.csproj new file mode 100644 index 0000000..1a26ab4 --- /dev/null +++ b/Postall.Infra.MongoDB/Postall.Infra.MongoDB.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + diff --git a/Postall.Infra.MongoDB/Repositories/ChannelRepository.cs b/Postall.Infra.MongoDB/Repositories/ChannelRepository.cs new file mode 100644 index 0000000..b9c3cd4 --- /dev/null +++ b/Postall.Infra.MongoDB/Repositories/ChannelRepository.cs @@ -0,0 +1,244 @@ +using Microsoft.Extensions.Options; +using MongoDB.Bson; +using MongoDB.Driver; +using Postall.Domain; +using Postall.Domain.Dtos; +using Postall.Domain.Entities; +using Postall.Infra.MongoDB.Settings; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Channels; +using System.Threading.Tasks; +using static System.Runtime.InteropServices.JavaScript.JSType; + +namespace Postall.Infra.MongoDB.Repositories +{ + public class ChannelRepository : IChannelRepository + { + private readonly IMongoCollection _channelsCollection; + + public ChannelRepository(IOptions mongoDbSettings) + { + var client = new MongoClient(mongoDbSettings.Value.ConnectionString); + var database = client.GetDatabase(mongoDbSettings.Value.DatabaseName); + _channelsCollection = database.GetCollection(mongoDbSettings.Value.ChannelsCollectionName); + + var indexKeysDefinition = Builders.IndexKeys.Ascending(c => c.YoutubeId); + _channelsCollection.Indexes.CreateOne(new CreateIndexModel(indexKeysDefinition, new CreateIndexOptions { Unique = true })); + + var textIndexDefinition = Builders.IndexKeys + .Text(c => c.Title) + .Text(c => c.Description); + _channelsCollection.Indexes.CreateOne(new CreateIndexModel(textIndexDefinition)); + } + + /// + /// Obtém todos os canais + /// + public async Task> GetAllAsync() + { + return await _channelsCollection.Find(c => true).ToListAsync(); + } + + /// + /// Obtém um canal pelo ID do MongoDB + /// + public async Task GetByIdAsync(string id) + { + if (!ObjectId.TryParse(id, out _)) + return null; + + return await _channelsCollection.Find(c => c.Id == id).FirstOrDefaultAsync(); + } + + public async Task> GetByUserIdAsync(string userId) + { + return await _channelsCollection.Find(c => c.UserId == userId).ToListAsync(); + } + + /// + /// Obtém os canais pelo ID do usuario e o channelId + /// + public async Task GetByUserIdAndChannelIdAsync(string userId, string channelId) + { + return await _channelsCollection.Find(c => c.UserId == userId && c.ChannelId == channelId).FirstOrDefaultAsync(); + } + + /// + /// Obtém um canal pelo ID do YouTube + /// + public async Task GetByYoutubeIdAsync(string youtubeId) + { + return await _channelsCollection.Find(c => c.YoutubeId == youtubeId).FirstOrDefaultAsync(); + } + + /// + /// Adiciona um novo canal + /// + public async Task AddAsync(ChannelData ChannelData) + { + // Verifica se o canal já existe pelo ID do YouTube + var existingChannel = await GetByYoutubeIdAsync(ChannelData.YoutubeId); + if (existingChannel != null) + return null; + + // Gera novo ID para o MongoDB se não for fornecido + if (string.IsNullOrEmpty(ChannelData.Id) || !ObjectId.TryParse(ChannelData.Id, out _)) + { + ChannelData.Id = ObjectId.GenerateNewId().ToString(); + } + + await _channelsCollection.InsertOneAsync(ChannelData); + return ChannelData; + } + + /// + /// Adiciona vários canais de uma vez + /// + public async Task> AddManyAsync(IEnumerable channels) + { + if (channels == null || !channels.Any()) + return new List(); + + var channelsList = channels.ToList(); + + // Gera novos IDs para o MongoDB se necessário + foreach (var ChannelData in channelsList) + { + if (string.IsNullOrEmpty(ChannelData.Id) || !ObjectId.TryParse(ChannelData.Id, out _)) + { + ChannelData.Id = ObjectId.GenerateNewId().ToString(); + } + } + + // Verifica canais existentes pelo ID do YouTube + var youtubeIds = channelsList.Select(c => c.YoutubeId).ToList(); + var existingChannels = await _channelsCollection + .Find(c => youtubeIds.Contains(c.YoutubeId)) + .ToListAsync(); + + var existingYoutubeIds = existingChannels.Select(c => c.YoutubeId).ToHashSet(); + var newChannels = channelsList.Where(c => !existingYoutubeIds.Contains(c.YoutubeId)).ToList(); + + if (newChannels.Any()) + await _channelsCollection.InsertManyAsync(newChannels); + + return newChannels; + } + + /// + /// Atualiza um canal existente + /// + public async Task UpdateAsync(ChannelData ChannelData) + { + if (string.IsNullOrEmpty(ChannelData.Id) || !ObjectId.TryParse(ChannelData.Id, out _)) + return false; + + var result = await _channelsCollection.ReplaceOneAsync( + c => c.Id == ChannelData.Id, + ChannelData, + new ReplaceOptions { IsUpsert = false }); + + return result.IsAcknowledged && result.ModifiedCount > 0; + } + + /// + /// Remove um canal pelo ID do MongoDB + /// + public async Task DeleteAsync(string id) + { + if (!ObjectId.TryParse(id, out _)) + return false; + + var result = await _channelsCollection.DeleteOneAsync(c => c.Id == id); + return result.IsAcknowledged && result.DeletedCount > 0; + } + + /// + /// Remove um canal pelo ID do YouTube + /// + public async Task DeleteByYoutubeIdAsync(string youtubeId) + { + var result = await _channelsCollection.DeleteOneAsync(c => c.YoutubeId == youtubeId); + return result.IsAcknowledged && result.DeletedCount > 0; + } + + /// + /// Busca canais com base em um termo de pesquisa no título ou descrição + /// + public async Task> SearchAsync(string searchTerm) + { + if (string.IsNullOrWhiteSpace(searchTerm)) + return await GetAllAsync(); + + var filter = Builders.Filter.Text(searchTerm); + return await _channelsCollection.Find(filter).ToListAsync(); + } + + /// + /// Obtém canais selecionados (IsSelected = true) + /// + public async Task> GetSelectedAsync() + { + return await _channelsCollection.Find(c => c.IsSelected).ToListAsync(); + } + + /// + /// Marca ou desmarca um canal como selecionado + /// + public async Task SetSelectedStatusAsync(string id, bool isSelected) + { + if (!ObjectId.TryParse(id, out _)) + return false; + + var update = Builders.Update.Set(c => c.IsSelected, isSelected); + var result = await _channelsCollection.UpdateOneAsync(c => c.Id == id, update); + + return result.IsAcknowledged && result.ModifiedCount > 0; + } + + /// + /// Converte ChannelResponse para ChannelData + /// + public ChannelData ConvertFromResponse(ChannelResponse channelResponse) + { + if (channelResponse == null) + return null; + + return new ChannelData + { + YoutubeId = channelResponse.Id, + Title = channelResponse.Title, + Description = channelResponse.Description, + ThumbnailUrl = channelResponse.ThumbnailUrl, + PublishedAt = channelResponse.PublishedAt, + SubscriberCount = channelResponse.SubscriberCount, + VideoCount = channelResponse.VideoCount, + IsSelected = channelResponse.IsSelected + }; + } + + /// + /// Converte ChannelData para ChannelResponse + /// + public ChannelResponse ConvertToResponse(ChannelData ChannelData) + { + if (ChannelData == null) + return null; + + return new ChannelResponse + { + Id = ChannelData.YoutubeId, + Title = ChannelData.Title, + Description = ChannelData.Description, + ThumbnailUrl = ChannelData.ThumbnailUrl, + PublishedAt = ChannelData.PublishedAt, + SubscriberCount = ChannelData.SubscriberCount, + VideoCount = ChannelData.VideoCount, + IsSelected = ChannelData.IsSelected + }; + } + } +} diff --git a/Postall.Infra.MongoDB/Settings/MongoDbSetting.cs b/Postall.Infra.MongoDB/Settings/MongoDbSetting.cs new file mode 100644 index 0000000..cbb4c07 --- /dev/null +++ b/Postall.Infra.MongoDB/Settings/MongoDbSetting.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Postall.Infra.MongoDB.Settings +{ + /// + /// Configuração do MongoDB + /// + public class MongoDbSettings + { + public string ConnectionString { get; set; } + public string DatabaseName { get; set; } + public string ChannelsCollectionName { get; set; } + } +} diff --git a/Postall.Infra/Extensions/ServiceCollectionExtensions.cs b/Postall.Infra/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..10f2850 --- /dev/null +++ b/Postall.Infra/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.DependencyInjection; +using Postall.Domain; +using Postall.Domain.Services.Contracts; +using Postall.Infra.Services; + +namespace BaseDomain.Extensions +{ + public static class ServiceCollectionExtensions + { + public static IServiceCollection AddYouTubeServices(this IServiceCollection services) + { + services.AddHttpContextAccessor(); + + services.AddScoped(); + + services.AddScoped(); + + services.AddScoped(); + + return services; + } + } +} diff --git a/Postall.Infra/Postall.Infra.csproj b/Postall.Infra/Postall.Infra.csproj index 54b2459..97cfa89 100644 --- a/Postall.Infra/Postall.Infra.csproj +++ b/Postall.Infra/Postall.Infra.csproj @@ -7,6 +7,7 @@ + diff --git a/Postall.Infra/Services/ChannelYoutubeService.cs b/Postall.Infra/Services/ChannelYoutubeService.cs new file mode 100644 index 0000000..f7b0eaf --- /dev/null +++ b/Postall.Infra/Services/ChannelYoutubeService.cs @@ -0,0 +1,96 @@ +using BaseDomain.Results; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Postall.Domain.Dtos; +using Postall.Domain.Services.Contracts; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Text; +using System.Threading.Tasks; + +namespace Postall.Infra.Services +{ + public class ChannelYoutubeService: IChannelYoutubeService + { + private readonly IConfiguration _configuration; + private readonly string _apiKey; + + public ChannelYoutubeService(IConfiguration configuration) + { + _configuration = configuration; + _apiKey = _configuration["YouTube:ApiKey"]; + } + + /// + /// Busca os detalhes de um canal na API do YouTube + /// + public async Task> GetChannelDetailsAsync(string channelId) + { + var youtubeService = new YouTubeBaseServiceWrapper(_apiKey); + + var channelsRequest = youtubeService.Channels.List("snippet,statistics,contentDetails"); + channelsRequest.Id = channelId; + + var response = await channelsRequest.ExecuteAsync(); + + if (response.Items.Count == 0) + throw new Exception($"Canal não encontrado: {channelId}"); + + var channel = response.Items[0]; + + return new ChannelResponse + { + Id = channel.Id, + Title = channel.Snippet.Title, + Description = channel.Snippet.Description, + ThumbnailUrl = channel.Snippet.Thumbnails.Default__.Url, + PublishedAt = channel.Snippet.PublishedAt.Value, + SubscriberCount = channel.Statistics.SubscriberCount.GetValueOrDefault(), + VideoCount = channel.Statistics.VideoCount.GetValueOrDefault() + }; + } + + /// + /// Busca um canal pelo nome ou ID + /// + public async Task>> SearchChannelsAsync(string query, int maxResults = 5) + { + if (string.IsNullOrEmpty(query) || query.Length < 3) + return new List(); + + var youtubeService = new YouTubeBaseServiceWrapper(_apiKey); + + var searchRequest = youtubeService.Search.List("snippet"); + searchRequest.Q = query; + searchRequest.Type = "channel"; + searchRequest.MaxResults = maxResults; + + var searchResponse = await searchRequest.ExecuteAsync(); + + var results = new List(); + + foreach (var item in searchResponse.Items) + { + results.Add(new ChannelResponse + { + Id = item.Id.ChannelId, + Title = item.Snippet.Title, + Description = item.Snippet.Description, + ThumbnailUrl = item.Snippet.Thumbnails.Default__.Url, + PublishedAt = item.Snippet.PublishedAt.Value + }); + } + + return results; + } + } + + // Classe auxiliar para dados básicos de canal + public class ChannelData + { + public string Id { get; set; } + public string Title { get; set; } + } +} diff --git a/Postall.Infra/Services/FacebookTokenService.cs b/Postall.Infra/Services/FacebookTokenService.cs index 345ef5d..4ad5741 100644 --- a/Postall.Infra/Services/FacebookTokenService.cs +++ b/Postall.Infra/Services/FacebookTokenService.cs @@ -2,7 +2,7 @@ using MongoDB.Driver; using Postall.Domain.Dtos.Responses; using Postall.Domain.Entities; -using Postall.Domain.Services; +using Postall.Domain.Services.Contracts; using System; using System.Collections.Generic; using System.Linq; diff --git a/Postall.Infra/Services/YouTubeServiceVideo.cs b/Postall.Infra/Services/YouTubeServiceVideo.cs new file mode 100644 index 0000000..8557d40 --- /dev/null +++ b/Postall.Infra/Services/YouTubeServiceVideo.cs @@ -0,0 +1,227 @@ +using Google.Apis.Services; +using Google.Apis.YouTube.v3; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Postall.Domain.Services.Contracts; +using Postall.Infra.Dtos; +using System.Security.Claims; + +namespace Postall.Infra.Services +{ + public class YouTubeServiceVideo: IVideoService + { + private readonly IConfiguration _configuration; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly string _apiKey; + private readonly string _clientId; + private readonly string _clientSecret; + + public YouTubeServiceVideo(IConfiguration configuration, IHttpContextAccessor httpContextAccessor) + { + _configuration = configuration; + _httpContextAccessor = httpContextAccessor; + + _apiKey = _configuration["YouTube:ApiKey"]; + _clientId = _configuration["Authentication:Google:ClientId"]; + _clientSecret = _configuration["Authentication:Google:ClientSecret"]; + } + + /// + /// Obtém o ID do usuário atual a partir das claims + /// + private string GetCurrentUserId() + { + var user = _httpContextAccessor.HttpContext.User; + + // Primeiro tentar obter o NameIdentifier (que foi adicionado no ExternalLoginCallback) + var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + + // Se não encontrou, tenta usar o email como identificador + if (string.IsNullOrEmpty(userId)) + { + var email = user.FindFirst(ClaimTypes.Email)?.Value; + if (!string.IsNullOrEmpty(email)) + { + // Gerar ID baseado no email (mesmo método usado no ExternalLoginCallback) + userId = Convert.ToBase64String( + System.Text.Encoding.UTF8.GetBytes(email) + ).Replace("/", "_").Replace("+", "-").Replace("=", ""); + } + } + + return userId; + } + + /// + /// Obtém os vídeos dos canais que o usuário tem acesso, ordenados por data + /// + public async Task> GetUserChannelVideosAsync(int maxResults = 10) + { + // Obter o ID do usuário das claims + var userId = GetCurrentUserId(); + + if (string.IsNullOrEmpty(userId)) + return new List(); + + // Obter os canais do usuário do banco de dados + var userChannels = await GetUserChannelsAsync(userId); + + if (!userChannels.Any()) + return new List(); + + var videos = new List(); + + // Para cada canal, buscar os vídeos recentes + foreach (var channelId in userChannels) + { + var channelVideos = await GetChannelVideosAsync(channelId, maxResults); + videos.AddRange(channelVideos); + } + + // Ordenar por data de publicação e pegar os mais recentes + return videos + .OrderByDescending(v => v.PublishedAt) + .Take(maxResults) + .ToList(); + } + + /// + /// Obtém os vídeos de um canal específico + /// + public async Task> GetChannelVideosAsync(string channelId, int maxResults = 10) + { + try + { + var youtubeService = new YouTubeBaseServiceWrapper(_apiKey); + + // Primeiro, buscar os IDs dos últimos vídeos do canal + var searchRequest = youtubeService.Search.List("snippet"); + searchRequest.ChannelId = channelId; + searchRequest.Order = SearchResource.ListRequest.OrderEnum.Date; + searchRequest.MaxResults = maxResults; + searchRequest.Type = "video"; + + var searchResponse = await searchRequest.ExecuteAsync(); + + if (searchResponse.Items == null || searchResponse.Items.Count == 0) + return new List(); + + // Obter os IDs dos vídeos + var videoIds = searchResponse.Items.Select(i => i.Id.VideoId).ToList(); + + // Buscar detalhes dos vídeos + var videosRequest = youtubeService.Videos.List("snippet,contentDetails,statistics"); + videosRequest.Id = string.Join(",", videoIds); + var videosResponse = await videosRequest.ExecuteAsync(); + + // Converter para o modelo + var videos = new List(); + foreach (var videoItem in videosResponse.Items) + { + var searchItem = searchResponse.Items.FirstOrDefault(i => i.Id.VideoId == videoItem.Id); + + videos.Add(new VideoItemResponse + { + Id = videoItem.Id, + Title = videoItem.Snippet.Title, + Description = videoItem.Snippet.Description, + ThumbnailUrl = videoItem.Snippet.Thumbnails.High.Url, + PublishedAt = videoItem.Snippet.PublishedAt.Value, + ChannelId = videoItem.Snippet.ChannelId, + ChannelTitle = videoItem.Snippet.ChannelTitle + }); + } + + return videos; + } + catch (Exception ex) + { + // Em produção, use um logger adequado + Console.WriteLine($"Erro ao obter vídeos do canal {channelId}: {ex.Message}"); + return new List(); + } + } + + /// + /// Método para buscar os canais que o usuário gerencia ou tem acesso + /// Em um cenário real, isso viria do banco de dados + /// + private async Task> GetUserChannelsAsync(string userId) + { + // TODO: Substituir isso por uma consulta real ao banco de dados + + // Em produção, você deve implementar uma tabela que relacione + // os usuários com seus canais do YouTube + + // Para teste, aqui estão alguns canais populares + // Na implementação real, você deve armazenar os canais que o usuário tem permissão + return new List + { + "UC_x5XG1OV2P6uZZ5FSM9Ttw", // Google Developers + "UCt84aUC9OG6di8kSdKzEHTQ", // Outro canal de exemplo + "UCkw4JCwteGrDHIsyIIKo4tQ" // Google + }; + } + + /// + /// Salva um vídeo para o usuário atual + /// Em produção, isso salvaria no banco de dados + /// + public async Task SaveVideoForUserAsync(string videoId) + { + var userId = GetCurrentUserId(); + + if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(videoId)) + return; + + // TODO: Salvar no banco de dados a relação entre o usuário e o vídeo + + // Código de exemplo: + // await _dbContext.UserVideos.AddAsync(new UserVideo + // { + // UserId = userId, + // VideoId = videoId, + // DateAdded = DateTime.UtcNow + // }); + // await _dbContext.SaveChangesAsync(); + } + + /// + /// Remove um vídeo do usuário atual + /// Em produção, isso removeria do banco de dados + /// + public async Task RemoveVideoForUserAsync(string videoId) + { + var userId = GetCurrentUserId(); + + if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(videoId)) + return; + + // TODO: Remover do banco de dados a relação entre o usuário e o vídeo + + // Código de exemplo: + // var userVideo = await _dbContext.UserVideos + // .FirstOrDefaultAsync(uv => uv.UserId == userId && uv.VideoId == videoId); + // if (userVideo != null) + // { + // _dbContext.UserVideos.Remove(userVideo); + // await _dbContext.SaveChangesAsync(); + // } + } + } + + /// + /// Wrapper para o serviço base do YouTube + /// + public class YouTubeBaseServiceWrapper : YouTubeService + { + public YouTubeBaseServiceWrapper(string apiKey) + : base(new BaseClientService.Initializer() + { + ApiKey = apiKey, + ApplicationName = "PostAll" + }) + { + } + } +} diff --git a/Postall/Controllers/ChannelsController.cs b/Postall/Controllers/ChannelsController.cs new file mode 100644 index 0000000..f9a380e --- /dev/null +++ b/Postall/Controllers/ChannelsController.cs @@ -0,0 +1,98 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Postall.Domain.Services.Contracts; +using Postall.Infra.Services; + +namespace Postall.Controllers +{ + [Authorize] + public class ChannelsController : Controller + { + private readonly IChannelService _channelService; + + public ChannelsController(IChannelService channelService) + { + _channelService = channelService; + } + + public async Task Index() + { + var userChannels = await _channelService.GetUserChannelsAsync(); + return View(userChannels); + } + + [HttpGet] + public IActionResult Add() + { + return View(); + } + + [HttpPost] + public async Task Add(string channelId) + { + if (string.IsNullOrEmpty(channelId)) + { + TempData["Error"] = "ID do canal é obrigatório"; + return RedirectToAction("Add"); + } + + try + { + var success = await _channelService.AddChannelAsync(channelId); + + if (success.IsSuccess) + { + TempData["Message"] = "Canal adicionado com sucesso!"; + return RedirectToAction("Index"); + } + + TempData["Error"] = "Não foi possível adicionar o canal. Ele já existe ou é inválido."; + return RedirectToAction("Add"); + } + catch (Exception ex) + { + TempData["Error"] = $"Erro ao adicionar canal: {ex.Message}"; + return RedirectToAction("Add"); + } + } + + [HttpPost] + public async Task Remove(string id) + { + try + { + var success = await _channelService.RemoveChannel(id); + + if (success.IsSuccess) + TempData["Message"] = "Canal removido com sucesso!"; + else + TempData["Error"] = "Não foi possível remover o canal."; + } + catch (Exception ex) + { + TempData["Error"] = $"Erro ao remover canal: {ex.Message}"; + } + + return RedirectToAction("Index"); + } + + [HttpGet] + public async Task Search(string query) + { + if (string.IsNullOrEmpty(query) || query.Length < 3) + { + return Json(new { success = false, message = "A consulta deve ter pelo menos 3 caracteres" }); + } + + try + { + var results = await _channelService.SearchChannelsAsync(query); + return Json(new { success = true, data = results }); + } + catch (Exception ex) + { + return Json(new { success = false, message = ex.Message }); + } + } + } +} diff --git a/Postall/Controllers/LoginController.cs b/Postall/Controllers/LoginController.cs index 594336f..95e5ccc 100644 --- a/Postall/Controllers/LoginController.cs +++ b/Postall/Controllers/LoginController.cs @@ -44,63 +44,70 @@ namespace Postall.Controllers return Challenge(properties, "Google"); } - [AllowAnonymous] - [HttpGet] - public async Task ExternalLoginCallback(string code="") + [AllowAnonymous] + [HttpGet] + public async Task ExternalLoginCallback(string code = "") { - //TODO: Temporário - var emailExist = HttpContext.User.FindFirst(ClaimTypes.Email).Value; - if (emailExist != null) - { - var claims = new List - { - new Claim(ClaimTypes.Name, emailExist), + var emailExist = HttpContext.User.FindFirst(ClaimTypes.Email).Value; + if (emailExist != null) + { + var uniqueId = Convert.ToBase64String( + System.Text.Encoding.UTF8.GetBytes(emailExist) + ).Replace("/", "_").Replace("+", "-").Replace("=", ""); + + var claims = new List + { + // Adicionando o NameIdentifier que serve como userId + new Claim(ClaimTypes.NameIdentifier, uniqueId), + + // Claims existentes + new Claim(ClaimTypes.Name, emailExist), + new Claim(ClaimTypes.Email, emailExist), // Garantindo que o email esteja nas claims new Claim("FirstName", HttpContext.User.FindFirst(ClaimTypes.GivenName).Value), new Claim("FullName", HttpContext.User.FindFirst(ClaimTypes.GivenName).Value + " " + HttpContext.User.FindFirst(ClaimTypes.Surname).Value), - new Claim(ClaimTypes.Role, "User"), - }; + new Claim(ClaimTypes.Role, "User"), + + // Opcionalmente, adicionar informações do Google que serão úteis para o YouTube + new Claim("GoogleAccount", "true") + }; - var claimsIdentity = new ClaimsIdentity( - claims, - CookieAuthenticationDefaults.AuthenticationScheme - ); + var claimsIdentity = new ClaimsIdentity( + claims, + CookieAuthenticationDefaults.AuthenticationScheme + ); - var authProperties = new AuthenticationProperties - { - //AllowRefresh = , - // Refreshing the authentication session should be allowed. + var authProperties = new AuthenticationProperties + { + //AllowRefresh = , + // Refreshing the authentication session should be allowed. + //ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(10), + // The time at which the authentication ticket expires. A + // value set here overrides the ExpireTimeSpan option of + // CookieAuthenticationOptions set with AddCookie. + //IsPersistent = true, + // Whether the authentication session is persisted across + // multiple requests. When used with cookies, controls + // whether the cookie's lifetime is absolute (matching the + // lifetime of the authentication ticket) or session-based. + //IssuedUtc = , + // The time at which the authentication ticket was issued. + //RedirectUri = + // The full path or absolute URI to be used as an http + // redirect response value. + }; - //ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(10), - // The time at which the authentication ticket expires. A - // value set here overrides the ExpireTimeSpan option of - // CookieAuthenticationOptions set with AddCookie. - - //IsPersistent = true, - // Whether the authentication session is persisted across - // multiple requests. When used with cookies, controls - // whether the cookie's lifetime is absolute (matching the - // lifetime of the authentication ticket) or session-based. - - //IssuedUtc = , - // The time at which the authentication ticket was issued. - - //RedirectUri = - // The full path or absolute URI to be used as an http - // redirect response value. - }; - - await HttpContext.SignInAsync( - CookieAuthenticationDefaults.AuthenticationScheme, - new ClaimsPrincipal(claimsIdentity), - authProperties); + await HttpContext.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + new ClaimsPrincipal(claimsIdentity), + authProperties); return RedirectToAction("Index", "Startup"); } - ViewBag.ErrorTitle = $"Email claim not received from: Microsoft"; - ViewBag.ErrorMessage = "Please contact support on info@dotnettutorials.net"; - return View("Error"); - } + ViewBag.ErrorTitle = $"Email claim not received from: Microsoft"; + ViewBag.ErrorMessage = "Please contact support on info@dotnettutorials.net"; + return View("Error"); + } [HttpGet] public async Task Logout() diff --git a/Postall/Controllers/OtherLoginsController.cs b/Postall/Controllers/OtherLoginsController.cs index a195188..e964645 100644 --- a/Postall/Controllers/OtherLoginsController.cs +++ b/Postall/Controllers/OtherLoginsController.cs @@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Facebook; using Microsoft.AspNetCore.Mvc; -using Postall.Domain.Services; +using Postall.Domain.Services.Contracts; using System.Security.Claims; namespace Postall.Controllers diff --git a/Postall/Controllers/VideosController.cs b/Postall/Controllers/VideosController.cs new file mode 100644 index 0000000..319525e --- /dev/null +++ b/Postall/Controllers/VideosController.cs @@ -0,0 +1,75 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Postall.Models; +using System.Security.Claims; +using System.Threading.Tasks; +using System.Collections.Generic; +using Postall.Models; + +namespace Postall.Controllers +{ + [Authorize] + public class VideosController : Controller + { + // Simulação de dados para exemplo - em produção, estes dados viriam de uma API ou banco de dados + private List GetSampleVideos() + { + return new List + { + new VideoViewModel + { + Id = "video1", + Title = "Como usar o PostAll - Tutorial", + Description = "Aprenda a usar todas as funcionalidades do PostAll para gerenciar suas redes sociais.", + ThumbnailUrl = "https://i.ytimg.com/vi/sample1/maxresdefault.jpg", + PublishedAt = DateTime.Now.AddDays(-2) + }, + new VideoViewModel + { + Id = "video2", + Title = "Estratégias de Marketing Digital para 2024", + Description = "Conheça as melhores estratégias para alavancar seu negócio nas redes sociais em 2024.", + ThumbnailUrl = "https://i.ytimg.com/vi/sample2/maxresdefault.jpg", + PublishedAt = DateTime.Now.AddDays(-5) + }, + new VideoViewModel + { + Id = "video3", + Title = "Análise de Métricas nas Redes Sociais", + Description = "Aprenda a interpretar as métricas das suas redes sociais e tomar decisões baseadas em dados.", + ThumbnailUrl = "https://i.ytimg.com/vi/sample3/maxresdefault.jpg", + PublishedAt = DateTime.Now.AddDays(-7) + } + }; + } + + public IActionResult Index() + { + var userVideos = GetSampleVideos(); + return View(userVideos); + } + + [HttpGet] + public IActionResult GetChannelVideos() + { + // Em um cenário real, você buscaria os vídeos da API do YouTube + var channelVideos = GetSampleVideos().OrderByDescending(v => v.PublishedAt).Take(10).ToList(); + return PartialView("_ChannelVideosPartial", channelVideos); + } + + [HttpPost] + public IActionResult AddVideos(string[] selectedVideos) + { + if (selectedVideos == null || selectedVideos.Length == 0) + { + return RedirectToAction("Index"); + } + + // Em um cenário real, você salvaria esses IDs no banco de dados + // Aqui apenas redirecionamos de volta para o Index + + TempData["Message"] = $"{selectedVideos.Length} vídeo(s) adicionado(s) com sucesso!"; + return RedirectToAction("Index"); + } + } +} \ No newline at end of file diff --git a/Postall/Models/VideoViewModel.cs b/Postall/Models/VideoViewModel.cs new file mode 100644 index 0000000..c2f6c0a --- /dev/null +++ b/Postall/Models/VideoViewModel.cs @@ -0,0 +1,21 @@ +using System; + +namespace Postall.Models +{ + + public class VideoViewModel + { + public string Id { get; set; } + public string Title { get; set; } + public string Description { get; set; } + public string ThumbnailUrl { get; set; } + public DateTime PublishedAt { get; set; } + public bool IsSelected { get; set; } + + // Propriedades adicionais que podem ser úteis no futuro + public string ChannelTitle { get; set; } + public string ChannelId { get; set; } + public string VideoUrl => $"https://www.youtube.com/watch?v={Id}"; + } +} + diff --git a/Postall/Postall.csproj b/Postall/Postall.csproj index e2f4b9b..68388a3 100644 --- a/Postall/Postall.csproj +++ b/Postall/Postall.csproj @@ -34,6 +34,7 @@ + diff --git a/Postall/Program.cs b/Postall/Program.cs index 8a02ed6..acf1627 100644 --- a/Postall/Program.cs +++ b/Postall/Program.cs @@ -1,10 +1,11 @@ +using BaseDomain.Extensions; using Blinks.LogConfig; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Google; using Microsoft.AspNetCore.Localization; using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.Extensions.Options; -using Postall.Domain.Services; +using Postall.Domain.Services.Contracts; using Postall.Infra.Services; using Serilog; using Serilog.Sinks.Grafana.Loki; @@ -12,6 +13,8 @@ using Stripe; using Stripe.Forwarding; using System.Globalization; using System.Security.Policy; +using Postall.Infra.MongoDB.Extensions; +using Postall.Infra.MongoDB.Settings; var builder = WebApplication.CreateBuilder(args); @@ -88,8 +91,15 @@ builder.Services.AddControllersWithViews(); builder.Services.AddHttpClient(); builder.Services.AddSerilog(); +builder.Services.AddYouTubeServices(); + +builder.Services.AddRepositories(); + builder.Services.AddScoped(); +builder.Services.Configure( + builder.Configuration.GetSection("MongoDbSettings")); + var app = builder.Build(); var locOptions = app.Services.GetService>(); diff --git a/Postall/Properties/launchSettings.json b/Postall/Properties/launchSettings.json index 24f44d9..2debb53 100644 --- a/Postall/Properties/launchSettings.json +++ b/Postall/Properties/launchSettings.json @@ -16,7 +16,7 @@ "ASPNETCORE_ENVIRONMENT": "Development" }, "dotnetRunMessages": true, - "applicationUrl": "https://localhost:7078;http://localhost:5094" + "applicationUrl": "https://localhost:7078" }, "IIS Express": { "commandName": "IISExpress", diff --git a/Postall/Views/Channels/Add.cshtml b/Postall/Views/Channels/Add.cshtml new file mode 100644 index 0000000..fb9d00c --- /dev/null +++ b/Postall/Views/Channels/Add.cshtml @@ -0,0 +1,167 @@ +@{ + ViewData["Title"] = "Adicionar Canal"; +} + +
+

Adicionar Canal

+ +
+
+ @if (TempData["Error"] != null) + { +
+ @TempData["Error"] +
+ } + +
+ +
+
+ +
+
+
+ + + + O ID do canal pode ser encontrado na URL do canal, ex: youtube.com/channel/UCkw4JCwteGrDHIsyIIKo4tQ + +
+ +
+
+
+
+
+ + +
+ +
+
+
+
Dica
+

+ Adicione os canais do YouTube que você gerencia ou tem permissão para acessar. + Depois de adicionar os canais, você poderá ver os vídeos deles na seção "Vídeos". +

+
Como encontrar o ID do canal?
+
    +
  1. Visite o canal no YouTube
  2. +
  3. Observe a URL: youtube.com/channel/ID-DO-CANAL
  4. +
  5. Copie o ID que aparece após "/channel/"
  6. +
+
+
+
+
+
+ +@section Scripts { + +} \ No newline at end of file diff --git a/Postall/Views/Channels/Index.cshtml b/Postall/Views/Channels/Index.cshtml new file mode 100644 index 0000000..9a27399 --- /dev/null +++ b/Postall/Views/Channels/Index.cshtml @@ -0,0 +1,93 @@ +@using Postall.Domain.Dtos +@model List +@{ + ViewData["Title"] = "Meus Canais"; +} + +
+
+
+

Meus Canais

+

Gerencie seus canais do YouTube

+
+ +
+ + @if (TempData["Message"] != null) + { + + } + + @if (TempData["Error"] != null) + { + + } + +
+ @if (Model != null && Model.Any()) + { + foreach (var channel in Model) + { +
+
+
+
+
+ @channel.Title +
+
+
@channel.Title
+

+ @(channel.Description?.Length > 100 ? channel.Description.Substring(0, 100) + "..." : channel.Description) +

+
+ + @channel.SubscriberCount.ToString("N0") inscritos + + + @channel.VideoCount.ToString("N0") vídeos + +
+
+
+
+ +
+
+ } + } + else + { +
+
+ Você ainda não possui canais. Clique em "Adicionar Canal" para começar. +
+
+ } +
+
\ No newline at end of file diff --git a/Postall/Views/Shared/_Layout.cshtml b/Postall/Views/Shared/_Layout.cshtml index e26105c..cb05ffe 100644 --- a/Postall/Views/Shared/_Layout.cshtml +++ b/Postall/Views/Shared/_Layout.cshtml @@ -12,6 +12,30 @@ @await RenderSectionAsync("Styles", required: false) + @@ -26,14 +50,17 @@ - @if (User!=null && User.Identity!=null && User.Identity.IsAuthenticated) { + + @@ -102,8 +129,26 @@ $(function () { $(document).ready(function () { $('.loading').hide(); + $('#wrapper').show(); //$('body').fadeIn(1000); $('body').slideDown('slow'); + + // Intercepta cliques em links + $('a:not([target="_blank"]):not([href^="#"]):not([href^="javascript"])').click(function(e) { + if (this.hostname === window.location.hostname) { + e.preventDefault(); + const href = $(this).attr('href'); + + // Adiciona fade out + $('#wrapper').addClass('fade-out'); + $('.loading').show(); + + // Navega após a animação + setTimeout(() => { + window.location.href = href; + }, 300); + } + }); }); $('a[href="#search"]').on('click', function (event) { @@ -132,7 +177,7 @@ $(document).ready(function () { $('#wrapper').fadeIn('slow'); - $('a.nav-link').click(function () { + $('#navbarNav a.nav-link').click(function () { if ($(this).hasClass('dropdown-toggle')) { return; } @@ -184,6 +229,19 @@ $('body').slideUp('slow'); } + // Remove fade-out quando a página carrega + $(window).on('pageshow', function() { + $('#wrapper').removeClass('fade-out'); + $('.loading').hide(); + setActiveByLocation(); + }); + + // Gerencia submissão de formulários + $(document).on('submit', 'form', function () { + $('#wrapper').addClass('fade-out'); + $('.loading').show(); + }); + //$(".hide-body").fadeOut(2000); }); diff --git a/Postall/Views/Videos/Index.cshtml b/Postall/Views/Videos/Index.cshtml new file mode 100644 index 0000000..751f882 --- /dev/null +++ b/Postall/Views/Videos/Index.cshtml @@ -0,0 +1,169 @@ +@model List +@{ + ViewData["Title"] = "Gerenciador de Vídeos"; +} + +
+
+
+

Meus Vídeos

+

Gerencie seus vídeos do YouTube

+
+
+ +
+
+ + @if (TempData["Message"] != null) + { + + } + +
+ @if (Model != null && Model.Any()) + { + foreach (var video in Model) + { +
+
+
+
+
@video.Title
+ +
+
+
+
+
+ @video.Title +
+
+

@(video.Description?.Length > 100 ? video.Description.Substring(0, 100) + "..." : video.Description)

+

Publicado em: @video.PublishedAt.ToString("dd/MM/yyyy")

+
+
+
+
+ +
+
+
+ } + } + else + { +
+
+ Você ainda não possui vídeos. Clique em "Adicionar Vídeos" para começar. +
+
+ } +
+
+ + + + +@section Scripts { + +} \ No newline at end of file diff --git a/Postall/Views/Videos/_ChannelVideosPartial.cshtml b/Postall/Views/Videos/_ChannelVideosPartial.cshtml new file mode 100644 index 0000000..83415fe --- /dev/null +++ b/Postall/Views/Videos/_ChannelVideosPartial.cshtml @@ -0,0 +1,51 @@ +@model List + +
+
Últimos 10 vídeos disponíveis (ordenados por data):
+ + @if (Model != null && Model.Any()) + { +
+ @foreach (var video in Model) + { +
+
+
+
+ +
+
+
+
+ @video.Title +
+
+
+
@video.Title
+ @video.PublishedAt.ToString("dd/MM/yyyy") +
+

@video.Description

+ + + @(video.ChannelTitle ?? "Seu Canal") + +
+
+
+
+ } +
+ } + else + { +
+ Nenhum vídeo encontrado nos canais aos quais você tem acesso. +
+ } +
+ + \ No newline at end of file diff --git a/Postall/appsettings.Development.json b/Postall/appsettings.Development.json index 0c208ae..9a2632a 100644 --- a/Postall/appsettings.Development.json +++ b/Postall/appsettings.Development.json @@ -4,5 +4,10 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "MongoDbSettings": { + "ConnectionString": "mongodb://localhost:27017", + "DatabaseName": "YouTubeChannelsDB", + "ChannelsCollectionName": "Channels" } } diff --git a/Postall/appsettings.json b/Postall/appsettings.json index 169924d..5418b64 100644 --- a/Postall/appsettings.json +++ b/Postall/appsettings.json @@ -21,5 +21,13 @@ "AppId": "963281005306692", "AppSecret": "575839ccbb36d1457715f1f6dd0a8db9" } + }, + "Youtube": { + "ApiKey": "AIzaSyBwFTW5WRAaBupyTgayVIeaS3LjGn0gpNI" + }, + "MongoDbSettings": { + "ConnectionString": "mongodb://localhost:27017", + "DatabaseName": "YouTubeChannelsDB", + "ChannelsCollectionName": "Channels" } } \ No newline at end of file diff --git a/Postall/wwwroot/css/videos.css b/Postall/wwwroot/css/videos.css new file mode 100644 index 0000000..dae6e7e --- /dev/null +++ b/Postall/wwwroot/css/videos.css @@ -0,0 +1,82 @@ +/* Estilos para a página de vídeos */ + +.card { + transition: all 0.3s ease; + border: 1px solid rgba(0,0,0,.125); +} + + .card:hover { + box-shadow: 0 5px 15px rgba(0,0,0,.1); + } + +.card-header button.btn-link { + text-decoration: none; + color: #212529; +} + + .card-header button.btn-link:hover { + color: #007bff; + } + +.card-body img { + max-height: 120px; + object-fit: cover; + width: 100%; +} + +.video-checkbox { + cursor: pointer; + width: 20px; + height: 20px; +} + +.list-group-item { + transition: background-color 0.2s ease; +} + + .list-group-item:hover { + background-color: rgba(0,123,255,.03); + } + + .list-group-item img { + max-height: 100px; + object-fit: cover; + } + +/* Animação para o colapso */ +.collapse { + transition: all 0.3s ease; +} + +/* Estilo para o botão de expansão do card */ +.card-header button.btn-link { + transform: rotate(0deg); + transition: transform 0.3s ease; +} + + .card-header button.btn-link[aria-expanded="true"] i { + transform: rotate(180deg); + } + +/* Ajustes para responsividade */ +@media (max-width: 768px) { + .col-md-6.mb-4 { + padding-left: 5px; + padding-right: 5px; + } + + .card-body .row { + flex-direction: column; + } + + .card-body .col-md-5, + .card-body .col-md-7 { + width: 100%; + max-width: 100%; + flex: 0 0 100%; + } + + .card-body .col-md-5 { + margin-bottom: 15px; + } +}