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
{