diff --git a/Core.Application/DTO/LinkedIn/Request/LinkedInPostWithMediaRequest.cs b/Core.Application/DTO/LinkedIn/Request/LinkedInPostWithMediaRequest.cs new file mode 100644 index 0000000..14fbe16 --- /dev/null +++ b/Core.Application/DTO/LinkedIn/Request/LinkedInPostWithMediaRequest.cs @@ -0,0 +1,17 @@ +using Core.Application.DTO.PostAttachment.Response; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Core.Application.DTO.LinkedIn.Request +{ + public class LinkedInPostWithMediaRequest + { + public required string AccessToken { get; set; } + public required string ExternalAccountId { get; set; } + public required string Content { get; set; } + public List Attachments { get; set; } = []; + } +} diff --git a/Core.Application/DTO/PostPublication/Request/PostPublicationParamsRequest.cs b/Core.Application/DTO/PostPublication/Request/PostPublicationParamsRequest.cs index 0e9884a..ccccd41 100644 --- a/Core.Application/DTO/PostPublication/Request/PostPublicationParamsRequest.cs +++ b/Core.Application/DTO/PostPublication/Request/PostPublicationParamsRequest.cs @@ -15,8 +15,13 @@ public class PostPublicationParamsRequest : PaginatedListQueryParams public PostPublicationStatus? Status { get; set; } public DateTime? PublishedBefore { get; set; } public DateTime? PublishedAfter { get; set; } - public DateTime? CreatedBefore { get; set; } - public DateTime? CreatedAfter { get; set; } public PlatformType? PublishedOn { get; set; } + public PostPublishSortBy? SortBy { get; set; } + } + + public enum PostPublishSortBy + { + CreatedAt, + Platform, } } diff --git a/Core.Application/DTO/PostPublication/Response/PostPublicationResponse.cs b/Core.Application/DTO/PostPublication/Response/PostPublicationResponse.cs index 3f1d894..4f032e2 100644 --- a/Core.Application/DTO/PostPublication/Response/PostPublicationResponse.cs +++ b/Core.Application/DTO/PostPublication/Response/PostPublicationResponse.cs @@ -9,8 +9,9 @@ namespace Core.Application.DTO.PostPublication.Response { public class PostPublicationResponse { - public int PostId { get; set; } - public int UserPlatformId { get; set; } + public string PostBody { get; set; } + public PlatformType PlatformType { get; set; } + public string AccountUsername { get; set; } public PostPublicationStatus Status { get; set; } public DateTime? PublishedAt { get; set; } public string? ExternalPostId { get; set; } diff --git a/Core.Application/Services/IPostPublishService.cs b/Core.Application/Services/IPostPublishService.cs index 4a7b1c6..87ad23e 100644 --- a/Core.Application/Services/IPostPublishService.cs +++ b/Core.Application/Services/IPostPublishService.cs @@ -11,5 +11,6 @@ namespace Core.Application.Services public interface IPostPublishService { public Task PublishPostToLinkedInAsync(PublishPostRequest request, string userId); + public Task> GetUserPostPublicationsAsync(PostPublicationParamsRequest request, string userId); } } diff --git a/Core.Application/Services/IPostService.cs b/Core.Application/Services/IPostService.cs index 6a6da01..c2b6dcb 100644 --- a/Core.Application/Services/IPostService.cs +++ b/Core.Application/Services/IPostService.cs @@ -11,7 +11,7 @@ namespace Core.Application.Services public interface IPostService { public Task> GetAllPostsAsync(PostPaginatedParamsRequest request); - public Task> GetAllPostsPerUserAsync(PostPaginatedParamsRequest request, string userId); + public Task> GetAllPostsPerUserAsync(PostPaginatedParamsRequest request, string userId); public Task GetUserPostById(int postId, string userId); public Task GetPostByIdLong(int postId, string userId); public Task CreatePostAsync(PostRequest request, string userId); diff --git a/Core.Application/Services/LinkedIn/ILinkedInService.cs b/Core.Application/Services/LinkedIn/ILinkedInService.cs index da04493..62afa3c 100644 --- a/Core.Application/Services/LinkedIn/ILinkedInService.cs +++ b/Core.Application/Services/LinkedIn/ILinkedInService.cs @@ -10,6 +10,7 @@ public interface ILinkedInService Task ExchangeCodeForAccessToken(string code, string redirectUri); Task GetAccountInfo(string accessToken); Task PostTextAsync(LinkedInPostRequest request); + Task PostTextWithMediaAsync(LinkedInPostWithMediaRequest request, string userId); Task> GetUserCompanies(string accessToken); } } diff --git a/Repository/Repositories/PostPublicationRepository.cs b/Repository/Repositories/PostPublicationRepository.cs index e84d069..ba756ea 100644 --- a/Repository/Repositories/PostPublicationRepository.cs +++ b/Repository/Repositories/PostPublicationRepository.cs @@ -25,6 +25,8 @@ public IQueryable GetAllAsQueryPerUserIdAsync(string userId) { return dbContext.PostPublications .Include(pp => pp.UserPlatform) + .ThenInclude(platform => platform.Platform) + .Include(pp => pp.Post) .Where(pb => pb.UserPlatform.UserId == userId).AsNoTracking(); } diff --git a/Repository/Repositories/PostRepository.cs b/Repository/Repositories/PostRepository.cs index dc4c9ce..878d7e1 100644 --- a/Repository/Repositories/PostRepository.cs +++ b/Repository/Repositories/PostRepository.cs @@ -23,7 +23,9 @@ public IQueryable GetAllAsQueryAsync() public IQueryable GetAllAsQueryPerUserIdAsync(string userId) { - return dbContext.Posts.Where(p => p.UserId == userId).AsNoTracking(); + return dbContext.Posts. + Include(p => p.Attachments) + .Where(p => p.UserId == userId).AsNoTracking(); } public async Task> GetAllAsync() diff --git a/Service/LinkedIn/LinkedInService.cs b/Service/LinkedIn/LinkedInService.cs index abc0b1f..ddfb3be 100644 --- a/Service/LinkedIn/LinkedInService.cs +++ b/Service/LinkedIn/LinkedInService.cs @@ -1,4 +1,12 @@ +using Core.Application.DTO.LinkedIn; +using Core.Application.DTO.LinkedIn.Request; +using Core.Application.DTO.LinkedIn.Response; +using Core.Application.DTO.PostAttachment.Response; +using Core.Application.Services; +using Core.Domain.Entities; using Microsoft.Extensions.Configuration; +using Service.Exceptions; +using Service.Services; using System; using System.Collections.Generic; using System.Net.Http; @@ -7,15 +15,12 @@ using System.Text.Json; using System.Threading.Tasks; using System.Xml.Linq; -using Core.Application.DTO.LinkedIn; -using Core.Application.DTO.LinkedIn.Request; -using Core.Application.DTO.LinkedIn.Response; -using Core.Application.Services; -using Service.Exceptions; namespace Service.LinkedIn { - public class LinkedInService(HttpClient client, LinkedInConfig enviromentalConfig, IConfiguration appsettingsConfig) : ILinkedInService + public class LinkedInService(HttpClient client, LinkedInConfig enviromentalConfig, + IUserUploadedFileService userUploadedFileService, + IConfiguration appsettingsConfig) : ILinkedInService { public async Task ExchangeCodeForAccessToken(string code, string redirectUri) @@ -153,14 +158,187 @@ public async Task PostTextAsync(LinkedInPostRequest request) throw new BadRequestException($"LinkedIn post failed: {resp.StatusCode} - {json}"); } - var response = JsonSerializer.Deserialize(json); + if (json.StartsWith("\"")) + { + json = JsonSerializer.Deserialize(json); + } + + using var doc = JsonDocument.Parse(json); + + var id = doc.RootElement.GetProperty("id").GetString(); + + return id; + } + + public async Task PostTextWithMediaAsync(LinkedInPostWithMediaRequest request, string userId) + { + var accessToken = request.AccessToken; + var authorUrn = $"urn:li:person:{request.ExternalAccountId}"; + + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", accessToken); + + client.DefaultRequestHeaders.Remove("X-Restli-Protocol-Version"); + client.DefaultRequestHeaders.Add("X-Restli-Protocol-Version", "2.0.0"); + + var media = await CreateMediaAsync( + request.Attachments, + authorUrn, + accessToken, + userId); + + var body = new + { + author = authorUrn, + lifecycleState = "PUBLISHED", + specificContent = new + { + com_linkedin_ugc_ShareContent = new + { + shareCommentary = new + { + text = request.Content + }, + shareMediaCategory = "IMAGE", + media + } + }, + visibility = new + { + com_linkedin_ugc_MemberNetworkVisibility = "PUBLIC" + } + }; + + var raw = JsonSerializer.Serialize(body) + .Replace("com_linkedin_ugc_ShareContent", "com.linkedin.ugc.ShareContent") + .Replace("com_linkedin_ugc_MemberNetworkVisibility", "com.linkedin.ugc.MemberNetworkVisibility"); + + var resp = await client.PostAsync( + $"{appsettingsConfig["LinkedIn:ApiUrl"]}ugcPosts", + new StringContent(raw, Encoding.UTF8, "application/json")); + + var json = await resp.Content.ReadAsStringAsync(); - if(response == null) + if (!resp.IsSuccessStatusCode) + { + throw new BadRequestException($"LinkedIn post failed: {resp.StatusCode} - {json}"); + } + + if (json.StartsWith("\"")) { - throw new BadRequestException("Failed to parse LinkedIn post response."); + json = JsonSerializer.Deserialize(json); } - return response.Id; + using var doc = JsonDocument.Parse(json); + + var id = doc.RootElement.GetProperty("id").GetString(); + + return id; + } + + + private async Task> CreateMediaAsync( + IEnumerable attachments, + string ownerUrn, + string accessToken, + string userId) + { + var media = new List(); + + foreach (var attachment in attachments) + { + var (asset, uploadUrl) = await RegisterUploadAsync(ownerUrn); + + await UploadMediaAsync(uploadUrl, accessToken, attachment, userId); + + media.Add(new + { + status = "READY", + media = asset + }); + } + + return media; } + + + private async Task<(string Asset, string UploadUrl)> RegisterUploadAsync(string ownerUrn) + { + var body = new + { + registerUploadRequest = new + { + recipes = new[] + { + "urn:li:digitalmediaRecipe:feedshare-image" + }, + owner = ownerUrn, + serviceRelationships = new[] + { + new + { + relationshipType = "OWNER", + identifier = "urn:li:userGeneratedContent" + } + } + } + }; + + var response = await client.PostAsync( + $"{appsettingsConfig["LinkedIn:ApiUrl"]}assets?action=registerUpload", + new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json")); + + var json = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + throw new BadRequestException($"LinkedIn register upload failed: {json}"); + + using var document = JsonDocument.Parse(json); + + var value = document.RootElement.GetProperty("value"); + + var asset = value.GetProperty("asset").GetString()!; + + var uploadUrl = value + .GetProperty("uploadMechanism") + .GetProperty("com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest") + .GetProperty("uploadUrl") + .GetString()!; + + return (asset, uploadUrl); + } + + + private async Task UploadMediaAsync( + string uploadUrl, + string accessToken, + PostAttachmentResponse attachment, + string userId) + { + var fileResult = await userUploadedFileService.DownloadFileById(attachment.UserUploadedFileId, userId); + + fileResult.FileStream.Position = 0; + + using var request = new HttpRequestMessage(HttpMethod.Post, uploadUrl); + + request.Headers.Authorization = + new AuthenticationHeaderValue("Bearer", accessToken); + + request.Content = new StreamContent(fileResult.FileStream); + + request.Content.Headers.ContentType = + new MediaTypeHeaderValue(fileResult.ContentType); + + var response = await client.SendAsync(request); + + if (!response.IsSuccessStatusCode) + { + var error = await response.Content.ReadAsStringAsync(); + throw new BadRequestException($"LinkedIn upload failed: {error}"); + } + } + } + + } diff --git a/Service/Mapping/AutoMapperProfile.cs b/Service/Mapping/AutoMapperProfile.cs index 8b3d406..b7c3cbb 100644 --- a/Service/Mapping/AutoMapperProfile.cs +++ b/Service/Mapping/AutoMapperProfile.cs @@ -48,7 +48,10 @@ public AutoMapperProfile() CreateMap(); - CreateMap(); + CreateMap() + .ForMember(d => d.AccountUsername, o => o.MapFrom(s => s.UserPlatform.AccountUsername)) + .ForMember(d => d.PostBody, o => o.MapFrom(s => s.Post.Body)); + CreateMap(); CreateMap() diff --git a/Service/Services/PostPublicationService.cs b/Service/Services/PostPublicationService.cs index d6deee9..6803768 100644 --- a/Service/Services/PostPublicationService.cs +++ b/Service/Services/PostPublicationService.cs @@ -1,34 +1,51 @@ using AutoMapper; +using AutoMapper.QueryableExtensions; using Core.Application.DTO.LinkedIn.Request; +using Core.Application.DTO.Post.Request; +using Core.Application.DTO.Post.Response; using Core.Application.DTO.PostPublication.Request; using Core.Application.DTO.PostPublication.Response; +using Core.Application.Helpers; +using Core.Application.Services; using Core.Domain.Entities; using Core.Domain.Enums; -using Core.Application.Helpers; using Core.Infrastructure.Repositories; -using Core.Application.Services; +using Microsoft.EntityFrameworkCore; +using Repository.Repositories; using Service.Exceptions; +using System.Linq.Dynamic.Core; namespace Service.Services { public class PostPublicationService(IMapper mapper, IPostService postService, IUserPlatformService userPlatformService, - IPostPublicationRepository postPublicationRepository, ILinkedInService linkedInService) : BaseHelpers, IPostPublishService + IPostPublicationRepository postPublicationRepository, ILinkedInService linkedInService) + : BaseHelpers, IPostPublishService { - public async Task PublishPostToLinkedInAsync(PublishPostRequest request, string userId) + public async Task> GetUserPostPublicationsAsync(PostPublicationParamsRequest request,string userId) { - var post = await postService.GetUserPostById(request.PostId, userId); + var posts = postPublicationRepository.GetAllAsQueryPerUserIdAsync(userId); - if(post == null) - throw new NotFoundException("Post not found"); + ValidateQueryParamsDates(request.PublishedBefore, request.PublishedAfter); + + posts = ApplyFilters(request, posts); + + var sortColumn = request.SortBy?.ToString() ?? "Id"; + var direction = request.IsAscending ? "asc" : "desc"; + + posts = posts.OrderBy($"{sortColumn} {direction}"); + + return await posts + .ProjectTo(mapper.ConfigurationProvider) + .ToListAsync(); + } + + public async Task PublishPostToLinkedInAsync(PublishPostRequest request, string userId) + { + var post = await postService.GetPostByIdLong(request.PostId, userId); if(post.Body == null) throw new BadRequestException("Post body cannot be null"); - if (post.UserId != userId) - { - throw new UnauthorizedException("User is not the owner of the post"); - } - // to verify if the user has the platform linked before trying to publish and is the owner of the platform var userPlatform = await userPlatformService.GetUserPlatformByIdAsync(request.UserPlatformId, userId); @@ -37,15 +54,31 @@ public async Task PublishPostToLinkedInAsync(PublishPos //publication logic - LinkedInPostRequest linkedInPostRequest = new LinkedInPostRequest + string? code; + + if (post.Attachments != null && post.Attachments.Count > 0) { - AccessToken = userPlatform.AccessToken, - Content = post.Body, - ExternalAccountId = userPlatform.ExternalAccountId - }; - + LinkedInPostWithMediaRequest linkedInPostWithMediaRequest = new() + { + AccessToken = userPlatform.AccessToken, + Content = post.Body, + ExternalAccountId = userPlatform.ExternalAccountId, + Attachments = post.Attachments + }; - var code = await linkedInService.PostTextAsync(linkedInPostRequest); + code = await linkedInService.PostTextWithMediaAsync(linkedInPostWithMediaRequest, userId); + } + else + { + LinkedInPostRequest linkedInPostRequest = new() + { + AccessToken = userPlatform.AccessToken, + Content = post.Body, + ExternalAccountId = userPlatform.ExternalAccountId + }; + + code = await linkedInService.PostTextAsync(linkedInPostRequest); + } var postPublicationEntity = mapper.Map(request); @@ -56,11 +89,44 @@ public async Task PublishPostToLinkedInAsync(PublishPos await postPublicationRepository.AddAsync(postPublicationEntity); await postPublicationRepository.SaveChangesAsync(); - var response = mapper.Map(postPublicationEntity); + PostPublicationResponse response = new() + { + AccountUsername = userPlatform.AccountUsername, + ExternalPostId = code, + PlatformType = PlatformType.LinkedIn, + PostBody = post.Body, + PublishedAt = postPublicationEntity.PublishedAt, + Status = PostPublicationStatus.Published, + }; return response; } + private static IQueryable ApplyFilters(PostPublicationParamsRequest request, IQueryable postPublications) + { + + if (request.Status != null) + postPublications = postPublications.Where(p => p.Status == request.Status); + + if(request.PostId != null) + postPublications = postPublications.Where(p => p.PostId == request.PostId); + + if(request.UserPlatformId != null) + postPublications = postPublications.Where(p => p.UserPlatformId == request.UserPlatformId); + + if (request.PublishedAfter != null) + postPublications = postPublications.Where(p => p.PublishedAt >= request.PublishedAfter); + + if (request.PublishedBefore != null) + postPublications = postPublications.Where(p => p.PublishedAt <= request.PublishedBefore); + + if(request.PublishedOn != null) + postPublications = postPublications.Where(p => p.UserPlatform.Platform.Type == request.PublishedOn); + + + return postPublications; + } + // TODO : implement scheduling and other platforms } } diff --git a/Service/Services/PostService.cs b/Service/Services/PostService.cs index eec2fa7..e5e069b 100644 --- a/Service/Services/PostService.cs +++ b/Service/Services/PostService.cs @@ -73,7 +73,7 @@ public async Task> GetAllPostsAsync(PostPaginatedParamsReques .ToListAsync(); } - public async Task> GetAllPostsPerUserAsync(PostPaginatedParamsRequest request, string userId) + public async Task> GetAllPostsPerUserAsync(PostPaginatedParamsRequest request, string userId) { var posts = postRepository.GetAllAsQueryPerUserIdAsync(userId); @@ -87,7 +87,7 @@ public async Task> GetAllPostsPerUserAsync(PostPaginatedParam posts = posts.OrderBy($"{sortColumn} {direction}"); return await posts - .ProjectTo(mapper.ConfigurationProvider) + .ProjectTo(mapper.ConfigurationProvider) .ToListAsync(); } diff --git a/Web/Controllers/PostController.cs b/Web/Controllers/PostController.cs index 432bf79..b842d25 100644 --- a/Web/Controllers/PostController.cs +++ b/Web/Controllers/PostController.cs @@ -34,14 +34,14 @@ public async Task> GetAllPosts([FromQuery] PostPagin } [HttpGet] - [ProducesResponseType(typeof(PaginatedList), StatusCodes.Status200OK)] - public async Task> GetAllUserPosts([FromQuery] PostPaginatedParamsRequest request) + [ProducesResponseType(typeof(PaginatedList), StatusCodes.Status200OK)] + public async Task> GetAllUserPosts([FromQuery] PostPaginatedParamsRequest request) { var userId = UserHelpers.GetUserIdFromClaims(User); var posts = await postService.GetAllPostsPerUserAsync(request, userId); - return await PaginatedList.CreateAsync(posts.AsQueryable(), mapper, request.PageNumber, request.PageSize); + return await PaginatedList.CreateAsync(posts.AsQueryable(), mapper, request.PageNumber, request.PageSize); } [HttpGet("{id:int}")] diff --git a/Web/Controllers/PostPublishController.cs b/Web/Controllers/PostPublishController.cs index be68c8d..b7f6758 100644 --- a/Web/Controllers/PostPublishController.cs +++ b/Web/Controllers/PostPublishController.cs @@ -3,13 +3,16 @@ using Web.Helpers; using Core.Application.DTO.PostPublication.Request; using Core.Application.Services; +using Service.Mapping; +using Core.Application.DTO.PostPublication.Response; +using AutoMapper; namespace Web.Controllers { [Authorize] [ApiController] [Route("api/[controller]")] - public class PostPublishController(IPostPublishService postPublishService) : ControllerBase + public class PostPublishController(IPostPublishService postPublishService, IMapper mapper) : ControllerBase { [HttpPost("publish/linkedin")] public async Task PublishPostToLinkedIn([FromBody] PublishPostRequest postRequest) @@ -20,5 +23,15 @@ public async Task PublishPostToLinkedIn([FromBody] PublishPostReq return Ok(post); } + + [HttpGet] + public async Task> GetUserPostPublications([FromQuery] PostPublicationParamsRequest request) + { + var userId = UserHelpers.GetUserIdFromClaims(User); + + var posts = await postPublishService.GetUserPostPublicationsAsync(request, userId); + + return await PaginatedList.CreateAsync(posts.AsQueryable(), mapper, request.PageNumber, request.PageSize); + } } }