From 6dcccb7bfc4e667812781c34a2b2efeac242e932 Mon Sep 17 00:00:00 2001 From: SculptTechProject <150788324+SculptTechProject@users.noreply.github.com> Date: Thu, 27 Nov 2025 20:16:59 +0100 Subject: [PATCH 1/2] Add support for posts: database migration, domain model, repository, service, DTOs, API endpoints, and related tests. --- .../20251126210845_AddPosts.Designer.cs | 254 ++++++++++++++++++ Migrations/20251126210845_AddPosts.cs | 61 +++++ Migrations/AppDbContextModelSnapshot.cs | 62 +++++ Program.cs | 4 + sparkly-server.sln.DotSettings.user | 8 +- sparkly-server.test/ProjectTest.cs | 1 + src/Controllers/Posts/PostsController.cs | 114 ++++++++ .../Projects/ProjectsController.cs | 4 +- src/Controllers/User/AuthController.cs | 4 +- src/Controllers/User/ProfileController.cs | 5 +- src/DTO/Posts/CreatePostRequest.cs | 4 + src/DTO/Posts/Mapper/PostMapper.cs | 26 ++ src/DTO/Posts/PostResponse.cs | 13 + src/Domain/Posts/Post.cs | 95 +++++++ src/Domain/Projects/Project.cs | 4 +- src/Domain/Users/User.cs | 2 + src/Infrastructure/AppDbContext.cs | 39 +++ .../Auth/{ => provider}/IJwtProvider.cs | 2 +- .../Auth/{ => provider}/JwtProvider.cs | 2 +- .../Auth/{ => service}/AuthService.cs | 9 +- .../Auth/{ => service}/IAuthService.cs | 4 +- src/Services/Posts/repo/IPostRepository.cs | 14 + src/Services/Posts/repo/PostRepository.cs | 111 ++++++++ src/Services/Posts/service/IPostService.cs | 13 + src/Services/Posts/service/PostService.cs | 79 ++++++ .../Projects/{ => repo}/IProjectRepository.cs | 3 +- .../Projects/{ => repo}/ProjectRepository.cs | 3 +- .../Projects/{ => service}/IProjectService.cs | 2 +- .../Projects/{ => service}/ProjectService.cs | 3 +- 29 files changed, 918 insertions(+), 27 deletions(-) create mode 100644 Migrations/20251126210845_AddPosts.Designer.cs create mode 100644 Migrations/20251126210845_AddPosts.cs create mode 100644 src/Controllers/Posts/PostsController.cs create mode 100644 src/DTO/Posts/CreatePostRequest.cs create mode 100644 src/DTO/Posts/Mapper/PostMapper.cs create mode 100644 src/DTO/Posts/PostResponse.cs create mode 100644 src/Domain/Posts/Post.cs rename src/Services/Auth/{ => provider}/IJwtProvider.cs (78%) rename src/Services/Auth/{ => provider}/JwtProvider.cs (98%) rename src/Services/Auth/{ => service}/AuthService.cs (94%) rename src/Services/Auth/{ => service}/IAuthService.cs (74%) create mode 100644 src/Services/Posts/repo/IPostRepository.cs create mode 100644 src/Services/Posts/repo/PostRepository.cs create mode 100644 src/Services/Posts/service/IPostService.cs create mode 100644 src/Services/Posts/service/PostService.cs rename src/Services/Projects/{ => repo}/IProjectRepository.cs (92%) rename src/Services/Projects/{ => repo}/ProjectRepository.cs (99%) rename src/Services/Projects/{ => service}/IProjectService.cs (97%) rename src/Services/Projects/{ => service}/ProjectService.cs (99%) diff --git a/Migrations/20251126210845_AddPosts.Designer.cs b/Migrations/20251126210845_AddPosts.Designer.cs new file mode 100644 index 0000000..ae8a450 --- /dev/null +++ b/Migrations/20251126210845_AddPosts.Designer.cs @@ -0,0 +1,254 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using sparkly_server.Infrastructure; + +#nullable disable + +namespace sparkly_server.Services.Users.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20251126210845_AddPosts")] + partial class AddPosts + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ProjectUser", b => + { + b.Property("MembersId") + .HasColumnType("uuid"); + + b.Property("ProjectsId") + .HasColumnType("uuid"); + + b.HasKey("MembersId", "ProjectsId"); + + b.HasIndex("ProjectsId"); + + b.ToTable("project_members", (string)null); + }); + + modelBuilder.Entity("sparkly_server.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByToken") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedByIp") + .HasColumnType("text"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("sparkly_server.Domain.Posts.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("ProjectId"); + + b.ToTable("posts", (string)null); + }); + + modelBuilder.Entity("sparkly_server.Domain.Projects.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("ProjectName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Visibility") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("ProjectName") + .IsUnique(); + + b.ToTable("projects", (string)null); + }); + + modelBuilder.Entity("sparkly_server.Domain.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("ProjectUser", b => + { + b.HasOne("sparkly_server.Domain.Users.User", null) + .WithMany() + .HasForeignKey("MembersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("sparkly_server.Domain.Projects.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("sparkly_server.Domain.Auth.RefreshToken", b => + { + b.HasOne("sparkly_server.Domain.Users.User", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("sparkly_server.Domain.Posts.Post", b => + { + b.HasOne("sparkly_server.Domain.Users.User", "Author") + .WithMany("Posts") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("sparkly_server.Domain.Projects.Project", "Project") + .WithMany("Posts") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Author"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("sparkly_server.Domain.Projects.Project", b => + { + b.Navigation("Posts"); + }); + + modelBuilder.Entity("sparkly_server.Domain.Users.User", b => + { + b.Navigation("Posts"); + + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20251126210845_AddPosts.cs b/Migrations/20251126210845_AddPosts.cs new file mode 100644 index 0000000..f76550f --- /dev/null +++ b/Migrations/20251126210845_AddPosts.cs @@ -0,0 +1,61 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace sparkly_server.Services.Users.Migrations +{ + /// + public partial class AddPosts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "posts", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + AuthorId = table.Column(type: "uuid", nullable: false), + ProjectId = table.Column(type: "uuid", nullable: true), + Title = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Content = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_posts", x => x.Id); + table.ForeignKey( + name: "FK_posts_projects_ProjectId", + column: x => x.ProjectId, + principalTable: "projects", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_posts_users_AuthorId", + column: x => x.AuthorId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_posts_AuthorId", + table: "posts", + column: "AuthorId"); + + migrationBuilder.CreateIndex( + name: "IX_posts_ProjectId", + table: "posts", + column: "ProjectId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "posts"); + } + } +} diff --git a/Migrations/AppDbContextModelSnapshot.cs b/Migrations/AppDbContextModelSnapshot.cs index a1daabb..7612ded 100644 --- a/Migrations/AppDbContextModelSnapshot.cs +++ b/Migrations/AppDbContextModelSnapshot.cs @@ -73,6 +73,43 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("refresh_tokens", (string)null); }); + modelBuilder.Entity("sparkly_server.Domain.Posts.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("ProjectId"); + + b.ToTable("posts", (string)null); + }); + modelBuilder.Entity("sparkly_server.Domain.Projects.Project", b => { b.Property("Id") @@ -179,8 +216,33 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("sparkly_server.Domain.Posts.Post", b => + { + b.HasOne("sparkly_server.Domain.Users.User", "Author") + .WithMany("Posts") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("sparkly_server.Domain.Projects.Project", "Project") + .WithMany("Posts") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Author"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("sparkly_server.Domain.Projects.Project", b => + { + b.Navigation("Posts"); + }); + modelBuilder.Entity("sparkly_server.Domain.Users.User", b => { + b.Navigation("Posts"); + b.Navigation("RefreshTokens"); }); #pragma warning restore 612, 618 diff --git a/Program.cs b/Program.cs index b6b01ee..e7b8208 100644 --- a/Program.cs +++ b/Program.cs @@ -5,7 +5,11 @@ using sparkly_server.Enum; using sparkly_server.Infrastructure; using sparkly_server.Services.Auth; +using sparkly_server.Services.Auth.provider; +using sparkly_server.Services.Auth.service; using sparkly_server.Services.Projects; +using sparkly_server.Services.Projects.repo; +using sparkly_server.Services.Projects.service; using sparkly_server.Services.Users; using System.Text; diff --git a/sparkly-server.sln.DotSettings.user b/sparkly-server.sln.DotSettings.user index bc41af0..23d04f9 100644 --- a/sparkly-server.sln.DotSettings.user +++ b/sparkly-server.sln.DotSettings.user @@ -1,8 +1,8 @@  ForceIncluded ForceIncluded - <SessionState ContinuousTestingMode="0" Name="CreateProject_Should_Create_Project_For_Authenticated_User" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <TestAncestor> - <TestId>xUnit::E26AB9F3-8A34-4AB8-A503-F0B851823527::net9.0::sparkly_server.test.ProjectTest.CreateProject_Should_Create_Project_For_Authenticated_User</TestId> - </TestAncestor> + <SessionState ContinuousTestingMode="0" Name="CreateProject_Should_Create_Project_For_Authenticated_User" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <TestAncestor> + <TestId>xUnit::E26AB9F3-8A34-4AB8-A503-F0B851823527::net9.0::sparkly_server.test.ProjectTest.CreateProject_Should_Create_Project_For_Authenticated_User</TestId> + </TestAncestor> </SessionState> \ No newline at end of file diff --git a/sparkly-server.test/ProjectTest.cs b/sparkly-server.test/ProjectTest.cs index e962267..f4946d9 100644 --- a/sparkly-server.test/ProjectTest.cs +++ b/sparkly-server.test/ProjectTest.cs @@ -7,6 +7,7 @@ using sparkly_server.Enum; using sparkly_server.Infrastructure; using sparkly_server.Services.Auth; +using sparkly_server.Services.Auth.provider; using sparkly_server.test.config; using System.Net; using Xunit.Abstractions; diff --git a/src/Controllers/Posts/PostsController.cs b/src/Controllers/Posts/PostsController.cs new file mode 100644 index 0000000..2d52caa --- /dev/null +++ b/src/Controllers/Posts/PostsController.cs @@ -0,0 +1,114 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using sparkly_server.Domain.Posts; +using sparkly_server.DTO.Posts; +using sparkly_server.DTO.Posts.Mapper; +using sparkly_server.Services.Posts.service; +using System.Security.Claims; + +namespace sparkly_server.Controllers.Posts +{ + [ApiController] + [Route("api/v1/posts")] + [Authorize] + public class PostsController : ControllerBase + { + private readonly IPostService _posts; + + private PostsController(IPostService posts) => _posts = posts; + + // helper to get the current user's id + private Guid GetUserId() + { + var idValue = User.FindFirstValue(ClaimTypes.NameIdentifier); + if(idValue is null) + { + return Guid.Empty; + } + return Guid.Parse(idValue); + } + + // Controllers + + /// + /// Retrieves a single post by its unique identifier. + /// + /// The unique identifier (GUID) of the post to retrieve. + /// + /// An containing the requested post if found, or a NotFound result if the post does not exist. + /// + [HttpGet("{id:guid}")] + public async Task> GetPostById(Guid id) + { + var post = await _posts.GetPostByIdAsync(id); + + if (post is null) + { + return NotFound(); + } + + return Ok(post); + } + + /// + /// Retrieves a list of posts associated with a specific project. + /// + /// The unique identifier (GUID) of the project for which posts should be retrieved. + /// A to observe while waiting for the task to complete. + /// + /// An containing a read-only list of posts associated with the specified project. + /// + [HttpGet("project/{projectId:guid}")] + public async Task>> GetProjectPosts( + Guid projectId, + [FromQuery] CancellationToken ct) + { + var posts = await _posts.GetProjectPostsAsync(projectId, ct); + return Ok(posts); + } + + /// + /// Retrieves a paginated list of posts for the authenticated user's feed. + /// + /// The page number to retrieve, starting from 1. + /// The number of posts per page. + /// A cancellation token for the operation. + /// + /// An containing a read-only list of posts in the user's feed. + /// + [HttpGet("feed")] + public async Task>> GetFeed( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] CancellationToken ct = default) + { + var userId = GetUserId(); + var posts = await _posts.GetFeedPostAsync(userId, page, pageSize, ct); + return Ok(posts); + } + + /// + /// Creates a new post associated with a specific project and authored by the current user. + /// + /// The details of the post to be created, including project ID, title, and content. + /// + /// An containing the created post's response details, + /// or a BadRequest result if the creation fails. + /// + [HttpPost] + public async Task> Create([FromBody] CreatePostRequest request) + { + Guid userId = GetUserId(); + + var post = await _posts.AddPostAsync( + request.ProjectId, + userId, + request.Title, + request.Content); + + var response = post.ToResponse(); + + return Created(string.Empty, response); + } + } +} diff --git a/src/Controllers/Projects/ProjectsController.cs b/src/Controllers/Projects/ProjectsController.cs index 342db79..a236f0b 100644 --- a/src/Controllers/Projects/ProjectsController.cs +++ b/src/Controllers/Projects/ProjectsController.cs @@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Mvc; using sparkly_server.DTO.Projects; using sparkly_server.DTO.Projects.Feed; -using sparkly_server.Services.Projects; +using sparkly_server.Services.Projects.service; namespace sparkly_server.Controllers.Projects { @@ -13,7 +13,7 @@ public class ProjectsController : ControllerBase { private readonly IProjectService _projects; - public ProjectsController(IProjectService projects) => _projects = projects; + private ProjectsController(IProjectService projects) => _projects = projects; /// /// Retrieves a specified number of random public projects. diff --git a/src/Controllers/User/AuthController.cs b/src/Controllers/User/AuthController.cs index ee2d606..f8c01f6 100644 --- a/src/Controllers/User/AuthController.cs +++ b/src/Controllers/User/AuthController.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using sparkly_server.DTO.Auth; -using sparkly_server.Services.Auth; +using sparkly_server.Services.Auth.service; using sparkly_server.Services.Users; namespace sparkly_server.Controllers.User @@ -13,7 +13,7 @@ public class AuthController : ControllerBase private readonly IUserService _userService; private readonly IAuthService _authService; - public AuthController(IUserService userService, IAuthService authService) + private AuthController(IUserService userService, IAuthService authService) { _userService = userService; _authService = authService; diff --git a/src/Controllers/User/ProfileController.cs b/src/Controllers/User/ProfileController.cs index b97df4e..8d9c6c4 100644 --- a/src/Controllers/User/ProfileController.cs +++ b/src/Controllers/User/ProfileController.cs @@ -1,6 +1,5 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using sparkly_server.Infrastructure; using sparkly_server.Services.Users; namespace sparkly_server.Controllers.User @@ -11,12 +10,10 @@ namespace sparkly_server.Controllers.User public class ProfileController : ControllerBase { private readonly ICurrentUser _currentUser; - private readonly AppDbContext _db; - public ProfileController(ICurrentUser currentUser, AppDbContext db) + private ProfileController(ICurrentUser currentUser) { _currentUser = currentUser; - _db = db; } [HttpGet("me")] diff --git a/src/DTO/Posts/CreatePostRequest.cs b/src/DTO/Posts/CreatePostRequest.cs new file mode 100644 index 0000000..3a89e17 --- /dev/null +++ b/src/DTO/Posts/CreatePostRequest.cs @@ -0,0 +1,4 @@ +namespace sparkly_server.DTO.Posts +{ + public record CreatePostRequest(Guid ProjectId, string Title, string Content); +} diff --git a/src/DTO/Posts/Mapper/PostMapper.cs b/src/DTO/Posts/Mapper/PostMapper.cs new file mode 100644 index 0000000..61a24f4 --- /dev/null +++ b/src/DTO/Posts/Mapper/PostMapper.cs @@ -0,0 +1,26 @@ +using sparkly_server.Domain.Posts; + +namespace sparkly_server.DTO.Posts.Mapper +{ + public static class PostMapper + { + /// + /// Maps a domain object to a DTO. + /// + /// The instance to be mapped. + /// A representing the mapped data. + public static PostResponse ToResponse(this Post post) + { + return new PostResponse + { + Id = post.Id, + ProjectId = post.ProjectId ?? Guid.Empty, + AuthorId = post.AuthorId, + Title = post.Title, + Content = post.Content, + CreatedAt = post.CreatedAt, + UpdatedAt = post.UpdatedAt + }; + } + } +} diff --git a/src/DTO/Posts/PostResponse.cs b/src/DTO/Posts/PostResponse.cs new file mode 100644 index 0000000..85d9b57 --- /dev/null +++ b/src/DTO/Posts/PostResponse.cs @@ -0,0 +1,13 @@ +namespace sparkly_server.DTO.Posts +{ + public class PostResponse + { + public Guid Id { get; set; } + public Guid ProjectId { get; set; } + public Guid AuthorId { get; set; } + public string Title { get; set; } = ""; + public string Content { get; set; } = ""; + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + } +} diff --git a/src/Domain/Posts/Post.cs b/src/Domain/Posts/Post.cs new file mode 100644 index 0000000..6acadee --- /dev/null +++ b/src/Domain/Posts/Post.cs @@ -0,0 +1,95 @@ +using sparkly_server.Domain.Projects; +using sparkly_server.Domain.Users; + +namespace sparkly_server.Domain.Posts +{ + public class Post + { + public Guid Id { get; private set; } + public Guid AuthorId { get; private set; } + public Guid? ProjectId { get; private set; } + public string Title { get; private set; } + public string Content { get; private set; } + public DateTime CreatedAt { get; private set; } + public DateTime UpdatedAt { get; private set; } + public User Author { get; private set; } = null!; + public Project? Project { get; private set; } + + private Post() { } + + private void Touch() + { + UpdatedAt = DateTime.UtcNow; + } + + public static Post CreateProjectUpdate(Guid authorId, Guid projectId, string title, string content) + { + if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(content)) + { + throw new ArgumentException("Title and content are required."); + } + + var post = new Post + { + Id = Guid.NewGuid(), + AuthorId = authorId, + ProjectId = projectId, + Title = title, + Content = content, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + return post; + } + + public static Post CreatePost(Guid projectId, Guid authorId, string title, string content) + { + if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(content)) + { + throw new ArgumentException("Title and content are required."); + } + + var post = new Post + { + Id = Guid.NewGuid(), + ProjectId = projectId, + AuthorId = authorId, + Title = title, + Content = content, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + return post; + } + + public static Post UpdatePost(Post post, string title, string content) + { + if (post is null) + { + throw new ArgumentNullException(nameof(post)); + } + + if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(content)) + { + throw new ArgumentException("New title and content are required."); + } + + post.Title = title.Trim(); + post.Content = content.Trim(); + post.Touch(); + + return post; + } + + public void EnsureCanBeDeletedBy(Guid authorId) + { + if (authorId == Guid.Empty) throw new ArgumentException("AuthorId is required."); + if (authorId != AuthorId) + throw new InvalidOperationException("Only the author can delete this post."); + } + + public bool IsOwner(Guid userId) => AuthorId == userId; + } +} diff --git a/src/Domain/Projects/Project.cs b/src/Domain/Projects/Project.cs index abdc9ee..2103f20 100644 --- a/src/Domain/Projects/Project.cs +++ b/src/Domain/Projects/Project.cs @@ -1,4 +1,5 @@ -using sparkly_server.Domain.Users; +using sparkly_server.Domain.Posts; +using sparkly_server.Domain.Users; using sparkly_server.Enum; namespace sparkly_server.Domain.Projects @@ -17,6 +18,7 @@ public class Project public ProjectVisibility Visibility { get; private set; } private readonly List _members = new(); public IReadOnlyCollection Members => _members.AsReadOnly(); + public ICollection Posts { get; private set; } = new List(); private Project() { } diff --git a/src/Domain/Users/User.cs b/src/Domain/Users/User.cs index f819d3e..50bfed6 100644 --- a/src/Domain/Users/User.cs +++ b/src/Domain/Users/User.cs @@ -1,4 +1,5 @@ using sparkly_server.Domain.Auth; +using sparkly_server.Domain.Posts; using sparkly_server.Domain.Projects; using System.ComponentModel.DataAnnotations; @@ -17,6 +18,7 @@ public class User public ICollection RefreshTokens { get; set; } = new List(); public DateTime CreatedAt { get; private set; } = DateTime.UtcNow; public ICollection Projects { get; private set; } = new List(); + public ICollection Posts { get; private set; } = new List(); private User() { } diff --git a/src/Infrastructure/AppDbContext.cs b/src/Infrastructure/AppDbContext.cs index c7ed389..258037f 100644 --- a/src/Infrastructure/AppDbContext.cs +++ b/src/Infrastructure/AppDbContext.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using sparkly_server.Domain.Auth; +using sparkly_server.Domain.Posts; using sparkly_server.Domain.Projects; using sparkly_server.Domain.Users; using sparkly_server.Enum; @@ -10,6 +11,7 @@ public class AppDbContext : DbContext { public DbSet Users => Set(); public DbSet Projects => Set(); + public DbSet Posts => Set(); public DbSet RefreshTokens { get; set; } = null!; public AppDbContext(DbContextOptions options) @@ -107,6 +109,43 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) }); } ); + + // Posts + modelBuilder.Entity(cfg => + { + cfg.ToTable("posts"); + + cfg.HasKey(p => p.Id); + + cfg.Property(p => p.Title) + .IsRequired() + .HasMaxLength(200); + + cfg.Property(p => p.Content) + .IsRequired() + .HasMaxLength(4000); + + cfg.Property(p => p.CreatedAt) + .IsRequired(); + + cfg.Property(p => p.UpdatedAt) + .IsRequired(); + + cfg.Property(p => p.AuthorId) + .IsRequired(); + + // relation: Post -> Author (User) + cfg.HasOne(p => p.Author) + .WithMany(u => u.Posts) + .HasForeignKey(p => p.AuthorId) + .OnDelete(DeleteBehavior.Cascade); + + // relation: Post -> Project (optional) + cfg.HasOne(p => p.Project) + .WithMany(pr => pr.Posts) + .HasForeignKey(p => p.ProjectId) + .OnDelete(DeleteBehavior.Cascade); + }); } } } diff --git a/src/Services/Auth/IJwtProvider.cs b/src/Services/Auth/provider/IJwtProvider.cs similarity index 78% rename from src/Services/Auth/IJwtProvider.cs rename to src/Services/Auth/provider/IJwtProvider.cs index 784300c..4d10d79 100644 --- a/src/Services/Auth/IJwtProvider.cs +++ b/src/Services/Auth/provider/IJwtProvider.cs @@ -1,6 +1,6 @@ using sparkly_server.Domain.Users; -namespace sparkly_server.Services.Auth +namespace sparkly_server.Services.Auth.provider { public interface IJwtProvider { diff --git a/src/Services/Auth/JwtProvider.cs b/src/Services/Auth/provider/JwtProvider.cs similarity index 98% rename from src/Services/Auth/JwtProvider.cs rename to src/Services/Auth/provider/JwtProvider.cs index f3cbdcf..63002dc 100644 --- a/src/Services/Auth/JwtProvider.cs +++ b/src/Services/Auth/provider/JwtProvider.cs @@ -5,7 +5,7 @@ using System.Security.Cryptography; using System.Text; -namespace sparkly_server.Services.Auth +namespace sparkly_server.Services.Auth.provider { public class JwtProvider : IJwtProvider { diff --git a/src/Services/Auth/AuthService.cs b/src/Services/Auth/service/AuthService.cs similarity index 94% rename from src/Services/Auth/AuthService.cs rename to src/Services/Auth/service/AuthService.cs index eedad74..be28455 100644 --- a/src/Services/Auth/AuthService.cs +++ b/src/Services/Auth/service/AuthService.cs @@ -1,9 +1,10 @@ using Microsoft.EntityFrameworkCore; using sparkly_server.Domain.Auth; using sparkly_server.Infrastructure; +using sparkly_server.Services.Auth.provider; using sparkly_server.Services.Users; -namespace sparkly_server.Services.Auth +namespace sparkly_server.Services.Auth.service { public class AuthService : IAuthService { @@ -64,11 +65,11 @@ public AuthService(IUserService userService, /// The existing refresh token issued to the user for renewing authentication. /// A cancellation token to observe while performing the refresh operation. /// An AuthResult object containing the new access token, the provided refresh token, and their respective expiry times. Returns null if the refresh token is invalid or inactive. - public async Task RefreshAsync(string refreshToken, CancellationToken ct = default) + public async Task RefreshAsync(string refreshToken, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(refreshToken)) { - return null!; + return null; } var entity = await _db.RefreshTokens @@ -77,7 +78,7 @@ public async Task RefreshAsync(string refreshToken, CancellationToke if (entity is null || !entity.IsActive) { - return null!; + return null; } var user = entity.User; diff --git a/src/Services/Auth/IAuthService.cs b/src/Services/Auth/service/IAuthService.cs similarity index 74% rename from src/Services/Auth/IAuthService.cs rename to src/Services/Auth/service/IAuthService.cs index f63d995..05491e5 100644 --- a/src/Services/Auth/IAuthService.cs +++ b/src/Services/Auth/service/IAuthService.cs @@ -1,4 +1,4 @@ -namespace sparkly_server.Services.Auth +namespace sparkly_server.Services.Auth.service { public record AuthResult( string AccessToken, @@ -10,7 +10,7 @@ DateTime RefreshTokenExpiresAt public interface IAuthService { Task LoginAsync(string identifier, string password, CancellationToken ct = default); - Task RefreshAsync(string refreshToken, CancellationToken ct = default); + Task RefreshAsync(string refreshToken, CancellationToken ct = default); Task LogoutAsync(string refreshToken, CancellationToken ct = default); } } diff --git a/src/Services/Posts/repo/IPostRepository.cs b/src/Services/Posts/repo/IPostRepository.cs new file mode 100644 index 0000000..ff99fa5 --- /dev/null +++ b/src/Services/Posts/repo/IPostRepository.cs @@ -0,0 +1,14 @@ +using sparkly_server.Domain.Posts; + +namespace sparkly_server.Services.Posts.repo +{ + public interface IPostRepository + { + Task> GetProjectPostsAsync(Guid projectId, CancellationToken ct = default); + Task AddPostAsync(Post post, CancellationToken ct = default); + Task> GetUserFeedPostsAsync(Guid userId, int page, int pageSize, CancellationToken ct = default); + Task> GetPostsForUserAsync(Guid userId, CancellationToken ct = default); + Task GetPostByIdAsync(Guid id, CancellationToken ct = default); + Task DeletePostAsync(Guid id, CancellationToken ct = default); + } +} diff --git a/src/Services/Posts/repo/PostRepository.cs b/src/Services/Posts/repo/PostRepository.cs new file mode 100644 index 0000000..45f850c --- /dev/null +++ b/src/Services/Posts/repo/PostRepository.cs @@ -0,0 +1,111 @@ +using Microsoft.EntityFrameworkCore; +using sparkly_server.Domain.Posts; +using sparkly_server.Infrastructure; + +namespace sparkly_server.Services.Posts.repo +{ + public class PostRepository : IPostRepository + { + private readonly AppDbContext _db; + + public PostRepository(AppDbContext db) + { + _db = db; + } + + /// + /// Saves a new or updated post to the database asynchronously. + /// + /// The post entity to be saved. + /// A cancellation token to observe while waiting for the task to complete. + /// The saved post entity. + private async Task SavePostAsync(Post post, CancellationToken ct) + { + await _db.Posts.AddAsync(post, ct); + await _db.SaveChangesAsync(ct); + return post; + } + + /// + /// Retrieves all posts associated with a specific project, ordered by their creation date in descending order. + /// + /// The unique identifier of the project for which posts are being retrieved. + /// A cancellation token to observe while waiting for the task to complete. + /// A read-only list of posts associated with the specified project. + public async Task> GetProjectPostsAsync(Guid projectId, CancellationToken ct = default) + { + var posts = await _db.Posts + .Where(p => p.ProjectId == projectId) + .OrderByDescending(p => p.CreatedAt) + .ToListAsync(ct); + + return posts; + } + + /// + /// Adds a new post to the database asynchronously. + /// + /// The post entity to be added. + /// A cancellation token to observe while waiting for the task to complete. + /// The added post entity. + public Task AddPostAsync(Post post, CancellationToken ct = default) + => SavePostAsync(post, ct); + + /// + /// Retrieves a collection of posts authored by a specific user, ordered by creation date in descending order, asynchronously. + /// + /// The unique identifier of the user whose posts are being retrieved. + /// A cancellation token to observe while waiting for the task to complete. + /// A read-only list of posts authored by the specified user. + public async Task> GetPostsForUserAsync(Guid userId, CancellationToken ct = default) + { + return await _db.Posts + .Where(p => p.AuthorId == userId) + .OrderByDescending(p => p.CreatedAt) + .ToListAsync(ct); + } + + /// + /// Retrieves a paginated list of posts authored by the specified user, ordered by creation date in descending order. + /// + /// The unique identifier of the user whose posts are to be retrieved. + /// The page number to retrieve, starting from 1. + /// The number of posts to retrieve per page. + /// A cancellation token to observe while waiting for the task to complete. + /// A read-only list of posts authored by the specified user for the given page. + public async Task> GetUserFeedPostsAsync(Guid userId, int page, int pageSize, CancellationToken ct = default) + { + return await _db.Posts + .Where(p => p.AuthorId == userId) // na start prosta wersja feedu + .OrderByDescending(p => p.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(ct); + } + + /// + /// Retrieves a post by its unique identifier asynchronously. + /// + /// The unique identifier of the post to retrieve. + /// A cancellation token to observe while waiting for the task to complete. + /// The post entity if found; otherwise, null. + public async Task GetPostByIdAsync(Guid id, CancellationToken ct = default) + { + var post = await _db.Posts.FirstOrDefaultAsync(p => p.Id == id, ct); + return post; + } + + /// + /// Deletes a post from the database asynchronously. + /// + /// The unique identifier of the post to be deleted. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous delete operation. + public async Task DeletePostAsync(Guid id, CancellationToken ct = default) + { + await _db.Posts + .Where(p => p.Id == id) + .ExecuteDeleteAsync(ct); + } + } +} diff --git a/src/Services/Posts/service/IPostService.cs b/src/Services/Posts/service/IPostService.cs new file mode 100644 index 0000000..56b733e --- /dev/null +++ b/src/Services/Posts/service/IPostService.cs @@ -0,0 +1,13 @@ +using sparkly_server.Domain.Posts; + +namespace sparkly_server.Services.Posts.service +{ + public interface IPostService + { + Task AddPostAsync(Guid projectId, Guid userId, string title, string content); + Task GetPostByIdAsync(Guid id); + Task DeletePostAsync(Guid postId, Guid userId); + Task> GetFeedPostAsync(Guid userId, int page, int pageSize, CancellationToken ct = default); + Task> GetProjectPostsAsync(Guid projectId, CancellationToken ct = default); + } +} diff --git a/src/Services/Posts/service/PostService.cs b/src/Services/Posts/service/PostService.cs new file mode 100644 index 0000000..c5b371c --- /dev/null +++ b/src/Services/Posts/service/PostService.cs @@ -0,0 +1,79 @@ +using sparkly_server.Domain.Posts; +using sparkly_server.Services.Posts.repo; + +namespace sparkly_server.Services.Posts.service +{ + public class PostService : IPostService + { + private readonly IPostRepository _posts; + + public PostService(IPostRepository posts) + { + _posts = posts; + } + + public Task AddPostAsync(Guid projectId, Guid userId, string title, string content) + { + var newPost = Post.CreatePost(projectId, userId, title, content); + return _posts.AddPostAsync(newPost); + } + + public Task GetPostByIdAsync(Guid id) + { + if (id == Guid.Empty) + { + throw new ArgumentException("Post ID cannot be empty.", nameof(id)); + } + + return _posts.GetPostByIdAsync(id); + } + + public async Task DeletePostAsync(Guid postId, Guid userId) + { + if (postId == Guid.Empty) + throw new ArgumentException("Post ID cannot be empty.", nameof(postId)); + + if (userId == Guid.Empty) + throw new ArgumentException("User ID cannot be empty.", nameof(userId)); + + var post = await _posts.GetPostByIdAsync(postId); + + if (post is null) + { + throw new InvalidOperationException("Post not found"); + } + + if (!post.IsOwner(userId)) + { + throw new UnauthorizedAccessException("You are not the owner of this post."); + } + + await _posts.DeletePostAsync(postId); + } + + public Task> GetFeedPostAsync( + Guid userId, int page, int pageSize, CancellationToken ct = default) + { + if (userId == Guid.Empty) + throw new ArgumentException("User ID cannot be empty.", nameof(userId)); + + if (page <= 0) + throw new ArgumentOutOfRangeException(nameof(page), "Page must be greater than 0."); + + if (pageSize <= 0 || pageSize > 100) + throw new ArgumentOutOfRangeException(nameof(pageSize), "Page size must be between 1 and 100."); + + return _posts.GetUserFeedPostsAsync(userId, page, pageSize, ct); + } + + public Task> GetProjectPostsAsync(Guid projectId, CancellationToken ct = default) + { + if (projectId == Guid.Empty) + { + throw new ArgumentException("Project ID cannot be empty.", nameof(projectId)); + } + + return _posts.GetProjectPostsAsync(projectId, ct); + } + } +} diff --git a/src/Services/Projects/IProjectRepository.cs b/src/Services/Projects/repo/IProjectRepository.cs similarity index 92% rename from src/Services/Projects/IProjectRepository.cs rename to src/Services/Projects/repo/IProjectRepository.cs index ef25455..70a832b 100644 --- a/src/Services/Projects/IProjectRepository.cs +++ b/src/Services/Projects/repo/IProjectRepository.cs @@ -1,8 +1,7 @@ using sparkly_server.Domain.Projects; -using sparkly_server.DTO.Projects; using sparkly_server.DTO.Projects.Feed; -namespace sparkly_server.Services.Projects +namespace sparkly_server.Services.Projects.repo { public interface IProjectRepository { diff --git a/src/Services/Projects/ProjectRepository.cs b/src/Services/Projects/repo/ProjectRepository.cs similarity index 99% rename from src/Services/Projects/ProjectRepository.cs rename to src/Services/Projects/repo/ProjectRepository.cs index e250bc2..08eb722 100644 --- a/src/Services/Projects/ProjectRepository.cs +++ b/src/Services/Projects/repo/ProjectRepository.cs @@ -1,11 +1,10 @@ using Microsoft.EntityFrameworkCore; using sparkly_server.Domain.Projects; -using sparkly_server.DTO.Projects; using sparkly_server.DTO.Projects.Feed; using sparkly_server.Enum; using sparkly_server.Infrastructure; -namespace sparkly_server.Services.Projects +namespace sparkly_server.Services.Projects.repo { public class ProjectRepository : IProjectRepository { diff --git a/src/Services/Projects/IProjectService.cs b/src/Services/Projects/service/IProjectService.cs similarity index 97% rename from src/Services/Projects/IProjectService.cs rename to src/Services/Projects/service/IProjectService.cs index bd0b883..ff90a80 100644 --- a/src/Services/Projects/IProjectService.cs +++ b/src/Services/Projects/service/IProjectService.cs @@ -3,7 +3,7 @@ using sparkly_server.DTO.Projects.Feed; using sparkly_server.Enum; -namespace sparkly_server.Services.Projects +namespace sparkly_server.Services.Projects.service { public interface IProjectService { diff --git a/src/Services/Projects/ProjectService.cs b/src/Services/Projects/service/ProjectService.cs similarity index 99% rename from src/Services/Projects/ProjectService.cs rename to src/Services/Projects/service/ProjectService.cs index d88a123..a60cb05 100644 --- a/src/Services/Projects/ProjectService.cs +++ b/src/Services/Projects/service/ProjectService.cs @@ -2,9 +2,10 @@ using sparkly_server.DTO.Projects; using sparkly_server.DTO.Projects.Feed; using sparkly_server.Enum; +using sparkly_server.Services.Projects.repo; using sparkly_server.Services.Users; -namespace sparkly_server.Services.Projects +namespace sparkly_server.Services.Projects.service { public class ProjectService : IProjectService { From 383cd8b52c5699117f89cd167a52a1b87b678cf0 Mon Sep 17 00:00:00 2001 From: Mateusz Dalke Date: Sat, 29 Nov 2025 21:35:27 +0100 Subject: [PATCH 2/2] Refactor user and post-related services, add XML comments, and enhance post functionality. - Renamed and reorganized namespaces for `Users` and `Posts` services to improve structure. - Deleted and recreated `CurrentUser.cs` under a new namespace for better organization. - Added extensive XML comments and documentation for methods in `Post`, `PostService`, and related services. - Introduced `UpdatePostRequest` DTO for post updates. - Enhanced post creation by separating feed and project post logic in `PostService`. - Updated `PostResponse` DTO to include `CanEdit` and `CanDelete` flags for user-specific features. - Adjusted `IUserRepository`, `IUserService`, and respective implementations to use updated namespaces. - Added functionality in `PostRepository` to support post updates. - Updated DI container in `Program.cs` to reflect changes. --- Program.cs | 11 ++ sparkly-server.sln.DotSettings.user | 8 +- src/Controllers/Posts/PostsController.cs | 126 +++++++++++++++--- .../Projects/ProjectsController.cs | 2 +- src/Controllers/User/AuthController.cs | 31 ++++- src/Controllers/User/ProfileController.cs | 10 +- src/DTO/Posts/CreatePostRequest.cs | 2 +- src/DTO/Posts/Mapper/PostMapper.cs | 15 ++- src/DTO/Posts/PostResponse.cs | 2 + src/DTO/Posts/UpdatePostRequest.cs | 4 + src/Domain/Posts/Post.cs | 99 ++++++++++---- src/Services/Auth/service/AuthService.cs | 1 + src/Services/Posts/repo/IPostRepository.cs | 1 + src/Services/Posts/repo/PostRepository.cs | 14 +- src/Services/Posts/service/IPostService.cs | 6 +- src/Services/Posts/service/PostService.cs | 104 +++++++++++++-- .../Projects/service/ProjectService.cs | 2 + src/Services/Users/CurrentUser.cs | 26 ---- src/Services/Users/CurrentUser/CurrentUser.cs | 66 +++++++++ .../Users/{ => CurrentUser}/ICurrentUser.cs | 2 +- .../Users/{ => repo}/IUserRepository.cs | 2 +- .../Users/{ => repo}/UserRepository.cs | 2 +- .../Users/{ => service}/IUserService.cs | 2 +- .../Users/{ => service}/UserService.cs | 3 +- 24 files changed, 434 insertions(+), 107 deletions(-) create mode 100644 src/DTO/Posts/UpdatePostRequest.cs delete mode 100644 src/Services/Users/CurrentUser.cs create mode 100644 src/Services/Users/CurrentUser/CurrentUser.cs rename src/Services/Users/{ => CurrentUser}/ICurrentUser.cs (82%) rename src/Services/Users/{ => repo}/IUserRepository.cs (91%) rename src/Services/Users/{ => repo}/UserRepository.cs (95%) rename src/Services/Users/{ => service}/IUserService.cs (86%) rename src/Services/Users/{ => service}/UserService.cs (97%) diff --git a/Program.cs b/Program.cs index e7b8208..f137085 100644 --- a/Program.cs +++ b/Program.cs @@ -7,10 +7,15 @@ using sparkly_server.Services.Auth; using sparkly_server.Services.Auth.provider; using sparkly_server.Services.Auth.service; +using sparkly_server.Services.Posts.repo; +using sparkly_server.Services.Posts.service; using sparkly_server.Services.Projects; using sparkly_server.Services.Projects.repo; using sparkly_server.Services.Projects.service; using sparkly_server.Services.Users; +using sparkly_server.Services.Users.CurrentUser; +using sparkly_server.Services.Users.repo; +using sparkly_server.Services.Users.service; using System.Text; var builder = WebApplication.CreateBuilder(args); @@ -51,12 +56,18 @@ // Domain / app services builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + // Database if (builder.Environment.IsEnvironment("Testing")) { diff --git a/sparkly-server.sln.DotSettings.user b/sparkly-server.sln.DotSettings.user index 23d04f9..bc41af0 100644 --- a/sparkly-server.sln.DotSettings.user +++ b/sparkly-server.sln.DotSettings.user @@ -1,8 +1,8 @@  ForceIncluded ForceIncluded - <SessionState ContinuousTestingMode="0" Name="CreateProject_Should_Create_Project_For_Authenticated_User" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <TestAncestor> - <TestId>xUnit::E26AB9F3-8A34-4AB8-A503-F0B851823527::net9.0::sparkly_server.test.ProjectTest.CreateProject_Should_Create_Project_For_Authenticated_User</TestId> - </TestAncestor> + <SessionState ContinuousTestingMode="0" Name="CreateProject_Should_Create_Project_For_Authenticated_User" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <TestAncestor> + <TestId>xUnit::E26AB9F3-8A34-4AB8-A503-F0B851823527::net9.0::sparkly_server.test.ProjectTest.CreateProject_Should_Create_Project_For_Authenticated_User</TestId> + </TestAncestor> </SessionState> \ No newline at end of file diff --git a/src/Controllers/Posts/PostsController.cs b/src/Controllers/Posts/PostsController.cs index 2d52caa..3c887be 100644 --- a/src/Controllers/Posts/PostsController.cs +++ b/src/Controllers/Posts/PostsController.cs @@ -15,9 +15,14 @@ public class PostsController : ControllerBase { private readonly IPostService _posts; - private PostsController(IPostService posts) => _posts = posts; + public PostsController(IPostService posts) => _posts = posts; - // helper to get the current user's id + /// + /// Retrieves the unique identifier (GUID) of the currently authenticated user. + /// + /// + /// A representing the user's unique identifier, or if the user is not authenticated. + /// private Guid GetUserId() { var idValue = User.FindFirstValue(ClaimTypes.NameIdentifier); @@ -68,47 +73,130 @@ public async Task>> GetProjectPosts( } /// - /// Retrieves a paginated list of posts for the authenticated user's feed. + /// Retrieves a paginated feed of posts for the current user. /// - /// The page number to retrieve, starting from 1. - /// The number of posts per page. - /// A cancellation token for the operation. + /// The page number of the feed to retrieve. Defaults to 1. + /// The number of posts to include per page. Defaults to 20. + /// A cancellation token to observe while awaiting the task. /// - /// An containing a read-only list of posts in the user's feed. + /// An containing a list of objects representing the user's feed. /// [HttpGet("feed")] - public async Task>> GetFeed( + public async Task>> GetFeed( [FromQuery] int page = 1, [FromQuery] int pageSize = 20, - [FromQuery] CancellationToken ct = default) + CancellationToken ct = default) { var userId = GetUserId(); + Console.WriteLine($"FEED userId = {userId}"); + var posts = await _posts.GetFeedPostAsync(userId, page, pageSize, ct); - return Ok(posts); + + var response = posts + .Select(p => p.ToResponse(userId)) + .ToList(); + + return Ok(response); } + /// - /// Creates a new post associated with a specific project and authored by the current user. + /// Creates a new post associated with a specific project. /// - /// The details of the post to be created, including project ID, title, and content. + /// The details of the post to be created, including title and content. + /// The unique identifier (GUID) of the project the post belongs to. /// - /// An containing the created post's response details, - /// or a BadRequest result if the creation fails. + /// An containing the created post response including its properties. /// - [HttpPost] - public async Task> Create([FromBody] CreatePostRequest request) + [HttpPost("create/project/{projectId:guid}")] + public async Task> Create([FromBody] CreatePostRequest request, [FromRoute] Guid projectId) { Guid userId = GetUserId(); - var post = await _posts.AddPostAsync( - request.ProjectId, + var post = await _posts.AddProjectPostAsync( userId, + projectId, request.Title, request.Content); - var response = post.ToResponse(); + var response = post.ToResponse(userId); return Created(string.Empty, response); } + + /// + /// Creates a new post for the feed with the specified content and title. + /// + /// The details of the post to create, including title and content. + /// The cancellation token to monitor for request cancellation. + /// + /// An containing the created feed post details. + /// + [HttpPost("create/feed")] + public async Task> CreateFeedPost( + [FromBody] CreatePostRequest request, + CancellationToken ct) + { + var userId = GetUserId(); + + var post = await _posts.AddFeedPostAsync( + userId, + request.Title, + request.Content, + ct); + + var response = post.ToResponse(userId); + + return Created(string.Empty, response); + } + + /// + /// Updates an existing post with new information provided by the user. + /// + /// The unique identifier (GUID) of the post to be updated. + /// An containing the new title and content for the post. + /// A to observe while waiting for the task to complete. + /// + /// An indicating the result of the update operation. + /// Returns a NotFound result if the post does not exist, or an Ok result containing the updated post. + /// + [HttpPut("{postId:guid}")] + public async Task Update([FromRoute] Guid postId, [FromBody] UpdatePostRequest request, + CancellationToken ct = default) + { + Guid userId = GetUserId(); + + var updatedPost = await _posts.UpdatePostAsync( + postId, + userId, + request.Title, + request.Content, + ct); + + if (updatedPost is null) + { + return NotFound(); + } + + return Ok(updatedPost); + } + + /// + /// Deletes a post specified by its unique identifier. + /// + /// The unique identifier (GUID) of the post to delete. + /// + /// An indicating the result of the delete operation. + /// Returns a NoContent response if the deletion is successful. + /// + [HttpDelete("{postId:guid}")] + public async Task Delete([FromRoute] Guid postId) + { + Guid userId = GetUserId(); + + await _posts.DeletePostAsync(postId, userId); + + return NoContent(); + } } } diff --git a/src/Controllers/Projects/ProjectsController.cs b/src/Controllers/Projects/ProjectsController.cs index a236f0b..0dfadd7 100644 --- a/src/Controllers/Projects/ProjectsController.cs +++ b/src/Controllers/Projects/ProjectsController.cs @@ -13,7 +13,7 @@ public class ProjectsController : ControllerBase { private readonly IProjectService _projects; - private ProjectsController(IProjectService projects) => _projects = projects; + public ProjectsController(IProjectService projects) => _projects = projects; /// /// Retrieves a specified number of random public projects. diff --git a/src/Controllers/User/AuthController.cs b/src/Controllers/User/AuthController.cs index f8c01f6..d5bf0a7 100644 --- a/src/Controllers/User/AuthController.cs +++ b/src/Controllers/User/AuthController.cs @@ -3,6 +3,7 @@ using sparkly_server.DTO.Auth; using sparkly_server.Services.Auth.service; using sparkly_server.Services.Users; +using sparkly_server.Services.Users.service; namespace sparkly_server.Controllers.User { @@ -13,12 +14,18 @@ public class AuthController : ControllerBase private readonly IUserService _userService; private readonly IAuthService _authService; - private AuthController(IUserService userService, IAuthService authService) + public AuthController(IUserService userService, IAuthService authService) { _userService = userService; _authService = authService; } + /// + /// Registers a new user with the provided registration details. + /// + /// An object containing the user's username, email, and password. + /// A cancellation token to cancel the operation if needed. + /// An asynchronous operation result indicating the outcome of the registration process. [AllowAnonymous] [HttpPost("register")] public async Task Register([FromBody] RegisterRequest request, CancellationToken ct) @@ -28,6 +35,12 @@ public async Task Register([FromBody] RegisterRequest request, Ca return NoContent(); } + /// + /// Authenticates a user with the provided login credentials. + /// + /// An object containing the user's identifier (username or email) and password. + /// A cancellation token to cancel the operation if needed. + /// An asynchronous operation result containing authentication tokens if successful, or an unauthorized response if credentials are invalid. [AllowAnonymous] [HttpPost("login")] public async Task Login([FromBody] LoginRequest request, CancellationToken ct) @@ -45,7 +58,13 @@ public async Task Login([FromBody] LoginRequest request, Cancella return Ok(response); } - + + /// + /// Logs out a user by invalidating their refresh token. + /// + /// An object containing the refresh token to be invalidated. + /// A cancellation token to cancel the operation if needed. + /// An asynchronous operation result indicating the outcome of the logout process. [Authorize] [HttpPost("logout")] public async Task Logout([FromBody] LogoutRequest request, CancellationToken ct) @@ -53,7 +72,13 @@ public async Task Logout([FromBody] LogoutRequest request, Cancel await _authService.LogoutAsync(request.RefreshToken, ct); return NoContent(); } - + + /// + /// Issues a new access token and refresh token pair using a valid refresh token. + /// + /// An object containing the current refresh token. + /// A cancellation token to cancel the operation if needed. + /// An asynchronous result containing the newly issued tokens or an appropriate error response. [HttpPost("refresh")] [AllowAnonymous] public async Task Refresh( diff --git a/src/Controllers/User/ProfileController.cs b/src/Controllers/User/ProfileController.cs index 8d9c6c4..051c498 100644 --- a/src/Controllers/User/ProfileController.cs +++ b/src/Controllers/User/ProfileController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using sparkly_server.Services.Users; +using sparkly_server.Services.Users.CurrentUser; namespace sparkly_server.Controllers.User { @@ -11,11 +12,18 @@ public class ProfileController : ControllerBase { private readonly ICurrentUser _currentUser; - private ProfileController(ICurrentUser currentUser) + public ProfileController(ICurrentUser currentUser) { _currentUser = currentUser; } + /// + /// Retrieves the current authenticated user's profile details such as UserId, Email, UserName, and Role. + /// + /// + /// An HTTP 200 OK response containing the user's profile information if the user is authenticated. + /// An HTTP 401 Unauthorized response if the user is not authenticated. + /// [HttpGet("me")] public IActionResult Me() { diff --git a/src/DTO/Posts/CreatePostRequest.cs b/src/DTO/Posts/CreatePostRequest.cs index 3a89e17..66bf73e 100644 --- a/src/DTO/Posts/CreatePostRequest.cs +++ b/src/DTO/Posts/CreatePostRequest.cs @@ -1,4 +1,4 @@ namespace sparkly_server.DTO.Posts { - public record CreatePostRequest(Guid ProjectId, string Title, string Content); + public record CreatePostRequest(string Title, string Content); } diff --git a/src/DTO/Posts/Mapper/PostMapper.cs b/src/DTO/Posts/Mapper/PostMapper.cs index 61a24f4..00d61fa 100644 --- a/src/DTO/Posts/Mapper/PostMapper.cs +++ b/src/DTO/Posts/Mapper/PostMapper.cs @@ -5,12 +5,15 @@ namespace sparkly_server.DTO.Posts.Mapper public static class PostMapper { /// - /// Maps a domain object to a DTO. + /// Converts a Post domain object into a PostResponse DTO object. /// - /// The instance to be mapped. - /// A representing the mapped data. - public static PostResponse ToResponse(this Post post) + /// The Post domain object to convert. + /// The unique identifier of the current user to determine ownership. + /// A PostResponse object containing the mapped data and ownership-related properties. + public static PostResponse ToResponse(this Post post, Guid currentUserId) { + var isOwner = post.AuthorId == currentUserId; + return new PostResponse { Id = post.Id, @@ -19,7 +22,9 @@ public static PostResponse ToResponse(this Post post) Title = post.Title, Content = post.Content, CreatedAt = post.CreatedAt, - UpdatedAt = post.UpdatedAt + UpdatedAt = post.UpdatedAt, + CanEdit = isOwner, + CanDelete = isOwner }; } } diff --git a/src/DTO/Posts/PostResponse.cs b/src/DTO/Posts/PostResponse.cs index 85d9b57..50e39db 100644 --- a/src/DTO/Posts/PostResponse.cs +++ b/src/DTO/Posts/PostResponse.cs @@ -9,5 +9,7 @@ public class PostResponse public string Content { get; set; } = ""; public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } + public bool CanEdit { get; set; } + public bool CanDelete { get; set; } } } diff --git a/src/DTO/Posts/UpdatePostRequest.cs b/src/DTO/Posts/UpdatePostRequest.cs new file mode 100644 index 0000000..7fc1cd2 --- /dev/null +++ b/src/DTO/Posts/UpdatePostRequest.cs @@ -0,0 +1,4 @@ +namespace sparkly_server.DTO.Posts +{ + public record UpdatePostRequest(string Title, string Content); +} diff --git a/src/Domain/Posts/Post.cs b/src/Domain/Posts/Post.cs index 6acadee..302d2c7 100644 --- a/src/Domain/Posts/Post.cs +++ b/src/Domain/Posts/Post.cs @@ -16,56 +16,86 @@ public class Post public Project? Project { get; private set; } private Post() { } - + + /// + /// Updates the property of the current post instance to the current UTC time. + /// private void Touch() { UpdatedAt = DateTime.UtcNow; } - public static Post CreateProjectUpdate(Guid authorId, Guid projectId, string title, string content) + /// + /// Creates a new instance of the Post class using the specified parameters. + /// + /// The unique identifier of the author of the post. + /// The unique identifier of the associated project. Can be null for feed posts. + /// The title of the post. Must not be null or whitespace. + /// The content of the post. Must not be null or whitespace. + /// A newly created instance of the class. + /// Thrown if the title or content is null, empty, or whitespace. + private static Post CreateInternal(Guid authorId, Guid? projectId, string title, string content) { if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(content)) { throw new ArgumentException("Title and content are required."); } - var post = new Post + var now = DateTime.UtcNow; + + return new Post { Id = Guid.NewGuid(), AuthorId = authorId, ProjectId = projectId, - Title = title, - Content = content, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow + Title = title.Trim(), + Content = content.Trim(), + CreatedAt = now, + UpdatedAt = now }; - - return post; } - public static Post CreatePost(Guid projectId, Guid authorId, string title, string content) + /// + /// Creates a new project-specific update post using the specified parameters. + /// + /// The unique identifier of the author of the post. + /// The unique identifier of the associated project. + /// The title of the post. Must not be null or whitespace. + /// The content of the post. Must not be null or whitespace. + /// A newly created instance of the class representing a project update. + /// Thrown if the title or content is null, empty, or whitespace. + public static Post CreateProjectUpdate(Guid authorId, Guid projectId, string title, string content) + => CreateInternal(authorId, projectId, title, content); + + /// + /// Creates a new feed post with the specified parameters. + /// + /// The unique identifier of the author of the feed post. + /// The title of the feed post. Must not be null or whitespace. + /// The content of the feed post. Must not be null or whitespace. + /// A newly created instance of the class representing the feed post. + /// Thrown if the title or content is null, empty, or whitespace. + public static Post CreateFeedPost(Guid authorId, string title, string content) + => CreateInternal(authorId, null, title, content); + + /// + /// Updates the specified post with new title and content if the provided authorId matches the post's author. + /// + /// The post to be updated. Must not be null. + /// The new title for the post. Must not be null, empty, or whitespace. + /// The new content for the post. Must not be null, empty, or whitespace. + /// The unique identifier of the author attempting to update the post. Must match the post's AuthorId. + /// The updated instance of the class. + /// Thrown if the authorId does not match the post's AuthorId. + /// Thrown if the post is null. + /// Thrown if the title or content is null, empty, or whitespace. + public Post UpdatePost(Post post, string title, string content, Guid authorId) { - if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(content)) + if (authorId != post.AuthorId) { - throw new ArgumentException("Title and content are required."); + throw new InvalidOperationException("Only the author can update this post."); } - - var post = new Post - { - Id = Guid.NewGuid(), - ProjectId = projectId, - AuthorId = authorId, - Title = title, - Content = content, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow - }; - - return post; - } - public static Post UpdatePost(Post post, string title, string content) - { if (post is null) { throw new ArgumentNullException(nameof(post)); @@ -83,13 +113,24 @@ public static Post UpdatePost(Post post, string title, string content) return post; } + /// + /// Ensures that the specified user has permission to delete the post. + /// + /// The unique identifier of the user attempting to delete the post. + /// Thrown if the provided authorId is empty. + /// Thrown if the provided authorId does not match the author's unique identifier for this post. public void EnsureCanBeDeletedBy(Guid authorId) { if (authorId == Guid.Empty) throw new ArgumentException("AuthorId is required."); if (authorId != AuthorId) throw new InvalidOperationException("Only the author can delete this post."); } - + + /// + /// Determines whether the specified user is the owner of the post. + /// + /// The unique identifier of the user to check ownership against. + /// True if the specified user is the owner of the post; otherwise, false. public bool IsOwner(Guid userId) => AuthorId == userId; } } diff --git a/src/Services/Auth/service/AuthService.cs b/src/Services/Auth/service/AuthService.cs index be28455..36b1731 100644 --- a/src/Services/Auth/service/AuthService.cs +++ b/src/Services/Auth/service/AuthService.cs @@ -3,6 +3,7 @@ using sparkly_server.Infrastructure; using sparkly_server.Services.Auth.provider; using sparkly_server.Services.Users; +using sparkly_server.Services.Users.service; namespace sparkly_server.Services.Auth.service { diff --git a/src/Services/Posts/repo/IPostRepository.cs b/src/Services/Posts/repo/IPostRepository.cs index ff99fa5..6bad171 100644 --- a/src/Services/Posts/repo/IPostRepository.cs +++ b/src/Services/Posts/repo/IPostRepository.cs @@ -6,6 +6,7 @@ public interface IPostRepository { Task> GetProjectPostsAsync(Guid projectId, CancellationToken ct = default); Task AddPostAsync(Post post, CancellationToken ct = default); + Task UpdatePostAsync(Post post, CancellationToken ct = default); Task> GetUserFeedPostsAsync(Guid userId, int page, int pageSize, CancellationToken ct = default); Task> GetPostsForUserAsync(Guid userId, CancellationToken ct = default); Task GetPostByIdAsync(Guid id, CancellationToken ct = default); diff --git a/src/Services/Posts/repo/PostRepository.cs b/src/Services/Posts/repo/PostRepository.cs index 45f850c..c718441 100644 --- a/src/Services/Posts/repo/PostRepository.cs +++ b/src/Services/Posts/repo/PostRepository.cs @@ -76,7 +76,7 @@ public async Task> GetPostsForUserAsync(Guid userId, Cancell public async Task> GetUserFeedPostsAsync(Guid userId, int page, int pageSize, CancellationToken ct = default) { return await _db.Posts - .Where(p => p.AuthorId == userId) // na start prosta wersja feedu + .Where(p => p.AuthorId == userId) .OrderByDescending(p => p.CreatedAt) .Skip((page - 1) * pageSize) .Take(pageSize) @@ -107,5 +107,17 @@ await _db.Posts .Where(p => p.Id == id) .ExecuteDeleteAsync(ct); } + + /// + /// Updates an existing post asynchronously. + /// + /// The post entity with updated properties. + /// A cancellation token to observe while waiting for the task to complete. + /// The updated post entity. + public async Task UpdatePostAsync(Post post, CancellationToken ct = default) + { + await _db.SaveChangesAsync(ct); + return post; + } } } diff --git a/src/Services/Posts/service/IPostService.cs b/src/Services/Posts/service/IPostService.cs index 56b733e..8f10e21 100644 --- a/src/Services/Posts/service/IPostService.cs +++ b/src/Services/Posts/service/IPostService.cs @@ -4,8 +4,10 @@ namespace sparkly_server.Services.Posts.service { public interface IPostService { - Task AddPostAsync(Guid projectId, Guid userId, string title, string content); - Task GetPostByIdAsync(Guid id); + Task AddProjectPostAsync(Guid authorId, Guid projectId, string title, string content, CancellationToken ct = default); + Task AddFeedPostAsync(Guid authorId, string title, string content, CancellationToken ct = default); + Task GetPostByIdAsync(Guid id, CancellationToken cancellationToken = default); + Task UpdatePostAsync(Guid postId, Guid userId, string title, string content, CancellationToken ct = default); Task DeletePostAsync(Guid postId, Guid userId); Task> GetFeedPostAsync(Guid userId, int page, int pageSize, CancellationToken ct = default); Task> GetProjectPostsAsync(Guid projectId, CancellationToken ct = default); diff --git a/src/Services/Posts/service/PostService.cs b/src/Services/Posts/service/PostService.cs index c5b371c..5b33833 100644 --- a/src/Services/Posts/service/PostService.cs +++ b/src/Services/Posts/service/PostService.cs @@ -12,27 +12,95 @@ public PostService(IPostRepository posts) _posts = posts; } - public Task AddPostAsync(Guid projectId, Guid userId, string title, string content) + /// + /// Adds a new project-related post created by a specific author for a given project. + /// + /// The unique identifier of the author creating the post. + /// The unique identifier of the project associated with the post. + /// The title of the post. + /// The content of the post. + /// The cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the newly created post. + /// Thrown if any of the provided parameters are invalid. + public Task AddProjectPostAsync(Guid authorId, Guid projectId, string title, string content, + CancellationToken ct = default) { - var newPost = Post.CreatePost(projectId, userId, title, content); - return _posts.AddPostAsync(newPost); + var post = Post.CreateProjectUpdate(authorId, projectId, title, content); + return _posts.AddPostAsync(post, ct); } - - public Task GetPostByIdAsync(Guid id) + + /// + /// Adds a new feed post created by a specific author. + /// + /// The unique identifier of the author creating the post. + /// The title of the feed post. + /// The content of the feed post. + /// The cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the newly created feed post. + /// Thrown if the provided parameters are invalid. + public Task AddFeedPostAsync(Guid authorId, string title, string content, CancellationToken ct = default) + { + var post = Post.CreateFeedPost(authorId, title, content); + return _posts.AddPostAsync(post, ct); + } + + /// + /// Retrieves a post by its unique identifier. + /// + /// The unique identifier of the post to retrieve. + /// The cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the post if found; otherwise, null. + /// Thrown when the provided post ID is empty. + public Task GetPostByIdAsync(Guid id, CancellationToken ct = default) { if (id == Guid.Empty) { throw new ArgumentException("Post ID cannot be empty.", nameof(id)); } - return _posts.GetPostByIdAsync(id); + return _posts.GetPostByIdAsync(id, ct); } + /// + /// Updates an existing post with the specified details. + /// + /// The unique identifier of the post to be updated. + /// The unique identifier of the user making the update. + /// The new title of the post. + /// The new content of the post. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the updated post, or null if the post is not found. + /// Thrown if the post does not exist. + public async Task UpdatePostAsync(Guid postId, Guid userId, string title, string content, CancellationToken ct = default) + { + var post = await _posts.GetPostByIdAsync(postId, ct); + + if (post is null) + { + throw new InvalidOperationException("Post not found"); + } + + post.UpdatePost(post, title, content, userId); + + await _posts.UpdatePostAsync(post, ct); + + return post; + } + + /// + /// Deletes a post with the specified ID if the user is the owner. + /// + /// The unique identifier of the post to be deleted. + /// The unique identifier of the user attempting to delete the post. + /// A task that represents the asynchronous operation. + /// Thrown when the post ID or user ID is invalid. + /// Thrown when the post is not found. + /// Thrown when the user is not the owner of the post. public async Task DeletePostAsync(Guid postId, Guid userId) { if (postId == Guid.Empty) throw new ArgumentException("Post ID cannot be empty.", nameof(postId)); - + if (userId == Guid.Empty) throw new ArgumentException("User ID cannot be empty.", nameof(userId)); @@ -51,6 +119,16 @@ public async Task DeletePostAsync(Guid postId, Guid userId) await _posts.DeletePostAsync(postId); } + /// + /// Retrieves a paginated list of feed posts for a specific user. + /// + /// The unique identifier of the user for whom the feed posts are being retrieved. + /// The page number to retrieve. Must be greater than 0. + /// The number of posts per page. Must be between 1 and 100. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains a read-only list of feed posts. + /// Thrown if the provided user ID is empty. + /// Thrown if the page is less than or equal to 0, or if the page size is not between 1 and 100. public Task> GetFeedPostAsync( Guid userId, int page, int pageSize, CancellationToken ct = default) { @@ -60,12 +138,18 @@ public Task> GetFeedPostAsync( if (page <= 0) throw new ArgumentOutOfRangeException(nameof(page), "Page must be greater than 0."); - if (pageSize <= 0 || pageSize > 100) - throw new ArgumentOutOfRangeException(nameof(pageSize), "Page size must be between 1 and 100."); + return pageSize is <= 0 or > 100 ? throw new ArgumentOutOfRangeException(nameof(pageSize), + "Page size must be between 1 and 100.") : _posts.GetUserFeedPostsAsync(userId, page, pageSize, ct); - return _posts.GetUserFeedPostsAsync(userId, page, pageSize, ct); } + /// + /// Retrieves all posts associated with the specified project. + /// + /// The unique identifier of the project to retrieve posts for. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains a read-only list of posts associated with the given project. + /// Thrown if the provided projectId is an empty GUID. public Task> GetProjectPostsAsync(Guid projectId, CancellationToken ct = default) { if (projectId == Guid.Empty) diff --git a/src/Services/Projects/service/ProjectService.cs b/src/Services/Projects/service/ProjectService.cs index a60cb05..bcf5dba 100644 --- a/src/Services/Projects/service/ProjectService.cs +++ b/src/Services/Projects/service/ProjectService.cs @@ -4,6 +4,8 @@ using sparkly_server.Enum; using sparkly_server.Services.Projects.repo; using sparkly_server.Services.Users; +using sparkly_server.Services.Users.CurrentUser; +using sparkly_server.Services.Users.repo; namespace sparkly_server.Services.Projects.service { diff --git a/src/Services/Users/CurrentUser.cs b/src/Services/Users/CurrentUser.cs deleted file mode 100644 index a7979e9..0000000 --- a/src/Services/Users/CurrentUser.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Security.Claims; - -namespace sparkly_server.Services.Users -{ - public class CurrentUser : ICurrentUser - { - private readonly IHttpContextAccessor _httpContextAccessor; - - public CurrentUser(IHttpContextAccessor httpContextAccessor) - { - _httpContextAccessor = httpContextAccessor; - } - - private ClaimsPrincipal? Principal => _httpContextAccessor.HttpContext?.User; - - public Guid? UserId => - Guid.TryParse(Principal?.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null; - - public string? Email => Principal?.FindFirstValue(ClaimTypes.Email); - public string? UserName => Principal?.FindFirstValue(ClaimTypes.Name); - public string? Role => Principal?.FindFirstValue(ClaimTypes.Role); - public bool IsAuthenticated => Principal?.Identity?.IsAuthenticated == true; - - public bool IsInRole(string role) => Principal?.IsInRole(role) == true; - } -} diff --git a/src/Services/Users/CurrentUser/CurrentUser.cs b/src/Services/Users/CurrentUser/CurrentUser.cs new file mode 100644 index 0000000..54c2184 --- /dev/null +++ b/src/Services/Users/CurrentUser/CurrentUser.cs @@ -0,0 +1,66 @@ +using System.Security.Claims; + +namespace sparkly_server.Services.Users.CurrentUser +{ + public class CurrentUser : ICurrentUser + { + private readonly IHttpContextAccessor _httpContextAccessor; + + public CurrentUser(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + private ClaimsPrincipal? Principal => _httpContextAccessor.HttpContext?.User; + + /// Gets the unique identifier of the current user. + /// This property retrieves the user's identifier, typically from the authentication context. + /// If the user is not authenticated or the identifier cannot be parsed, this property returns null. + /// The value is extracted from the claim associated with the user's identity using the `ClaimTypes.NameIdentifier`. + /// It serves as a primary reference for identifying the user within the application. + /// Returns: + /// A `Guid?` that represents the user's unique identifier, or null if unavailable. + public Guid? UserId => + Guid.TryParse(Principal?.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null; + + /// Gets the email address of the currently authenticated user. + /// This property extracts the email information from the claims associated with the user's identity. + /// If the user is not authenticated or no email claim is present, this property returns null. + /// The value is retrieved using the `ClaimTypes.Email` claim type, as provided by the identity provider. + /// Returns: + /// A `string?` representing the user's email address, or null if unavailable. + public string? Email => Principal?.FindFirstValue(ClaimTypes.Email); + + /// Gets the username of the current user. + /// This property retrieves the user's name, typically from the authentication context. + /// If the user is not authenticated or the name claim is not available, this property returns null. + /// The value is extracted from the claim associated with the user's identity using the `ClaimTypes.Name`. + /// Returns: + /// A `string?` that represents the user's username, or null if unavailable. + public string? UserName => Principal?.FindFirstValue(ClaimTypes.Name); + + /// Gets the role of the current user. + /// This property retrieves the role assigned to the user, typically from their identity claims. + /// The value is derived from the claim associated with the `ClaimTypes.Role`. + /// It can be used to determine the user's permissions or access level within the application. + /// Returns: + /// A `string?` representing the user's role, or null if the role is not specified. + public string? Role => Principal?.FindFirstValue(ClaimTypes.Role); + + /// Indicates whether the current user is authenticated. + /// This property determines the authentication status of the user based on their associated identity. + /// It checks the `IsAuthenticated` property of the user's identity within the claims principal. + /// If the user is authenticated, this property returns true; otherwise, it returns false. + /// This serves as a straightforward way to verify whether the user has successfully logged in or not. + /// Returns: + /// A boolean value indicating the user's authentication status: true if authenticated, false otherwise. + public bool IsAuthenticated => Principal?.Identity?.IsAuthenticated == true; + + /// + /// Determines whether the current user is in the specified role. + /// + /// The name of the role to check. + /// True if the user is in the specified role; otherwise, false. + public bool IsInRole(string role) => Principal?.IsInRole(role) == true; + } +} diff --git a/src/Services/Users/ICurrentUser.cs b/src/Services/Users/CurrentUser/ICurrentUser.cs similarity index 82% rename from src/Services/Users/ICurrentUser.cs rename to src/Services/Users/CurrentUser/ICurrentUser.cs index 8efd697..343483b 100644 --- a/src/Services/Users/ICurrentUser.cs +++ b/src/Services/Users/CurrentUser/ICurrentUser.cs @@ -1,4 +1,4 @@ -namespace sparkly_server.Services.Users +namespace sparkly_server.Services.Users.CurrentUser { public interface ICurrentUser { diff --git a/src/Services/Users/IUserRepository.cs b/src/Services/Users/repo/IUserRepository.cs similarity index 91% rename from src/Services/Users/IUserRepository.cs rename to src/Services/Users/repo/IUserRepository.cs index 8d5199e..f648710 100644 --- a/src/Services/Users/IUserRepository.cs +++ b/src/Services/Users/repo/IUserRepository.cs @@ -1,6 +1,6 @@ using sparkly_server.Domain.Users; -namespace sparkly_server.Services.Users +namespace sparkly_server.Services.Users.repo { public interface IUserRepository { diff --git a/src/Services/Users/UserRepository.cs b/src/Services/Users/repo/UserRepository.cs similarity index 95% rename from src/Services/Users/UserRepository.cs rename to src/Services/Users/repo/UserRepository.cs index 34dfe7d..fcb7cd5 100644 --- a/src/Services/Users/UserRepository.cs +++ b/src/Services/Users/repo/UserRepository.cs @@ -2,7 +2,7 @@ using sparkly_server.Domain.Users; using sparkly_server.Infrastructure; -namespace sparkly_server.Services.Users +namespace sparkly_server.Services.Users.repo { public class UserRepository : IUserRepository { diff --git a/src/Services/Users/IUserService.cs b/src/Services/Users/service/IUserService.cs similarity index 86% rename from src/Services/Users/IUserService.cs rename to src/Services/Users/service/IUserService.cs index ffb8f68..7104514 100644 --- a/src/Services/Users/IUserService.cs +++ b/src/Services/Users/service/IUserService.cs @@ -1,6 +1,6 @@ using sparkly_server.Domain.Users; -namespace sparkly_server.Services.Users +namespace sparkly_server.Services.Users.service { public interface IUserService { diff --git a/src/Services/Users/UserService.cs b/src/Services/Users/service/UserService.cs similarity index 97% rename from src/Services/Users/UserService.cs rename to src/Services/Users/service/UserService.cs index 38d7c37..e046ee4 100644 --- a/src/Services/Users/UserService.cs +++ b/src/Services/Users/service/UserService.cs @@ -1,7 +1,8 @@ using Microsoft.AspNetCore.Identity; using sparkly_server.Domain.Users; +using sparkly_server.Services.Users.repo; -namespace sparkly_server.Services.Users +namespace sparkly_server.Services.Users.service { public class UserService : IUserService {