using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Nuuru.Server.Auth; using Nuuru.Server.DTOs; using Nuuru.Server.DTOs.Forum; using Nuuru.Server.Extensions; using Nuuru.Server.Models; using Nuuru.Server.Models.Forum; using Nuuru.Server.Services; namespace Nuuru.Server.Controllers { [ApiController] [Route("api/forum/categories/{categorySlug}/threads")] public class ForumThreadController : ControllerBase { private readonly IForumThreadService _threadService; private readonly IForumCategoryService _categoryService; private readonly IUserBadgeService _userBadgeService; private readonly INotificationService _notificationService; private readonly IUserService _userService; private readonly ILogger _logger; public ForumThreadController( IForumThreadService threadService, IForumCategoryService categoryService, IUserBadgeService userBadgeService, INotificationService notificationService, IUserService userService, ILogger logger) { _threadService = threadService; _categoryService = categoryService; _userBadgeService = userBadgeService; _notificationService = notificationService; _userService = userService; _logger = logger; } /// /// Get threads in a category /// [HttpGet] [AllowAnonymous] public async Task GetThreads(string categorySlug, [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 userId = User.GetUserId(); // Resolve "clan" slug to the user's clan forum ForumCategory? category; if (categorySlug.Equals("clan", StringComparison.OrdinalIgnoreCase)) { if (!userId.HasValue) return StatusCode(403, new { error = "Log in to access your clan forum." }); category = await _categoryService.GetClanCategoryForUserAsync(userId.Value); if (category == null) return NotFound(new { error = "Your clan does not have a forum." }); } else { category = await _categoryService.GetCategoryBySlugAsync(categorySlug); } if (category == null) { return NotFound(new { error = "Category not found" }); } if (!await _categoryService.CanAccessCategoryAsync(category.Id, userId)) { return StatusCode(403, new { error = "This category is restricted to clan members." }); } var threads = await _threadService.GetThreadsByCategoryAsync(category.Id, page, pageSize); var totalCount = await _threadService.GetThreadCountByCategoryAsync(category.Id); // Batch-fetch display info for all thread and last post authors var authorIds = threads .Where(t => t.Author != null) .Select(t => t.Author!.Id) .Concat(threads.Where(t => t.LastPost?.Author != null).Select(t => t.LastPost!.Author!.Id)) .Distinct(); var displayInfoMap = await _userBadgeService.GetUsersDisplayInfoAsync(authorIds); var threadDtos = threads.ToDto(includeFirstPost: false, displayInfoMap: displayInfoMap); return Ok(new PagedResult { Items = threadDtos.ToList(), Page = page, PageSize = pageSize, TotalCount = totalCount }); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving threads for category {CategorySlug}", categorySlug); return StatusCode(500, new { error = "Failed to retrieve threads" }); } } /// /// Get a single thread with its first post /// [HttpGet("{threadId:int}")] [AllowAnonymous] public async Task GetThread(string categorySlug, int threadId) { try { var thread = await _threadService.GetThreadByIdAsync(threadId); if (thread == null) { return NotFound(new { error = "Thread not found" }); } // Verify thread belongs to this category (allow "clan" virtual slug) var userId = User.GetUserId(); if (categorySlug.Equals("clan", StringComparison.OrdinalIgnoreCase)) { if (!userId.HasValue || !await _categoryService.CanAccessCategoryAsync(thread.CategoryId, userId)) return StatusCode(403, new { error = "This thread is restricted to clan members." }); } else if (!thread.Category.Slug.Equals(categorySlug, StringComparison.OrdinalIgnoreCase)) { return NotFound(new { error = "Thread not found in this category" }); } else if (!await _categoryService.CanAccessCategoryAsync(thread.CategoryId, userId)) { return StatusCode(403, new { error = "This thread is restricted to clan members." }); } // Increment view count await _threadService.IncrementViewCountAsync(threadId); // Fetch display info for thread author, last post author and first post author var authorIds = new List(); if (thread.Author != null) authorIds.Add(thread.Author.Id); if (thread.LastPost?.Author != null) authorIds.Add(thread.LastPost.Author.Id); if (thread.FirstPost?.Author != null) authorIds.Add(thread.FirstPost.Author.Id); var displayInfoMap = await _userBadgeService.GetUsersDisplayInfoAsync(authorIds.Distinct()); var requestingUserId = User.GetUserId(); if (requestingUserId.HasValue) { await _notificationService.MarkReadByForumThreadAsync(requestingUserId.Value, threadId); } var threadDto = thread.ToDto(includeFirstPost: true, requestingUserId: requestingUserId, displayInfoMap: displayInfoMap); return Ok(threadDto); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving thread {ThreadId}", threadId); return StatusCode(500, new { error = "Failed to retrieve thread" }); } } /// /// Create a new thread /// [HttpPost] [Authorize(Policy = Permissions.Forum.CreateThread)] public async Task CreateThread(string categorySlug, [FromBody] CreateThreadRequest request) { try { var userId = User.GetUserId(); if (userId == null) { return Unauthorized(new { error = "User ID not found in token" }); } ForumCategory? category; if (categorySlug.Equals("clan", StringComparison.OrdinalIgnoreCase)) { category = await _categoryService.GetClanCategoryForUserAsync(userId.Value); if (category == null) return NotFound(new { error = "Your clan does not have a forum." }); } else { category = await _categoryService.GetCategoryBySlugAsync(categorySlug); } if (category == null) { return NotFound(new { error = "Category not found" }); } if (!await _categoryService.CanAccessCategoryAsync(category.Id, userId)) { return StatusCode(403, new { error = "This category is restricted to clan members." }); } var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); var thread = await _threadService.CreateThreadAsync(category.Id, userId.Value, request.Title, request.Content, ipAddress); if (thread == null) { return StatusCode(500, new { error = "Failed to create thread" }); } _logger.LogInformation("User {UserId} created thread {ThreadId} in category {CategorySlug}", userId, thread.Id, categorySlug); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = $"{categorySlug}:{thread.Id}"; HttpContext.Items[AuditLog.TargetTypeKey] = "Thread"; // Reload to get full navigation properties thread = await _threadService.GetThreadByIdAsync(thread.Id); // Fetch display info for the author var displayInfoMap = await _userBadgeService.GetUsersDisplayInfoAsync([userId.Value]); var threadDto = thread!.ToDto(includeFirstPost: true, requestingUserId: userId, displayInfoMap: displayInfoMap); return CreatedAtAction(nameof(GetThread), new { categorySlug, threadId = thread.Id }, threadDto); } catch (Exception ex) { _logger.LogError(ex, "Error creating thread in category {CategorySlug}", categorySlug); return StatusCode(500, new { error = "Failed to create thread" }); } } /// /// Ban a user from replying in this thread (thread author only) /// [HttpPost("{threadId:int}/bans")] [Authorize] public async Task BanUser(string categorySlug, int threadId, [FromBody] ThreadBanRequest request) { try { var userId = User.GetUserId(); if (!userId.HasValue) { return Unauthorized(new { error = "User ID not found in token" }); } var thread = await _threadService.GetThreadByIdAsync(threadId); var matchesCategory = thread != null && ( categorySlug.Equals("clan", StringComparison.OrdinalIgnoreCase) || thread.Category.Slug.Equals(categorySlug, StringComparison.OrdinalIgnoreCase)); if (thread == null || !matchesCategory) { return NotFound(new { error = "Thread not found" }); } if (thread.AuthorId != userId.Value) { return Forbid(); } if (thread.Category.Slug.Equals("gen", StringComparison.OrdinalIgnoreCase)) { return BadRequest(new { error = "Thread bans are not available in this category." }); } if (request.UserId == thread.AuthorId) { return BadRequest(new { error = "You cannot ban the thread author." }); } var result = await _threadService.BanUserAsync(threadId, userId.Value, request.UserId); if (!result) { return StatusCode(500, new { error = "Failed to ban user from thread" }); } HttpContext.Items[AuditLog.TargetIdKey] = $"{categorySlug}:{threadId}:{request.UserId}"; HttpContext.Items[AuditLog.TargetTypeKey] = "ForumThreadBan"; return NoContent(); } catch (Exception ex) { _logger.LogError(ex, "Error banning user {TargetUserId} from thread {ThreadId}", request.UserId, threadId); return StatusCode(500, new { error = "Failed to ban user from thread" }); } } /// /// Remove a thread ban (thread author only) /// [HttpDelete("{threadId:int}/bans/{targetUserId:guid}")] [Authorize] public async Task UnbanUser(string categorySlug, int threadId, Guid targetUserId) { try { var userId = User.GetUserId(); if (!userId.HasValue) { return Unauthorized(new { error = "User ID not found in token" }); } var thread = await _threadService.GetThreadByIdAsync(threadId); var matchesCategory = thread != null && ( categorySlug.Equals("clan", StringComparison.OrdinalIgnoreCase) || thread.Category.Slug.Equals(categorySlug, StringComparison.OrdinalIgnoreCase)); if (thread == null || !matchesCategory) { return NotFound(new { error = "Thread not found" }); } if (thread.AuthorId != userId.Value) { return Forbid(); } if (thread.Category.Slug.Equals("gen", StringComparison.OrdinalIgnoreCase)) { return BadRequest(new { error = "Thread bans are not available in this category." }); } var result = await _threadService.UnbanUserAsync(threadId, userId.Value, targetUserId); if (!result) { return StatusCode(500, new { error = "Failed to unban user from thread" }); } HttpContext.Items[AuditLog.TargetIdKey] = $"{categorySlug}:{threadId}:{targetUserId}"; HttpContext.Items[AuditLog.TargetTypeKey] = "ForumThreadBan"; return NoContent(); } catch (Exception ex) { _logger.LogError(ex, "Error unbanning user {TargetUserId} from thread {ThreadId}", targetUserId, threadId); return StatusCode(500, new { error = "Failed to unban user from thread" }); } } [HttpGet("/api/forum/threads/{threadId:int}/access")] [AllowAnonymous] public async Task GetThreadAccessInfo(int threadId) { var accessInfo = await _threadService.GetThreadAccessInfoAsync(threadId); if (accessInfo == null) { return NotFound(new { error = "Thread not found" }); } return Ok(accessInfo); } [HttpPost("{threadId:int}/feature")] [Authorize(Policy = Permissions.User.FeatureThread)] public async Task FeatureThread(string categorySlug, int threadId) { try { var userId = User.GetUserId(); if (!userId.HasValue) { return Unauthorized(new { error = "User ID not found in token" }); } var thread = await _threadService.GetThreadByIdAsync(threadId); if (thread == null || !thread.Category.Slug.Equals(categorySlug, StringComparison.OrdinalIgnoreCase)) { return NotFound(new { error = "Thread not found" }); } if (thread.AuthorId != userId.Value && !User.HasPermission(Permissions.User.FeatureThread)) { return Forbid(); } // Call UserService to set featured thread var result = await _userService.FeatureThreadAsync(userId.Value, threadId); if (!result.Success) { return StatusCode(500, new { error = result.ErrorMessage }); } _logger.LogInformation("Thread {ThreadId} featured by user {UserId}", threadId, userId); return Ok(new { featured = true }); } catch (Exception ex) { _logger.LogError(ex, "Error featuring thread {ThreadId}", threadId); return StatusCode(500, new { error = "Failed to feature thread" }); } } /// /// Pin or unpin a thread (moderator only) /// [HttpPost("{threadId:int}/pin")] [Authorize(Policy = Permissions.Forum.PinThread)] public async Task PinThread(string categorySlug, int threadId, [FromBody] PinThreadRequest request) { try { var thread = await _threadService.GetThreadByIdAsync(threadId); if (thread == null || !thread.Category.Slug.Equals(categorySlug, StringComparison.OrdinalIgnoreCase)) { return NotFound(new { error = "Thread not found" }); } var result = await _threadService.PinThreadAsync(threadId, request.Pinned); if (!result) { return StatusCode(500, new { error = "Failed to update thread" }); } _logger.LogInformation("Thread {ThreadId} {Action} by user {UserId}", threadId, request.Pinned ? "pinned" : "unpinned", User.GetUserId()); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = $"{categorySlug}:{threadId}"; HttpContext.Items[AuditLog.TargetTypeKey] = "Thread"; return Ok(new { pinned = request.Pinned }); } catch (Exception ex) { _logger.LogError(ex, "Error pinning thread {ThreadId}", threadId); return StatusCode(500, new { error = "Failed to update thread" }); } } /// /// Lock or unlock a thread (moderator only) /// [HttpPost("{threadId:int}/lock")] [Authorize(Policy = Permissions.Forum.LockThread)] public async Task LockThread(string categorySlug, int threadId, [FromBody] LockThreadRequest request) { try { var thread = await _threadService.GetThreadByIdAsync(threadId); if (thread == null || !thread.Category.Slug.Equals(categorySlug, StringComparison.OrdinalIgnoreCase)) { return NotFound(new { error = "Thread not found" }); } var result = await _threadService.LockThreadAsync(threadId, request.Locked); if (!result) { return StatusCode(500, new { error = "Failed to update thread" }); } _logger.LogInformation("Thread {ThreadId} {Action} by user {UserId}", threadId, request.Locked ? "locked" : "unlocked", User.GetUserId()); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = $"{categorySlug}:{threadId}"; HttpContext.Items[AuditLog.TargetTypeKey] = "Thread"; return Ok(new { locked = request.Locked }); } catch (Exception ex) { _logger.LogError(ex, "Error locking thread {ThreadId}", threadId); return StatusCode(500, new { error = "Failed to update thread" }); } } /// /// Move a thread to another category (moderator only) /// [HttpPost("{threadId:int}/move")] [Authorize(Policy = Permissions.Forum.MoveThread)] public async Task MoveThread(string categorySlug, int threadId, [FromBody] MoveThreadRequest request) { try { var thread = await _threadService.GetThreadByIdAsync(threadId); if (thread == null || !thread.Category.Slug.Equals(categorySlug, StringComparison.OrdinalIgnoreCase)) { return NotFound(new { error = "Thread not found" }); } var targetCategory = await _categoryService.GetCategoryByIdAsync(request.TargetCategoryId); if (targetCategory == null) { return BadRequest(new { error = "Target category not found" }); } var result = await _threadService.MoveThreadAsync(threadId, request.TargetCategoryId); if (!result) { return StatusCode(500, new { error = "Failed to move thread" }); } _logger.LogInformation("Thread {ThreadId} moved to {TargetCategory} by user {UserId}", threadId, targetCategory.Slug, User.GetUserId()); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = $"{categorySlug}:{threadId}"; HttpContext.Items[AuditLog.TargetTypeKey] = "Thread"; return Ok(new { moved = true, newCategory = targetCategory.ToSummaryDto() }); } catch (Exception ex) { _logger.LogError(ex, "Error moving thread {ThreadId}", threadId); return StatusCode(500, new { error = "Failed to move thread" }); } } /// /// Delete a thread (moderator only) /// [HttpDelete("{threadId:int}")] [Authorize(Policy = Permissions.Forum.DeleteThread)] public async Task DeleteThread(string categorySlug, int threadId) { try { var thread = await _threadService.GetThreadByIdAsync(threadId); if (thread == null || !thread.Category.Slug.Equals(categorySlug, StringComparison.OrdinalIgnoreCase)) { return NotFound(new { error = "Thread not found" }); } var result = await _threadService.DeleteThreadAsync(threadId); if (!result) { return StatusCode(500, new { error = "Failed to delete thread" }); } _logger.LogInformation("Thread {ThreadId} deleted by user {UserId}", threadId, User.GetUserId()); // Set target for audit log HttpContext.Items[AuditLog.TargetIdKey] = $"{categorySlug}:{threadId}"; HttpContext.Items[AuditLog.TargetTypeKey] = "Thread"; return NoContent(); } catch (Exception ex) { _logger.LogError(ex, "Error deleting thread {ThreadId}", threadId); return StatusCode(500, new { error = "Failed to delete thread" }); } } } }