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..f137085 100644 --- a/Program.cs +++ b/Program.cs @@ -5,8 +5,17 @@ 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.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); @@ -47,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.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..3c887be --- /dev/null +++ b/src/Controllers/Posts/PostsController.cs @@ -0,0 +1,202 @@ +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; + + public PostsController(IPostService posts) => _posts = posts; + + /// + /// 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); + 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 feed of posts for the current user. + /// + /// 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 list of objects representing the user's feed. + /// + [HttpGet("feed")] + public async Task>> GetFeed( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + CancellationToken ct = default) + { + var userId = GetUserId(); + Console.WriteLine($"FEED userId = {userId}"); + + var posts = await _posts.GetFeedPostAsync(userId, page, pageSize, ct); + + var response = posts + .Select(p => p.ToResponse(userId)) + .ToList(); + + return Ok(response); + } + + + /// + /// Creates a new post associated with a specific project. + /// + /// 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 response including its properties. + /// + [HttpPost("create/project/{projectId:guid}")] + public async Task> Create([FromBody] CreatePostRequest request, [FromRoute] Guid projectId) + { + Guid userId = GetUserId(); + + var post = await _posts.AddProjectPostAsync( + userId, + projectId, + request.Title, + request.Content); + + 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 342db79..0dfadd7 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 { diff --git a/src/Controllers/User/AuthController.cs b/src/Controllers/User/AuthController.cs index ee2d606..d5bf0a7 100644 --- a/src/Controllers/User/AuthController.cs +++ b/src/Controllers/User/AuthController.cs @@ -1,8 +1,9 @@ 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; +using sparkly_server.Services.Users.service; namespace sparkly_server.Controllers.User { @@ -19,6 +20,12 @@ public AuthController(IUserService userService, IAuthService authService) _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 b97df4e..051c498 100644 --- a/src/Controllers/User/ProfileController.cs +++ b/src/Controllers/User/ProfileController.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using sparkly_server.Infrastructure; using sparkly_server.Services.Users; +using sparkly_server.Services.Users.CurrentUser; namespace sparkly_server.Controllers.User { @@ -11,14 +11,19 @@ namespace sparkly_server.Controllers.User public class ProfileController : ControllerBase { private readonly ICurrentUser _currentUser; - private readonly AppDbContext _db; - public ProfileController(ICurrentUser currentUser, AppDbContext db) + public ProfileController(ICurrentUser currentUser) { _currentUser = currentUser; - _db = db; } + /// + /// 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 new file mode 100644 index 0000000..66bf73e --- /dev/null +++ b/src/DTO/Posts/CreatePostRequest.cs @@ -0,0 +1,4 @@ +namespace sparkly_server.DTO.Posts +{ + public record CreatePostRequest(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..00d61fa --- /dev/null +++ b/src/DTO/Posts/Mapper/PostMapper.cs @@ -0,0 +1,31 @@ +using sparkly_server.Domain.Posts; + +namespace sparkly_server.DTO.Posts.Mapper +{ + public static class PostMapper + { + /// + /// Converts a Post domain object into a PostResponse DTO object. + /// + /// 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, + ProjectId = post.ProjectId ?? Guid.Empty, + AuthorId = post.AuthorId, + Title = post.Title, + Content = post.Content, + CreatedAt = post.CreatedAt, + UpdatedAt = post.UpdatedAt, + CanEdit = isOwner, + CanDelete = isOwner + }; + } + } +} diff --git a/src/DTO/Posts/PostResponse.cs b/src/DTO/Posts/PostResponse.cs new file mode 100644 index 0000000..50e39db --- /dev/null +++ b/src/DTO/Posts/PostResponse.cs @@ -0,0 +1,15 @@ +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; } + 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 new file mode 100644 index 0000000..302d2c7 --- /dev/null +++ b/src/Domain/Posts/Post.cs @@ -0,0 +1,136 @@ +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() { } + + /// + /// Updates the property of the current post instance to the current UTC time. + /// + private void Touch() + { + UpdatedAt = DateTime.UtcNow; + } + + /// + /// 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 now = DateTime.UtcNow; + + return new Post + { + Id = Guid.NewGuid(), + AuthorId = authorId, + ProjectId = projectId, + Title = title.Trim(), + Content = content.Trim(), + CreatedAt = now, + UpdatedAt = now + }; + } + + /// + /// 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 (authorId != post.AuthorId) + { + throw new InvalidOperationException("Only the author can update this post."); + } + + 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; + } + + /// + /// 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/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 93% rename from src/Services/Auth/AuthService.cs rename to src/Services/Auth/service/AuthService.cs index eedad74..36b1731 100644 --- a/src/Services/Auth/AuthService.cs +++ b/src/Services/Auth/service/AuthService.cs @@ -1,9 +1,11 @@ using Microsoft.EntityFrameworkCore; using sparkly_server.Domain.Auth; 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 +namespace sparkly_server.Services.Auth.service { public class AuthService : IAuthService { @@ -64,11 +66,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 +79,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..6bad171 --- /dev/null +++ b/src/Services/Posts/repo/IPostRepository.cs @@ -0,0 +1,15 @@ +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 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); + 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..c718441 --- /dev/null +++ b/src/Services/Posts/repo/PostRepository.cs @@ -0,0 +1,123 @@ +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) + .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); + } + + /// + /// 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 new file mode 100644 index 0000000..8f10e21 --- /dev/null +++ b/src/Services/Posts/service/IPostService.cs @@ -0,0 +1,15 @@ +using sparkly_server.Domain.Posts; + +namespace sparkly_server.Services.Posts.service +{ + public interface IPostService + { + 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 new file mode 100644 index 0000000..5b33833 --- /dev/null +++ b/src/Services/Posts/service/PostService.cs @@ -0,0 +1,163 @@ +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; + } + + /// + /// 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 post = Post.CreateProjectUpdate(authorId, projectId, title, content); + return _posts.AddPostAsync(post, ct); + } + + /// + /// 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, 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)); + + 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); + } + + /// + /// 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) + { + 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."); + + 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); + + } + + /// + /// 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) + { + 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..bcf5dba 100644 --- a/src/Services/Projects/ProjectService.cs +++ b/src/Services/Projects/service/ProjectService.cs @@ -2,9 +2,12 @@ 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; +using sparkly_server.Services.Users.CurrentUser; +using sparkly_server.Services.Users.repo; -namespace sparkly_server.Services.Projects +namespace sparkly_server.Services.Projects.service { public class ProjectService : IProjectService { 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 {