using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; using Nuuru.Server.Auth; using Nuuru.Server.DTOs; using Nuuru.Server.DTOs.Forum; using Nuuru.Server.Extensions; using Nuuru.Server.Hubs; using Nuuru.Server.Models; using Nuuru.Server.Services; namespace Nuuru.Server.Controllers { [ApiController] [Route("api/forum/threads/{threadId:int}/posts")] public class ForumPostController : ControllerBase { private readonly IForumPostService _postService; private readonly IForumThreadService _threadService; private readonly IForumCategoryService _categoryService; private readonly IUserBadgeService _userBadgeService; private readonly IReactionService _reactionService; private readonly IHubContext _hubContext; private readonly ILogger _logger; public ForumPostController( IForumPostService postService, IForumThreadService threadService, IForumCategoryService categoryService, IUserBadgeService userBadgeService, IReactionService reactionService, IHubContext hubContext, ILogger logger) { _postService = postService; _threadService = threadService; _categoryService = categoryService; _userBadgeService = userBadgeService; _reactionService = reactionService; _hubContext = hubContext; _logger = logger; } /// /// Get latest posts across all threads /// [HttpGet("/api/forum/posts/latest")] [AllowAnonymous] public async Task GetLatestPosts([FromQuery] int count = 10) { try { if (count < 1 || count > 50) { return BadRequest(new { error = "Count must be between 1 and 50" }); } var posts = await _postService.GetLatestPostsAsync(count); // Batch-fetch display info for all post authors var authorIds = posts .Where(p => p.Author != null) .Select(p => p.Author!.Id) .Distinct(); var displayInfoMap = await _userBadgeService.GetUsersDisplayInfoAsync(authorIds); // Batch-fetch reactions for all posts var postIds = posts.Select(p => p.Id); var requestingUserId = User.GetUserId(); var reactionsMap = await _reactionService.GetBatchReactionsAsync( ReactionEntityType.ForumPost, postIds, requestingUserId); var postDtos = posts.ToDto(requestingUserId, displayInfoMap, reactionsMap); return Ok(postDtos); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving latest posts"); return StatusCode(500, new { error = "Failed to retrieve latest posts" }); } } /// /// Get posts in a thread /// [HttpGet] [AllowAnonymous] public async Task GetPosts(int threadId, [FromQuery] int page = 1, [FromQuery] int pageSize = 20) { try { if (page < 1) { return BadRequest(new { error = "Page must be greater than 0" }); } if (pageSize < 1 || pageSize > 100) { return BadRequest(new { error = "Page size must be between 1 and 100" }); } var thread = await _threadService.GetThreadAccessInfoAsync(threadId); if (thread == null) { return NotFound(new { error = "Thread not found" }); } if (!await _categoryService.CanAccessCategoryAsync(thread.CategoryId, User.GetUserId())) return StatusCode(403, new { error = "This thread is restricted to clan members." }); var posts = await _postService.GetPostsByThreadAsync(threadId, page, pageSize); var totalCount = thread.TotalPostCount; // Batch-fetch display info for all post authors var authorIds = posts .Where(p => p.Author != null) .Select(p => p.Author!.Id) .Distinct(); var displayInfoMap = await _userBadgeService.GetUsersDisplayInfoAsync(authorIds); // Batch-fetch reactions for all posts var postIds = posts.Select(p => p.Id); var requestingUserId = User.GetUserId(); var reactionsMap = await _reactionService.GetBatchReactionsAsync( ReactionEntityType.ForumPost, postIds, requestingUserId); var postDtos = posts.ToDto(requestingUserId, displayInfoMap, reactionsMap); var highlightedPostIds = await _postService.GetHighlightedPostIdsAsync(threadId, totalCount); return Ok(new { items = postDtos.ToList(), page, pageSize, totalCount, totalPages = (int)Math.Ceiling(totalCount / (double)pageSize), highlightedPostIds }); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving posts for thread {ThreadId}", threadId); return StatusCode(500, new { error = "Failed to retrieve posts" }); } } /// /// Get a single post /// [HttpGet("{postId:int}")] [AllowAnonymous] public async Task GetPost(int threadId, int postId) { try { var post = await _postService.GetPostByIdAsync(postId); if (post == null || post.ThreadId != threadId) { return NotFound(new { error = "Post not found" }); } // Fetch display info for the post author Dictionary? displayInfoMap = null; if (post.Author != null) { displayInfoMap = await _userBadgeService.GetUsersDisplayInfoAsync([post.Author.Id]); } // Fetch reactions for this post var requestingUserId = User.GetUserId(); var reactionsMap = await _reactionService.GetBatchReactionsAsync( ReactionEntityType.ForumPost, [post.Id], requestingUserId); var postDto = post.ToDto(requestingUserId, displayInfoMap, reactionsMap); return Ok(postDto); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving post {PostId}", postId); return StatusCode(500, new { error = "Failed to retrieve post" }); } } /// /// Get the page number for a specific post /// [HttpGet("{postId:int}/page")] [AllowAnonymous] public async Task GetPostPage(int threadId, int postId, [FromQuery] int pageSize = 20) { try { if (pageSize < 1 || pageSize > 100) { return BadRequest(new { error = "Page size must be between 1 and 100" }); } var page = await _postService.GetPostPageAsync(threadId, postId, pageSize); if (page == null) { return NotFound(new { error = "Post not found" }); } return Ok(new { page }); } catch (Exception ex) { _logger.LogError(ex, "Error getting page for post {PostId}", postId); return StatusCode(500, new { error = "Failed to get post page" }); } } /// /// Create a new post (reply to thread) /// [HttpPost] [Authorize(Policy = Permissions.Forum.Reply)] public async Task CreatePost(int threadId, [FromBody] CreateForumPostRequest request) { try { var userId = User.GetUserId(); if (userId == null) { return Unauthorized(new { error = "User ID not found in token" }); } var thread = await _threadService.GetThreadAccessInfoAsync(threadId); if (thread == null) { return NotFound(new { error = "Thread not found" }); } if (!await _categoryService.CanAccessCategoryAsync(thread.CategoryId, userId)) return StatusCode(403, new { error = "This thread is restricted to clan members." }); if (thread.IsLocked) { return BadRequest(new { error = "Thread is locked and cannot receive new replies" }); } if (await _threadService.IsUserBannedAsync(threadId, userId.Value)) { return StatusCode(403, new { error = "You are banned from replying in this thread." }); } var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); var post = await _postService.CreatePostAsync(threadId, userId.Value, request.Content, ipAddress); if (post == null) { return StatusCode(500, new { error = "Failed to create post" }); } _logger.LogInformation("User {UserId} created post {PostId} in thread {ThreadId}", userId, post.Id, threadId); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = $"{thread.CategorySlug}:{threadId}:{post.Id}"; HttpContext.Items[AuditLog.TargetTypeKey] = "ForumPost"; // Fetch display info for the author var displayInfoMap = await _userBadgeService.GetUsersDisplayInfoAsync([userId.Value]); // New post has no reactions yet var postDto = post.ToDto(userId, displayInfoMap, null); await _hubContext.NotifyGroupAsync(LiveUpdateGroups.Thread(threadId)); return CreatedAtAction(nameof(GetPost), new { threadId, postId = post.Id }, postDto); } catch (Exception ex) { _logger.LogError(ex, "Error creating post in thread {ThreadId}", threadId); return StatusCode(500, new { error = "Failed to create post" }); } } /// /// Update a post (owner only) /// [HttpPut("{postId:int}")] [Authorize(Policy = Permissions.User.EditOwnContent)] public async Task UpdatePost(int threadId, int postId, [FromBody] UpdateForumPostRequest request) { try { var userId = User.GetUserId(); if (userId == null) { return Unauthorized(new { error = "User ID not found in token" }); } var post = await _postService.GetPostByIdAsync(postId); if (post == null || post.ThreadId != threadId) { return NotFound(new { error = "Post not found" }); } // Only owner can edit if (post.AuthorId != userId.Value) { _logger.LogWarning("User {UserId} attempted to edit post {PostId} without permission", userId, postId); return Forbid(); } var updatedPost = await _postService.UpdatePostAsync(postId, request.Content); if (updatedPost == null) { return StatusCode(500, new { error = "Failed to update post" }); } _logger.LogInformation("User {UserId} updated post {PostId}", userId, postId); // Set target for audit log var categorySlug = updatedPost.Thread?.Category?.Slug ?? "unknown"; HttpContext.Items[AuditLog.TargetIdKey] = $"{categorySlug}:{threadId}:{postId}"; HttpContext.Items[AuditLog.TargetTypeKey] = "ForumPost"; // Fetch display info for the author var displayInfoMap = await _userBadgeService.GetUsersDisplayInfoAsync([userId.Value]); // Fetch reactions for this post var reactionsMap = await _reactionService.GetBatchReactionsAsync( ReactionEntityType.ForumPost, [postId], userId); var postDto = updatedPost.ToDto(userId, displayInfoMap, reactionsMap); await _hubContext.NotifyGroupAsync(LiveUpdateGroups.Thread(threadId)); return Ok(postDto); } catch (Exception ex) { _logger.LogError(ex, "Error updating post {PostId}", postId); return StatusCode(500, new { error = "Failed to update post" }); } } /// /// Delete a post (owner or moderator) /// [HttpDelete("{postId:int}")] [Authorize(Policy = Permissions.User.DeleteOwnContent)] public async Task DeletePost(int threadId, int postId) { try { var userId = User.GetUserId(); if (userId == null) { return Unauthorized(new { error = "User ID not found in token" }); } var post = await _postService.GetPostByIdAsync(postId); if (post == null || post.ThreadId != threadId) { return NotFound(new { error = "Post not found" }); } // Check if this is the first post var thread = await _threadService.GetThreadByIdAsync(threadId); if (thread?.FirstPostId == postId) { return BadRequest(new { error = "Cannot delete the first post. Delete the thread instead." }); } // Check if user is owner or has moderator permission var isOwner = post.AuthorId == userId.Value; var isModerator = User.HasPermission(Permissions.Forum.DeletePost); if (!isOwner && !isModerator) { _logger.LogWarning("User {UserId} attempted to delete post {PostId} without permission", userId, postId); return Forbid(); } var deleted = await _postService.DeletePostAsync(postId); if (!deleted) { return StatusCode(500, new { error = "Failed to delete post" }); } _logger.LogInformation("User {UserId} deleted post {PostId}", userId, postId); // Set target for audit log var categorySlug = post.Thread?.Category?.Slug ?? "unknown"; HttpContext.Items[AuditLog.TargetIdKey] = $"{categorySlug}:{threadId}:{postId}"; HttpContext.Items[AuditLog.TargetTypeKey] = "ForumPost"; await _hubContext.NotifyGroupAsync(LiveUpdateGroups.Thread(threadId)); return NoContent(); } catch (Exception ex) { _logger.LogError(ex, "Error deleting post {PostId}", postId); return StatusCode(500, new { error = "Failed to delete post" }); } } } }