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
{