diff --git a/services/mission-service-net/src/MissionService.API/Application/Commands/TaskCommandHandlers.cs b/services/mission-service-net/src/MissionService.API/Application/Commands/TaskCommandHandlers.cs new file mode 100644 index 00000000..6be719d4 --- /dev/null +++ b/services/mission-service-net/src/MissionService.API/Application/Commands/TaskCommandHandlers.cs @@ -0,0 +1,230 @@ +using MediatR; +using MissionService.Domain.AggregatesModel.MissionAggregate; +using MissionService.Domain.AggregatesModel.TaskAggregate; +using TaskStatus = MissionService.Domain.AggregatesModel.TaskAggregate.TaskStatus; + +namespace MissionService.API.Application.Commands; + +/// +/// EN: Handler for StartMissionTaskCommand +/// VI: Handler cho StartMissionTaskCommand +/// +public class StartMissionTaskCommandHandler : IRequestHandler +{ + private readonly IMissionRepository _missionRepository; + private readonly IUserTaskRepository _taskRepository; + private readonly ILogger _logger; + + public StartMissionTaskCommandHandler( + IMissionRepository missionRepository, + IUserTaskRepository taskRepository, + ILogger logger) + { + _missionRepository = missionRepository; + _taskRepository = taskRepository; + _logger = logger; + } + + public async Task Handle(StartMissionTaskCommand request, CancellationToken cancellationToken) + { + // EN: Check if mission exists and is available / VI: Kiểm tra mission tồn tại và khả dụng + var mission = await _missionRepository.GetByIdAsync(request.MissionId, cancellationToken); + if (mission == null) + { + return new StartTaskResult(false, null, "Mission not found / Không tìm thấy mission"); + } + + if (!mission.IsAvailable()) + { + return new StartTaskResult(false, null, "Mission not available / Mission không khả dụng"); + } + + // EN: Check if user already has active task for this mission + // VI: Kiểm tra user đã có task đang hoạt động cho mission này chưa + var existingTask = await _taskRepository.GetByUserAndMissionAsync( + request.UserId, request.MissionId, cancellationToken); + + if (existingTask != null) + { + return new StartTaskResult(false, existingTask.Id, + "Task already exists / Task đã tồn tại"); + } + + // EN: Check max completions / VI: Kiểm tra số lần hoàn thành tối đa + var completions = await _taskRepository.CountCompletionsByUserAndMissionAsync( + request.UserId, request.MissionId, cancellationToken); + + if (completions >= mission.MaxCompletions) + { + return new StartTaskResult(false, null, + "Max completions reached / Đã đạt số lần hoàn thành tối đa"); + } + + // EN: Create new task / VI: Tạo task mới + var task = new UserTask(request.UserId, request.MissionId); + _taskRepository.Add(task); + await _taskRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Task {TaskId} started for user {UserId} on mission {MissionId}", + task.Id, request.UserId, request.MissionId); + + return new StartTaskResult(true, task.Id, "Task started / Đã bắt đầu task"); + } +} + +/// +/// EN: Handler for UpdateTaskProgressCommand +/// VI: Handler cho UpdateTaskProgressCommand +/// +public class UpdateTaskProgressCommandHandler : IRequestHandler +{ + private readonly IUserTaskRepository _taskRepository; + private readonly ILogger _logger; + + public UpdateTaskProgressCommandHandler( + IUserTaskRepository taskRepository, + ILogger logger) + { + _taskRepository = taskRepository; + _logger = logger; + } + + public async Task Handle(UpdateTaskProgressCommand request, CancellationToken cancellationToken) + { + var task = await _taskRepository.GetByIdAsync(request.TaskId, cancellationToken); + + if (task == null) + { + return new UpdateProgressResult(false, 0, 0, 0, false, "Task not found / Không tìm thấy task"); + } + + if (task.UserId != request.UserId) + { + return new UpdateProgressResult(false, 0, 0, 0, false, "Unauthorized / Không có quyền"); + } + + if (task.Status != TaskStatus.InProgress) + { + return new UpdateProgressResult(false, + task.Progress.CurrentValue, + task.Progress.TargetValue, + task.Progress.PercentComplete, + task.Progress.IsComplete, + "Task not in progress / Task không đang thực hiện"); + } + + try + { + task.UpdateProgress(request.CurrentValue); + + // EN: Submit evidence if provided / VI: Nộp bằng chứng nếu có + if (request.Evidence != null && task.Progress.IsComplete) + { + var evidence = MapEvidence(request.Evidence); + task.SubmitEvidence(evidence); + } + + // EN: Auto-complete if no verification needed / VI: Tự động hoàn thành nếu không cần xác thực + if (task.Progress.IsComplete && request.Evidence == null) + { + task.AutoComplete(); + } + + _taskRepository.Update(task); + await _taskRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return new UpdateProgressResult( + true, + task.Progress.CurrentValue, + task.Progress.TargetValue, + task.Progress.PercentComplete, + task.Progress.IsComplete, + task.Progress.IsComplete ? "Task completed / Hoàn thành task" : "Progress updated / Đã cập nhật tiến độ"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update task {TaskId}", request.TaskId); + return new UpdateProgressResult(false, 0, 0, 0, false, "Update failed / Cập nhật thất bại"); + } + } + + private static TaskEvidence MapEvidence(TaskEvidenceDto dto) + { + return dto.Type.ToLowerInvariant() switch + { + "watchduration" => TaskEvidence.WatchDuration(int.Parse(dto.Data)), + "click" => TaskEvidence.Click(dto.Data), + "socialproof" => TaskEvidence.SocialProof(dto.ScreenshotUrl ?? "", dto.Data), + "invitecode" => TaskEvidence.InviteCode(dto.Data), + _ => TaskEvidence.Upload(dto.Data, dto.ScreenshotUrl != null) + }; + } +} + +/// +/// EN: Handler for ClaimTaskRewardCommand +/// VI: Handler cho ClaimTaskRewardCommand +/// +public class ClaimTaskRewardCommandHandler : IRequestHandler +{ + private readonly IUserTaskRepository _taskRepository; + private readonly IMissionRepository _missionRepository; + private readonly ILogger _logger; + + public ClaimTaskRewardCommandHandler( + IUserTaskRepository taskRepository, + IMissionRepository missionRepository, + ILogger logger) + { + _taskRepository = taskRepository; + _missionRepository = missionRepository; + _logger = logger; + } + + public async Task Handle(ClaimTaskRewardCommand request, CancellationToken cancellationToken) + { + var task = await _taskRepository.GetByIdAsync(request.TaskId, cancellationToken); + + if (task == null) + { + return new ClaimRewardResult(false, null, "Task not found / Không tìm thấy task"); + } + + if (task.UserId != request.UserId) + { + return new ClaimRewardResult(false, null, "Unauthorized / Không có quyền"); + } + + if (task.Status != TaskStatus.Completed) + { + return new ClaimRewardResult(false, null, "Task not completed / Task chưa hoàn thành"); + } + + if (task.RewardClaimed) + { + return new ClaimRewardResult(false, null, "Reward already claimed / Đã nhận thưởng rồi"); + } + + try + { + var mission = await _missionRepository.GetByIdAsync(task.MissionId, cancellationToken); + var points = mission?.Reward.Points ?? 0; + + task.ClaimReward(); + _taskRepository.Update(task); + await _taskRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Reward claimed for task {TaskId} by user {UserId}, points: {Points}", + request.TaskId, request.UserId, points); + + return new ClaimRewardResult(true, points, "Reward claimed / Đã nhận thưởng"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to claim reward for task {TaskId}", request.TaskId); + return new ClaimRewardResult(false, null, "Claim failed / Nhận thưởng thất bại"); + } + } +} diff --git a/services/mission-service-net/src/MissionService.API/Application/Commands/TaskCommands.cs b/services/mission-service-net/src/MissionService.API/Application/Commands/TaskCommands.cs new file mode 100644 index 00000000..7422c131 --- /dev/null +++ b/services/mission-service-net/src/MissionService.API/Application/Commands/TaskCommands.cs @@ -0,0 +1,65 @@ +using MediatR; + +namespace MissionService.API.Application.Commands; + +/// +/// EN: Command to start a mission task +/// VI: Command bắt đầu một task mission +/// +public record StartMissionTaskCommand(Guid UserId, Guid MissionId) : IRequest; + +/// +/// EN: Result of starting a task +/// VI: Kết quả bắt đầu task +/// +public record StartTaskResult( + bool Success, + Guid? TaskId, + string? Message); + +/// +/// EN: Command to update task progress +/// VI: Command cập nhật tiến độ task +/// +public record UpdateTaskProgressCommand( + Guid UserId, + Guid TaskId, + int CurrentValue, + TaskEvidenceDto? Evidence = null) : IRequest; + +/// +/// EN: Task evidence DTO +/// VI: DTO bằng chứng task +/// +public record TaskEvidenceDto( + string Type, + string Data, + string? ScreenshotUrl = null, + string? VideoUrl = null); + +/// +/// EN: Result of updating progress +/// VI: Kết quả cập nhật tiến độ +/// +public record UpdateProgressResult( + bool Success, + int CurrentValue, + int TargetValue, + decimal PercentComplete, + bool IsComplete, + string? Message); + +/// +/// EN: Command to claim task reward +/// VI: Command nhận thưởng task +/// +public record ClaimTaskRewardCommand(Guid UserId, Guid TaskId) : IRequest; + +/// +/// EN: Result of claiming reward +/// VI: Kết quả nhận thưởng +/// +public record ClaimRewardResult( + bool Success, + decimal? PointsEarned, + string? Message); diff --git a/services/mission-service-net/src/MissionService.API/Application/Queries/MissionQueries.cs b/services/mission-service-net/src/MissionService.API/Application/Queries/MissionQueries.cs index 1154aa72..ced7ae3d 100644 --- a/services/mission-service-net/src/MissionService.API/Application/Queries/MissionQueries.cs +++ b/services/mission-service-net/src/MissionService.API/Application/Queries/MissionQueries.cs @@ -110,7 +110,7 @@ public record UserTaskProgressDto( /// public record UserMissionProgressResult( int TotalMissions, - int CompletedMissions, + int CompletedMissionsCount, int InProgressMissions, decimal TotalPointsEarned, IReadOnlyList ActiveMissions, diff --git a/services/mission-service-net/src/MissionService.API/Application/Queries/MissionQueryHandlers.cs b/services/mission-service-net/src/MissionService.API/Application/Queries/MissionQueryHandlers.cs new file mode 100644 index 00000000..8c7e5770 --- /dev/null +++ b/services/mission-service-net/src/MissionService.API/Application/Queries/MissionQueryHandlers.cs @@ -0,0 +1,165 @@ +using MediatR; +using MissionService.Domain.AggregatesModel.MissionAggregate; +using MissionService.Domain.AggregatesModel.TaskAggregate; +using TaskStatus = MissionService.Domain.AggregatesModel.TaskAggregate.TaskStatus; + +namespace MissionService.API.Application.Queries; + +/// +/// EN: Handler for GetAvailableMissionsQuery +/// VI: Handler cho GetAvailableMissionsQuery +/// +public class GetAvailableMissionsQueryHandler : IRequestHandler +{ + private readonly IMissionRepository _missionRepository; + private readonly IUserTaskRepository _taskRepository; + + public GetAvailableMissionsQueryHandler( + IMissionRepository missionRepository, + IUserTaskRepository taskRepository) + { + _missionRepository = missionRepository; + _taskRepository = taskRepository; + } + + public async Task Handle(GetAvailableMissionsQuery request, CancellationToken cancellationToken) + { + var missions = await _missionRepository.GetActiveMissionsAsync(cancellationToken); + var userTasks = await _taskRepository.GetByUserIdAsync(request.UserId, cancellationToken); + + var summaries = missions.Select(m => MapToSummary(m, userTasks)).ToList(); + return new MissionsListResult(summaries); + } + + private static MissionSummaryDto MapToSummary(Mission mission, IReadOnlyList userTasks) + { + var userTask = userTasks.FirstOrDefault(t => t.MissionId == mission.Id && + (t.Status == TaskStatus.InProgress || t.Status == TaskStatus.PendingVerification)); + + return new MissionSummaryDto( + Id: mission.Id, + Code: mission.Code, + Title: mission.TitleEn, + Description: mission.DescriptionEn, + Type: mission.Type.Name, + Category: mission.Category.Name, + RewardPoints: mission.Reward.Points, + Frequency: mission.Frequency.Name, + MaxCompletions: mission.MaxCompletions, + IsAvailable: mission.IsAvailable(), + UserProgress: userTask != null ? MapToProgress(userTask) : null); + } + + private static UserTaskProgressDto MapToProgress(UserTask task) + { + return new UserTaskProgressDto( + TaskId: task.Id, + CurrentValue: task.Progress.CurrentValue, + TargetValue: task.Progress.TargetValue, + PercentComplete: task.Progress.PercentComplete, + Status: task.Status.Name, + StartedAt: task.StartedAt, + CompletedAt: task.CompletedAt, + RewardClaimed: task.RewardClaimed); + } +} + +/// +/// EN: Handler for GetMissionDetailsQuery +/// VI: Handler cho GetMissionDetailsQuery +/// +public class GetMissionDetailsQueryHandler : IRequestHandler +{ + private readonly IMissionRepository _missionRepository; + private readonly IUserTaskRepository _taskRepository; + + public GetMissionDetailsQueryHandler( + IMissionRepository missionRepository, + IUserTaskRepository taskRepository) + { + _missionRepository = missionRepository; + _taskRepository = taskRepository; + } + + public async Task Handle(GetMissionDetailsQuery request, CancellationToken cancellationToken) + { + var mission = await _missionRepository.GetByIdAsync(request.MissionId, cancellationToken); + if (mission == null) return null; + + UserTaskProgressDto? userProgress = null; + if (request.UserId.HasValue) + { + var userTask = await _taskRepository.GetByUserAndMissionAsync( + request.UserId.Value, request.MissionId, cancellationToken); + if (userTask != null) + { + userProgress = new UserTaskProgressDto( + TaskId: userTask.Id, + CurrentValue: userTask.Progress.CurrentValue, + TargetValue: userTask.Progress.TargetValue, + PercentComplete: userTask.Progress.PercentComplete, + Status: userTask.Status.Name, + StartedAt: userTask.StartedAt, + CompletedAt: userTask.CompletedAt, + RewardClaimed: userTask.RewardClaimed); + } + } + + return new MissionDetailsResult( + Id: mission.Id, + Code: mission.Code, + TitleEn: mission.TitleEn, + TitleVi: mission.TitleVi, + DescriptionEn: mission.DescriptionEn, + DescriptionVi: mission.DescriptionVi, + Type: mission.Type.Name, + Category: mission.Category.Name, + Reward: new MissionRewardDto( + mission.Reward.Points, + mission.Reward.MiningBoostPercent, + mission.Reward.ExperiencePoints, + mission.Reward.BadgeId), + Frequency: mission.Frequency.Name, + MaxCompletions: mission.MaxCompletions, + StartDate: mission.StartDate, + EndDate: mission.EndDate, + Status: mission.Status.Name, + Rules: mission.Rules.Select(r => new MissionRuleDto(r.RuleType, r.Operator, r.Value)).ToList(), + UserProgress: userProgress); + } +} + +/// +/// EN: Handler for GetMissionsByCategoryQuery +/// VI: Handler cho GetMissionsByCategoryQuery +/// +public class GetMissionsByCategoryQueryHandler : IRequestHandler +{ + private readonly IMissionRepository _missionRepository; + + public GetMissionsByCategoryQueryHandler(IMissionRepository missionRepository) + { + _missionRepository = missionRepository; + } + + public async Task Handle(GetMissionsByCategoryQuery request, CancellationToken cancellationToken) + { + var category = MissionCategory.FromDisplayName(request.Category); + var missions = await _missionRepository.GetByCategoryAsync(category, cancellationToken); + + var summaries = missions.Select(m => new MissionSummaryDto( + Id: m.Id, + Code: m.Code, + Title: m.TitleEn, + Description: m.DescriptionEn, + Type: m.Type.Name, + Category: m.Category.Name, + RewardPoints: m.Reward.Points, + Frequency: m.Frequency.Name, + MaxCompletions: m.MaxCompletions, + IsAvailable: m.IsAvailable(), + UserProgress: null)).ToList(); + + return new MissionsListResult(summaries); + } +} diff --git a/services/mission-service-net/src/MissionService.API/Controllers/AdminController.cs b/services/mission-service-net/src/MissionService.API/Controllers/AdminController.cs new file mode 100644 index 00000000..82612cbf --- /dev/null +++ b/services/mission-service-net/src/MissionService.API/Controllers/AdminController.cs @@ -0,0 +1,241 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MissionService.Domain.AggregatesModel.CheckInAggregate; +using MissionService.Domain.AggregatesModel.TaskAggregate; +using TaskStatus = MissionService.Domain.AggregatesModel.TaskAggregate.TaskStatus; + +namespace MissionService.API.Controllers; + +/// +/// EN: Admin controller for check-in and task management +/// VI: Controller admin để quản lý check-in và task +/// +[ApiController] +[Route("api/v1/admin")] +[Authorize(Roles = "Admin")] +public class AdminController : ControllerBase +{ + private readonly IUserCheckInRepository _checkInRepository; + private readonly IUserTaskRepository _taskRepository; + private readonly ILogger _logger; + + public AdminController( + IUserCheckInRepository checkInRepository, + IUserTaskRepository taskRepository, + ILogger logger) + { + _checkInRepository = checkInRepository; + _taskRepository = taskRepository; + _logger = logger; + } + + #region Check-In Admin + + /// + /// EN: Get user's check-in details / VI: Lấy chi tiết check-in của user + /// + [HttpGet("checkins/users/{userId:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetUserCheckIn(Guid userId) + { + var checkIn = await _checkInRepository.GetByUserIdAsync(userId); + if (checkIn == null) + { + return NotFound(new { Message = "Check-in profile not found" }); + } + + return Ok(new + { + checkIn.Id, + checkIn.UserId, + checkIn.CurrentStreak, + checkIn.LongestStreak, + checkIn.TotalCheckIns, + checkIn.LastCheckInDate, + CanCheckInToday = checkIn.CanCheckInToday() + }); + } + + /// + /// EN: Reset user's streak (admin action) / VI: Reset streak của user (hành động admin) + /// + [HttpPost("checkins/users/{userId:guid}/reset-streak")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ResetUserStreak(Guid userId) + { + var checkIn = await _checkInRepository.GetByUserIdAsync(userId); + if (checkIn == null) + { + return NotFound(new { Message = "Check-in profile not found" }); + } + + checkIn.ResetStreak(); + _checkInRepository.Update(checkIn); + await _checkInRepository.UnitOfWork.SaveEntitiesAsync(); + + _logger.LogWarning("Admin reset streak for user {UserId}", userId); + + return Ok(new { Message = "Streak reset successfully", checkIn.CurrentStreak }); + } + + /// + /// EN: Get top streaks / VI: Lấy streaks cao nhất + /// + [HttpGet("checkins/top-streaks")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task GetTopStreaks([FromQuery] int count = 20) + { + var topStreaks = await _checkInRepository.GetTopStreaksAsync(Math.Min(count, 100)); + + return Ok(topStreaks.Select((c, i) => new + { + Rank = i + 1, + c.UserId, + c.CurrentStreak, + c.LongestStreak, + c.TotalCheckIns, + c.LastCheckInDate + })); + } + + #endregion + + #region Task Admin + + /// + /// EN: Get pending verification tasks / VI: Lấy tasks đang chờ xác thực + /// + [HttpGet("tasks/pending-verification")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task GetPendingVerificationTasks() + { + var tasks = await _taskRepository.GetPendingVerificationAsync(); + + return Ok(tasks.Select(t => new + { + t.Id, + t.UserId, + t.MissionId, + Status = t.Status.Name, + Progress = new + { + t.Progress.CurrentValue, + t.Progress.TargetValue, + t.Progress.PercentComplete + }, + Evidence = t.Evidence != null ? new + { + Type = t.Evidence.Type.ToString(), + t.Evidence.Data, + t.Evidence.ScreenshotUrl, + t.Evidence.VideoUrl, + t.Evidence.CapturedAt + } : null, + t.StartedAt + })); + } + + /// + /// EN: Approve a task / VI: Duyệt task + /// + [HttpPost("tasks/{taskId:guid}/approve")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task ApproveTask(Guid taskId) + { + var task = await _taskRepository.GetByIdAsync(taskId); + if (task == null) + { + return NotFound(new { Message = "Task not found" }); + } + + if (task.Status != TaskStatus.PendingVerification) + { + return BadRequest(new { Message = "Task is not pending verification" }); + } + + var adminId = GetCurrentUserId(); + task.Complete(VerificationResult.ManualApproved(adminId)); + _taskRepository.Update(task); + await _taskRepository.UnitOfWork.SaveEntitiesAsync(); + + _logger.LogInformation("Task {TaskId} approved by admin {AdminId}", taskId, adminId); + + return Ok(new { Message = "Task approved", Status = task.Status.Name }); + } + + /// + /// EN: Reject a task / VI: Từ chối task + /// + [HttpPost("tasks/{taskId:guid}/reject")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task RejectTask(Guid taskId, [FromBody] RejectTaskRequest request) + { + var task = await _taskRepository.GetByIdAsync(taskId); + if (task == null) + { + return NotFound(new { Message = "Task not found" }); + } + + if (task.Status != TaskStatus.PendingVerification) + { + return BadRequest(new { Message = "Task is not pending verification" }); + } + + var adminId = GetCurrentUserId(); + task.Complete(VerificationResult.Rejected(request.Reason, VerificationMethod.Manual, adminId)); + _taskRepository.Update(task); + await _taskRepository.UnitOfWork.SaveEntitiesAsync(); + + _logger.LogInformation("Task {TaskId} rejected by admin {AdminId}: {Reason}", taskId, adminId, request.Reason); + + return Ok(new { Message = "Task rejected", Status = task.Status.Name, request.Reason }); + } + + /// + /// EN: Get user's tasks / VI: Lấy tasks của user + /// + [HttpGet("tasks/users/{userId:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task GetUserTasks(Guid userId) + { + var tasks = await _taskRepository.GetByUserIdAsync(userId); + + return Ok(tasks.Select(t => new + { + t.Id, + t.MissionId, + Status = t.Status.Name, + Progress = new + { + t.Progress.CurrentValue, + t.Progress.TargetValue, + t.Progress.PercentComplete + }, + t.RewardClaimed, + t.StartedAt, + t.CompletedAt, + t.ClaimedAt + })); + } + + #endregion + + private Guid GetCurrentUserId() + { + var userIdClaim = User.FindFirst("sub") ?? User.FindFirst("user_id"); + if (userIdClaim != null && Guid.TryParse(userIdClaim.Value, out var userId)) + { + return userId; + } + throw new UnauthorizedAccessException("User ID not found in token"); + } +} + +/// +/// EN: Request to reject a task / VI: Request từ chối task +/// +public record RejectTaskRequest(string Reason); diff --git a/services/mission-service-net/src/MissionService.API/Controllers/AdminMissionsController.cs b/services/mission-service-net/src/MissionService.API/Controllers/AdminMissionsController.cs new file mode 100644 index 00000000..97d2c179 --- /dev/null +++ b/services/mission-service-net/src/MissionService.API/Controllers/AdminMissionsController.cs @@ -0,0 +1,223 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MissionService.Domain.AggregatesModel.MissionAggregate; + +namespace MissionService.API.Controllers; + +/// +/// EN: Admin controller for mission management +/// VI: Controller admin để quản lý mission +/// +[ApiController] +[Route("api/v1/admin/missions")] +[Authorize(Roles = "Admin")] +public class AdminMissionsController : ControllerBase +{ + private readonly IMissionRepository _missionRepository; + private readonly ILogger _logger; + + public AdminMissionsController( + IMissionRepository missionRepository, + ILogger logger) + { + _missionRepository = missionRepository; + _logger = logger; + } + + /// + /// EN: Get all missions (admin) / VI: Lấy tất cả missions (admin) + /// + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task GetAllMissions() + { + var missions = await _missionRepository.GetActiveMissionsAsync(); + return Ok(missions.Select(m => MapToAdminDto(m))); + } + + /// + /// EN: Create new mission / VI: Tạo mission mới + /// + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task CreateMission([FromBody] CreateMissionRequest request) + { + try + { + var reward = MissionReward.Create( + request.RewardPoints, + request.RewardMiningBoost, + request.RewardXp, + request.RewardBadgeId); + + var mission = new Mission( + code: request.Code, + titleEn: request.TitleEn, + titleVi: request.TitleVi, + type: MissionType.FromValue(request.TypeId), + category: MissionCategory.FromValue(request.CategoryId), + reward: reward, + frequency: FrequencyType.FromValue(request.FrequencyId), + maxCompletions: request.MaxCompletions, + startDate: request.StartDate, + endDate: request.EndDate, + priority: request.Priority); + + _missionRepository.Add(mission); + await _missionRepository.UnitOfWork.SaveEntitiesAsync(); + + _logger.LogInformation("Mission {Code} created by admin", request.Code); + + return CreatedAtAction(nameof(GetMissionById), new { id = mission.Id }, MapToAdminDto(mission)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create mission {Code}", request.Code); + return BadRequest(new { Message = ex.Message }); + } + } + + /// + /// EN: Get mission by ID / VI: Lấy mission theo ID + /// + [HttpGet("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetMissionById(Guid id) + { + var mission = await _missionRepository.GetByIdAsync(id); + if (mission == null) + { + return NotFound(); + } + return Ok(MapToAdminDto(mission)); + } + + /// + /// EN: Activate a mission / VI: Kích hoạt mission + /// + [HttpPost("{id:guid}/activate")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task ActivateMission(Guid id) + { + var mission = await _missionRepository.GetByIdAsync(id); + if (mission == null) + { + return NotFound(); + } + + try + { + mission.Activate(); + _missionRepository.Update(mission); + await _missionRepository.UnitOfWork.SaveEntitiesAsync(); + + _logger.LogInformation("Mission {Id} activated", id); + return Ok(MapToAdminDto(mission)); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { Message = ex.Message }); + } + } + + /// + /// EN: Pause a mission / VI: Tạm dừng mission + /// + [HttpPost("{id:guid}/pause")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task PauseMission(Guid id) + { + var mission = await _missionRepository.GetByIdAsync(id); + if (mission == null) + { + return NotFound(); + } + + try + { + mission.Pause(); + _missionRepository.Update(mission); + await _missionRepository.UnitOfWork.SaveEntitiesAsync(); + + _logger.LogInformation("Mission {Id} paused", id); + return Ok(MapToAdminDto(mission)); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { Message = ex.Message }); + } + } + + /// + /// EN: Archive a mission / VI: Lưu trữ mission + /// + [HttpPost("{id:guid}/archive")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task ArchiveMission(Guid id) + { + var mission = await _missionRepository.GetByIdAsync(id); + if (mission == null) + { + return NotFound(); + } + + mission.Archive(); + _missionRepository.Update(mission); + await _missionRepository.UnitOfWork.SaveEntitiesAsync(); + + _logger.LogInformation("Mission {Id} archived", id); + return Ok(MapToAdminDto(mission)); + } + + private static object MapToAdminDto(Mission m) => new + { + m.Id, + m.Code, + m.TitleEn, + m.TitleVi, + m.DescriptionEn, + m.DescriptionVi, + Type = m.Type.Name, + Category = m.Category.Name, + Status = m.Status.Name, + Frequency = m.Frequency.Name, + Reward = new + { + m.Reward.Points, + m.Reward.MiningBoostPercent, + m.Reward.ExperiencePoints, + m.Reward.BadgeId + }, + m.MaxCompletions, + m.StartDate, + m.EndDate, + m.Priority, + Rules = m.Rules.Select(r => new { r.RuleType, r.Operator, r.Value }) + }; +} + +/// +/// EN: Request to create a new mission / VI: Request tạo mission mới +/// +public record CreateMissionRequest( + string Code, + string TitleEn, + string TitleVi, + string? DescriptionEn, + string? DescriptionVi, + int TypeId, + int CategoryId, + int FrequencyId, + decimal RewardPoints, + decimal RewardMiningBoost, + int RewardXp, + string? RewardBadgeId, + int MaxCompletions, + DateTime StartDate, + DateTime? EndDate, + int Priority = 0); diff --git a/services/mission-service-net/src/MissionService.API/Controllers/MissionsController.cs b/services/mission-service-net/src/MissionService.API/Controllers/MissionsController.cs new file mode 100644 index 00000000..337e13b6 --- /dev/null +++ b/services/mission-service-net/src/MissionService.API/Controllers/MissionsController.cs @@ -0,0 +1,156 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MissionService.API.Application.Commands; +using MissionService.API.Application.Queries; + +namespace MissionService.API.Controllers; + +/// +/// EN: Controller for mission operations +/// VI: Controller cho các thao tác mission +/// +[ApiController] +[Route("api/v1/[controller]")] +[Authorize] +public class MissionsController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public MissionsController(IMediator mediator, ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// EN: Get all available missions / VI: Lấy tất cả missions khả dụng + /// + [HttpGet] + [ProducesResponseType(typeof(MissionsListResult), StatusCodes.Status200OK)] + public async Task GetAvailableMissions() + { + var userId = GetCurrentUserId(); + var result = await _mediator.Send(new GetAvailableMissionsQuery(userId)); + return Ok(result); + } + + /// + /// EN: Get missions by category / VI: Lấy missions theo danh mục + /// + [HttpGet("category/{category}")] + [AllowAnonymous] + [ProducesResponseType(typeof(MissionsListResult), StatusCodes.Status200OK)] + public async Task GetByCategory(string category) + { + var result = await _mediator.Send(new GetMissionsByCategoryQuery(category)); + return Ok(result); + } + + /// + /// EN: Get mission details / VI: Lấy chi tiết mission + /// + [HttpGet("{id:guid}")] + [AllowAnonymous] + [ProducesResponseType(typeof(MissionDetailsResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetDetails(Guid id) + { + var userId = TryGetCurrentUserId(); + var result = await _mediator.Send(new GetMissionDetailsQuery(id, userId)); + + if (result == null) + { + return NotFound(new { Message = "Mission not found / Không tìm thấy mission" }); + } + + return Ok(result); + } + + /// + /// EN: Start a mission task / VI: Bắt đầu task mission + /// + [HttpPost("{id:guid}/start")] + [ProducesResponseType(typeof(StartTaskResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task StartMission(Guid id) + { + var userId = GetCurrentUserId(); + var result = await _mediator.Send(new StartMissionTaskCommand(userId, id)); + + if (!result.Success) + { + return BadRequest(result); + } + + return Ok(result); + } + + /// + /// EN: Update task progress / VI: Cập nhật tiến độ task + /// + [HttpPut("tasks/{taskId:guid}/progress")] + [ProducesResponseType(typeof(UpdateProgressResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task UpdateProgress(Guid taskId, [FromBody] UpdateProgressRequest request) + { + var userId = GetCurrentUserId(); + var result = await _mediator.Send(new UpdateTaskProgressCommand( + userId, + taskId, + request.CurrentValue, + request.Evidence)); + + if (!result.Success) + { + return BadRequest(result); + } + + return Ok(result); + } + + /// + /// EN: Claim task reward / VI: Nhận thưởng task + /// + [HttpPost("tasks/{taskId:guid}/claim")] + [ProducesResponseType(typeof(ClaimRewardResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task ClaimReward(Guid taskId) + { + var userId = GetCurrentUserId(); + var result = await _mediator.Send(new ClaimTaskRewardCommand(userId, taskId)); + + if (!result.Success) + { + return BadRequest(result); + } + + return Ok(result); + } + + private Guid GetCurrentUserId() + { + var userIdClaim = User.FindFirst("sub") ?? User.FindFirst("user_id"); + if (userIdClaim != null && Guid.TryParse(userIdClaim.Value, out var userId)) + { + return userId; + } + throw new UnauthorizedAccessException("User ID not found in token"); + } + + private Guid? TryGetCurrentUserId() + { + var userIdClaim = User.FindFirst("sub") ?? User.FindFirst("user_id"); + if (userIdClaim != null && Guid.TryParse(userIdClaim.Value, out var userId)) + { + return userId; + } + return null; + } +} + +/// +/// EN: Request to update progress / VI: Request cập nhật tiến độ +/// +public record UpdateProgressRequest(int CurrentValue, TaskEvidenceDto? Evidence = null);