From f6aca477d58cdae30b1c1d952aecbd10bf54f378 Mon Sep 17 00:00:00 2001 From: DuongNManh Date: Fri, 6 Feb 2026 00:13:40 +0700 Subject: [PATCH 01/31] Refactor SnakeAid Service: - Remove unused ServiceImplement class. - Introduce SessionTimeoutBackgroundService for managing session timeouts with in-memory scheduling. - Update SnakebiteIncidentService to integrate session management and rescue request handling. - Add new interfaces for rescue mission and notification services. - Enhance ISnakebiteIncidentService with methods for triggering and starting rescues. - Remove obsolete ServiceInterface. --- .../Controllers/MonitoringController.cs | 86 ++ .../Controllers/RescueDemoController.cs | 880 +++++++++++ .../SnakebiteIncidentController.cs | 28 +- SnakeAid.Api/Hubs/RescuerHub.cs | 132 ++ SnakeAid.Api/Pages/Demo/RescueDemo.cshtml | 1203 +++++++++++++++ SnakeAid.Api/Pages/Demo/RescueDemo.cshtml.cs | 12 + SnakeAid.Api/Program.cs | 12 +- .../SignalRRescueNotificationService.cs | 132 ++ SnakeAid.Core/Domains/RescueRequestSession.cs | 2 +- SnakeAid.Core/Domains/SnakeSpecies.cs | 1 + .../Mappings/SnakebiteIncidentMapper.cs | 10 +- .../SnakebiteIncident/AcceptRescueResponse.cs | 18 + .../CreateIncidentResponse.cs | 15 +- .../SnakebiteIncident/RejectRescueResponse.cs | 15 + .../TriggerRescueResponse.cs | 19 + SnakeAid.Repository/Seeds/DataSeeder.cs | 1346 +++++++++-------- .../Implements/RescueMissionService.cs | 311 ++++ .../Implements/RescueRequestSessionService.cs | 642 +++++++- .../Implements/ServiceImplement.cs | 12 - .../SessionTimeoutBackgroundService.cs | 287 ++++ .../Implements/SnakebiteIncidentService.cs | 253 +++- .../Interfaces/IRescueMissionService.cs | 21 + .../Interfaces/IRescueNotificationService.cs | 19 + .../IRescueRequestSessionService.cs | 28 +- .../Interfaces/ISessionTimeoutService.cs | 25 + .../Interfaces/ISnakebiteIncidentService.cs | 24 +- .../Interfaces/ServiceInterface.cs | 12 - 27 files changed, 4821 insertions(+), 724 deletions(-) create mode 100644 SnakeAid.Api/Controllers/MonitoringController.cs create mode 100644 SnakeAid.Api/Controllers/RescueDemoController.cs create mode 100644 SnakeAid.Api/Hubs/RescuerHub.cs create mode 100644 SnakeAid.Api/Pages/Demo/RescueDemo.cshtml create mode 100644 SnakeAid.Api/Pages/Demo/RescueDemo.cshtml.cs create mode 100644 SnakeAid.Api/Services/SignalRRescueNotificationService.cs create mode 100644 SnakeAid.Core/Responses/SnakebiteIncident/AcceptRescueResponse.cs create mode 100644 SnakeAid.Core/Responses/SnakebiteIncident/RejectRescueResponse.cs create mode 100644 SnakeAid.Core/Responses/SnakebiteIncident/TriggerRescueResponse.cs create mode 100644 SnakeAid.Service/Implements/RescueMissionService.cs delete mode 100644 SnakeAid.Service/Implements/ServiceImplement.cs create mode 100644 SnakeAid.Service/Implements/SessionTimeoutBackgroundService.cs create mode 100644 SnakeAid.Service/Interfaces/IRescueMissionService.cs create mode 100644 SnakeAid.Service/Interfaces/IRescueNotificationService.cs create mode 100644 SnakeAid.Service/Interfaces/ISessionTimeoutService.cs delete mode 100644 SnakeAid.Service/Interfaces/ServiceInterface.cs diff --git a/SnakeAid.Api/Controllers/MonitoringController.cs b/SnakeAid.Api/Controllers/MonitoringController.cs new file mode 100644 index 00000000..50791803 --- /dev/null +++ b/SnakeAid.Api/Controllers/MonitoringController.cs @@ -0,0 +1,86 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SnakeAid.Core.Meta; +using SnakeAid.Service.Interfaces; +using Swashbuckle.AspNetCore.Annotations; + +namespace SnakeAid.Api.Controllers +{ + [Route("api/monitoring")] + [ApiController] + [Authorize] // Only authenticated users can access monitoring + public class MonitoringController : ControllerBase + { + private readonly ISessionTimeoutService _timeoutService; + private readonly ILogger _logger; + + public MonitoringController( + ISessionTimeoutService timeoutService, + ILogger logger) + { + _timeoutService = timeoutService; + _logger = logger; + } + + + /// Get session timeout service status + [HttpGet("session-timeout-status")] + [SwaggerOperation(Summary = "Session Timeout Status", Description = "Get current status of session timeout monitoring service")] + [SwaggerResponse(200, "Service status retrieved", typeof(ApiResponse))] + public IActionResult GetSessionTimeoutStatus() + { + try + { + var (totalSessions, expiredCount, pendingCount) = _timeoutService.GetQueueStatus(); + var isHealthy = _timeoutService.IsHealthy(); + + var status = new + { + IsHealthy = isHealthy, + TotalSessionsMonitored = totalSessions, + ExpiredSessionsInQueue = expiredCount, + PendingSessionsInQueue = pendingCount, + CheckedAt = DateTime.UtcNow, + ServiceStatus = isHealthy ? "Healthy" : "Unhealthy" + }; + + var response = ApiResponseBuilder.BuildSuccessResponse(status, "Session timeout service status retrieved"); + return Ok(response); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving session timeout status: {Message}", ex.Message); + var response = ApiResponseBuilder.BuildErrorResponse("Failed to retrieve session timeout status"); + return StatusCode(500, response); + } + } + + + /// Health check endpoint for session timeout service + [HttpGet("health/session-timeout")] + [SwaggerOperation(Summary = "Session Timeout Health Check", Description = "Simple health check for session timeout monitoring")] + [SwaggerResponse(200, "Service is healthy")] + [SwaggerResponse(503, "Service is unhealthy")] + public IActionResult SessionTimeoutHealthCheck() + { + try + { + var isHealthy = _timeoutService.IsHealthy(); + + if (isHealthy) + { + return Ok(new { Status = "Healthy", CheckedAt = DateTime.UtcNow }); + } + else + { + return StatusCode(503, new { Status = "Unhealthy", CheckedAt = DateTime.UtcNow }); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Session timeout health check failed: {Message}", ex.Message); + return StatusCode(503, new { Status = "Unhealthy", Error = ex.Message, CheckedAt = DateTime.UtcNow }); + } + } + } +} \ No newline at end of file diff --git a/SnakeAid.Api/Controllers/RescueDemoController.cs b/SnakeAid.Api/Controllers/RescueDemoController.cs new file mode 100644 index 00000000..92ab0f7f --- /dev/null +++ b/SnakeAid.Api/Controllers/RescueDemoController.cs @@ -0,0 +1,880 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using SnakeAid.Api.Hubs; +using SnakeAid.Api.Services; +using SnakeAid.Core.Domains; +using System.Collections.Concurrent; + +namespace SnakeAid.Api.Controllers +{ + /// + /// Demo controller for testing rescue SignalR flow with mock data (no database required) + /// + [Route("api/[controller]")] + [ApiController] + public class RescueDemoController : ControllerBase + { + private readonly IHubContext _hubContext; + private readonly ILogger _logger; + + // ====== MOCK DATA STORAGE (thay vì database) ====== + + // Mock Users + public static ConcurrentDictionary MockUsers { get; } = new() + { + [Guid.Parse("11111111-1111-1111-1111-111111111111")] = new MockUser + { + Id = Guid.Parse("11111111-1111-1111-1111-111111111111"), + Name = "Demo User", + Role = "Member", + Lat = 10.762622, // HCM City center + Lng = 106.660172 + } + }; + + // Mock Rescuers (4 rescuers at different locations around HCM) + public static ConcurrentDictionary MockRescuers { get; } = new() + { + [Guid.Parse("22222222-2222-2222-2222-222222222221")] = new MockRescuer + { + Id = Guid.Parse("22222222-2222-2222-2222-222222222221"), + Name = "Rescuer A - Quận 1", + Lat = 10.7700, + Lng = 106.6980, + IsOnline = false, + DistanceFromUserKm = 4.5 + }, + [Guid.Parse("22222222-2222-2222-2222-222222222222")] = new MockRescuer + { + Id = Guid.Parse("22222222-2222-2222-2222-222222222222"), + Name = "Rescuer B - Quận 3", + Lat = 10.7831, + Lng = 106.6859, + IsOnline = false, + DistanceFromUserKm = 3.2 + }, + [Guid.Parse("22222222-2222-2222-2222-222222222223")] = new MockRescuer + { + Id = Guid.Parse("22222222-2222-2222-2222-222222222223"), + Name = "Rescuer C - Quận 7", + Lat = 10.7295, + Lng = 106.7215, + IsOnline = false, + DistanceFromUserKm = 8.0 + }, + [Guid.Parse("22222222-2222-2222-2222-222222222224")] = new MockRescuer + { + Id = Guid.Parse("22222222-2222-2222-2222-222222222224"), + Name = "Rescuer D - Tân Bình", + Lat = 10.8053, + Lng = 106.6482, + IsOnline = false, + DistanceFromUserKm = 6.5 + } + }; + + // Mock Incidents + public static ConcurrentDictionary MockIncidents { get; } = new(); + + // Mock Sessions + public static ConcurrentDictionary MockSessions { get; } = new(); + + // Mock Requests (per rescuer per session) + public static ConcurrentDictionary MockRequests { get; } = new(); + + // Mock Missions + public static ConcurrentDictionary MockMissions { get; } = new(); + + // Session timeout timers + private static ConcurrentDictionary SessionTimers { get; } = new(); + + public RescueDemoController(IHubContext hubContext, ILogger logger) + { + _hubContext = hubContext; + _logger = logger; + } + + #region User Actions + + /// + /// User tạo incident mới + /// + [HttpPost("incident/create")] + public async Task CreateIncident([FromBody] CreateIncidentDto dto) + { + var userId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + + var incident = new MockIncident + { + Id = Guid.NewGuid(), + UserId = userId, + Lat = dto.Lat, + Lng = dto.Lng, + Status = "Pending", + CurrentSessionNumber = 0, + CurrentRadiusKm = 0, + CreatedAt = DateTime.UtcNow + }; + + MockIncidents[incident.Id] = incident; + + _logger.LogInformation("Created mock incident {IncidentId}", incident.Id); + + // Notify all connected clients about new incident + await _hubContext.Clients.All.SendAsync("IncidentCreated", incident); + + return Ok(new { incident, message = "Incident created successfully. Ready to trigger rescue." }); + } + + /// + /// User trigger rescue session (bắt đầu tìm rescuer) + /// + [HttpPost("incident/{incidentId}/trigger")] + public async Task TriggerRescue(Guid incidentId) + { + if (!MockIncidents.TryGetValue(incidentId, out var incident)) + { + return NotFound("Incident not found"); + } + + if (incident.Status != "Pending") + { + return BadRequest($"Cannot trigger rescue for incident with status: {incident.Status}"); + } + + // Create first session + var session = await CreateNewSessionAsync(incident, 1, 5, "Initial"); + + // Start timeout timer (60 seconds) + StartSessionTimer(session.Id, 60); + + return Ok(new { session, message = "Rescue triggered. Broadcasting to nearby rescuers..." }); + } + + /// + /// User cancel incident + /// + [HttpPost("incident/{incidentId}/cancel")] + public async Task CancelIncident(Guid incidentId) + { + if (!MockIncidents.TryGetValue(incidentId, out var incident)) + { + return NotFound("Incident not found"); + } + + if (incident.Status != "Pending" && incident.Status != "Assigned") + { + return BadRequest($"Cannot cancel incident with status: {incident.Status}"); + } + + incident.Status = "Cancelled"; + + // Cancel all active sessions and requests + var activeSessions = MockSessions.Values.Where(s => s.IncidentId == incidentId && s.Status == "Active"); + foreach (var session in activeSessions) + { + await CancelSessionAsync(session.Id); + } + + // Cancel mission if exists + var mission = MockMissions.Values.FirstOrDefault(m => m.IncidentId == incidentId); + if (mission != null && (mission.Status == "Preparing" || mission.Status == "EnRoute")) + { + mission.Status = "Cancelled"; + mission.UpdatedAt = DateTime.UtcNow; + + // Notify rescuer + var rescuerId = mission.RescuerId.ToString(); + if (SignalRRescueNotificationService.ConnectedRescuers.ContainsKey(rescuerId)) + { + await _hubContext.Clients.Client(SignalRRescueNotificationService.ConnectedRescuers[rescuerId]) + .SendAsync("MissionCancelled", new { MissionId = mission.Id, Reason = "User cancelled incident" }); + } + } + + await _hubContext.Clients.All.SendAsync("IncidentCancelled", incident); + + return Ok(new { incident, message = "Incident cancelled" }); + } + + /// + /// User raise session range (mở rộng bán kính tìm kiếm) + /// + [HttpPost("incident/{incidentId}/raise-range")] + public async Task RaiseSessionRange(Guid incidentId) + { + if (!MockIncidents.TryGetValue(incidentId, out var incident)) + { + return NotFound("Incident not found"); + } + + if (incident.Status != "Pending") + { + return BadRequest($"Cannot raise range for incident with status: {incident.Status}"); + } + + // Close current session as Failed + var currentSession = MockSessions.Values + .FirstOrDefault(s => s.IncidentId == incidentId && s.SessionNumber == incident.CurrentSessionNumber); + + if (currentSession != null) + { + // Stop timer + StopSessionTimer(currentSession.Id); + + currentSession.Status = "Failed"; + currentSession.CompletedAt = DateTime.UtcNow; + + // Mark all pending requests as expired + var pendingRequests = MockRequests.Values.Where(r => r.SessionId == currentSession.Id && r.Status == "Pending"); + foreach (var req in pendingRequests) + { + req.Status = "Expired"; + req.UpdatedAt = DateTime.UtcNow; + + // Notify rescuer + await NotifyRescuerAsync(req.RescuerId.ToString(), "RequestExpired", new { RequestId = req.Id }); + } + } + + // Check max sessions + if (incident.CurrentSessionNumber >= 3) + { + incident.Status = "NoRescuerFound"; + await _hubContext.Clients.All.SendAsync("IncidentNoRescuerFound", incident); + return Ok(new { incident, message = "Maximum session range expansions reached. No rescuers found." }); + } + + // Calculate new radius + int newRadius = incident.CurrentRadiusKm switch + { + 5 => 7, + 7 => 10, + _ => incident.CurrentRadiusKm + 5 + }; + + // Create new session + var newSession = await CreateNewSessionAsync(incident, incident.CurrentSessionNumber + 1, newRadius, "RadiusExpanded"); + + // Start timeout timer + StartSessionTimer(newSession.Id, 60); + + return Ok(new { session = newSession, message = $"Range expanded to {newRadius}km" }); + } + + #endregion + + #region Rescuer Actions + + /// + /// Rescuer accept request + /// + [HttpPost("request/{requestId}/accept")] + public async Task AcceptRequest(Guid requestId, [FromQuery] Guid rescuerId) + { + if (!MockRequests.TryGetValue(requestId, out var request)) + { + return NotFound("Request not found"); + } + + if (request.RescuerId != rescuerId) + { + return BadRequest("This request is not assigned to you"); + } + + if (request.Status != "Pending") + { + return BadRequest($"Cannot accept request with status: {request.Status}"); + } + + // Check if expired + if (DateTime.UtcNow > request.ExpiredAt) + { + request.Status = "Expired"; + request.UpdatedAt = DateTime.UtcNow; + return BadRequest("Request has expired"); + } + + // Check if session already completed + var session = MockSessions[request.SessionId]; + if (session.Status == "Completed") + { + request.Status = "Taken"; + request.UpdatedAt = DateTime.UtcNow; + return BadRequest("Another rescuer has already accepted this incident"); + } + + // Accept this request + request.Status = "Accepted"; + request.ResponseAt = DateTime.UtcNow; + request.UpdatedAt = DateTime.UtcNow; + + // Stop session timer + StopSessionTimer(session.Id); + + // Mark all other requests in session as Taken + var otherRequests = MockRequests.Values.Where(r => r.SessionId == session.Id && r.Id != requestId && r.Status == "Pending").ToList(); + foreach (var otherReq in otherRequests) + { + otherReq.Status = "Taken"; + otherReq.UpdatedAt = DateTime.UtcNow; + + // Notify other rescuers + await NotifyRescuerAsync(otherReq.RescuerId.ToString(), "RequestTaken", new + { + RequestId = otherReq.Id, + Message = "This request has been taken by another rescuer." + }); + } + + // Mark session as completed + session.Status = "Completed"; + session.CompletedAt = DateTime.UtcNow; + + // Create mission + var incident = MockIncidents[request.IncidentId]; + incident.Status = "Assigned"; + incident.AssignedRescuerId = rescuerId; + incident.AssignedAt = DateTime.UtcNow; + + var mission = new MockMission + { + Id = Guid.NewGuid(), + IncidentId = request.IncidentId, + RescuerId = rescuerId, + Status = "Preparing", + CreatedAt = DateTime.UtcNow + }; + MockMissions[mission.Id] = mission; + + // Notify caller + await NotifyRescuerAsync(rescuerId.ToString(), "RequestAccepted", new + { + RequestId = requestId, + MissionId = mission.Id, + Message = "Request accepted! You have been assigned to this rescue mission." + }); + + // Notify user + await _hubContext.Clients.All.SendAsync("IncidentAssigned", new + { + IncidentId = incident.Id, + RescuerId = rescuerId, + RescuerName = MockRescuers[rescuerId].Name, + MissionId = mission.Id + }); + + _logger.LogInformation("Rescuer {RescuerId} accepted request {RequestId}, mission {MissionId} created", + rescuerId, requestId, mission.Id); + + return Ok(new { request, mission, message = "Request accepted successfully" }); + } + + /// + /// Rescuer reject request + /// + [HttpPost("request/{requestId}/reject")] + public async Task RejectRequest(Guid requestId) + { + if (!MockRequests.TryGetValue(requestId, out var request)) + { + return NotFound("Request not found"); + } + + if (request.Status != "Pending") + { + return BadRequest($"Cannot reject request with status: {request.Status}"); + } + + request.Status = "Rejected"; + request.ResponseAt = DateTime.UtcNow; + request.UpdatedAt = DateTime.UtcNow; + + await NotifyRescuerAsync(request.RescuerId.ToString(), "RequestRejected", new { RequestId = requestId }); + + return Ok(new { request, message = "Request rejected" }); + } + + /// + /// Cancel mission by rescuer + /// + [HttpPost("mission/{missionId}/cancel")] + public async Task CancelMission(Guid missionId, [FromQuery] string reason = "Rescuer cancelled") + { + if (!MockMissions.TryGetValue(missionId, out var mission)) + { + return NotFound("Mission not found"); + } + + if (mission.Status != "Preparing" && mission.Status != "EnRoute") + { + return BadRequest($"Cannot cancel mission with status: {mission.Status}"); + } + + mission.Status = "Cancelled"; + mission.CancellationReason = reason; + mission.UpdatedAt = DateTime.UtcNow; + + var incident = MockIncidents[mission.IncidentId]; + + // Reset incident to Pending for retry + incident.Status = "Pending"; + incident.AssignedRescuerId = null; + incident.AssignedAt = null; + + // Create new session triggered by mission cancellation + var newSession = await CreateNewSessionAsync(incident, incident.CurrentSessionNumber + 1, incident.CurrentRadiusKm, "MissionCancelled"); + + // Start timeout timer + StartSessionTimer(newSession.Id, 60); + + // Notify user + await _hubContext.Clients.All.SendAsync("MissionCancelledByRescuer", new + { + MissionId = missionId, + IncidentId = incident.Id, + Reason = reason, + NewSessionId = newSession.Id, + Message = "Rescuer cancelled. Looking for another rescuer..." + }); + + return Ok(new { mission, newSession, message = "Mission cancelled, new session created" }); + } + + /// + /// Update mission status + /// + [HttpPost("mission/{missionId}/status")] + public async Task UpdateMissionStatus(Guid missionId, [FromQuery] string status) + { + if (!MockMissions.TryGetValue(missionId, out var mission)) + { + return NotFound("Mission not found"); + } + + var oldStatus = mission.Status; + mission.Status = status; + mission.UpdatedAt = DateTime.UtcNow; + + switch (status) + { + case "EnRoute": + mission.StartedAt = DateTime.UtcNow; + break; + case "RescuerArrived": + mission.ArrivedAt = DateTime.UtcNow; + break; + case "MissionCompleted": + mission.CompletedAt = DateTime.UtcNow; + var incident = MockIncidents[mission.IncidentId]; + incident.Status = "Finished"; + break; + } + + await _hubContext.Clients.All.SendAsync("MissionStatusUpdated", new + { + MissionId = missionId, + OldStatus = oldStatus, + NewStatus = status, + Mission = mission + }); + + return Ok(new { mission, message = $"Mission status updated to {status}" }); + } + + #endregion + + #region Query APIs + + /// + /// Get all mock data state + /// + [HttpGet("state")] + public IActionResult GetState() + { + return Ok(new + { + users = MockUsers.Values.ToList(), + rescuers = MockRescuers.Values.Select(r => new + { + r.Id, + r.Name, + r.Lat, + r.Lng, + r.IsOnline, + r.DistanceFromUserKm, + IsConnected = SignalRRescueNotificationService.ConnectedRescuers.ContainsKey(r.Id.ToString()) + }).ToList(), + incidents = MockIncidents.Values.ToList(), + sessions = MockSessions.Values.ToList(), + requests = MockRequests.Values.ToList(), + missions = MockMissions.Values.ToList(), + connectedRescuers = SignalRRescueNotificationService.ConnectedRescuers.Keys.ToList() + }); + } + + /// + /// Get incident details + /// + [HttpGet("incident/{incidentId}")] + public IActionResult GetIncident(Guid incidentId) + { + if (!MockIncidents.TryGetValue(incidentId, out var incident)) + { + return NotFound("Incident not found"); + } + + var sessions = MockSessions.Values.Where(s => s.IncidentId == incidentId).OrderBy(s => s.SessionNumber).ToList(); + var requests = MockRequests.Values.Where(r => r.IncidentId == incidentId).ToList(); + var mission = MockMissions.Values.FirstOrDefault(m => m.IncidentId == incidentId); + + return Ok(new { incident, sessions, requests, mission }); + } + + /// + /// Get requests for a rescuer + /// + [HttpGet("rescuer/{rescuerId}/requests")] + public IActionResult GetRescuerRequests(Guid rescuerId) + { + var requests = MockRequests.Values.Where(r => r.RescuerId == rescuerId).ToList(); + return Ok(requests); + } + + /// + /// Reset all mock data + /// + [HttpPost("reset")] + public async Task ResetMockData() + { + // Stop all timers + foreach (var timer in SessionTimers.Values) + { + timer.Stop(); + timer.Dispose(); + } + SessionTimers.Clear(); + + MockIncidents.Clear(); + MockSessions.Clear(); + MockRequests.Clear(); + MockMissions.Clear(); + + // Reset rescuer online status + foreach (var rescuer in MockRescuers.Values) + { + rescuer.IsOnline = SignalRRescueNotificationService.ConnectedRescuers.ContainsKey(rescuer.Id.ToString()); + } + + await _hubContext.Clients.All.SendAsync("DataReset", new { Message = "All mock data has been reset" }); + + return Ok(new { message = "Mock data reset successfully" }); + } + + /// + /// Simulate session timeout manually (for testing) + /// + [HttpPost("session/{sessionId}/timeout")] + public async Task SimulateTimeout(Guid sessionId) + { + await HandleSessionTimeoutAsync(sessionId); + return Ok(new { message = "Session timeout handled" }); + } + + #endregion + + #region Private Helper Methods + + private async Task CreateNewSessionAsync(MockIncident incident, int sessionNumber, int radiusKm, string trigger) + { + var session = new MockSession + { + Id = Guid.NewGuid(), + IncidentId = incident.Id, + SessionNumber = sessionNumber, + RadiusKm = radiusKm, + Status = "Active", + TriggerType = trigger, + RescuersPinged = 0, + CreatedAt = DateTime.UtcNow + }; + + MockSessions[session.Id] = session; + + // Update incident + incident.CurrentSessionNumber = sessionNumber; + incident.CurrentRadiusKm = radiusKm; + incident.LastSessionAt = DateTime.UtcNow; + + // Find and ping connected rescuers within radius + var connectedRescuers = MockRescuers.Values + .Where(r => SignalRRescueNotificationService.ConnectedRescuers.ContainsKey(r.Id.ToString())) + .Where(r => r.DistanceFromUserKm <= radiusKm) + .ToList(); + + session.RescuersPinged = connectedRescuers.Count; + + var expiredAt = DateTime.UtcNow.AddSeconds(60); + + foreach (var rescuer in connectedRescuers) + { + var request = new MockRescuerRequest + { + Id = Guid.NewGuid(), + SessionId = session.Id, + IncidentId = incident.Id, + RescuerId = rescuer.Id, + Status = "Pending", + RequestSentAt = DateTime.UtcNow, + ExpiredAt = expiredAt, + CreatedAt = DateTime.UtcNow + }; + + MockRequests[request.Id] = request; + + // Send request to rescuer via SignalR + await NotifyRescuerAsync(rescuer.Id.ToString(), "NewRescueRequest", new + { + RequestId = request.Id, + SessionId = session.Id, + IncidentId = incident.Id, + IncidentLat = incident.Lat, + IncidentLng = incident.Lng, + RadiusKm = radiusKm, + SessionNumber = sessionNumber, + ExpiredAt = expiredAt, + RequestSentAt = request.RequestSentAt, + TimeoutSeconds = 60 + }); + } + + // Notify all about session creation + await _hubContext.Clients.All.SendAsync("SessionCreated", new + { + Session = session, + RescuersPinged = connectedRescuers.Count, + RescuerNames = connectedRescuers.Select(r => r.Name).ToList() + }); + + _logger.LogInformation("Session {SessionId} created with {Count} rescuers pinged in {RadiusKm}km", + session.Id, connectedRescuers.Count, radiusKm); + + return session; + } + + private async Task CancelSessionAsync(Guid sessionId) + { + if (!MockSessions.TryGetValue(sessionId, out var session)) + return; + + StopSessionTimer(sessionId); + + session.Status = "Cancelled"; + session.CompletedAt = DateTime.UtcNow; + + // Cancel all pending requests + var pendingRequests = MockRequests.Values.Where(r => r.SessionId == sessionId && r.Status == "Pending"); + foreach (var req in pendingRequests) + { + req.Status = "Cancelled"; + req.UpdatedAt = DateTime.UtcNow; + + await NotifyRescuerAsync(req.RescuerId.ToString(), "RequestCancelled", new + { + RequestId = req.Id, + Message = "Request cancelled by user" + }); + } + + await _hubContext.Clients.All.SendAsync("SessionCancelled", session); + } + + private async Task HandleSessionTimeoutAsync(Guid sessionId) + { + if (!MockSessions.TryGetValue(sessionId, out var session)) + return; + + if (session.Status != "Active") + return; + + // Mark all pending requests as expired + var pendingRequests = MockRequests.Values.Where(r => r.SessionId == sessionId && r.Status == "Pending").ToList(); + foreach (var req in pendingRequests) + { + req.Status = "Expired"; + req.UpdatedAt = DateTime.UtcNow; + + await NotifyRescuerAsync(req.RescuerId.ToString(), "RequestExpired", new + { + RequestId = req.Id, + Message = "Request has expired" + }); + } + + session.Status = "Failed"; + session.CompletedAt = DateTime.UtcNow; + + await _hubContext.Clients.All.SendAsync("SessionTimeout", new + { + SessionId = sessionId, + ExpiredRequests = pendingRequests.Count, + Message = "Session timed out. All pending requests expired." + }); + + // Try to expand and create new session + var incident = MockIncidents[session.IncidentId]; + + if (incident.Status == "Pending" && incident.CurrentSessionNumber < 3) + { + int nextRadius = session.RadiusKm switch + { + 5 => 7, + 7 => 10, + _ => session.RadiusKm + 5 + }; + + var newSession = await CreateNewSessionAsync(incident, incident.CurrentSessionNumber + 1, nextRadius, "RadiusExpanded"); + StartSessionTimer(newSession.Id, 60); + + await _hubContext.Clients.All.SendAsync("SessionAutoExpanded", new + { + OldSessionId = sessionId, + NewSession = newSession, + Message = $"Auto-expanded to {nextRadius}km" + }); + } + else if (incident.CurrentSessionNumber >= 3) + { + incident.Status = "NoRescuerFound"; + await _hubContext.Clients.All.SendAsync("IncidentNoRescuerFound", incident); + } + } + + private void StartSessionTimer(Guid sessionId, int seconds) + { + var timer = new System.Timers.Timer(seconds * 1000); + timer.Elapsed += async (sender, e) => + { + timer.Stop(); + await HandleSessionTimeoutAsync(sessionId); + }; + timer.AutoReset = false; + timer.Start(); + + SessionTimers[sessionId] = timer; + _logger.LogInformation("Started {Seconds}s timer for session {SessionId}", seconds, sessionId); + } + + private void StopSessionTimer(Guid sessionId) + { + if (SessionTimers.TryRemove(sessionId, out var timer)) + { + timer.Stop(); + timer.Dispose(); + _logger.LogInformation("Stopped timer for session {SessionId}", sessionId); + } + } + + private async Task NotifyRescuerAsync(string rescuerId, string method, object data) + { + if (SignalRRescueNotificationService.ConnectedRescuers.TryGetValue(rescuerId, out var connectionId)) + { + try + { + await _hubContext.Clients.Client(connectionId).SendAsync(method, data); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error sending {Method} to rescuer {RescuerId}", method, rescuerId); + } + } + } + + #endregion + } + + #region Mock DTOs + + public class CreateIncidentDto + { + public double Lat { get; set; } = 10.762622; + public double Lng { get; set; } = 106.660172; + } + + public class MockUser + { + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Role { get; set; } = "Member"; + public double Lat { get; set; } + public double Lng { get; set; } + } + + public class MockRescuer + { + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public double Lat { get; set; } + public double Lng { get; set; } + public bool IsOnline { get; set; } + public double DistanceFromUserKm { get; set; } + } + + public class MockIncident + { + public Guid Id { get; set; } + public Guid UserId { get; set; } + public double Lat { get; set; } + public double Lng { get; set; } + public string Status { get; set; } = "Pending"; + public int CurrentSessionNumber { get; set; } + public int CurrentRadiusKm { get; set; } + public DateTime? LastSessionAt { get; set; } + public Guid? AssignedRescuerId { get; set; } + public DateTime? AssignedAt { get; set; } + public DateTime CreatedAt { get; set; } + } + + public class MockSession + { + public Guid Id { get; set; } + public Guid IncidentId { get; set; } + public int SessionNumber { get; set; } + public int RadiusKm { get; set; } + public string Status { get; set; } = "Active"; + public string TriggerType { get; set; } = "Initial"; + public int RescuersPinged { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? CompletedAt { get; set; } + } + + public class MockRescuerRequest + { + public Guid Id { get; set; } + public Guid SessionId { get; set; } + public Guid IncidentId { get; set; } + public Guid RescuerId { get; set; } + public string Status { get; set; } = "Pending"; + public DateTime RequestSentAt { get; set; } + public DateTime? ResponseAt { get; set; } + public DateTime ExpiredAt { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + } + + public class MockMission + { + public Guid Id { get; set; } + public Guid IncidentId { get; set; } + public Guid RescuerId { get; set; } + public string Status { get; set; } = "Preparing"; + public string? CancellationReason { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? StartedAt { get; set; } + public DateTime? ArrivedAt { get; set; } + public DateTime? CompletedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + } + + #endregion +} diff --git a/SnakeAid.Api/Controllers/SnakebiteIncidentController.cs b/SnakeAid.Api/Controllers/SnakebiteIncidentController.cs index 44cc6ee8..2d4698e9 100644 --- a/SnakeAid.Api/Controllers/SnakebiteIncidentController.cs +++ b/SnakeAid.Api/Controllers/SnakebiteIncidentController.cs @@ -20,15 +20,18 @@ namespace SnakeAid.Api.Controllers public class SnakebiteIncidentController : BaseController { private readonly ISnakebiteIncidentService _incidentService; + private readonly IRescueRequestSessionService _sessionService; public SnakebiteIncidentController( ILogger logger, IHttpContextAccessor httpContextAccessor, IMapper mapper, - ISnakebiteIncidentService incidentService) + ISnakebiteIncidentService incidentService, + IRescueRequestSessionService sessionService) : base(logger, httpContextAccessor, mapper) { _incidentService = incidentService; + _sessionService = sessionService; } /// @@ -43,9 +46,21 @@ public async Task CreateSnakebiteIncident([FromBody] CreateIncide { var userId = GetCurrentUserId(); - // Create incident and first rescue request session + // Step 1: Create incident first var result = await _incidentService.CreateIncidentAsync(request, userId); - return StatusCode(result.StatusCode, result); + + // Step 2: Start rescue session and broadcast to rescuers + var rescueResult = await _incidentService.StartRescueAsync(result.Id); + + // Combine response data + result.SessionId = rescueResult.SessionId; + result.SessionNumber = rescueResult.SessionNumber; + result.RadiusKm = rescueResult.RadiusKm; + result.RescuersPinged = rescueResult.RescuersPinged; + + var response = ApiResponseBuilder.BuildSuccessResponse(result, + "Snakebite Incident created and rescue session started! Broadcasting to nearby rescuers."); + return StatusCode(response.StatusCode, response); } /// @@ -60,7 +75,9 @@ public async Task RaiseSessionRange(Guid incidentId) { var request = new RaiseSessionRangeRequest { IncidentId = incidentId }; var result = await _incidentService.RaiseSessionRangeAsync(request); - return StatusCode(result.StatusCode, result); + var response = ApiResponseBuilder.BuildSuccessResponse(result, + $"Session range expanded successfully. New radius: {result.CurrentRadiusKm}km (Session {result.CurrentSessionNumber})"); + return StatusCode(response.StatusCode, response); } /// @@ -74,7 +91,8 @@ public async Task RaiseSessionRange(Guid incidentId) public async Task UpdateSymptomReport(Guid incidentId, [FromBody] UpdateSymptomReportRequest request) { var result = await _incidentService.UpdateSymptomReportAsync(incidentId, request); - return StatusCode(result.StatusCode, result); + var response = ApiResponseBuilder.BuildSuccessResponse(result, "Symptom report updated successfully!"); + return StatusCode(response.StatusCode, response); } } } diff --git a/SnakeAid.Api/Hubs/RescuerHub.cs b/SnakeAid.Api/Hubs/RescuerHub.cs new file mode 100644 index 00000000..264bd95c --- /dev/null +++ b/SnakeAid.Api/Hubs/RescuerHub.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using SnakeAid.Core.Domains; +using SnakeAid.Api.Services; +using SnakeAid.Service.Interfaces; + +namespace SnakeAid.Api.Hubs +{ + public class RescuerHub : Hub + { + private readonly IRescueRequestSessionService _sessionService; + private readonly ILogger _logger; + + // Static dictionary để track connected rescuers: userId -> connectionId + public static ConcurrentDictionary ConnectedRescuers => SignalRRescueNotificationService.ConnectedRescuers; + + public RescuerHub( + IRescueRequestSessionService sessionService, + ILogger logger) + { + _sessionService = sessionService; + _logger = logger; + } + + /// + /// Khi rescuer connect và join để nhận requests + /// + public async Task JoinAsRescuer(string userId) + { + // Add connection to notification service + SignalRRescueNotificationService.AddConnection(userId, Context.ConnectionId); + + _logger.LogInformation("Rescuer {UserId} joined with connectionId {ConnectionId}", userId, Context.ConnectionId); + await Clients.Caller.SendAsync("Joined", new + { + UserId = userId, + ConnectionId = Context.ConnectionId, + Message = $"Rescuer {userId} joined successfully. Waiting for rescue requests..." + }); + } + + /// Rescuer accept request - Ai accept nhanh nhất sẽ nhận mission + public async Task AcceptRequest(Guid requestId, Guid rescuerId) + { + try + { + await _sessionService.AcceptRequestAsync(requestId, rescuerId); + + await Clients.Caller.SendAsync("RequestAccepted", new + { + RequestId = requestId, + Message = "Request accepted successfully! You have been assigned to this rescue mission." + }); + + _logger.LogInformation("Rescuer {RescuerId} accepted request {RequestId}", rescuerId, requestId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error accepting request {RequestId}: {Message}", requestId, ex.Message); + await Clients.Caller.SendAsync("RequestError", new + { + RequestId = requestId, + Error = ex.Message + }); + } + } + + /// Rescuer reject request + public async Task RejectRequest(Guid requestId) + { + try + { + await _sessionService.RejectRequestAsync(requestId); + + await Clients.Caller.SendAsync("RequestRejected", new + { + RequestId = requestId, + Message = "Request rejected." + }); + + _logger.LogInformation("Request {RequestId} rejected", requestId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error rejecting request {RequestId}: {Message}", requestId, ex.Message); + await Clients.Caller.SendAsync("RequestError", new + { + RequestId = requestId, + Error = ex.Message + }); + } + } + + public async Task UpdateLocation(string userId, double latitude, double longitude) + { + _logger.LogInformation("Rescuer {UserId} updated location: {Lat}, {Lng}", userId, latitude, longitude); + await Clients.Caller.SendAsync("LocationUpdated", new + { + UserId = userId, + Latitude = latitude, + Longitude = longitude, + UpdatedAt = DateTime.UtcNow + }); + } + + public override async Task OnDisconnectedAsync(Exception? exception) + { + var userId = ConnectedRescuers.FirstOrDefault(x => x.Value == Context.ConnectionId).Key; + if (userId != null) + { + SignalRRescueNotificationService.RemoveConnection(userId); + _logger.LogInformation("Rescuer {UserId} disconnected", userId); + } + await base.OnDisconnectedAsync(exception); + } + + public async Task GetConnectedRescuers() + { + var rescuers = ConnectedRescuers.Keys.ToList(); + await Clients.Caller.SendAsync("ConnectedRescuers", new + { + Count = rescuers.Count, + RescuerIds = rescuers + }); + } + } +} \ No newline at end of file diff --git a/SnakeAid.Api/Pages/Demo/RescueDemo.cshtml b/SnakeAid.Api/Pages/Demo/RescueDemo.cshtml new file mode 100644 index 00000000..0988d86f --- /dev/null +++ b/SnakeAid.Api/Pages/Demo/RescueDemo.cshtml @@ -0,0 +1,1203 @@ +@page +@model SnakeAid.Api.Pages.Demo.RescueDemoModel +@{ + ViewData["Title"] = "Rescue Demo - SignalR Test"; + Layout = null; +} + + + + + + + 🐍 SnakeAid - Rescue Demo + + + + + +
+
+

🐍 SnakeAid - Rescue Demo

+

Test SignalR rescue flow với 4 rescuers giả lập và 1 user

+
+ +
+ +
+
+ 👤 +

User Panel

+
+
+
+
+ Disconnected +
+ +
+ + + + + +
+ +
+ + +
+ + +
+

📋 Event Log

+
+
+
+
+ + +
+
+ 🚑 +

Rescuers (4 Mock)

+
+
+
+ +
+
+
+ + +
+
+ 📊 +

Incident Status

+
+
+ + + + + + + + + + +

📜 Session History

+
+
+ No sessions yet. Create an incident to start. +
+
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/SnakeAid.Api/Pages/Demo/RescueDemo.cshtml.cs b/SnakeAid.Api/Pages/Demo/RescueDemo.cshtml.cs new file mode 100644 index 00000000..4595f7af --- /dev/null +++ b/SnakeAid.Api/Pages/Demo/RescueDemo.cshtml.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace SnakeAid.Api.Pages.Demo +{ + public class RescueDemoModel : PageModel + { + public void OnGet() + { + // No server-side logic needed - all handled by SignalR and API + } + } +} diff --git a/SnakeAid.Api/Program.cs b/SnakeAid.Api/Program.cs index 914685d8..27a73f99 100644 --- a/SnakeAid.Api/Program.cs +++ b/SnakeAid.Api/Program.cs @@ -17,6 +17,7 @@ using SQLitePCL; using Swashbuckle.AspNetCore.SwaggerUI; using System.Text.Json.Serialization; +using SnakeAid.Service.Interfaces; namespace SnakeAid.Api { @@ -114,6 +115,13 @@ public static async Task Main(string[] args) .AsImplementedInterfaces() .WithScopedLifetime()); + // Register SessionTimeoutBackgroundService manually as singleton for background service + builder.Services.AddSingleton(); + builder.Services.AddSingleton(provider => + provider.GetRequiredService()); + builder.Services.AddHostedService(provider => + provider.GetRequiredService()); + builder.Services.AddMemoryCache(); // Health checks endpoint @@ -149,7 +157,7 @@ public static async Task Main(string[] args) }); builder.Services.AddControllers(); - + // Add Razor Pages for lightweight UI admin pages builder.Services.AddRazorPages(); @@ -307,6 +315,8 @@ public static async Task Main(string[] args) // Map SignalR Hub with specific CORS policy app.MapHub("/chat-hub").RequireCors("SignalRCorsPolicy"); + app.MapHub("/rescuer-hub").RequireCors("SignalRCorsPolicy"); + // Map Razor pages app.MapRazorPages(); diff --git a/SnakeAid.Api/Services/SignalRRescueNotificationService.cs b/SnakeAid.Api/Services/SignalRRescueNotificationService.cs new file mode 100644 index 00000000..0d1d88b4 --- /dev/null +++ b/SnakeAid.Api/Services/SignalRRescueNotificationService.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Concurrent; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using SnakeAid.Api.Hubs; +using SnakeAid.Service.Interfaces; + +namespace SnakeAid.Api.Services +{ + /// + /// SignalR implementation of IRescueNotificationService. + /// Lives in API layer to keep Service layer clean from SignalR dependencies. + /// + public class SignalRRescueNotificationService : IRescueNotificationService + { + private readonly IHubContext _hubContext; + private readonly ILogger _logger; + + // Static dictionary để track connected rescuers: userId -> connectionId + public static ConcurrentDictionary ConnectedRescuers { get; } = new(); + + public SignalRRescueNotificationService( + IHubContext hubContext, + ILogger logger) + { + _hubContext = hubContext; + _logger = logger; + } + + public bool IsRescuerConnected(string rescuerId) + { + return ConnectedRescuers.ContainsKey(rescuerId); + } + + public async Task SendNewRequestAsync(string rescuerId, object requestData) + { + if (ConnectedRescuers.TryGetValue(rescuerId, out var connectionId)) + { + try + { + await _hubContext.Clients.Client(connectionId).SendAsync("NewRescueRequest", requestData); + _logger.LogInformation("Sent rescue request to rescuer {RescuerId}", rescuerId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error sending request to rescuer {RescuerId}: {Message}", rescuerId, ex.Message); + } + } + else + { + _logger.LogWarning("Rescuer {RescuerId} not connected, cannot send request", rescuerId); + } + } + + public async Task NotifyRequestTakenAsync(string rescuerId, Guid requestId) + { + if (ConnectedRescuers.TryGetValue(rescuerId, out var connectionId)) + { + try + { + await _hubContext.Clients.Client(connectionId).SendAsync("RequestTaken", new + { + RequestId = requestId, + Message = "This request has been taken by another rescuer." + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error notifying rescuer {RescuerId} about taken request: {Message}", rescuerId, ex.Message); + } + } + } + + public async Task NotifyRequestCancelledAsync(string rescuerId, Guid requestId) + { + if (ConnectedRescuers.TryGetValue(rescuerId, out var connectionId)) + { + try + { + await _hubContext.Clients.Client(connectionId).SendAsync("RequestCancelled", new + { + RequestId = requestId, + Message = "This request has been cancelled by the user." + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error notifying rescuer {RescuerId} about cancelled request: {Message}", rescuerId, ex.Message); + } + } + } + + public async Task NotifyRequestExpiredAsync(string rescuerId, Guid requestId) + { + if (ConnectedRescuers.TryGetValue(rescuerId, out var connectionId)) + { + try + { + await _hubContext.Clients.Client(connectionId).SendAsync("RequestExpired", new + { + RequestId = requestId, + Message = "This request has expired." + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error notifying rescuer {RescuerId} about expired request: {Message}", rescuerId, ex.Message); + } + } + } + + #region Static methods for Hub to manage connections + + public static void AddConnection(string userId, string connectionId) + { + ConnectedRescuers[userId] = connectionId; + } + + public static void RemoveConnection(string userId) + { + ConnectedRescuers.TryRemove(userId, out _); + } + + public static string? GetConnectionId(string userId) + { + return ConnectedRescuers.TryGetValue(userId, out var connectionId) ? connectionId : null; + } + + #endregion + } +} diff --git a/SnakeAid.Core/Domains/RescueRequestSession.cs b/SnakeAid.Core/Domains/RescueRequestSession.cs index b1b9ddff..c2501d82 100644 --- a/SnakeAid.Core/Domains/RescueRequestSession.cs +++ b/SnakeAid.Core/Domains/RescueRequestSession.cs @@ -17,7 +17,7 @@ public class RescueRequestSession : BaseEntity public Guid IncidentId { get; set; } [Required] - public int SessionNumber { get; set; } // 1, 2, 3, 4, 5, 6 + public int SessionNumber { get; set; } // 1, 2, 3 [Required] public int RadiusKm { get; set; } // 5, 10, 20 - radius hiện tại đang quét diff --git a/SnakeAid.Core/Domains/SnakeSpecies.cs b/SnakeAid.Core/Domains/SnakeSpecies.cs index 58a80abd..7b213b58 100644 --- a/SnakeAid.Core/Domains/SnakeSpecies.cs +++ b/SnakeAid.Core/Domains/SnakeSpecies.cs @@ -99,5 +99,6 @@ public class FirstAidOverride { public OverrideMode Mode { get; set; } = OverrideMode.Append; public List Steps { get; set; } = new(); + // public FirstAidContent Content { get; set; } = new(); } } \ No newline at end of file diff --git a/SnakeAid.Core/Mappings/SnakebiteIncidentMapper.cs b/SnakeAid.Core/Mappings/SnakebiteIncidentMapper.cs index 4e8cbcb2..86adf337 100644 --- a/SnakeAid.Core/Mappings/SnakebiteIncidentMapper.cs +++ b/SnakeAid.Core/Mappings/SnakebiteIncidentMapper.cs @@ -1,4 +1,5 @@ using Mapster; +using NetTopologySuite.Geometries; using SnakeAid.Core.Domains; using SnakeAid.Core.Responses.SnakebiteIncident; using System; @@ -13,8 +14,13 @@ public class SnakebiteIncidentMapper : IRegister { public void Register(TypeAdapterConfig config) { - TypeAdapterConfig - .NewConfig() + // Map Point → GeoPointResponse + config.NewConfig() + .Map(dest => dest.Latitude, src => src.Y) + .Map(dest => dest.Longitude, src => src.X); + + // Map Incident → Response + config.NewConfig() .Map(dest => dest.LocationCoordinates, src => src.LocationCoordinates); } } diff --git a/SnakeAid.Core/Responses/SnakebiteIncident/AcceptRescueResponse.cs b/SnakeAid.Core/Responses/SnakebiteIncident/AcceptRescueResponse.cs new file mode 100644 index 00000000..64084cd7 --- /dev/null +++ b/SnakeAid.Core/Responses/SnakebiteIncident/AcceptRescueResponse.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SnakeAid.Core.Responses.SnakebiteIncident +{ + public class AcceptRescueResponse + { + public Guid RequestId { get; set; } + public Guid IncidentId { get; set; } + public Guid RescuerId { get; set; } + public Guid MissionId { get; set; } + public DateTime AcceptedAt { get; set; } + public string Message { get; set; } = string.Empty; + } +} diff --git a/SnakeAid.Core/Responses/SnakebiteIncident/CreateIncidentResponse.cs b/SnakeAid.Core/Responses/SnakebiteIncident/CreateIncidentResponse.cs index 57769b48..fb60f345 100644 --- a/SnakeAid.Core/Responses/SnakebiteIncident/CreateIncidentResponse.cs +++ b/SnakeAid.Core/Responses/SnakebiteIncident/CreateIncidentResponse.cs @@ -17,7 +17,7 @@ public class CreateIncidentResponse public Guid UserId { get; set; } // FK to MemberProfile - public Point LocationCoordinates { get; set; } + public GeoPointResponse LocationCoordinates { get; set; } public SnakebiteIncidentStatus Status { get; set; } = SnakebiteIncidentStatus.Pending; @@ -28,6 +28,19 @@ public class CreateIncidentResponse public DateTime? IncidentOccurredAt { get; set; } // Khi nào bị cắn + // Session details (populated when StartRescueAsync is called) + public Guid? SessionId { get; set; } + public int SessionNumber { get; set; } + public int RadiusKm { get; set; } + public int RescuersPinged { get; set; } + public List Sessions { get; set; } = new List(); } + + public class GeoPointResponse + { + public double Latitude { get; set; } + public double Longitude { get; set; } + } + } diff --git a/SnakeAid.Core/Responses/SnakebiteIncident/RejectRescueResponse.cs b/SnakeAid.Core/Responses/SnakebiteIncident/RejectRescueResponse.cs new file mode 100644 index 00000000..5c6485eb --- /dev/null +++ b/SnakeAid.Core/Responses/SnakebiteIncident/RejectRescueResponse.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SnakeAid.Core.Responses.SnakebiteIncident +{ + public class RejectRescueResponse + { + public Guid RequestId { get; set; } + public DateTime RejectedAt { get; set; } + public string Message { get; set; } = string.Empty; + } +} diff --git a/SnakeAid.Core/Responses/SnakebiteIncident/TriggerRescueResponse.cs b/SnakeAid.Core/Responses/SnakebiteIncident/TriggerRescueResponse.cs new file mode 100644 index 00000000..b5aa2887 --- /dev/null +++ b/SnakeAid.Core/Responses/SnakebiteIncident/TriggerRescueResponse.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SnakeAid.Core.Responses.SnakebiteIncident +{ + public class TriggerRescueResponse + { + public Guid IncidentId { get; set; } + public Guid SessionId { get; set; } + public int SessionNumber { get; set; } + public int RadiusKm { get; set; } + public int RescuersPinged { get; set; } + public DateTime CreatedAt { get; set; } + public string Message { get; set; } = string.Empty; + } +} diff --git a/SnakeAid.Repository/Seeds/DataSeeder.cs b/SnakeAid.Repository/Seeds/DataSeeder.cs index ced15a13..dfcce63d 100644 --- a/SnakeAid.Repository/Seeds/DataSeeder.cs +++ b/SnakeAid.Repository/Seeds/DataSeeder.cs @@ -240,629 +240,729 @@ public static async Task SeedAsync(SnakeAidDbContext context) }; context.FilterOptions.AddRange(filterOptions); - var snakes = new List - { - // 1. RẮN CẠP NIA BẮC (Bungarus multicinctus) - new SnakeSpecies - { - Id = 1, - ScientificName = "Bungarus multicinctus", - CommonName = "Rắn Cạp Nia Bắc", - Slug = "ran-cap-nia-bac", - Description = "Một trong những loài rắn độc nhất châu Á, thường gặp ở vùng đồng bằng và trung du Bắc Bộ.", - IdentificationSummary = "Chiều dài trung bình 1.0m - 1.5m. Thân có các khoanh trắng và đen rõ rệt, vảy trơn bóng.", - PrimaryVenomType = PrimaryVenomType.Neurotoxic, - RiskLevel = 9.5f, - IsVenomous = true, - ImageUrl = "https://e.khoahoc.tv/photos/image/2020/09/19/ran-cap-nia-1.jpg", - Identification = new IdentificationFeature - { - PhysicalTraits = new List { "Chiều dài: 100 - 150 cm", "Khoanh trắng đen rõ rệt", "Đầu bầu dục", "Vảy bóng", "Thân hình tam giác nhẹ" }, - Behaviors = new List { "Hoạt động mạnh về đêm", "Thích nơi ẩm ướt", "Thường chui vào nhà dân tìm mồi" }, - Habitat = "Cánh đồng, ven sông, khu dân cư miền Bắc" - }, - SymptomsByTime = new List - { - new SymptomTimeline { TimeRange = "0 - 30 phút", Signs = new List { "Đau nhẹ tại vết cắn, ít cảm giác", "Tê nhẹ" }, IsCritical = false }, - new SymptomTimeline { TimeRange = "1 - 3 giờ", Signs = new List { "Sụp mí mắt", "Khó nói", "Khó nuốt", "Yếu cơ" }, IsCritical = true }, - new SymptomTimeline { TimeRange = "3 - 6 giờ", Signs = new List { "Liệt cơ hô hấp", "Ngừng thở" }, IsCritical = true } - }, - FirstAidGuidelineOverride = new FirstAidOverride - { - Mode = OverrideMode.Append, - Steps = new List { "Đặc biệt chú ý hỗ trợ hô hấp nhân tạo nếu nạn nhân có dấu hiệu ngưng thở." } - } - }, - - // 2. RẮN LỤC ĐUÔI ĐỎ (Trimeresurus albolabris) - new SnakeSpecies - { - Id = 2, - ScientificName = "Trimeresurus albolabris", - CommonName = "Rắn Lục Đuôi Đỏ", - Slug = "ran-luc-duoi-do", - Description = "Loài rắn lục phổ biến nhất, thường sống trên cây và gây ra nhiều vụ tai nạn tại Việt Nam.", - IdentificationSummary = "Chiều dài trung bình 60cm - 90cm. Thân màu xanh lá cây, đầu hình tam giác rõ rệt, chót đuôi có màu đỏ cam.", - PrimaryVenomType = PrimaryVenomType.Hemotoxic, - RiskLevel = 7.5f, - IsVenomous = true, - ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/32/1736395426_0.jpg", - Identification = new IdentificationFeature - { - PhysicalTraits = new List { "Chiều dài: 60 - 90 cm", "Thân xanh lá", "Đuôi màu đỏ", "Đầu tam giác phình to", "Vảy nhám" }, - Behaviors = new List { "Sống trên cây", "Ngụy trang cực tốt trong lá cây", "Hay xuất hiện ở bụi hoa, vườn nhà" }, - Habitat = "Vườn cây, bụi rậm, rừng thưa trên toàn quốc" - }, - SymptomsByTime = new List - { - new SymptomTimeline { TimeRange = "0 - 15 phút", Signs = new List { "Đau nhức dữ dội", "Sưng nề nhanh chóng" }, IsCritical = false }, - new SymptomTimeline { TimeRange = "1 - 6 giờ", Signs = new List { "Xuất hiện bọng nước", "Chảy máu không cầm", "Bầm tím diện rộng" }, IsCritical = true } - }, - FirstAidGuidelineOverride = new FirstAidOverride - { - Mode = OverrideMode.Replace, - Steps = new List { "Rửa sạch vết thương.", "Bất động lỏng chi.", "TUYỆT ĐỐI KHÔNG BĂNG ÉP CHẶT vì gây hoại tử nhanh." } - } - }, - - // 3. RẮN HỔ MANG CHÚA (Ophiophagus hannah) - new SnakeSpecies - { - Id = 3, - ScientificName = "Ophiophagus hannah", - CommonName = "Rắn Hổ Mang Chúa", - Slug = "ran-ho-mang-chua", - Description = "Loài rắn độc dài nhất thế giới, cực kỳ nguy hiểm với lượng nọc độc khổng lồ.", - IdentificationSummary = "Kích thước khổng lồ (4-6m), Vảy đầu lớn, Cổ phình mang hẹp, vân chữ V ngược ở cổ.", - PrimaryVenomType = PrimaryVenomType.Neurotoxic, - RiskLevel = 10.0f, - IsVenomous = true, - ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/55/1737107747_0.jpg", - Identification = new IdentificationFeature - { - PhysicalTraits = new List { "Kích thước khổng lồ (4-6m)", - "Cặp vảy chẩm hình cánh bướm, nằm ở ngay phía sau vảy đầu", - "Phình mang hẹp dài", "Màu đen, nâu hoặc vàng chì" }, - Behaviors = new List { "Chủ động tấn công nếu bị kích động", "Có khả năng rướn cao thân mình" ,"Là một loài rắn thông minh, sẽ quan sát và phản ứng." }, - Habitat = "Rừng rậm, nương rẫy, gần nguồn nước" - }, - SymptomsByTime = new List - { - new SymptomTimeline { TimeRange = "0 - 15 phút", Signs = new List { "Đau nhức", "Chóng mặt", "Hoa mắt" }, IsCritical = true }, - new SymptomTimeline { TimeRange = "30 - 60 phút", Signs = new List { "Hôn mê", "Suy hô hấp cấp", "Tử vong nhanh nếu không cấp cứu" }, IsCritical = true } - }, - FirstAidGuidelineOverride = new FirstAidOverride - { - Mode = OverrideMode.Append, - Steps = new List { "Vận chuyển nạn nhân bằng phương tiện nhanh nhất có thể đến bệnh viện lớn." } - } - }, - - // 4. RẮN RÁO (Ptyas korros) - KHÔNG ĐỘC - new SnakeSpecies - { - Id = 4, - ScientificName = "Ptyas korros", - CommonName = "Rắn Ráo", - Slug = "ran-rao", - Description = "Loài rắn không độc phổ biến, thường bị nhầm lẫn với rắn hổ mang.", - IdentificationSummary = "Dài trung bình 1,2 - 2,0m. Mắt rất lớn, thân thon dài màu nâu đất hoặc xám chì, di chuyển cực nhanh.", - PrimaryVenomType = PrimaryVenomType.None, - RiskLevel = 1.0f, - IsVenomous = false, - ImageUrl = "https://www.cakhotranluan.com/images/2022/2ran-rao1.jpg", - Identification = new IdentificationFeature - { - PhysicalTraits = new List { "Chiều dài: 120 - 200 cm", "Mắt to", "Thân dài thon", "Vảy trơn bóng", "Đầu bầu dục" }, - Behaviors = new List { "Di chuyển rất nhanh", "Hoạt động ban ngày", "Thường chạy trốn khi gặp người" }, - Habitat = "Đồng ruộng, bụi rậm, vườn nhà" - }, - SymptomsByTime = new List - { - new SymptomTimeline { TimeRange = "Sau khi cắn", Signs = new List { "Chảy máu nhẹ", "Vết xước li ti", "Không sưng nề" }, IsCritical = false } - }, - FirstAidGuidelineOverride = new FirstAidOverride { Mode = OverrideMode.Append, Steps = new List { "Sát trùng vết thương bằng cồn hoặc nước sạch." } } - }, - - new SnakeSpecies - { - Id = 5, - ScientificName = "Bungarus fasciatus", - CommonName = "Rắn Cạp Nong", - Slug = "ran-cap-nong", - Description = "Loài rắn độc thần kinh nguy hiểm, dễ nhận biết với các khoanh vàng đen xen kẽ đều nhau.", - IdentificationSummary = "Kích thước 1.8 - 2.3m. Thân hình tam giác với sống lưng gồ cao, đầu có vệt vàng hình mũi tên.", - PrimaryVenomType = PrimaryVenomType.Neurotoxic, - RiskLevel = 9.0f, - IsVenomous = true, - ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/46/1736402914_0.jpg", - Identification = new IdentificationFeature - { - PhysicalTraits = new List { "Khoanh đen và vàng xen kẽ đều nhau", "Thân hình tam giác, sống lưng gồ", "Vệt vàng hai bên má tạo hình mũi tên" }, - Behaviors = new List { "Săn mồi ban đêm", "Bị thu hút bởi ánh lửa", "Tính tình thường nhút nhát nhưng độc tính rất mạnh" }, - Habitat = "Rừng núi, bụi rậm, ven nguồn nước" - }, - SymptomsByTime = new List - { - new SymptomTimeline { TimeRange = "0 - 30 phút", Signs = new List { "Ngứa nhẹ hoặc tê rát tại vết cắn", "Ít đau khiến nạn nhân chủ quan" }, IsCritical = false }, - new SymptomTimeline { TimeRange = "1 - 2 giờ", Signs = new List { "Mệt mỏi bất thường", "Tức ngực nhẹ", "Sụp mí mắt nhẹ" }, IsCritical = true }, - new SymptomTimeline { TimeRange = "2 - 6 giờ", Signs = new List { "Đau nhức toàn thân", "Yếu liệt cơ tiến triển", "Suy hô hấp cấp" }, IsCritical = true } - }, - FirstAidGuidelineOverride = new FirstAidOverride - { - Mode = OverrideMode.Append, - Steps = new List { "Cảnh giác cao độ nếu bị cắn khi đang cắm trại hoặc đi rừng ban đêm." } - } - }, - - new SnakeSpecies - { - Id = 6, - ScientificName = "Bungarus candidus", - CommonName = "Rắn Cạp Nia Nam", - Slug = "ran-cap-nia-nam", - Description = "Loài rắn độc thần kinh cực mạnh ở miền Nam, có tập tính lẻn vào nhà người dân.", - IdentificationSummary = "Khoanh đen và trắng có độ rộng gần bằng nhau, thân tròn bóng, đầu nhỏ.", - PrimaryVenomType = PrimaryVenomType.Neurotoxic, - RiskLevel = 9.8f, - IsVenomous = true, - ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/38/1736401130_0.jpg", - Identification = new IdentificationFeature - { - PhysicalTraits = new List { "Khoanh đen và trắng/vàng nhạt đều nhau", "Thân tròn, vảy trơn bóng", "Đầu nhỏ không phân biệt rõ với cổ" }, - Behaviors = new List { "Hoạt động đêm", "Hay chui vào nhà dân", "Cắn người khi đang ngủ" }, - Habitat = "Vùng đồng bằng, khu dân cư miền Nam Việt Nam" - }, - SymptomsByTime = new List - { - new SymptomTimeline { TimeRange = "0 - 15 phút", Signs = new List { "Vết cắn rất nhẹ, không sưng đau", "Khó thấy dấu răng" }, IsCritical = false }, - new SymptomTimeline { TimeRange = "2 - 3 giờ", Signs = new List { "Sụp mí mắt nặng", "Đồng tử giãn", "Nói ngọng", "Yếu chi" }, IsCritical = true }, - new SymptomTimeline { TimeRange = "3 - 6 giờ", Signs = new List { "Liệt cơ toàn thân", "Suy hô hấp hoàn toàn" }, IsCritical = true } - }, - FirstAidGuidelineOverride = new FirstAidOverride - { - Mode = OverrideMode.Append, - Steps = new List { "Tuyệt đối không chờ triệu chứng đau mới đi viện vì nọc cạp nia không gây đau." } - } - }, - - new SnakeSpecies - { - Id = 7, - ScientificName = "Naja kaouthia", - CommonName = "Rắn Hổ Mang Xiêm", - Slug = "ran-ho-mang-xiem", - Description = "Loài rắn hổ mang có nọc độc hỗn hợp, gây hoại tử mô nghiêm trọng và liệt thần kinh.", - IdentificationSummary = "Màu nâu xám hoặc đen, có bành cổ với một hình tròn đơn (hình kính mắt) ở mặt sau.", - PrimaryVenomType = PrimaryVenomType.Neurotoxic, // Lưu ý: Hỗn hợp thần kinh và tế bào - RiskLevel = 9.0f, - IsVenomous = true, - ImageUrl = "https://photo.znews.vn/w660/Uploaded/rotnrz/2023_07_04/ho_meo_1.jpg", - Identification = new IdentificationFeature - { - PhysicalTraits = new List { "Bành cổ rộng", "Hình kính mắt một vòng tròn sau cổ", "Màu nâu hoặc xám đen" }, - Behaviors = new List { "Có thể phun nọc độc xa và chuẩn", "Ngóc đầu cao và phình mang khi tấn công" }, - Habitat = "Ruộng lúa, vườn tược, khu dân cư gần nguồn nước" - }, - SymptomsByTime = new List - { - new SymptomTimeline { TimeRange = "0 - 10 phút", Signs = new List { "Đau rõ rệt", "Chảy máu tại vết cắn" }, IsCritical = false }, - new SymptomTimeline { TimeRange = "10 - 60 phút", Signs = new List { "Sưng nhanh", "Đau tăng dần", "Có thể phồng rộp da" }, IsCritical = false }, - new SymptomTimeline { TimeRange = "1 - 3 giờ", Signs = new List { "Sụp mí mắt", "Yếu cơ", "Khó nuốt" }, IsCritical = true }, - new SymptomTimeline { TimeRange = "6 - 24 giờ", Signs = new List { "Hoại tử mô tại chỗ", "Nhiễm trùng vết thương" }, IsCritical = true } - }, - FirstAidGuidelineOverride = new FirstAidOverride - { - Mode = OverrideMode.Append, - Steps = new List { "Nếu bị nọc phun vào mắt, phải rửa bằng nước sạch liên tục 15-20 phút." } - } - }, - - new SnakeSpecies - { - Id = 8, - ScientificName = "Rhabdophis subminiatus", - CommonName = "Rắn Hoa Cỏ Cổ Đỏ", - Slug = "ran-hoa-co-co-do", - Description = "Loài rắn có độc nguy hiểm điều kiện (độc nanh sau và độc da vùng cổ), gây rối loạn đông máu nặng và chưa có huyết thanh đặc hiệu.", - IdentificationSummary = "Cổ màu đỏ rực hoặc cam vàng đặc trưng, thân xanh ô liu, mắt tròn lớn.", - PrimaryVenomType = PrimaryVenomType.Hemotoxic, - RiskLevel = 8.5f, - IsVenomous = true, - ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/93/1737024030_0.jpeg", - Identification = new IdentificationFeature - { - PhysicalTraits = new List { "Vòng cổ màu đỏ rực hoặc vàng cam", "Đầu thuôn không hình tam giác", "Mắt tròn, đồng tử tròn" }, - Behaviors = new List { "Ngóc đầu, tiết độc trắng đục và bẹt cổ giống rắn hổ khi bị đe dọa", "Tính khí không ổn định (lúc hiền lúc dữ)" }, - Habitat = "Rừng, nương rẫy, gần nguồn nước" - }, - SymptomsByTime = new List - { - new SymptomTimeline { TimeRange = "0 - 1 giờ", Signs = new List { "Đau rát tại chỗ", "Ít sưng ban đầu" }, IsCritical = false }, - new SymptomTimeline { TimeRange = "6 - 24 giờ", Signs = new List { "Rối loạn đông máu nặng", "Chảy máu cam", "Tiểu ra máu" }, IsCritical = true }, - new SymptomTimeline { TimeRange = "Sau 24 giờ", Signs = new List { "Suy thận cấp", "Tụt huyết áp" }, IsCritical = true } - }, - FirstAidGuidelineOverride = new FirstAidOverride - { - Mode = OverrideMode.Replace, - Steps = new List { "HIỆN CHƯA CÓ HUYẾT THANH ĐẶC HIỆU. Chuyển ngay đến bệnh viện có khả năng hồi sức cấp cứu và truyền máu." } - } - }, - - new SnakeSpecies - { - Id = 9, - ScientificName = "Coelognathus radiatus", - CommonName = "Rắn Hổ Ngựa", - Slug = "ran-ho-ngua", - Description = "Loài rắn không độc, di chuyển cực nhanh và thường có hành vi tự vệ hung dữ.", - IdentificationSummary = "Màu nâu vàng, có 4 sọc đen chạy dọc phần trước thân, đầu dài.", - PrimaryVenomType = PrimaryVenomType.None, - RiskLevel = 1.0f, - IsVenomous = false, - ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/122/1737364008_0.jpg", - Identification = new IdentificationFeature - { - PhysicalTraits = new List { "Có thể dài đến 2m", "4 sọc đen trên thân trước", "Đầu dài bầu dục", "Mắt lớn" }, - Behaviors = new List { "Ngóc cao thân mình, bẹt cổ để hù dọa giống rắn hổ", " Miệng há rộng, hung hăng, doạ nạt, dữ tợn khi bị đe dọa", "Giả chết nếu cảm thấy nguy hiểm trước đối phương" }, - Habitat = "Đồng ruộng, bụi cây, khu vực nông nghiệp" - }, - SymptomsByTime = new List - { - new SymptomTimeline { TimeRange = "Sau khi cắn", Signs = new List { "Vết xước li ti", "Chảy máu nhẹ", "Không có triệu chứng thần kinh hay sưng nề lớn" }, IsCritical = false } - }, - FirstAidGuidelineOverride = new FirstAidOverride - { - Mode = OverrideMode.Append, - Steps = new List { "Chỉ cần rửa sạch vết thương bằng xà phòng để tránh nhiễm trùng." } - } - }, - - new SnakeSpecies - { - Id = 10, - ScientificName = "Elaphe carinata", - CommonName = "Rắn Chuột Vua", - Slug = "ran-chuot-vua", - Description = "Loài rắn không độc nhưng cực kỳ hung dữ, có kích thước lớn và mùi hôi đặc trưng để xua đuổi kẻ thù. Dễ bị nhầm lẫn với rắn hổ mang chúa do kích thước lớn và họa tiết đầu gần giống nhau.", - IdentificationSummary = "Thân màu nâu vàng hoặc xám đen với các vảy có gờ nổi rất mạnh (nhám). Không có nanh độc, không phình mang.", - PrimaryVenomType = PrimaryVenomType.None, - RiskLevel = 2.0f, - IsVenomous = false, - ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/123/1737365069_2.jpeg", - Identification = new IdentificationFeature - { - PhysicalTraits = new List { "Vảy có gờ nổi rất rõ (thân nhám)", "Mắt lớn, đầu thuôn dài", "Kích thước có thể lên tới 2.4m", "Màu sắc pha trộn vàng - đen - nâu ô liu" }, - Behaviors = new List { "Cực kỳ hung dữ, sẵn sàng tấn công khi bị kích động", "Phát ra mùi hôi thối nồng nặc từ tuyến sau", "Ăn thịt các loài rắn khác (kể cả rắn độc)" }, - Habitat = "Vùng đồi núi, bụi rậm, trang trại chăn nuôi" - }, - SymptomsByTime = new List - { - new SymptomTimeline { TimeRange = "Sau khi cắn", Signs = new List { "Vết cắn hình vòng cung", "Chảy máu khá nhiều do răng sắc nhọn", "Đau rát cục bộ" }, IsCritical = false } - }, - FirstAidGuidelineOverride = new FirstAidOverride - { - Mode = OverrideMode.Append, - Steps = new List { "Sát trùng kỹ vết thương vì miệng loài này chứa nhiều vi khuẩn do ăn chuột và thịt thối." } - } - }, - - // 11. RẮN LỤC CƯỜM (LỤC GẤM) - Protobothrops mucrosquamatus - new SnakeSpecies - { - Id = 11, - ScientificName = "Protobothrops mucrosquamatus", - CommonName = "Rắn Lục Cườm", - Slug = "ran-luc-cuom", - Description = "Loài rắn độc máu nguy hiểm, đầu hình tam giác lớn, hoa văn đốm sâm so le nhau ở sống lưng.", - IdentificationSummary = "Đầu tam giác rõ rệt, thân có các vệt hoa văn màu nâu đen trên nền xám/vàng đất, vảy nhám.", - PrimaryVenomType = PrimaryVenomType.Hemotoxic, - RiskLevel = 8.5f, - IsVenomous = true, - ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/54/1736524361_0.jpg", - Identification = new IdentificationFeature { - PhysicalTraits = new List { "Đầu tam giác lớn", "Hoa văn vện gấm đốm nâu", "Vảy nhám", "Mắt có con ngươi dọc" }, - Behaviors = new List { "Hoạt động đêm",", Chuyên phục kích săn mồi", "Tính hung dữ, sẵn sàng tấn công", "Thường ở hốc đá, bụi rậm, lá khô" }, - Habitat = "Rừng núi, vùng đồi gò, hang hốc" - }, - SymptomsByTime = new List { - new SymptomTimeline { TimeRange = "0 - 15 phút", Signs = new List { "Đau rát dữ dội", "Sưng nề tức thì" }, IsCritical = false }, - new SymptomTimeline { TimeRange = "1 - 6 giờ", Signs = new List { "Xuất huyết dưới da", "Máu chảy không cầm tại vết cắn", "Bầm tím nặng" }, IsCritical = true } - }, - FirstAidGuidelineOverride = new FirstAidOverride { Mode = OverrideMode.Replace, Steps = new List { "KHÔNG garô/băng ép.", "Bất động chi bằng nẹp lỏng.", "Chuyển viện gấp." } } - }, - - // 12. RẮN LỤC NƯA (CHÀM QUẠP) - Calloselasma rhodostoma - new SnakeSpecies - { - Id = 12, - ScientificName = "Calloselasma rhodostoma", - CommonName = "Rắn Lục Nưa", - Slug = "ran-luc-nua", - Description = "Loài rắn cực nguy hiểm ở miền Nam/Tây Nguyên, ngụy trang hoàn hảo dưới lá khô.", - IdentificationSummary = "Thân mập, đầu tam giác, hoa văn hình tam giác sẫm màu dọc hai bên thân.", - PrimaryVenomType = PrimaryVenomType.Hemotoxic, // và có độc tế bào cytotoxic - RiskLevel = 9.5f, - IsVenomous = true, - ImageUrl = "https://cdn.kienthuc.net.vn/images/cf739f51f3276a5be16e9cbb75eb670590e4e1a049c04ce64210426ce976f3c08b805acba731385fd614eebaf46f4c3502d128915c73af35a8698059b85f0f9824f61d459aaa6ca7ad4acb289d18b91958ebfab71a3d1d5ad62d6dd95e9bd598a65c32335617c1b43812b9de6f4caea5/thot-tim-loai-ran-cuc-doc-nam-im-lim-cho-can-nguoi-o-viet-nam-Hinh-9.png", - Identification = new IdentificationFeature { - PhysicalTraits = new List { "Thân mập, ngắn", "Hoa văn hình tam giác đối xứng", "Màu nâu lá khô", "Đầu tam giác rất nhọn" }, - Behaviors = new List { "Nằm bất động dưới lá khô", "Không bỏ chạy khi có người, chủ động cắn", "Tấn công bất ngờ cực nhanh" }, - Habitat = "Rừng cao su, vườn điều, rừng khộp" - }, - SymptomsByTime = new List { - new SymptomTimeline { TimeRange = "0 - 30 phút", Signs = new List { "Sưng nề cực nhanh", "Đau buốt như lửa đốt" }, IsCritical = true }, - new SymptomTimeline { TimeRange = "6 - 12 giờ", Signs = new List { "Hoại tử mô diện rộng", "Xuất huyết toàn thân", "Phồng rộp máu" }, IsCritical = true } - }, - FirstAidGuidelineOverride = new FirstAidOverride { Mode = OverrideMode.Replace, Steps = new List { "Tuyệt đối không rạch vết thương vì nọc gây rối loạn đông máu cực nặng.", "Băng ép nhẹ bằng băng thun (không chặt)." } } - }, - - // 13. RẮN LỤC XANH - Trimeresurus stejnegeri - new SnakeSpecies - { - Id = 13, - ScientificName = "Trimeresurus stejnegeri", - CommonName = "Rắn Lục Xanh (Lục Vẻ)", - Slug = "ran-luc-xanh", - Description = "Thường bị nhầm với lục đuôi đỏ nhưng không có màu đỏ ở đuôi, độc tính tương tự.", - IdentificationSummary = "Toàn thân xanh lá, đầu tam giác, có hố nhiệt giữa mắt và mũi.", - PrimaryVenomType = PrimaryVenomType.Hemotoxic, - RiskLevel = 7.0f, - IsVenomous = true, - ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/34/1736396208_0.jpg", - Identification = new IdentificationFeature { - PhysicalTraits = new List { "Thân xanh mướt", "Đầu tam giác", "Mắt vàng/cam", "Không có đuôi đỏ" }, - Behaviors = new List { "Sống hoàn toàn trên cây", "Ngụy trang trong lá", "Hoạt động ban đêm" }, - Habitat = "Bụi rậm, vườn cây trái, rừng rậm" - }, - SymptomsByTime = new List { - new SymptomTimeline { TimeRange = "0 - 15 phút", Signs = new List { "Sưng đau cục bộ", "Buồn nôn" }, IsCritical = false }, - new SymptomTimeline { TimeRange = "2 - 12 giờ", Signs = new List { "Vết thương thâm đen", "Chảy máu chân răng" }, IsCritical = true } - } - }, - - // 14. RẮN KHIẾM VẠCH - Oligodon fasciolatus - new SnakeSpecies - { - Id = 14, - ScientificName = "Oligodon fasciolatus", - CommonName = "Rắn Khiếm Vạch", - Slug = "ran-khiem-vach", - Description = "Loài rắn không độc nhưng có răng sắc nhọn để ăn trứng chim/bò sát.", - IdentificationSummary = "Kích thước nhỏ, tối đa 45 cm. Màu nâu/xám, có các vạch ngang mờ và hình chữ V trên đỉnh đầu.", - PrimaryVenomType = PrimaryVenomType.None, - RiskLevel = 1.5f, - IsVenomous = false, - ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/154/1737700148_0.jpg", - Identification = new IdentificationFeature { - PhysicalTraits = new List { "Hình chữ V trên đầu", "Vảy trơn bóng", "Đầu bầu dục", "Kích thước nhỏ (tối đa 45 cm)", "Có 2 sọc đen dọc thân" }, - Behaviors = new List { "Săn mồi ban ngày", "Khá nhút nhát", "Thường gặp dưới đống gạch đá" }, - Habitat = "Vườn nhà, khu vực nông nghiệp, rừng thưa" - }, - SymptomsByTime = new List { - new SymptomTimeline { TimeRange = "Sau khi cắn", Signs = new List { "Vết thương lớn", "Chảy máu nhiều", "Không sưng nề" }, IsCritical = false } - }, - FirstAidGuidelineOverride = new FirstAidOverride - { - Mode = OverrideMode.Replace, - Steps = new List - { - "Rửa sạch vết thương bằng xà phòng hoặc dung dịch sát khuẩn.", - "Cầm máu nếu cần thiết.", - "Theo dõi dấu hiệu nhiễm trùng (sưng, đỏ, mưng mủ).", - "Đến cơ sở y tế nếu vết thương không lành hoặc có dấu hiệu nhiễm trùng." - } - } - }, - - // 15. RẮN CƯỜM (HẢI XÀ) - Chrysopelea ornata - new SnakeSpecies - { - Id = 15, - ScientificName = "Chrysopelea ornata", - CommonName = "Rắn Cườm (Rắn Bay)", - Slug = "ran-cuom", - Description = "Loài rắn nước có khả năng 'bay' bằng cách hóp bụng để lượn qua các cành cây.", - IdentificationSummary = "Màu xanh vàng nhạt với các họa tiết viền đen chi tiết trên từng vảy.", - PrimaryVenomType = PrimaryVenomType.None, - RiskLevel = 1.0f, - IsVenomous = false, - ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/3/1737361056_0.jpg", - Identification = new IdentificationFeature { - PhysicalTraits = new List { "Vảy màu vàng chanh viền đen", "Thân thon dài", "Mắt to tròn" }, - Behaviors = new List { "Leo trèo cực giỏi", "Nhảy từ trên cây cao xuống", "Rất hiền lành" }, - Habitat = "Cây cao, vườn nhà, rừng rậm" - } - }, - - // 16. RẮN RÁO TRÂU - Ptyas mucosa - new SnakeSpecies - { - Id = 16, - ScientificName = "Ptyas mucosa", - CommonName = "Rắn Ráo Trâu (Rắn Lãi Lớn)", - Slug = "ran-rao-trau", - Description = "Loài rắn không độc có kích thước lớn, di chuyển tốc độ rất nhanh.", - IdentificationSummary = "Màu nâu/vàng đất, nửa thân sau có các vạch đen ngang rõ rệt như vằn hổ. Mắt rất to. Họa tiet đầu giống rắn hổ mang.", - PrimaryVenomType = PrimaryVenomType.None, - RiskLevel = 2.0f, - IsVenomous = false, - ImageUrl = "https://sgaqua.vn/wp-content/uploads/2026/01/ran-rao-trau-co-doc-khong-1.jpg", - Identification = new IdentificationFeature { - PhysicalTraits = new List { "Kích thước lớn (tới 3m)", "Vằn ngang zig zag trắng nửa thân trước chuyển đen nửa thân sau", "Mắt rất to, tròn", "Vảy trơn, óng ánh, xếp đều", "Họa tiết vảy đầu giống rắn hổ mang" }, - Behaviors = new List { "Chạy trốn cực nhanh", "Hung dữ khi bị dồn vào đường cùng", "Bị đe dọa sẽ mở rộng vùng cổ và tạo âm thanh rít liên tục" }, - Habitat = "Đồng ruộng, bụi rậm, hang hốc" - } - }, - - // 17. RẮN HOA CÂN VÂN ĐỐM - Sinonatrix aequifasciata - new SnakeSpecies - { - Id = 17, - ScientificName = "Sinonatrix aequifasciata", - CommonName = "Rắn Hoa Cân Vân Đốm", - Slug = "ran-hoa-can-van-dom", - Description = "Rắn nước không độc, thường sống gần các khe suối.", - IdentificationSummary = "Kích thước trung bình từ 0.7-1.4m. Thân mập, có các hoa văn hình mắt màu vàng đen chạy dọc thân.", - PrimaryVenomType = PrimaryVenomType.None, - RiskLevel = 1.0f, - IsVenomous = false, - ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/171/trimerodytes-aequifasciatus_1740063874_0.jpg", - Identification = new IdentificationFeature { - PhysicalTraits = new List { "Thân mập hình trụ", "Hoa văn hình mắt màu vàng đen chạy dọc thân", "Đầu bầu dục" }, - Behaviors = new List { "Sống bán thủy sinh", "Ăn cá và ếch nhái" }, - Habitat = "Suối, ao hồ, đầm lầy vùng núi" - } - }, - - // 18. RẮN RI CÁ - Homalopsis buccata - new SnakeSpecies - { - Id = 18, - ScientificName = "Homalopsis buccata", - CommonName = "Rắn Ri Cá", - Slug = "ran-ri-ca", - Description = "Rắn nước phổ biến ở Nam Bộ, thịt ngon nhưng không có độc.", - IdentificationSummary = "Kích thước trung bình khoảng 70cm.Đầu to, có hình mặt nạ trắng trên đầu, thân có nhiều khoanh màu nâu đỏ nhạt.", - PrimaryVenomType = PrimaryVenomType.None, - RiskLevel = 1.0f, - IsVenomous = false, - ImageUrl = "https://upload.wikimedia.org/wikipedia/commons/c/c1/Homalopsis_buccata.png", - Identification = new IdentificationFeature { - PhysicalTraits = new List { "Kích thước trung bình khoảng 70cm", "Đầu to rộng", "Hoa văn mặt nạ trên đỉnh đầu", "Thân chắc, vảy gồ" }, - Behaviors = new List { "Ăn đêm", "Sống dưới nước", "Nhút nhát" }, - Habitat = "Kênh rạch, ao hồ, đầm lầy bùn" - } - }, - - // 19. RẮN ROI - Ahaetulla prasina - new SnakeSpecies - { - Id = 19, - ScientificName = "Ahaetulla prasina", - CommonName = "Rắn Roi (Rắn Sinh Viên)", - Slug = "ran-roi", - Description = "Thân mảnh như sợi dây thừng, đầu rất nhọn, độc nhẹ, không gây nguy hiểm cho người.", - IdentificationSummary = "Màu xanh lá huỳnh quang nổi bật, mõm dài và nhọn, con ngươi nằm ngang.", - PrimaryVenomType = PrimaryVenomType.None, - RiskLevel = 1.0f, - IsVenomous = false, - ImageUrl = "https://cdn-i.vtcnews.vn/files/f2/2015/01/21/nhung-loai-ran-ky-di-nhat-the-gioi-tai-viet-nam-0.jpg", - Identification = new IdentificationFeature { - PhysicalTraits = new List { "Thân cực mảnh", "Mõm nhọn dài", "Con ngươi ngang đặc trưng", "Màu xanh lá hoặc nâu nhạt" }, - Behaviors = new List { "Sống trên cây", "Di chuyển chậm chạp", "Hay thò thụt lưỡi đánh hơi" }, - Habitat = "Vườn cây, rừng thưa, bụi rậm" - } - }, - - // 20. RẮN TRUN - Cylindrophis ruffus - new SnakeSpecies - { - Id = 20, - ScientificName = "Cylindrophis ruffus", - CommonName = "Rắn Trun", - Slug = "ran-trun", - Description = "Loài rắn không độc, thân hình trụ tròn, thường bị nhầm với rắn độc do màu sắc.", - IdentificationSummary = "Thân đen bóng có vạch vàng/trắng, đuôi ngắn tịt và có màu đỏ dưới mặt đuôi.", - PrimaryVenomType = PrimaryVenomType.None, - RiskLevel = 1.0f, - IsVenomous = false, - ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/131/1737365540_0.jpg", - Identification = new IdentificationFeature { - PhysicalTraits = new List { "Thân hình trụ đồng nhất", "Đuôi ngắn giống đầu", "Mặt dưới đuôi màu đỏ" }, - Behaviors = new List { "Chui rúc trong bùn đất", "Khi gặp nguy hiểm sẽ cuộn tròn và giơ đuôi đỏ lên để lừa kẻ thù" }, - Habitat = "Đầm lầy, ruộng lúa, nơi đất ẩm" - } - }, - - // 22. RẮN ĐAI LỚN - Lycodon fasciatus - new SnakeSpecies - { - Id = 21, - ScientificName = "Ptyas major", // Tên khoa học chính xác của Rắn Đại Lớn - CommonName = "Rắn Đai Lớn (Rắn Xanh Lớn)", - Slug = "ran-dai-lon-xanh", - Description = "Loài rắn hoàn toàn không độc, hiền lành, thường bị nhầm với rắn lục do màu xanh lục toàn thân.", - IdentificationSummary = "Kích thước có thể đạt 1,5-2,5m. Không độc, toàn thân màu xanh lá mượt mà, mắt rất to và tròn, đuôi thuôn dài.", - PrimaryVenomType = PrimaryVenomType.None, - RiskLevel = 1.0f, - IsVenomous = false, - ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/101/ptyas-major_1743324730_0.jpg", - Identification = new IdentificationFeature { - PhysicalTraits = new List { - "Thân dài, có thể đạt 1,5-2,5 mét", - "Toàn thân màu xanh lá cây đồng nhất", - "Bụng màu vàng nhạt hoặc trắng xanh", - "Mắt rất to, con ngươi tròn đen", - "Vảy trơn mịn, bóng mượt" - }, - Behaviors = new List { - "Hoạt động chủ yếu ban ngày", - "Rất hiền lành, hiếm khi cắn người kể cả khi bị bắt", - "Di chuyển nhanh nhẹn trên mặt đất và cây cỏ" - }, - Habitat = "Vườn tược, bụi rậm, rừng thưa, thường gặp ở vùng đồi núi và trung du" - }, - SymptomsByTime = new List { - new SymptomTimeline { - TimeRange = "Sau khi cắn", - Signs = new List { "Vết xước rất nhỏ", "Hầu như không đau", "Không sưng nề" }, - IsCritical = false - } - }, - FirstAidGuidelineOverride = new FirstAidOverride { - Mode = OverrideMode.Replace, - Steps = new List { - "Rửa sạch vết thương bằng nước hoặc xà phòng.", - "Bình tĩnh vì đây là loài rắn ích lợi, chuyên ăn côn trùng và sâu bọ." - } - } - }, - new SnakeSpecies - { - Id = 22, - ScientificName = "Amphiesma stolatum", - CommonName = "Rắn Sãi Cỏ", - Slug = "ran-sai-co", - Description = "Loài rắn nước không độc, hiền lành và có ích cho nông nghiệp. Chúng thường bị nhầm lẫn với một số loài rắn khác do hoa văn phức tạp.", - IdentificationSummary = "Không độc, dài trung bình 40cm - 80cm. Thân có 2 sọc sáng màu song song, nối với nhau bởi các vạch ngang tối màu trông như chiếc thang.", - PrimaryVenomType = PrimaryVenomType.None, - RiskLevel = 1.0f, - IsVenomous = false, - ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/27/1736340349_0.jpg", - Identification = new IdentificationFeature { - PhysicalTraits = new List { - "Chiều dài trung bình 40 - 80 cm", - "2 sọc sáng màu chạy dọc song song trên lưng", - "Các vạch ngang tối màu nối 2 sọc giống hình chiếc thang", - "Bụng màu kem nhạt với đốm đen nhỏ hai bên thân", - "Mép miệng màu vàng nhạt với vạch đen trước và sau mắt" - }, - Behaviors = new List { - "Hoạt động chủ yếu vào ban ngày (nhật hành)", - "Tính tình nhút nhát, thường bỏ chạy khi gặp người", - "Săn các sinh vật nhỏ như cá, giun đất và tắc kè" - }, - Habitat = "Vùng đồng bằng và đồi núi, thường ở gần nguồn nước (ao, hồ, suối)" - }, - SymptomsByTime = new List { - new SymptomTimeline { - TimeRange = "Sau khi cắn", - Signs = new List { "Vết xước nhỏ hình vòng cung", "Chảy máu nhẹ", "Không sưng nề, không gây độc" }, - IsCritical = false - } - }, - FirstAidGuidelineOverride = new FirstAidOverride { - Mode = OverrideMode.Replace, - Steps = new List { - "Rửa vết thương bằng xà phòng và nước sạch để tránh nhiễm trùng.", - "Bình tĩnh vì đây là loài rắn hoàn toàn vô hại." - } - } - } - }; - - context.SnakeSpecies.AddRange(snakes); + // var snakes = new List + // { + // // 1. RẮN CẠP NIA BẮC (Bungarus multicinctus) + // new SnakeSpecies + // { + // Id = 1, + // ScientificName = "Bungarus multicinctus", + // CommonName = "Rắn Cạp Nia Bắc", + // Slug = "ran-cap-nia-bac", + // Description = "Một trong những loài rắn độc nhất châu Á, thường gặp ở vùng đồng bằng và trung du Bắc Bộ.", + // IdentificationSummary = "Chiều dài trung bình 1.0m - 1.5m. Thân có các khoanh trắng và đen rõ rệt, vảy trơn bóng.", + // PrimaryVenomType = PrimaryVenomType.Neurotoxic, + // RiskLevel = 9.5f, + // IsVenomous = true, + // ImageUrl = "https://e.khoahoc.tv/photos/image/2020/09/19/ran-cap-nia-1.jpg", + // Identification = new IdentificationFeature + // { + // PhysicalTraits = new List { "Chiều dài: 100 - 150 cm", "Khoanh trắng đen rõ rệt", "Đầu bầu dục", "Vảy bóng", "Thân hình tam giác nhẹ" }, + // Behaviors = new List { "Hoạt động mạnh về đêm", "Thích nơi ẩm ướt", "Thường chui vào nhà dân tìm mồi" }, + // Habitat = "Cánh đồng, ven sông, khu dân cư miền Bắc" + // }, + // SymptomsByTime = new List + // { + // new SymptomTimeline { TimeRange = "0 - 30 phút", Signs = new List { "Đau nhẹ tại vết cắn, ít cảm giác", "Tê nhẹ" }, IsCritical = false }, + // new SymptomTimeline { TimeRange = "1 - 3 giờ", Signs = new List { "Sụp mí mắt", "Khó nói", "Khó nuốt", "Yếu cơ" }, IsCritical = true }, + // new SymptomTimeline { TimeRange = "3 - 6 giờ", Signs = new List { "Liệt cơ hô hấp", "Ngừng thở" }, IsCritical = true } + // }, + // FirstAidGuidelineOverride = new FirstAidOverride + // { + // Mode = OverrideMode.Append, + // Content = new FirstAidContent + // { + // Steps = new List + // { + // new FirstAidStep { Text = "Đặc biệt chú ý hỗ trợ hô hấp nhân tạo nếu nạn nhân có dấu hiệu ngưng thở." } + // } + // } + // } + // }, + + // // 2. RẮN LỤC ĐUÔI ĐỎ (Trimeresurus albolabris) + // new SnakeSpecies + // { + // Id = 2, + // ScientificName = "Trimeresurus albolabris", + // CommonName = "Rắn Lục Đuôi Đỏ", + // Slug = "ran-luc-duoi-do", + // Description = "Loài rắn lục phổ biến nhất, thường sống trên cây và gây ra nhiều vụ tai nạn tại Việt Nam.", + // IdentificationSummary = "Chiều dài trung bình 60cm - 90cm. Thân màu xanh lá cây, đầu hình tam giác rõ rệt, chót đuôi có màu đỏ cam.", + // PrimaryVenomType = PrimaryVenomType.Hemotoxic, + // RiskLevel = 7.5f, + // IsVenomous = true, + // ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/32/1736395426_0.jpg", + // Identification = new IdentificationFeature + // { + // PhysicalTraits = new List { "Chiều dài: 60 - 90 cm", "Thân xanh lá", "Đuôi màu đỏ", "Đầu tam giác phình to", "Vảy nhám" }, + // Behaviors = new List { "Sống trên cây", "Ngụy trang cực tốt trong lá cây", "Hay xuất hiện ở bụi hoa, vườn nhà" }, + // Habitat = "Vườn cây, bụi rậm, rừng thưa trên toàn quốc" + // }, + // SymptomsByTime = new List + // { + // new SymptomTimeline { TimeRange = "0 - 15 phút", Signs = new List { "Đau nhức dữ dội", "Sưng nề nhanh chóng" }, IsCritical = false }, + // new SymptomTimeline { TimeRange = "1 - 6 giờ", Signs = new List { "Xuất hiện bọng nước", "Chảy máu không cầm", "Bầm tím diện rộng" }, IsCritical = true } + // }, + // FirstAidGuidelineOverride = new FirstAidOverride + // { + // Mode = OverrideMode.Replace, + // Content = new FirstAidContent + // { + // Steps = new List + // { + // new FirstAidStep { Text = "Rửa sạch vết thương." }, + // new FirstAidStep { Text = "Bất động lỏng chi." }, + // new FirstAidStep { Text = "TUYỆT ĐỐI KHÔNG BĂNG ÉP CHẶT vì gây hoại tử nhanh." } + // } + // } + // } + // }, + + // // 3. RẮN HỔ MANG CHÚA (Ophiophagus hannah) + // new SnakeSpecies + // { + // Id = 3, + // ScientificName = "Ophiophagus hannah", + // CommonName = "Rắn Hổ Mang Chúa", + // Slug = "ran-ho-mang-chua", + // Description = "Loài rắn độc dài nhất thế giới, cực kỳ nguy hiểm với lượng nọc độc khổng lồ.", + // IdentificationSummary = "Kích thước khổng lồ (4-6m), Vảy đầu lớn, Cổ phình mang hẹp, vân chữ V ngược ở cổ.", + // PrimaryVenomType = PrimaryVenomType.Neurotoxic, + // RiskLevel = 10.0f, + // IsVenomous = true, + // ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/55/1737107747_0.jpg", + // Identification = new IdentificationFeature + // { + // PhysicalTraits = new List { "Kích thước khổng lồ (4-6m)", + // "Cặp vảy chẩm hình cánh bướm, nằm ở ngay phía sau vảy đầu", + // "Phình mang hẹp dài", "Màu đen, nâu hoặc vàng chì" }, + // Behaviors = new List { "Chủ động tấn công nếu bị kích động", "Có khả năng rướn cao thân mình" ,"Là một loài rắn thông minh, sẽ quan sát và phản ứng." }, + // Habitat = "Rừng rậm, nương rẫy, gần nguồn nước" + // }, + // SymptomsByTime = new List + // { + // new SymptomTimeline { TimeRange = "0 - 15 phút", Signs = new List { "Đau nhức", "Chóng mặt", "Hoa mắt" }, IsCritical = true }, + // new SymptomTimeline { TimeRange = "30 - 60 phút", Signs = new List { "Hôn mê", "Suy hô hấp cấp", "Tử vong nhanh nếu không cấp cứu" }, IsCritical = true } + // }, + // FirstAidGuidelineOverride = new FirstAidOverride + // { + // Mode = OverrideMode.Append, + // Content = new FirstAidContent + // { + // Steps = new List + // { + // new FirstAidStep { Text = "Vận chuyển nạn nhân bằng phương tiện nhanh nhất có thể đến bệnh viện lớn." } + // } + // } + // } + // }, + + // // 4. RẮN RÁO (Ptyas korros) - KHÔNG ĐỘC + // new SnakeSpecies + // { + // Id = 4, + // ScientificName = "Ptyas korros", + // CommonName = "Rắn Ráo", + // Slug = "ran-rao", + // Description = "Loài rắn không độc phổ biến, thường bị nhầm lẫn với rắn hổ mang.", + // IdentificationSummary = "Dài trung bình 1,2 - 2,0m. Mắt rất lớn, thân thon dài màu nâu đất hoặc xám chì, di chuyển cực nhanh.", + // PrimaryVenomType = PrimaryVenomType.None, + // RiskLevel = 1.0f, + // IsVenomous = false, + // ImageUrl = "https://www.cakhotranluan.com/images/2022/2ran-rao1.jpg", + // Identification = new IdentificationFeature + // { + // PhysicalTraits = new List { "Chiều dài: 120 - 200 cm", "Mắt to", "Thân dài thon", "Vảy trơn bóng", "Đầu bầu dục" }, + // Behaviors = new List { "Di chuyển rất nhanh", "Hoạt động ban ngày", "Thường chạy trốn khi gặp người" }, + // Habitat = "Đồng ruộng, bụi rậm, vườn nhà" + // }, + // SymptomsByTime = new List + // { + // new SymptomTimeline { TimeRange = "Sau khi cắn", Signs = new List { "Chảy máu nhẹ", "Vết xước li ti", "Không sưng nề" }, IsCritical = false } + // }, + // FirstAidGuidelineOverride = new FirstAidOverride + // { + // Mode = OverrideMode.Append, + // Content = new FirstAidContent + // { + // Steps = new List + // { + // new FirstAidStep { Text = "Sát trùng vết thương bằng cồn hoặc nước sạch." } + // } + // } + // } + // }, + + // new SnakeSpecies + // { + // Id = 5, + // ScientificName = "Bungarus fasciatus", + // CommonName = "Rắn Cạp Nong", + // Slug = "ran-cap-nong", + // Description = "Loài rắn độc thần kinh nguy hiểm, dễ nhận biết với các khoanh vàng đen xen kẽ đều nhau.", + // IdentificationSummary = "Kích thước 1.8 - 2.3m. Thân hình tam giác với sống lưng gồ cao, đầu có vệt vàng hình mũi tên.", + // PrimaryVenomType = PrimaryVenomType.Neurotoxic, + // RiskLevel = 9.0f, + // IsVenomous = true, + // ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/46/1736402914_0.jpg", + // Identification = new IdentificationFeature + // { + // PhysicalTraits = new List { "Khoanh đen và vàng xen kẽ đều nhau", "Thân hình tam giác, sống lưng gồ", "Vệt vàng hai bên má tạo hình mũi tên" }, + // Behaviors = new List { "Săn mồi ban đêm", "Bị thu hút bởi ánh lửa", "Tính tình thường nhút nhát nhưng độc tính rất mạnh" }, + // Habitat = "Rừng núi, bụi rậm, ven nguồn nước" + // }, + // SymptomsByTime = new List + // { + // new SymptomTimeline { TimeRange = "0 - 30 phút", Signs = new List { "Ngứa nhẹ hoặc tê rát tại vết cắn", "Ít đau khiến nạn nhân chủ quan" }, IsCritical = false }, + // new SymptomTimeline { TimeRange = "1 - 2 giờ", Signs = new List { "Mệt mỏi bất thường", "Tức ngực nhẹ", "Sụp mí mắt nhẹ" }, IsCritical = true }, + // new SymptomTimeline { TimeRange = "2 - 6 giờ", Signs = new List { "Đau nhức toàn thân", "Yếu liệt cơ tiến triển", "Suy hô hấp cấp" }, IsCritical = true } + // }, + // FirstAidGuidelineOverride = new FirstAidOverride + // { + // Mode = OverrideMode.Append, + // Content = new FirstAidContent + // { + // Steps = new List + // { + // new FirstAidStep { Text = "Cảnh giác cao độ nếu bị cắn khi đang cắm trại hoặc đi rừng ban đêm." } + // } + // } + // } + // }, + + // new SnakeSpecies + // { + // Id = 6, + // ScientificName = "Bungarus candidus", + // CommonName = "Rắn Cạp Nia Nam", + // Slug = "ran-cap-nia-nam", + // Description = "Loài rắn độc thần kinh cực mạnh ở miền Nam, có tập tính lẻn vào nhà người dân.", + // IdentificationSummary = "Khoanh đen và trắng có độ rộng gần bằng nhau, thân tròn bóng, đầu nhỏ.", + // PrimaryVenomType = PrimaryVenomType.Neurotoxic, + // RiskLevel = 9.8f, + // IsVenomous = true, + // ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/38/1736401130_0.jpg", + // Identification = new IdentificationFeature + // { + // PhysicalTraits = new List { "Khoanh đen và trắng/vàng nhạt đều nhau", "Thân tròn, vảy trơn bóng", "Đầu nhỏ không phân biệt rõ với cổ" }, + // Behaviors = new List { "Hoạt động đêm", "Hay chui vào nhà dân", "Cắn người khi đang ngủ" }, + // Habitat = "Vùng đồng bằng, khu dân cư miền Nam Việt Nam" + // }, + // SymptomsByTime = new List + // { + // new SymptomTimeline { TimeRange = "0 - 15 phút", Signs = new List { "Vết cắn rất nhẹ, không sưng đau", "Khó thấy dấu răng" }, IsCritical = false }, + // new SymptomTimeline { TimeRange = "2 - 3 giờ", Signs = new List { "Sụp mí mắt nặng", "Đồng tử giãn", "Nói ngọng", "Yếu chi" }, IsCritical = true }, + // new SymptomTimeline { TimeRange = "3 - 6 giờ", Signs = new List { "Liệt cơ toàn thân", "Suy hô hấp hoàn toàn" }, IsCritical = true } + // }, + // FirstAidGuidelineOverride = new FirstAidOverride + // { + // Mode = OverrideMode.Append, + // Content = new FirstAidContent + // { + // Steps = new List + // { + // new FirstAidStep { Text = "Tuyệt đối không chờ triệu chứng đau mới đi viện vì nọc cạp nia không gây đau." } + // } + // } + // } + // }, + + // new SnakeSpecies + // { + // Id = 7, + // ScientificName = "Naja kaouthia", + // CommonName = "Rắn Hổ Mang Xiêm", + // Slug = "ran-ho-mang-xiem", + // Description = "Loài rắn hổ mang có nọc độc hỗn hợp, gây hoại tử mô nghiêm trọng và liệt thần kinh.", + // IdentificationSummary = "Màu nâu xám hoặc đen, có bành cổ với một hình tròn đơn (hình kính mắt) ở mặt sau.", + // PrimaryVenomType = PrimaryVenomType.Neurotoxic, // Lưu ý: Hỗn hợp thần kinh và tế bào + // RiskLevel = 9.0f, + // IsVenomous = true, + // ImageUrl = "https://photo.znews.vn/w660/Uploaded/rotnrz/2023_07_04/ho_meo_1.jpg", + // Identification = new IdentificationFeature + // { + // PhysicalTraits = new List { "Bành cổ rộng", "Hình kính mắt một vòng tròn sau cổ", "Màu nâu hoặc xám đen" }, + // Behaviors = new List { "Có thể phun nọc độc xa và chuẩn", "Ngóc đầu cao và phình mang khi tấn công" }, + // Habitat = "Ruộng lúa, vườn tược, khu dân cư gần nguồn nước" + // }, + // SymptomsByTime = new List + // { + // new SymptomTimeline { TimeRange = "0 - 10 phút", Signs = new List { "Đau rõ rệt", "Chảy máu tại vết cắn" }, IsCritical = false }, + // new SymptomTimeline { TimeRange = "10 - 60 phút", Signs = new List { "Sưng nhanh", "Đau tăng dần", "Có thể phồng rộp da" }, IsCritical = false }, + // new SymptomTimeline { TimeRange = "1 - 3 giờ", Signs = new List { "Sụp mí mắt", "Yếu cơ", "Khó nuốt" }, IsCritical = true }, + // new SymptomTimeline { TimeRange = "6 - 24 giờ", Signs = new List { "Hoại tử mô tại chỗ", "Nhiễm trùng vết thương" }, IsCritical = true } + // }, + // FirstAidGuidelineOverride = new FirstAidOverride + // { + // Mode = OverrideMode.Append, + // Content = new FirstAidContent + // { + // Steps = new List + // { + // new FirstAidStep { Text = "Nếu bị nọc phun vào mắt, phải rửa bằng nước sạch liên tục 15-20 phút." } + // } + // } + // } + // }, + + // new SnakeSpecies + // { + // Id = 8, + // ScientificName = "Rhabdophis subminiatus", + // CommonName = "Rắn Hoa Cỏ Cổ Đỏ", + // Slug = "ran-hoa-co-co-do", + // Description = "Loài rắn có độc nguy hiểm điều kiện (độc nanh sau và độc da vùng cổ), gây rối loạn đông máu nặng và chưa có huyết thanh đặc hiệu.", + // IdentificationSummary = "Cổ màu đỏ rực hoặc cam vàng đặc trưng, thân xanh ô liu, mắt tròn lớn.", + // PrimaryVenomType = PrimaryVenomType.Hemotoxic, + // RiskLevel = 8.5f, + // IsVenomous = true, + // ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/93/1737024030_0.jpeg", + // Identification = new IdentificationFeature + // { + // PhysicalTraits = new List { "Vòng cổ màu đỏ rực hoặc vàng cam", "Đầu thuôn không hình tam giác", "Mắt tròn, đồng tử tròn" }, + // Behaviors = new List { "Ngóc đầu, tiết độc trắng đục và bẹt cổ giống rắn hổ khi bị đe dọa", "Tính khí không ổn định (lúc hiền lúc dữ)" }, + // Habitat = "Rừng, nương rẫy, gần nguồn nước" + // }, + // SymptomsByTime = new List + // { + // new SymptomTimeline { + // TimeRange = "0 - 1 giờ", + // Signs = new List { "Vết cắn đau nhẹ", "Sưng nhẹ cục bộ", "Có thể không có cảm giác bị nhiễm độc ngay" }, + // IsCritical = false + // }, + // new SymptomTimeline { + // TimeRange = "1 - 6 giờ", + // Signs = new List { "Máu rỉ rả không cầm tại vết cắn", "Bầm tím lan rộng", "Đau bụng, buồn nôn" }, + // IsCritical = true + // }, + // new SymptomTimeline { + // TimeRange = "6 - 24 giờ", + // Signs = new List { "Chảy máu chân răng, máu cam", "Tiểu ra máu", "Nôn ra máu", "Dấu hiệu suy thận" }, + // IsCritical = true + // } + // }, + // FirstAidGuidelineOverride = new FirstAidOverride + // { + // Mode = OverrideMode.Replace, // Thay thế hoàn toàn vì cách tiếp cận điều trị rất khác + // Content = new FirstAidContent + // { + // Steps = new List { + // new FirstAidStep { Text = "Đặt nạn nhân nằm yên, bất động hoàn toàn chi bị cắn.", MediaUrl = "https://assets.snakeaid.vn/aid/immobilize.gif" }, + // new FirstAidStep { Text = "Băng ép nhẹ bằng băng vải rộng để bảo vệ vết thương.", MediaUrl = "https://assets.snakeaid.vn/aid/light-bandage.jpg" }, + // new FirstAidStep { Text = "Nhanh chóng chuyển nạn nhân đến bệnh viện tuyến tỉnh hoặc trung ương có khả năng lọc máu và truyền máu.", MediaUrl = "" } + // }, + // Dos = new List { + // new FirstAidStep { Text = "Báo cho bác sĩ đây là rắn 'Rhabdophis subminiatus' (Hoa cỏ cổ đỏ).", MediaUrl = "" }, + // new FirstAidStep { Text = "Theo dõi sát màu nước tiểu và tình trạng chảy máu.", MediaUrl = "" } + // }, + // Donts = new List { + // new FirstAidStep { Text = "KHÔNG ĐƯỢC CHỦ QUAN nếu thấy vết cắn không sưng đau nhiều lúc đầu.", MediaUrl = "" }, + // new FirstAidStep { Text = "KHÔNG dùng ga-rô chặt (làm tăng hoại tử và rối loạn đông máu tại chỗ).", MediaUrl = "" }, + // new FirstAidStep { Text = "KHÔNG rạch hoặc hút máu tại vết cắn.", MediaUrl = "" } + // }, + // Notes = new List { + // "Lưu ý quan trọng: Việt Nam chưa có huyết thanh kháng độc cho loài này. Việc điều trị chủ yếu là hỗ trợ, truyền máu và lọc thận.", + // "Loài này có răng độc nằm sâu phía sau hàm (Hậu nha), nọc độc chỉ tiết ra khi rắn nhai hoặc cắn sâu." + // } + // } + // } + // }, + + // new SnakeSpecies + // { + // Id = 9, + // ScientificName = "Coelognathus radiatus", + // CommonName = "Rắn Hổ Ngựa", + // Slug = "ran-ho-ngua", + // Description = "Loài rắn không độc, di chuyển cực nhanh và thường có hành vi tự vệ hung dữ.", + // IdentificationSummary = "Màu nâu vàng, có 4 sọc đen chạy dọc phần trước thân, đầu dài.", + // PrimaryVenomType = PrimaryVenomType.None, + // RiskLevel = 1.0f, + // IsVenomous = false, + // ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/122/1737364008_0.jpg", + // Identification = new IdentificationFeature + // { + // PhysicalTraits = new List { "Có thể dài đến 2m", "4 sọc đen trên thân trước", "Đầu dài bầu dục", "Mắt lớn" }, + // Behaviors = new List { "Ngóc cao thân mình, bẹt cổ để hù dọa giống rắn hổ", " Miệng há rộng, hung hăng, doạ nạt, dữ tợn khi bị đe dọa", "Giả chết nếu cảm thấy nguy hiểm trước đối phương" }, + // Habitat = "Đồng ruộng, bụi cây, khu vực nông nghiệp" + // }, + // SymptomsByTime = new List + // { + // new SymptomTimeline { TimeRange = "Sau khi cắn", Signs = new List { "Vết xước li ti", "Chảy máu nhẹ", "Không có triệu chứng thần kinh hay sưng nề lớn" }, IsCritical = false } + // }, + // FirstAidGuidelineOverride = new FirstAidOverride + // { + // Mode = OverrideMode.Append, + // Content = new FirstAidContent + // { + // Steps = new List + // { + // new FirstAidStep { Text = "Chỉ cần rửa sạch vết thương bằng xà phòng để tránh nhiễm trùng." } + // } + // } + // } + // }, + + // new SnakeSpecies + // { + // Id = 10, + // ScientificName = "Elaphe carinata", + // CommonName = "Rắn Chuột Vua", + // Slug = "ran-chuot-vua", + // Description = "Loài rắn không độc nhưng cực kỳ hung dữ, có kích thước lớn và mùi hôi đặc trưng để xua đuổi kẻ thù. Dễ bị nhầm lẫn với rắn hổ mang chúa do kích thước lớn và họa tiết đầu gần giống nhau.", + // IdentificationSummary = "Thân màu nâu vàng hoặc xám đen với các vảy có gờ nổi rất mạnh (nhám). Không có nanh độc, không phình mang.", + // PrimaryVenomType = PrimaryVenomType.None, + // RiskLevel = 2.0f, + // IsVenomous = false, + // ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/123/1737365069_2.jpeg", + // Identification = new IdentificationFeature + // { + // PhysicalTraits = new List { "Vảy có gờ nổi rất rõ (thân nhám)", "Mắt lớn, đầu thuôn dài", "Kích thước có thể lên tới 2.4m", "Màu sắc pha trộn vàng - đen - nâu ô liu" }, + // Behaviors = new List { "Cực kỳ hung dữ, sẵn sàng tấn công khi bị kích động", "Phát ra mùi hôi thối nồng nặc từ tuyến sau", "Ăn thịt các loài rắn khác (kể cả rắn độc)" }, + // Habitat = "Vùng đồi núi, bụi rậm, trang trại chăn nuôi" + // }, + // SymptomsByTime = new List + // { + // new SymptomTimeline { TimeRange = "Sau khi cắn", Signs = new List { "Vết cắn hình vòng cung", "Chảy máu khá nhiều do răng sắc nhọn", "Đau rát cục bộ" }, IsCritical = false } + // }, + // FirstAidGuidelineOverride = new FirstAidOverride + // { + // Mode = OverrideMode.Append, + // Content = new FirstAidContent + // { + // Steps = new List + // { + // new FirstAidStep { Text = "Sát trùng kỹ vết thương vì miệng loài này chứa nhiều vi khuẩn do ăn chuột và thịt thối." } + // } + // } + // } + // }, + + // // 11. RẮN LỤC CƯỜM (LỤC GẤM) - Protobothrops mucrosquamatus + // new SnakeSpecies + // { + // Id = 11, + // ScientificName = "Protobothrops mucrosquamatus", + // CommonName = "Rắn Lục Cườm", + // Slug = "ran-luc-cuom", + // Description = "Loài rắn độc máu nguy hiểm, đầu hình tam giác lớn, hoa văn đốm sâm so le nhau ở sống lưng.", + // IdentificationSummary = "Đầu tam giác rõ rệt, thân có các vệt hoa văn màu nâu đen trên nền xám/vàng đất, vảy nhám.", + // PrimaryVenomType = PrimaryVenomType.Hemotoxic, + // RiskLevel = 8.5f, + // IsVenomous = true, + // ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/54/1736524361_0.jpg", + // Identification = new IdentificationFeature { + // PhysicalTraits = new List { "Đầu tam giác lớn", "Hoa văn vện gấm đốm nâu", "Vảy nhám", "Mắt có con ngươi dọc" }, + // Behaviors = new List { "Hoạt động đêm",", Chuyên phục kích săn mồi", "Tính hung dữ, sẵn sàng tấn công", "Thường ở hốc đá, bụi rậm, lá khô" }, + // Habitat = "Rừng núi, vùng đồi gò, hang hốc" + // }, + // SymptomsByTime = new List { + // new SymptomTimeline { TimeRange = "0 - 15 phút", Signs = new List { "Đau rát dữ dội", "Sưng nề tức thì" }, IsCritical = false }, + // new SymptomTimeline { TimeRange = "1 - 6 giờ", Signs = new List { "Xuất huyết dưới da", "Máu chảy không cầm tại vết cắn", "Bầm tím nặng" }, IsCritical = true } + // }, + // FirstAidGuidelineOverride = new FirstAidOverride { + // Mode = OverrideMode.Replace, Content = new FirstAidContent { Steps = new List { new FirstAidStep { Text = "KHÔNG garô/băng ép." }, new FirstAidStep { Text = "Bất động chi bằng nẹp lỏng." }, new FirstAidStep { Text = "Chuyển viện gấp." } } } } + // }, + + // // 12. RẮN LỤC NƯA (CHÀM QUẠP) - Calloselasma rhodostoma + // new SnakeSpecies + // { + // Id = 12, + // ScientificName = "Calloselasma rhodostoma", + // CommonName = "Rắn Lục Nưa", + // Slug = "ran-luc-nua", + // Description = "Loài rắn cực nguy hiểm ở miền Nam/Tây Nguyên, ngụy trang hoàn hảo dưới lá khô.", + // IdentificationSummary = "Thân mập, đầu tam giác, hoa văn hình tam giác sẫm màu dọc hai bên thân.", + // PrimaryVenomType = PrimaryVenomType.Hemotoxic, // và có độc tế bào cytotoxic + // RiskLevel = 9.5f, + // IsVenomous = true, + // ImageUrl = "https://cdn.kienthuc.net.vn/images/cf739f51f3276a5be16e9cbb75eb670590e4e1a049c04ce64210426ce976f3c08b805acba731385fd614eebaf46f4c3502d128915c73af35a8698059b85f0f9824f61d459aaa6ca7ad4acb289d18b91958ebfab71a3d1d5ad62d6dd95e9bd598a65c32335617c1b43812b9de6f4caea5/thot-tim-loai-ran-cuc-doc-nam-im-lim-cho-can-nguoi-o-viet-nam-Hinh-9.png", + // Identification = new IdentificationFeature { + // PhysicalTraits = new List { "Thân mập, ngắn", "Hoa văn hình tam giác đối xứng", "Màu nâu lá khô", "Đầu tam giác rất nhọn" }, + // Behaviors = new List { "Nằm bất động dưới lá khô", "Không bỏ chạy khi có người, chủ động cắn", "Tấn công bất ngờ cực nhanh" }, + // Habitat = "Rừng cao su, vườn điều, rừng khộp" + // }, + // SymptomsByTime = new List { + // new SymptomTimeline { TimeRange = "0 - 30 phút", Signs = new List { "Sưng nề cực nhanh", "Đau buốt như lửa đốt" }, IsCritical = true }, + // new SymptomTimeline { TimeRange = "6 - 12 giờ", Signs = new List { "Hoại tử mô diện rộng", "Xuất huyết toàn thân", "Phồng rộp máu" }, IsCritical = true } + // }, + // FirstAidGuidelineOverride = new FirstAidOverride { Mode = OverrideMode.Replace, Content = new FirstAidContent { Steps = new List { new FirstAidStep { Text = "Tuyệt đối không rạch vết thương vì nọc gây rối loạn đông máu cực nặng." }, new FirstAidStep { Text = "Băng ép nhẹ bằng băng thun (không chặt)." } } } } + // }, + + // // 13. RẮN LỤC XANH - Trimeresurus stejnegeri + // new SnakeSpecies + // { + // Id = 13, + // ScientificName = "Trimeresurus stejnegeri", + // CommonName = "Rắn Lục Xanh (Lục Vẻ)", + // Slug = "ran-luc-xanh", + // Description = "Thường bị nhầm với lục đuôi đỏ nhưng không có màu đỏ ở đuôi, độc tính tương tự.", + // IdentificationSummary = "Toàn thân xanh lá, đầu tam giác, có hố nhiệt giữa mắt và mũi.", + // PrimaryVenomType = PrimaryVenomType.Hemotoxic, + // RiskLevel = 7.0f, + // IsVenomous = true, + // ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/34/1736396208_0.jpg", + // Identification = new IdentificationFeature { + // PhysicalTraits = new List { "Thân xanh mướt", "Đầu tam giác", "Mắt vàng/cam", "Không có đuôi đỏ" }, + // Behaviors = new List { "Sống hoàn toàn trên cây", "Ngụy trang trong lá", "Hoạt động ban đêm" }, + // Habitat = "Bụi rậm, vườn cây trái, rừng rậm" + // }, + // SymptomsByTime = new List { + // new SymptomTimeline { TimeRange = "0 - 15 phút", Signs = new List { "Sưng đau cục bộ", "Buồn nôn" }, IsCritical = false }, + // new SymptomTimeline { TimeRange = "2 - 12 giờ", Signs = new List { "Vết thương thâm đen", "Chảy máu chân răng" }, IsCritical = true } + // } + // }, + + // // 14. RẮN KHIẾM VẠCH - Oligodon fasciolatus + // new SnakeSpecies + // { + // Id = 14, + // ScientificName = "Oligodon fasciolatus", + // CommonName = "Rắn Khiếm Vạch", + // Slug = "ran-khiem-vach", + // Description = "Loài rắn không độc nhưng có răng sắc nhọn để ăn trứng chim/bò sát.", + // IdentificationSummary = "Kích thước nhỏ, tối đa 45 cm. Màu nâu/xám, có các vạch ngang mờ và hình chữ V trên đỉnh đầu.", + // PrimaryVenomType = PrimaryVenomType.None, + // RiskLevel = 1.5f, + // IsVenomous = false, + // ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/154/1737700148_0.jpg", + // Identification = new IdentificationFeature { + // PhysicalTraits = new List { "Hình chữ V trên đầu", "Vảy trơn bóng", "Đầu bầu dục", "Kích thước nhỏ (tối đa 45 cm)", "Có 2 sọc đen dọc thân" }, + // Behaviors = new List { "Săn mồi ban ngày", "Khá nhút nhát", "Thường gặp dưới đống gạch đá" }, + // Habitat = "Vườn nhà, khu vực nông nghiệp, rừng thưa" + // }, + // SymptomsByTime = new List { + // new SymptomTimeline { TimeRange = "Sau khi cắn", Signs = new List { "Vết thương lớn", "Chảy máu nhiều", "Không sưng nề" }, IsCritical = false } + // }, + // FirstAidGuidelineOverride = new FirstAidOverride + // { + // Mode = OverrideMode.Replace, + // Content = new FirstAidContent + // { + // Steps = new List + // { + // new FirstAidStep { Text = "Rửa sạch vết thương bằng xà phòng hoặc dung dịch sát khuẩn." }, + // new FirstAidStep { Text = "Cầm máu nếu cần thiết." }, + // new FirstAidStep { Text = "Theo dõi dấu hiệu nhiễm trùng (sưng, đỏ, mưng mủ)." }, + // new FirstAidStep { Text = "Đến cơ sở y tế nếu vết thương không lành hoặc có dấu hiệu nhiễm trùng." } + // } + // } + // } + // }, + + // // 15. RẮN CƯỜM (HẢI XÀ) - Chrysopelea ornata + // new SnakeSpecies + // { + // Id = 15, + // ScientificName = "Chrysopelea ornata", + // CommonName = "Rắn Cườm (Rắn Bay)", + // Slug = "ran-cuom", + // Description = "Loài rắn nước có khả năng 'bay' bằng cách hóp bụng để lượn qua các cành cây.", + // IdentificationSummary = "Màu xanh vàng nhạt với các họa tiết viền đen chi tiết trên từng vảy.", + // PrimaryVenomType = PrimaryVenomType.None, + // RiskLevel = 1.0f, + // IsVenomous = false, + // ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/3/1737361056_0.jpg", + // Identification = new IdentificationFeature { + // PhysicalTraits = new List { "Vảy màu vàng chanh viền đen", "Thân thon dài", "Mắt to tròn" }, + // Behaviors = new List { "Leo trèo cực giỏi", "Nhảy từ trên cây cao xuống", "Rất hiền lành" }, + // Habitat = "Cây cao, vườn nhà, rừng rậm" + // } + // }, + + // // 16. RẮN RÁO TRÂU - Ptyas mucosa + // new SnakeSpecies + // { + // Id = 16, + // ScientificName = "Ptyas mucosa", + // CommonName = "Rắn Ráo Trâu (Rắn Lãi Lớn)", + // Slug = "ran-rao-trau", + // Description = "Loài rắn không độc có kích thước lớn, di chuyển tốc độ rất nhanh.", + // IdentificationSummary = "Màu nâu/vàng đất, nửa thân sau có các vạch đen ngang rõ rệt như vằn hổ. Mắt rất to. Họa tiet đầu giống rắn hổ mang.", + // PrimaryVenomType = PrimaryVenomType.None, + // RiskLevel = 2.0f, + // IsVenomous = false, + // ImageUrl = "https://sgaqua.vn/wp-content/uploads/2026/01/ran-rao-trau-co-doc-khong-1.jpg", + // Identification = new IdentificationFeature { + // PhysicalTraits = new List { "Kích thước lớn (tới 3m)", "Vằn ngang zig zag trắng nửa thân trước chuyển đen nửa thân sau", "Mắt rất to, tròn", "Vảy trơn, óng ánh, xếp đều", "Họa tiết vảy đầu giống rắn hổ mang" }, + // Behaviors = new List { "Chạy trốn cực nhanh", "Hung dữ khi bị dồn vào đường cùng", "Bị đe dọa sẽ mở rộng vùng cổ và tạo âm thanh rít liên tục" }, + // Habitat = "Đồng ruộng, bụi rậm, hang hốc" + // } + // }, + + // // 17. RẮN HOA CÂN VÂN ĐỐM - Sinonatrix aequifasciata + // new SnakeSpecies + // { + // Id = 17, + // ScientificName = "Sinonatrix aequifasciata", + // CommonName = "Rắn Hoa Cân Vân Đốm", + // Slug = "ran-hoa-can-van-dom", + // Description = "Rắn nước không độc, thường sống gần các khe suối.", + // IdentificationSummary = "Kích thước trung bình từ 0.7-1.4m. Thân mập, có các hoa văn hình mắt màu vàng đen chạy dọc thân.", + // PrimaryVenomType = PrimaryVenomType.None, + // RiskLevel = 1.0f, + // IsVenomous = false, + // ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/171/trimerodytes-aequifasciatus_1740063874_0.jpg", + // Identification = new IdentificationFeature { + // PhysicalTraits = new List { "Thân mập hình trụ", "Hoa văn hình mắt màu vàng đen chạy dọc thân", "Đầu bầu dục" }, + // Behaviors = new List { "Sống bán thủy sinh", "Ăn cá và ếch nhái" }, + // Habitat = "Suối, ao hồ, đầm lầy vùng núi" + // } + // }, + + // // 18. RẮN RI CÁ - Homalopsis buccata + // new SnakeSpecies + // { + // Id = 18, + // ScientificName = "Homalopsis buccata", + // CommonName = "Rắn Ri Cá", + // Slug = "ran-ri-ca", + // Description = "Rắn nước phổ biến ở Nam Bộ, thịt ngon nhưng không có độc.", + // IdentificationSummary = "Kích thước trung bình khoảng 70cm.Đầu to, có hình mặt nạ trắng trên đầu, thân có nhiều khoanh màu nâu đỏ nhạt.", + // PrimaryVenomType = PrimaryVenomType.None, + // RiskLevel = 1.0f, + // IsVenomous = false, + // ImageUrl = "https://upload.wikimedia.org/wikipedia/commons/c/c1/Homalopsis_buccata.png", + // Identification = new IdentificationFeature { + // PhysicalTraits = new List { "Kích thước trung bình khoảng 70cm", "Đầu to rộng", "Hoa văn mặt nạ trên đỉnh đầu", "Thân chắc, vảy gồ" }, + // Behaviors = new List { "Ăn đêm", "Sống dưới nước", "Nhút nhát" }, + // Habitat = "Kênh rạch, ao hồ, đầm lầy bùn" + // } + // }, + + // // 19. RẮN ROI - Ahaetulla prasina + // new SnakeSpecies + // { + // Id = 19, + // ScientificName = "Ahaetulla prasina", + // CommonName = "Rắn Roi (Rắn Sinh Viên)", + // Slug = "ran-roi", + // Description = "Thân mảnh như sợi dây thừng, đầu rất nhọn, độc nhẹ, không gây nguy hiểm cho người.", + // IdentificationSummary = "Màu xanh lá huỳnh quang nổi bật, mõm dài và nhọn, con ngươi nằm ngang.", + // PrimaryVenomType = PrimaryVenomType.None, + // RiskLevel = 1.0f, + // IsVenomous = false, + // ImageUrl = "https://cdn-i.vtcnews.vn/files/f2/2015/01/21/nhung-loai-ran-ky-di-nhat-the-gioi-tai-viet-nam-0.jpg", + // Identification = new IdentificationFeature { + // PhysicalTraits = new List { "Thân cực mảnh", "Mõm nhọn dài", "Con ngươi ngang đặc trưng", "Màu xanh lá hoặc nâu nhạt" }, + // Behaviors = new List { "Sống trên cây", "Di chuyển chậm chạp", "Hay thò thụt lưỡi đánh hơi" }, + // Habitat = "Vườn cây, rừng thưa, bụi rậm" + // } + // }, + + // // 20. RẮN TRUN - Cylindrophis ruffus + // new SnakeSpecies + // { + // Id = 20, + // ScientificName = "Cylindrophis ruffus", + // CommonName = "Rắn Trun", + // Slug = "ran-trun", + // Description = "Loài rắn không độc, thân hình trụ tròn, thường bị nhầm với rắn độc do màu sắc.", + // IdentificationSummary = "Thân đen bóng có vạch vàng/trắng, đuôi ngắn tịt và có màu đỏ dưới mặt đuôi.", + // PrimaryVenomType = PrimaryVenomType.None, + // RiskLevel = 1.0f, + // IsVenomous = false, + // ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/131/1737365540_0.jpg", + // Identification = new IdentificationFeature { + // PhysicalTraits = new List { "Thân hình trụ đồng nhất", "Đuôi ngắn giống đầu", "Mặt dưới đuôi màu đỏ" }, + // Behaviors = new List { "Chui rúc trong bùn đất", "Khi gặp nguy hiểm sẽ cuộn tròn và giơ đuôi đỏ lên để lừa kẻ thù" }, + // Habitat = "Đầm lầy, ruộng lúa, nơi đất ẩm" + // } + // }, + + // // 22. RẮN ĐAI LỚN - Lycodon fasciatus + // new SnakeSpecies + // { + // Id = 21, + // ScientificName = "Ptyas major", // Tên khoa học chính xác của Rắn Đại Lớn + // CommonName = "Rắn Đai Lớn (Rắn Xanh Lớn)", + // Slug = "ran-dai-lon-xanh", + // Description = "Loài rắn hoàn toàn không độc, hiền lành, thường bị nhầm với rắn lục do màu xanh lục toàn thân.", + // IdentificationSummary = "Kích thước có thể đạt 1,5-2,5m. Không độc, toàn thân màu xanh lá mượt mà, mắt rất to và tròn, đuôi thuôn dài.", + // PrimaryVenomType = PrimaryVenomType.None, + // RiskLevel = 1.0f, + // IsVenomous = false, + // ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/101/ptyas-major_1743324730_0.jpg", + // Identification = new IdentificationFeature { + // PhysicalTraits = new List { + // "Thân dài, có thể đạt 1,5-2,5 mét", + // "Toàn thân màu xanh lá cây đồng nhất", + // "Bụng màu vàng nhạt hoặc trắng xanh", + // "Mắt rất to, con ngươi tròn đen", + // "Vảy trơn mịn, bóng mượt" + // }, + // Behaviors = new List { + // "Hoạt động chủ yếu ban ngày", + // "Rất hiền lành, hiếm khi cắn người kể cả khi bị bắt", + // "Di chuyển nhanh nhẹn trên mặt đất và cây cỏ" + // }, + // Habitat = "Vườn tược, bụi rậm, rừng thưa, thường gặp ở vùng đồi núi và trung du" + // }, + // SymptomsByTime = new List { + // new SymptomTimeline { + // TimeRange = "Sau khi cắn", + // Signs = new List { "Vết xước rất nhỏ", "Hầu như không đau", "Không sưng nề" }, + // IsCritical = false + // } + // }, + // FirstAidGuidelineOverride = new FirstAidOverride { + // Mode = OverrideMode.Replace, + // Content = new FirstAidContent { + // Steps = new List { + // new FirstAidStep { Text = "Rửa sạch vết thương bằng nước hoặc xà phòng." }, + // new FirstAidStep { Text = "Bình tĩnh vì đây là loài rắn ích lợi, chuyên ăn côn trùng và sâu bọ." } + // } + // } + // } + // }, + // new SnakeSpecies + // { + // Id = 22, + // ScientificName = "Amphiesma stolatum", + // CommonName = "Rắn Sãi Cỏ", + // Slug = "ran-sai-co", + // Description = "Loài rắn nước không độc, hiền lành và có ích cho nông nghiệp. Chúng thường bị nhầm lẫn với một số loài rắn khác do hoa văn phức tạp.", + // IdentificationSummary = "Không độc, dài trung bình 40cm - 80cm. Thân có 2 sọc sáng màu song song, nối với nhau bởi các vạch ngang tối màu trông như chiếc thang.", + // PrimaryVenomType = PrimaryVenomType.None, + // RiskLevel = 1.0f, + // IsVenomous = false, + // ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/27/1736340349_0.jpg", + // Identification = new IdentificationFeature { + // PhysicalTraits = new List { + // "Chiều dài trung bình 40 - 80 cm", + // "2 sọc sáng màu chạy dọc song song trên lưng", + // "Các vạch ngang tối màu nối 2 sọc giống hình chiếc thang", + // "Bụng màu kem nhạt với đốm đen nhỏ hai bên thân", + // "Mép miệng màu vàng nhạt với vạch đen trước và sau mắt" + // }, + // Behaviors = new List { + // "Hoạt động chủ yếu vào ban ngày (nhật hành)", + // "Tính tình nhút nhát, thường bỏ chạy khi gặp người", + // "Săn các sinh vật nhỏ như cá, giun đất và tắc kè" + // }, + // Habitat = "Vùng đồng bằng và đồi núi, thường ở gần nguồn nước (ao, hồ, suối)" + // }, + // SymptomsByTime = new List { + // new SymptomTimeline { + // TimeRange = "Sau khi cắn", + // Signs = new List { "Vết xước nhỏ hình vòng cung", "Chảy máu nhẹ", "Không sưng nề, không gây độc" }, + // IsCritical = false + // } + // }, + // FirstAidGuidelineOverride = new FirstAidOverride { + // Mode = OverrideMode.Replace, + // Content = new FirstAidContent { + // Steps = new List { + // new FirstAidStep { Text = "Rửa vết thương bằng xà phòng và nước sạch để tránh nhiễm trùng." }, + // new FirstAidStep { Text = "Bình tĩnh vì đây là loài rắn hoàn toàn vô hại." } + // } + // } + // } + // } + // }; + + // context.SnakeSpecies.AddRange(snakes); var speciesVenoms = new List { diff --git a/SnakeAid.Service/Implements/RescueMissionService.cs b/SnakeAid.Service/Implements/RescueMissionService.cs new file mode 100644 index 00000000..22e83f1d --- /dev/null +++ b/SnakeAid.Service/Implements/RescueMissionService.cs @@ -0,0 +1,311 @@ +using Mapster; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using SnakeAid.Core.Domains; +using SnakeAid.Core.Exceptions; +using SnakeAid.Repository.Data; +using SnakeAid.Repository.Interfaces; +using SnakeAid.Service.Interfaces; + +namespace SnakeAid.Service.Implements +{ + public class RescueMissionService : IRescueMissionService + { + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + private readonly IRescueRequestSessionService _sessionService; + + // Default price for rescue mission (có thể lấy từ SystemSetting sau) + private const decimal DEFAULT_RESCUE_PRICE = 500000m; + + + public RescueMissionService( + IUnitOfWork unitOfWork, + ILogger logger, + IConfiguration configuration, + IRescueRequestSessionService sessionService) + { + _unitOfWork = unitOfWork; + _logger = logger; + _configuration = configuration; + _sessionService = sessionService; + } + + /// + /// Tạo mission khi rescuer accept request + /// + public async Task CreateMissionAsync(Guid incidentId, Guid rescuerId, decimal price) + { + try + { + return await _unitOfWork.ExecuteInTransactionAsync(async () => + { + // Verify incident exists and is in correct state + var incident = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: i => i.Id == incidentId + ); + + if (incident == null) + { + throw new NotFoundException("Incident not found."); + } + + if (incident.Status != SnakebiteIncidentStatus.Pending) + { + throw new BadRequestException($"Cannot create mission for incident with status: {incident.Status}"); + } + + // Verify rescuer exists + var rescuer = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: r => r.AccountId == rescuerId + ); + + if (rescuer == null) + { + throw new NotFoundException("Rescuer not found."); + } + + // Check if mission already exists for this incident + var existingMission = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: m => m.IncidentId == incidentId + ); + + if (existingMission != null) + { + throw new BadRequestException("Mission already exists for this incident."); + } + + // Create new mission + var mission = new RescueMission + { + Id = Guid.NewGuid(), + IncidentId = incidentId, + RescuerId = rescuerId, + Status = RescueMissionStatus.Preparing, + Price = price > 0 ? price : DEFAULT_RESCUE_PRICE, + CreatedAt = DateTime.UtcNow + }; + + // Update incident status + incident.Status = SnakebiteIncidentStatus.Assigned; + incident.AssignedRescuerId = rescuerId; + incident.AssignedAt = DateTime.UtcNow; + + await _unitOfWork.GetRepository().InsertAsync(mission); + _unitOfWork.GetRepository().Update(incident); + await _unitOfWork.CommitAsync(); + + _logger.LogInformation("Created mission {MissionId} for incident {IncidentId} with rescuer {RescuerId}", + mission.Id, incidentId, rescuerId); + + return mission; + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating mission for incident {IncidentId}: {Message}", incidentId, ex.Message); + throw; + } + } + + /// + /// Update mission status (e.g., EnRoute, Arrived, Completed) + /// + public async Task UpdateMissionStatusAsync(Guid missionId, RescueMissionStatus status) + { + try + { + await _unitOfWork.ExecuteInTransactionAsync(async () => + { + var mission = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: m => m.Id == missionId + ); + + if (mission == null) + { + throw new NotFoundException("Mission not found."); + } + + // Validate state transition + if (!IsValidStatusTransition(mission.Status, status)) + { + throw new BadRequestException($"Cannot transition from {mission.Status} to {status}"); + } + + mission.Status = status; + mission.UpdatedAt = DateTime.UtcNow; + + // Set timestamps based on status + switch (status) + { + case RescueMissionStatus.EnRoute: + mission.StartedAt = DateTime.UtcNow; + break; + case RescueMissionStatus.RescuerArrived: + mission.ArrivedAt = DateTime.UtcNow; + break; + case RescueMissionStatus.MissionCompleted: + mission.CompletedAt = DateTime.UtcNow; + // Update incident status to Finished + var incident = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: i => i.Id == mission.IncidentId + ); + if (incident != null) + { + incident.Status = SnakebiteIncidentStatus.Finished; + _unitOfWork.GetRepository().Update(incident); + } + break; + } + + _unitOfWork.GetRepository().Update(mission); + await _unitOfWork.CommitAsync(); + + _logger.LogInformation("Updated mission {MissionId} status to {Status}", missionId, status); + return mission; + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating mission {MissionId} status: {Message}", missionId, ex.Message); + throw; + } + } + + /// User cancel mission: Set status to Cancelled, no new session + /// Only allowed before rescuer updates to EnRoute status + public async Task UserCancelMissionAsync(Guid missionId, string reason) + { + try + { + await _unitOfWork.ExecuteInTransactionAsync(async () => + { + var mission = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: m => m.Id == missionId, + include: q => q.Include(m => m.Incident) + ); + + if (mission == null) + { + throw new NotFoundException("Mission not found."); + } + + // User can only cancel before rescuer goes EnRoute + if (mission.Status != RescueMissionStatus.Preparing) + { + throw new BadRequestException($"User cannot cancel mission with status: {mission.Status}. Only allowed during Preparing phase."); + } + + mission.Status = RescueMissionStatus.Cancelled; + mission.CancellationReason = reason; + mission.UpdatedAt = DateTime.UtcNow; + + // Set incident to Cancelled (user doesn't want rescue anymore) + var incident = mission.Incident; + incident.Status = SnakebiteIncidentStatus.Cancelled; + incident.AssignedRescuerId = null; + incident.AssignedAt = null; + + _unitOfWork.GetRepository().Update(mission); + _unitOfWork.GetRepository().Update(incident); + await _unitOfWork.CommitAsync(); + + _logger.LogInformation("User cancelled mission {MissionId} with reason: {Reason}", missionId, reason); + + // No new session created - user wants to end the incident + + return mission; + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in user cancelling mission {MissionId}: {Message}", missionId, ex.Message); + throw; + } + } + + /// Rescuer abort mission: Set status to MissionAborted, create new session with increased radius + /// Allowed during Preparing or EnRoute phases + public async Task RescuerAbortMissionAsync(Guid missionId, string reason) + { + try + { + await _unitOfWork.ExecuteInTransactionAsync(async () => + { + var mission = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: m => m.Id == missionId, + include: q => q.Include(m => m.Incident) + ); + + if (mission == null) + { + throw new NotFoundException("Mission not found."); + } + + // Rescuer can abort during Preparing or EnRoute + if (mission.Status != RescueMissionStatus.Preparing && mission.Status != RescueMissionStatus.EnRoute) + { + throw new BadRequestException($"Cannot abort mission with status: {mission.Status}. Only allowed during Preparing or EnRoute phases."); + } + + mission.Status = RescueMissionStatus.MissionAborted; + mission.CancellationReason = reason; + mission.UpdatedAt = DateTime.UtcNow; + + // Reset incident to Pending for retry with increased radius + var incident = mission.Incident; + incident.Status = SnakebiteIncidentStatus.Pending; + incident.AssignedRescuerId = null; + incident.AssignedAt = null; + + _unitOfWork.GetRepository().Update(mission); + _unitOfWork.GetRepository().Update(incident); + await _unitOfWork.CommitAsync(); + + _logger.LogInformation("Rescuer aborted mission {MissionId} with reason: {Reason}", missionId, reason); + + // Create new session with increased radius after rescuer abort + try + { + await _sessionService.HandleMissionAbortAsync(incident.Id); + _logger.LogInformation("Created new rescue session after rescuer abort for incident {IncidentId}", incident.Id); + } + catch (Exception sessionEx) + { + // Log but don't fail the mission abort + _logger.LogError(sessionEx, "Failed to create new session after rescuer abort for incident {IncidentId}: {Message}", + incident.Id, sessionEx.Message); + } + + return mission; + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in rescuer aborting mission {MissionId}: {Message}", missionId, ex.Message); + throw; + } + } + + /// + /// Validate mission status transition + /// + private bool IsValidStatusTransition(RescueMissionStatus current, RescueMissionStatus next) + { + return (current, next) switch + { + (RescueMissionStatus.Preparing, RescueMissionStatus.EnRoute) => true, + (RescueMissionStatus.Preparing, RescueMissionStatus.Cancelled) => true, + (RescueMissionStatus.EnRoute, RescueMissionStatus.RescuerArrived) => true, + (RescueMissionStatus.EnRoute, RescueMissionStatus.Cancelled) => true, + (RescueMissionStatus.EnRoute, RescueMissionStatus.MissionAborted) => true, + (RescueMissionStatus.RescuerArrived, RescueMissionStatus.MissionCompleted) => true, + (RescueMissionStatus.RescuerArrived, RescueMissionStatus.MissionUncompleted) => true, + (RescueMissionStatus.RescuerArrived, RescueMissionStatus.MissionAborted) => true, + _ => false + }; + } + } +} diff --git a/SnakeAid.Service/Implements/RescueRequestSessionService.cs b/SnakeAid.Service/Implements/RescueRequestSessionService.cs index ee708e5f..30184377 100644 --- a/SnakeAid.Service/Implements/RescueRequestSessionService.cs +++ b/SnakeAid.Service/Implements/RescueRequestSessionService.cs @@ -1,5 +1,8 @@ -using Microsoft.Extensions.Configuration; +using Mapster; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using NetTopologySuite.Geometries; using SnakeAid.Core.Domains; using SnakeAid.Core.Exceptions; using SnakeAid.Core.Meta; @@ -10,6 +13,7 @@ using SnakeAid.Repository.Interfaces; using SnakeAid.Service.Interfaces; using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Text; @@ -22,14 +26,646 @@ public class RescueRequestSessionService : IRescueRequestSessionService private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly IConfiguration _configuration; + private readonly IRescueMissionService _missionService; + private readonly IRescueNotificationService _notificationService; + private readonly ISessionTimeoutService _timeoutService; - public RescueRequestSessionService(IUnitOfWork unitOfWork, ILogger logger, IConfiguration configuration) + // Configuration constants (sau này lấy từ SystemSetting) + private const int MAX_SESSIONS = 3; + private const int REQUEST_TIMEOUT_SECONDS = 60; + private static readonly int[] RADIUS_PROGRESSION = { 10, 20, 30 }; // km + + public RescueRequestSessionService( + IUnitOfWork unitOfWork, + ILogger logger, + IConfiguration configuration, + IRescueMissionService missionService, + IRescueNotificationService notificationService, + ISessionTimeoutService timeoutService) { _unitOfWork = unitOfWork; _logger = logger; _configuration = configuration; + _missionService = missionService; + _notificationService = notificationService; + _timeoutService = timeoutService; + } + + /// Tạo session mới cho incident (initial hoặc expand) + public async Task CreateSessionAsync(Guid incidentId, int sessionNumber, int radiusKm, SessionTrigger trigger) + { + try + { + return await _unitOfWork.ExecuteInTransactionAsync(async () => + { + var incident = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: i => i.Id == incidentId + ); + + if (incident == null) + { + throw new NotFoundException("Incident not found."); + } + + // Validate max sessions + if (sessionNumber > MAX_SESSIONS) + { + incident.Status = SnakebiteIncidentStatus.NoRescuerFound; + _unitOfWork.GetRepository().Update(incident); + await _unitOfWork.CommitAsync(); + throw new BadRequestException($"Maximum sessions ({MAX_SESSIONS}) reached. No rescuers found."); + } + + var session = new RescueRequestSession + { + Id = Guid.NewGuid(), + IncidentId = incidentId, + SessionNumber = sessionNumber, + RadiusKm = radiusKm, + Status = SessionStatus.Active, + TriggerType = trigger, + RescuersPinged = 0, + CreatedAt = DateTime.UtcNow + }; + + // Update incident tracking + incident.CurrentSessionNumber = sessionNumber; + incident.CurrentRadiusKm = radiusKm; + incident.LastSessionAt = DateTime.UtcNow; + + await _unitOfWork.GetRepository().InsertAsync(session); + _unitOfWork.GetRepository().Update(incident); + await _unitOfWork.CommitAsync(); + + // Schedule timeout monitoring for this session + var timeoutAt = DateTime.UtcNow.AddSeconds(REQUEST_TIMEOUT_SECONDS); + _timeoutService.ScheduleSessionTimeout(session.Id, timeoutAt); + + _logger.LogInformation("Created session {SessionId} for incident {IncidentId}, radius {RadiusKm}km, trigger {Trigger}, timeout at {TimeoutAt}", + session.Id, incidentId, radiusKm, trigger, timeoutAt); + + return session; + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating session for incident {IncidentId}: {Message}", incidentId, ex.Message); + throw; + } + } + + public async Task BroadcastRequestsAsync(Guid sessionId) + { + try + { + await _unitOfWork.ExecuteInTransactionAsync(async () => + { + var session = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: s => s.Id == sessionId, + include: q => q.Include(s => s.Incident) + ); + + if (session == null) + { + throw new NotFoundException("Session not found."); + } + + var incident = session.Incident; + + // Query rescuers online trong radius bằng PostGIS + var rescuersInRadius = await GetRescuersInRadiusAsync( + incident.LocationCoordinates, + session.RadiusKm + ); + + if (!rescuersInRadius.Any()) + { + _logger.LogWarning("No rescuers found in {RadiusKm}km radius for session {SessionId}", + session.RadiusKm, sessionId); + return session; + } + + var expiredAt = DateTime.UtcNow.AddSeconds(REQUEST_TIMEOUT_SECONDS); + var requests = new List(); + + // Build all RescuerRequest objects + foreach (var rescuer in rescuersInRadius) + { + requests.Add(new RescuerRequest + { + Id = Guid.NewGuid(), + SessionId = sessionId, + IncidentId = incident.Id, + RescuerId = rescuer.AccountId, + Status = RescueRequestStatus.Pending, + RequestSentAt = DateTime.UtcNow, + ExpiredAt = expiredAt, + CreatedAt = DateTime.UtcNow + }); + } + + // Bulk insert all requests at once + await _unitOfWork.GetRepository().InsertRangeAsync(requests); + + // Create lookup dictionary for O(1) rescuer lookup (optimization) + var rescuerLookup = rescuersInRadius.ToDictionary(r => r.AccountId, r => r.AccountId.ToString()); + + // Push notifications to all connected rescuers (parallel execution) + var notificationTasks = requests.Select(request => + SendRequestToRescuerAsync( + rescuerLookup[request.RescuerId], // O(1) lookup instead of O(n) + request, + session + ) + ); + await Task.WhenAll(notificationTasks); + + // Update session tracking and commit + session.RescuersPinged = requests.Count; + _unitOfWork.GetRepository().Update(session); + await _unitOfWork.CommitAsync(); + + _logger.LogInformation("Successfully broadcasted {Count} requests for session {SessionId}, radius {RadiusKm}km. " + + "All data committed to database.", + requests.Count, sessionId, session.RadiusKm); + + return session; + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error broadcasting requests for session {SessionId}: {Message}", sessionId, ex.Message); + throw; + } } - + + /// Query rescuers online trong radius bằng PostGIS + private async Task> GetRescuersInRadiusAsync(Point incidentLocation, int radiusKm) + { + // Convert km to meters for PostGIS distance calculation + var radiusMeters = radiusKm * 1000; + + // Sử dụng CreateBaseQuery() theo pattern của GenericRepository + var rescuers = await _unitOfWork.GetRepository() + .CreateBaseQuery(asNoTracking: true) + .Where(r => r.IsOnline) + .Where(r => r.LastLocation != null) + .Where(r => r.Type == RescuerType.Emergency || r.Type == RescuerType.Both) + .Where(r => r.LastLocation!.Distance(incidentLocation) <= radiusMeters) + .OrderBy(r => r.LastLocation!.Distance(incidentLocation)) + .ToListAsync(); + + // Filter chỉ những rescuer đang connected tới hub (via notification service) + var connectedRescuers = rescuers + .Where(r => _notificationService.IsRescuerConnected(r.AccountId.ToString())) + .ToList(); + + return connectedRescuers; + } + + /// + /// Push request đến rescuer qua notification service + /// + private async Task SendRequestToRescuerAsync(string userId, RescuerRequest request, RescueRequestSession session) + { + await _notificationService.SendNewRequestAsync(userId, new + { + RequestId = request.Id, + SessionId = session.Id, + IncidentId = request.IncidentId, + RadiusKm = session.RadiusKm, + ExpiredAt = request.ExpiredAt, + RequestSentAt = request.RequestSentAt + }); + + _logger.LogInformation("Sent request {RequestId} to rescuer {UserId}", request.Id, userId); + } + + /// Handle timeout: Mark requests expired sau 60s, check nếu cần expand/create new session + public async Task HandleSessionTimeoutAsync(Guid sessionId) + { + try + { + await _unitOfWork.ExecuteInTransactionAsync(async () => + { + var session = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: s => s.Id == sessionId, + include: q => q.Include(s => s.Requests).Include(s => s.Incident) + ); + + if (session == null) + { + throw new NotFoundException("Session not found."); + } + + // Skip nếu session đã complete hoặc cancelled + if (session.Status != SessionStatus.Active) + { + _logger.LogInformation("Session {SessionId} already {Status}, skipping timeout handling", + sessionId, session.Status); + return session; + } + + // Mark all pending requests as expired (bulk update for better performance) + var pendingRequests = session.Requests.Where(r => r.Status == RescueRequestStatus.Pending).ToList(); + if (pendingRequests.Any()) + { + var updateTime = DateTime.UtcNow; + foreach (var request in pendingRequests) + { + request.Status = RescueRequestStatus.Expired; + request.UpdatedAt = updateTime; + } + // Batch update + _unitOfWork.GetRepository().UpdateRange(pendingRequests); + } + + // Mark session as failed + session.Status = SessionStatus.Failed; + session.CompletedAt = DateTime.UtcNow; + _unitOfWork.GetRepository().Update(session); + await _unitOfWork.CommitAsync(); + + _logger.LogInformation("Session {SessionId} timed out, {Count} requests expired", + sessionId, pendingRequests.Count); + + // Try expand and create new session + await TryExpandAndCreateNewSessionAsync(session.IncidentId); + + return session; + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling session timeout {SessionId}: {Message}", sessionId, ex.Message); + throw; + } + } + + /// Accept request: Update RescuerRequest, tạo RescueMission, mark others Taken + public async Task AcceptRequestAsync(Guid requestId, Guid rescuerId) + { + try + { + await _unitOfWork.ExecuteInTransactionAsync(async () => + { + var request = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: r => r.Id == requestId && r.RescuerId == rescuerId, + include: q => q.Include(r => r.Session).ThenInclude(s => s.Requests) + ); + + if (request == null) + { + throw new NotFoundException($"Request not found or not assigned to this rescuer {rescuerId}."); + } + + // Validate request status + if (request.Status != RescueRequestStatus.Pending) + { + throw new BadRequestException($"Cannot accept request with status: {request.Status}"); + } + + // Check if expired + if (DateTime.UtcNow > request.ExpiredAt) + { + request.Status = RescueRequestStatus.Expired; + _unitOfWork.GetRepository().Update(request); + await _unitOfWork.CommitAsync(); + throw new BadRequestException("Request has expired."); + } + + // Check if session already completed (someone else accepted first) + if (request.Session.Status == SessionStatus.Completed) + { + request.Status = RescueRequestStatus.Taken; + _unitOfWork.GetRepository().Update(request); + await _unitOfWork.CommitAsync(); + throw new BadRequestException("Another rescuer has already accepted this incident."); + } + + // Accept this request + request.Status = RescueRequestStatus.Accepted; + request.ResponseAt = DateTime.UtcNow; + request.UpdatedAt = DateTime.UtcNow; + _unitOfWork.GetRepository().Update(request); + + // Mark all other requests in the session as Taken + var otherRequests = request.Session.Requests.Where(r => r.Id != requestId && r.Status == RescueRequestStatus.Pending).ToList(); + if (otherRequests.Any()) + { + var updateTime = DateTime.UtcNow; + foreach (var otherRequest in otherRequests) + { + otherRequest.Status = RescueRequestStatus.Taken; + otherRequest.UpdatedAt = updateTime; + } + // Batch update + _unitOfWork.GetRepository().UpdateRange(otherRequests); + + // Notify other rescuers that request was taken (parallel notifications) + var notificationTasks = otherRequests.Select(otherRequest => + NotifyRequestTakenAsync(otherRequest.RescuerId.ToString(), otherRequest.Id) + ); + await Task.WhenAll(notificationTasks); + } + + // Mark session as completed + request.Session.Status = SessionStatus.Completed; + request.Session.CompletedAt = DateTime.UtcNow; + _unitOfWork.GetRepository().Update(request.Session); + + // Cancel timeout monitoring since session is completed + _timeoutService.CancelSessionTimeout(request.Session.Id); + + // Commit critical data first (request acceptance, session completion) + await _unitOfWork.CommitAsync(); + + // Create rescue mission (post-commit operation) + try + { + await _missionService.CreateMissionAsync(request.IncidentId, rescuerId, 0); + _logger.LogInformation("Mission created successfully for request {RequestId}", requestId); + } + catch (Exception missionEx) + { + // CRITICAL: Data already committed, log for manual intervention + _logger.LogError(missionEx, + "CRITICAL: Failed to create mission after accepting request {RequestId}. " + + "Data inconsistency detected. Manual intervention required. " + + "IncidentId: {IncidentId}, RescuerId: {RescuerId}", + requestId, request.IncidentId, rescuerId); + + // TODO: Add to failed operations queue for retry + // TODO: Send alert to admin/ops team + + // Re-throw để caller biết có issue (nhưng data đã committed) + throw new InvalidOperationException( + $"Request accepted but mission creation failed. RequestId: {requestId}", + missionEx); + } + + _logger.LogInformation("Rescuer {RescuerId} accepted request {RequestId} for incident {IncidentId}", + rescuerId, requestId, request.IncidentId); + + return request; + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error accepting request {RequestId}: {Message}", requestId, ex.Message); + throw; + } + } + + /// + /// Notify rescuer that request was taken by someone else + /// + private async Task NotifyRequestTakenAsync(string userId, Guid requestId) + { + await _notificationService.NotifyRequestTakenAsync(userId, requestId); + } + + /// + /// Reject request: Update status + /// + public async Task RejectRequestAsync(Guid requestId) + { + try + { + await _unitOfWork.ExecuteInTransactionAsync(async () => + { + var request = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: r => r.Id == requestId + ); + + if (request == null) + { + throw new NotFoundException("Request not found."); + } + + if (request.Status != RescueRequestStatus.Pending) + { + throw new BadRequestException($"Cannot reject request with status: {request.Status}"); + } + + request.Status = RescueRequestStatus.Rejected; + request.ResponseAt = DateTime.UtcNow; + request.UpdatedAt = DateTime.UtcNow; + + _unitOfWork.GetRepository().Update(request); + await _unitOfWork.CommitAsync(); + + _logger.LogInformation("Request {RequestId} rejected", requestId); + + return request; + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error rejecting request {RequestId}: {Message}", requestId, ex.Message); + throw; + } + } + + /// + /// Cancel session (user cancel incident) + /// + public async Task CancelSessionAsync(Guid sessionId) + { + try + { + await _unitOfWork.ExecuteInTransactionAsync(async () => + { + var session = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: s => s.Id == sessionId, + include: q => q.Include(s => s.Requests) + ); + + if (session == null) + { + throw new NotFoundException("Session not found."); + } + + // Cancel all pending requests + foreach (var request in session.Requests.Where(r => r.Status == RescueRequestStatus.Pending)) + { + request.Status = RescueRequestStatus.Cancelled; + request.UpdatedAt = DateTime.UtcNow; + _unitOfWork.GetRepository().Update(request); + + // Notify rescuer + await NotifyRequestCancelledAsync(request.RescuerId.ToString(), request.Id); + } + + session.Status = SessionStatus.Cancelled; + session.CompletedAt = DateTime.UtcNow; + + // Cancel timeout monitoring since session is cancelled + _timeoutService.CancelSessionTimeout(sessionId); + + _unitOfWork.GetRepository().Update(session); + await _unitOfWork.CommitAsync(); + + _logger.LogInformation("Session {SessionId} cancelled", sessionId); + + return session; + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error cancelling session {SessionId}: {Message}", sessionId, ex.Message); + throw; + } + } + + /// + /// Notify rescuer that request was cancelled + /// + private async Task NotifyRequestCancelledAsync(string userId, Guid requestId) + { + await _notificationService.NotifyRequestCancelledAsync(userId, requestId); + } + + /// + /// Expand radius và tạo session mới nếu cần + /// + public async Task TryExpandAndCreateNewSessionAsync(Guid incidentId) + { + try + { + var incident = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: i => i.Id == incidentId + ); + + if (incident == null) + { + throw new NotFoundException("Incident not found."); + } + + // Check if incident is still pending + if (incident.Status != SnakebiteIncidentStatus.Pending) + { + _logger.LogInformation("Incident {IncidentId} is no longer pending ({Status}), skipping expand", + incidentId, incident.Status); + return false; + } + + // Check if max sessions reached + if (incident.CurrentSessionNumber >= MAX_SESSIONS) + { + _logger.LogWarning("Max sessions ({MaxSessions}) reached for incident {IncidentId}, marking as NoRescuerFound", + MAX_SESSIONS, incidentId); + + incident.Status = SnakebiteIncidentStatus.NoRescuerFound; + _unitOfWork.GetRepository().Update(incident); + await _unitOfWork.CommitAsync(); + return false; + } + + // Get next radius from progression + var nextSessionNumber = incident.CurrentSessionNumber + 1; + var nextRadiusIndex = nextSessionNumber - 1; + var nextRadius = nextRadiusIndex < RADIUS_PROGRESSION.Length + ? RADIUS_PROGRESSION[nextRadiusIndex] + : RADIUS_PROGRESSION[^1]; // Use last value if exceeded + + // Create new session + var newSession = await CreateSessionAsync( + incidentId, + nextSessionNumber, + nextRadius, + SessionTrigger.RadiusExpanded + ); + + // Broadcast requests for new session + await BroadcastRequestsAsync(newSession.Id); + + _logger.LogInformation("Expanded to session {SessionNumber} with radius {RadiusKm}km for incident {IncidentId}", + nextSessionNumber, nextRadius, incidentId); + + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error expanding session for incident {IncidentId}: {Message}", incidentId, ex.Message); + throw; + } + } + + /// + /// Start initial rescue session for incident (called from SnakebiteIncidentService) + /// + public async Task StartRescueSessionAsync(Guid incidentId) + { + var initialRadius = RADIUS_PROGRESSION[0]; // 10km + var session = await CreateSessionAsync(incidentId, 1, initialRadius, SessionTrigger.Initial); + await BroadcastRequestsAsync(session.Id); + } + + /// + /// Handle mission abort: Create new session with increased radius + /// Called when rescuer aborts mission (after accepting request) + /// + public async Task HandleMissionAbortAsync(Guid incidentId) + { + try + { + var incident = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: i => i.Id == incidentId + ); + + if (incident == null) + { + throw new NotFoundException("Incident not found."); + } + + // Check if incident is still pending (should be reset by mission abort) + if (incident.Status != SnakebiteIncidentStatus.Pending) + { + _logger.LogWarning("Incident {IncidentId} is not pending ({Status}), cannot create new session after mission abort", + incidentId, incident.Status); + return; + } + + // Check if max sessions reached + if (incident.CurrentSessionNumber >= MAX_SESSIONS) + { + _logger.LogWarning("Max sessions ({MaxSessions}) reached for incident {IncidentId}, marking as NoRescuerFound", + MAX_SESSIONS, incidentId); + + incident.Status = SnakebiteIncidentStatus.NoRescuerFound; + _unitOfWork.GetRepository().Update(incident); + await _unitOfWork.CommitAsync(); + return; + } + + // Get next radius from progression (increase radius due to mission cancellation) + var nextSessionNumber = incident.CurrentSessionNumber + 1; + var nextRadiusIndex = nextSessionNumber - 1; + var nextRadius = nextRadiusIndex < RADIUS_PROGRESSION.Length + ? RADIUS_PROGRESSION[nextRadiusIndex] + : RADIUS_PROGRESSION[^1]; // Use last value if exceeded + + // Create new session with increased radius + var newSession = await CreateSessionAsync( + incidentId, + nextSessionNumber, + nextRadius, + SessionTrigger.MissionCancelled + ); + + // Broadcast requests for new session + await BroadcastRequestsAsync(newSession.Id); + + _logger.LogInformation("Created new session {SessionNumber} with radius {RadiusKm}km for incident {IncidentId} after mission abort", + nextSessionNumber, nextRadius, incidentId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling mission abort for incident {IncidentId}: {Message}", incidentId, ex.Message); + throw; + } + } } } diff --git a/SnakeAid.Service/Implements/ServiceImplement.cs b/SnakeAid.Service/Implements/ServiceImplement.cs deleted file mode 100644 index 36d4fe3c..00000000 --- a/SnakeAid.Service/Implements/ServiceImplement.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace SnakeAid.Service.Implements -{ - public class ServiceImplement - { - - } -} \ No newline at end of file diff --git a/SnakeAid.Service/Implements/SessionTimeoutBackgroundService.cs b/SnakeAid.Service/Implements/SessionTimeoutBackgroundService.cs new file mode 100644 index 00000000..e8403d4f --- /dev/null +++ b/SnakeAid.Service/Implements/SessionTimeoutBackgroundService.cs @@ -0,0 +1,287 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SnakeAid.Service.Interfaces; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace SnakeAid.Service.Implements +{ + + /// Background service để monitor và handle session timeouts + /// Sử dụng scheduled timer approach với in-memory storage cho precision timing without external dependencies + public class SessionTimeoutBackgroundService : BackgroundService, ISessionTimeoutService + { + private readonly ILogger _logger; + private readonly IServiceScopeFactory _serviceScopeFactory; + + // SortedDictionary để efficiently get earliest timeout + private readonly SortedDictionary> _timeoutSchedule = new(); + private readonly ConcurrentDictionary _sessionTimeouts = new(); + private readonly object _scheduleLock = new object(); + + // Current timer task cancellation + private CancellationTokenSource? _currentTimerCancellation; + + // Minimum delay để avoid too frequent checks + private static readonly TimeSpan MIN_DELAY = TimeSpan.FromSeconds(1); + private static readonly TimeSpan MAX_DELAY = TimeSpan.FromMinutes(5); + private static readonly TimeSpan DEFAULT_DELAY = TimeSpan.FromSeconds(30); + + public SessionTimeoutBackgroundService( + ILogger logger, + IServiceScopeFactory serviceScopeFactory) + { + _logger = logger; + _serviceScopeFactory = serviceScopeFactory; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("SessionTimeoutBackgroundService started with scheduled timer approach"); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + // Calculate next optimal delay based on earliest timeout + var nextDelay = CalculateNextDelay(); + + _logger.LogDebug("Next timeout check in {Delay}ms. Monitoring {Count} sessions", + nextDelay.TotalMilliseconds, _sessionTimeouts.Count); + + // Wait until next scheduled timeout or cancellation + using var timerCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + _currentTimerCancellation = timerCts; + + await Task.Delay(nextDelay, timerCts.Token); + + // Process any expired sessions + await ProcessExpiredSessions(); + } + catch (OperationCanceledException) + { + // Expected when cancellation is requested or timer is reset + if (stoppingToken.IsCancellationRequested) + break; + // Continue if it was just a timer reset + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in scheduled timer loop: {Message}", ex.Message); + // Use default delay on error to avoid tight loops + await Task.Delay(DEFAULT_DELAY, stoppingToken); + } + } + + _logger.LogInformation("SessionTimeoutBackgroundService stopped"); + } + + + /// Add session to monitoring with precise timeout scheduling + public void ScheduleSessionTimeout(Guid sessionId, DateTime timeoutAt) + { + lock (_scheduleLock) + { + // Remove existing if present + if (_sessionTimeouts.TryGetValue(sessionId, out var existingTimeout)) + { + RemoveFromSchedule(sessionId, existingTimeout); + } + + // Add to both collections + _sessionTimeouts[sessionId] = timeoutAt; + + if (!_timeoutSchedule.ContainsKey(timeoutAt)) + { + _timeoutSchedule[timeoutAt] = new List(); + } + _timeoutSchedule[timeoutAt].Add(sessionId); + + _logger.LogDebug("Scheduled precise timeout for session {SessionId} at {TimeoutAt}", sessionId, timeoutAt); + } + + // Reset timer để reschedule với timeout mới + RescheduleTimer(); + } + + + /// Remove session from monitoring (when session completed/cancelled before timeout) + public void CancelSessionTimeout(Guid sessionId) + { + lock (_scheduleLock) + { + if (_sessionTimeouts.TryRemove(sessionId, out var timeoutAt)) + { + RemoveFromSchedule(sessionId, timeoutAt); + _logger.LogDebug("Cancelled timeout monitoring for session {SessionId} (was scheduled for {TimeoutAt})", sessionId, timeoutAt); + } + } + + // Reset timer nếu có changes + RescheduleTimer(); + } + + + /// Process sessions that have timed out (more efficient with scheduled approach) + private async Task ProcessExpiredSessions() + { + var currentTime = DateTime.UtcNow; + var expiredSessions = new List(); + var expiredTimeSlots = new List(); + + lock (_scheduleLock) + { + // Get all time slots that have expired + foreach (var timeSlot in _timeoutSchedule.Keys.ToList()) + { + if (currentTime >= timeSlot) + { + expiredSessions.AddRange(_timeoutSchedule[timeSlot]); + expiredTimeSlots.Add(timeSlot); + } + else + { + break; // SortedDictionary is ordered, so we can break early + } + } + + // Cleanup expired time slots + foreach (var timeSlot in expiredTimeSlots) + { + _timeoutSchedule.Remove(timeSlot); + } + + // Remove from session tracking + foreach (var sessionId in expiredSessions) + { + _sessionTimeouts.TryRemove(sessionId, out _); + } + } + + if (!expiredSessions.Any()) + { + return; // No expired sessions + } + + _logger.LogInformation("Processing {Count} expired sessions", expiredSessions.Count); + + // Process each expired session + using var scope = _serviceScopeFactory.CreateScope(); + var sessionService = scope.ServiceProvider.GetRequiredService(); + + foreach (var sessionId in expiredSessions) + { + try + { + // Remove from monitoring queue first + _sessionTimeouts.TryRemove(sessionId, out _); + + // Handle the session timeout (includes expanding to new session if possible) + await sessionService.HandleSessionTimeoutAsync(sessionId); + + _logger.LogInformation("Successfully processed timeout for session {SessionId}", sessionId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling timeout for session {SessionId}: {Message}", sessionId, ex.Message); + // Continue processing other sessions even if one fails + } + } + } + + + + /// Get current queue status for monitoring/debugging + public (int TotalSessions, int ExpiredCount, int PendingCount) GetQueueStatus() + { + var currentTime = DateTime.UtcNow; + var total = _sessionTimeouts.Count; + var expired = _sessionTimeouts.Count(x => currentTime >= x.Value); + var pending = total - expired; + + return (total, expired, pending); + } + + + /// Calculate optimal delay until next timeout check + private TimeSpan CalculateNextDelay() + { + lock (_scheduleLock) + { + if (_timeoutSchedule.Count == 0) + { + // No sessions scheduled, use default delay + return DEFAULT_DELAY; + } + + // Get earliest timeout + var earliestTimeout = _timeoutSchedule.Keys.First(); + var now = DateTime.UtcNow; + + if (earliestTimeout <= now) + { + // Already have expired sessions, process immediately + return MIN_DELAY; + } + + var calculatedDelay = earliestTimeout - now; + + // Clamp between min and max delays + if (calculatedDelay < MIN_DELAY) + return MIN_DELAY; + if (calculatedDelay > MAX_DELAY) + return MAX_DELAY; + + return calculatedDelay; + } + } + + + /// Reset current timer to reschedule with new timeout + private void RescheduleTimer() + { + try + { + // Cancel current timer to trigger reschedule + _currentTimerCancellation?.Cancel(); + } + catch (ObjectDisposedException) + { + // Ignore if already disposed + } + } + + + /// Remove session from schedule collections + private void RemoveFromSchedule(Guid sessionId, DateTime timeoutAt) + { + if (_timeoutSchedule.TryGetValue(timeoutAt, out var sessionList)) + { + sessionList.Remove(sessionId); + if (sessionList.Count == 0) + { + _timeoutSchedule.Remove(timeoutAt); + } + } + } + + + /// For health checks - ensures service is running properly + public bool IsHealthy() + { + // Simple health check - service should be able to process the queue + return _sessionTimeouts.Count < 1000; // Reasonable limit + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("SessionTimeoutBackgroundService is stopping..."); + await base.StopAsync(cancellationToken); + } + } +} \ No newline at end of file diff --git a/SnakeAid.Service/Implements/SnakebiteIncidentService.cs b/SnakeAid.Service/Implements/SnakebiteIncidentService.cs index 1eb47d57..95d2187a 100644 --- a/SnakeAid.Service/Implements/SnakebiteIncidentService.cs +++ b/SnakeAid.Service/Implements/SnakebiteIncidentService.cs @@ -26,15 +26,24 @@ public class SnakebiteIncidentService : ISnakebiteIncidentService private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly IConfiguration _configuration; - - public SnakebiteIncidentService(IUnitOfWork unitOfWork, ILogger logger, IConfiguration configuration) + private readonly IRescueRequestSessionService _sessionService; + private const int MAX_SESSIONS = 3; + private const int REQUEST_TIMEOUT_SECONDS = 60; + private static readonly int[] RADIUS_PROGRESSION = { 10, 20, 30 }; // km + + public SnakebiteIncidentService( + IUnitOfWork unitOfWork, + ILogger logger, + IConfiguration configuration, + IRescueRequestSessionService sessionService) { _unitOfWork = unitOfWork; _logger = logger; _configuration = configuration; + _sessionService = sessionService; } - public async Task> CancelIncidentAsync(Guid incidentId) + public async Task CancelIncidentAsync(Guid incidentId) { try { @@ -56,7 +65,7 @@ public async Task> CancelIncidentAsync(Guid _unitOfWork.GetRepository().Update(existingIncident); await _unitOfWork.CommitAsync(); var responseData = existingIncident.Adapt(); - return ApiResponseBuilder.BuildSuccessResponse(responseData, "Snakebite Incident cancelled successfully!"); + return responseData; }); } catch (Exception ex) @@ -66,7 +75,7 @@ public async Task> CancelIncidentAsync(Guid } } - public async Task> CreateIncidentAsync(CreateIncidentRequest request, Guid userId) + public async Task CreateIncidentAsync(CreateIncidentRequest request, Guid userId) { try { @@ -97,37 +106,21 @@ public async Task> CreateIncidentAsync(Creat UserId = existingAccount.Id, LocationCoordinates = locationPoint, Status = SnakebiteIncidentStatus.Pending, - CurrentSessionNumber = 1, - CurrentRadiusKm = 5, - LastSessionAt = DateTime.UtcNow, + CurrentSessionNumber = 0, // Will be set when first session is created + CurrentRadiusKm = 0, // Will be set when first session is created + LastSessionAt = null, // Will be set when first session is created IncidentOccurredAt = DateTime.UtcNow }; - var firstRescueSession = new RescueRequestSession - { - Id = Guid.NewGuid(), - IncidentId = newIncident.Id, - SessionNumber = newIncident.CurrentSessionNumber, - RadiusKm = newIncident.CurrentRadiusKm, - Status = SessionStatus.Active, - CreatedAt = DateTime.UtcNow, - TriggerType = SessionTrigger.Initial, - RescuersPinged = 0 - }; - await _unitOfWork.GetRepository().InsertAsync(newIncident); - await _unitOfWork.GetRepository().InsertAsync(firstRescueSession); await _unitOfWork.CommitAsync(); var responseData = newIncident.Adapt(); - responseData.Sessions = new List - { - firstRescueSession.Adapt() - }; + responseData.Sessions = new List(); - return ApiResponseBuilder.BuildSuccessResponse(responseData, "Snakebite Incident created successfully!"); + return responseData; }); - + } catch (Exception ex) { @@ -136,7 +129,7 @@ public async Task> CreateIncidentAsync(Creat } } - public async Task> RaiseSessionRangeAsync(RaiseSessionRangeRequest request) + public async Task RaiseSessionRangeAsync(RaiseSessionRangeRequest request) { try { @@ -169,7 +162,7 @@ public async Task> RaiseSessionRangeAsync(Ra // Close current session as Failed var currentSession = existingIncident.Sessions .FirstOrDefault(s => s.SessionNumber == existingIncident.CurrentSessionNumber); - + if (currentSession != null) { currentSession.Status = SessionStatus.Failed; @@ -178,30 +171,24 @@ public async Task> RaiseSessionRangeAsync(Ra } // Check if maximum sessions reached (max 3 sessions) - if (existingIncident.CurrentSessionNumber >= 3) + // Check max session (trước khi tăng) + if (existingIncident.CurrentSessionNumber >= RADIUS_PROGRESSION.Length) { existingIncident.Status = SnakebiteIncidentStatus.NoRescuerFound; existingIncident.LastSessionAt = DateTime.UtcNow; _unitOfWork.GetRepository().Update(existingIncident); await _unitOfWork.CommitAsync(); - - throw new BadRequestException("Maximum session range expansions reached (3 sessions). No rescuers found in area."); + + throw new BadRequestException( + $"Maximum session range expansions reached ({RADIUS_PROGRESSION.Length} sessions). No rescuers found." + ); } - // Calculate new radius with progressive increment (5km -> 7km -> 10km) - int radiusIncrement = existingIncident.CurrentSessionNumber switch - { - 1 => 2, // Session 1 (5km) -> Session 2 (7km) - 2 => 3, // Session 2 (7km) -> Session 3 (10km) - _ => 5 // Fallback - }; + existingIncident.CurrentSessionNumber += 1; - int newRadius = existingIncident.CurrentRadiusKm + radiusIncrement; - - + int radiusIndex = existingIncident.CurrentSessionNumber - 1; + int newRadius = RADIUS_PROGRESSION[radiusIndex]; - // Update incident for new session - existingIncident.CurrentSessionNumber += 1; existingIncident.CurrentRadiusKm = newRadius; existingIncident.LastSessionAt = DateTime.UtcNow; @@ -219,8 +206,7 @@ public async Task> RaiseSessionRangeAsync(Ra }; await _unitOfWork.GetRepository().InsertAsync(newSession); - - + await _unitOfWork.CommitAsync(); // Reload sessions collection from DB to ensure consistency and proper order @@ -229,15 +215,12 @@ await _unitOfWork.Context.Entry(existingIncident) .LoadAsync(); var responseData = existingIncident.Adapt(); - // Manually set LocationCoordinates since Mapster doesn't handle NetTopologySuite Point - responseData.LocationCoordinates = existingIncident.LocationCoordinates; responseData.Sessions = existingIncident.Sessions .OrderBy(s => s.SessionNumber) .Select(s => s.Adapt()) .ToList(); - return ApiResponseBuilder.BuildSuccessResponse(responseData, - $"Session range expanded successfully. New radius: {newRadius}km (Session {existingIncident.CurrentSessionNumber})"); + return responseData; }); } @@ -248,7 +231,7 @@ await _unitOfWork.Context.Entry(existingIncident) } } - public async Task> UpdateSymptomReportAsync(Guid incidentId, UpdateSymptomReportRequest request) + public async Task UpdateSymptomReportAsync(Guid incidentId, UpdateSymptomReportRequest request) { try { @@ -268,7 +251,7 @@ public async Task> UpdateSymptomReportA // Calculate elapsed time from incident occurrence var currentTime = DateTime.UtcNow; - var elapsedMinutes = existingIncident.IncidentOccurredAt.HasValue + var elapsedMinutes = existingIncident.IncidentOccurredAt.HasValue ? (int)(currentTime - existingIncident.IncidentOccurredAt.Value).TotalMinutes : 0; @@ -282,7 +265,7 @@ public async Task> UpdateSymptomReportA var symptom = await _unitOfWork.GetRepository().FirstOrDefaultAsync( predicate: s => s.Id == symptomId ); - + if (symptom != null) { // Add symptom description @@ -333,9 +316,9 @@ public async Task> UpdateSymptomReportA existingIncident.SeverityLevel = severityLevel; _unitOfWork.GetRepository().Update(existingIncident); await _unitOfWork.CommitAsync(); - + var responseData = existingIncident.Adapt(); - return ApiResponseBuilder.BuildSuccessResponse(responseData, "Symptom report updated successfully!"); + return responseData; }); } catch (Exception ex) @@ -359,11 +342,169 @@ private int CalculateScoreByElapsedTime(List timeScoreList, int } // Find the TimeScorePoint where elapsedMinutes falls within MinMinutes and MaxMinutes - var matchingScore = timeScoreList.FirstOrDefault(ts => + var matchingScore = timeScoreList.FirstOrDefault(ts => elapsedMinutes >= ts.MinMinutes && elapsedMinutes <= ts.MaxMinutes ); return matchingScore?.Score ?? 0; } + + + public async Task TriggerRescueAsync(Guid incidentId) + { + try + { + // Validate incident exists and is pending + var incident = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: i => i.Id == incidentId + ); + + if (incident == null) + { + throw new NotFoundException("Incident not found."); + } + + if (incident.Status != SnakebiteIncidentStatus.Pending) + { + throw new BadRequestException($"Cannot trigger rescue for incident with status: {incident.Status}"); + } + + // Note: Actual session creation and broadcast will be handled by RescueRequestSessionService + // This method is called from Controller, which should also call RescueRequestSessionService.StartRescueSessionAsync + + return new TriggerRescueResponse + { + IncidentId = incidentId, + SessionId = Guid.Empty, // Will be set by session service + SessionNumber = 1, + RadiusKm = 10, + RescuersPinged = 0, + CreatedAt = DateTime.UtcNow, + Message = "Rescue session triggered, broadcasting to nearby rescuers." + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error triggering rescue for incident {IncidentId}: {Message}", incidentId, ex.Message); + throw; + } + } + + /// Handle rescuer accept - delegate to RescueRequestSessionService + public async Task AcceptRescueAsync(Guid requestId, Guid rescuerId) + { + try + { + // Get request to return info + var request = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: r => r.Id == requestId && r.RescuerId == rescuerId + ); + + if (request == null) + { + throw new NotFoundException("Request not found or not assigned to this rescuer."); + } + + // Note: Actual accept logic will be handled by RescueRequestSessionService.AcceptRequestAsync + // This is just validation and response building + + return new AcceptRescueResponse + { + RequestId = requestId, + IncidentId = request.IncidentId, + RescuerId = rescuerId, + MissionId = Guid.Empty, // Will be set by session service + AcceptedAt = DateTime.UtcNow, + Message = "Request accepted successfully." + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error accepting rescue request {RequestId}: {Message}", requestId, ex.Message); + throw; + } + } + + /// + /// Handle rescuer reject - delegate to RescueRequestSessionService + /// + public async Task RejectRescueAsync(Guid requestId) + { + try + { + // Get request to validate + var request = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: r => r.Id == requestId + ); + + if (request == null) + { + throw new NotFoundException("Request not found."); + } + + // Note: Actual reject logic will be handled by RescueRequestSessionService.RejectRequestAsync + + return new RejectRescueResponse + { + RequestId = requestId, + RejectedAt = DateTime.UtcNow, + Message = "Request rejected successfully." + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error rejecting rescue request {RequestId}: {Message}", requestId, ex.Message); + throw; + } + } + + /// Start rescue session for existing incident (tạo session và broadcast qua SignalR) + public async Task StartRescueAsync(Guid incidentId) + { + try + { + // Validate incident exists and is pending + var incident = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: i => i.Id == incidentId + ); + + if (incident == null) + { + throw new NotFoundException("Incident not found."); + } + + if (incident.Status != SnakebiteIncidentStatus.Pending) + { + throw new BadRequestException($"Cannot start rescue for incident with status: {incident.Status}"); + } + + // Delegate to session service to create session and broadcast + await _sessionService.StartRescueSessionAsync(incidentId); + + // Get updated incident info + var updatedIncident = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: i => i.Id == incidentId, + include: q => q.Include(i => i.Sessions.OrderByDescending(s => s.SessionNumber).Take(1)) + ); + + var latestSession = updatedIncident?.Sessions?.FirstOrDefault(); + + return new TriggerRescueResponse + { + IncidentId = incidentId, + SessionId = latestSession?.Id ?? Guid.Empty, + SessionNumber = latestSession?.SessionNumber ?? 1, + RadiusKm = latestSession?.RadiusKm ?? 10, + RescuersPinged = latestSession?.RescuersPinged ?? 0, + CreatedAt = DateTime.UtcNow, + Message = "Rescue session started, broadcasting to nearby rescuers." + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error starting rescue for incident {IncidentId}: {Message}", incidentId, ex.Message); + throw; + } + } } } diff --git a/SnakeAid.Service/Interfaces/IRescueMissionService.cs b/SnakeAid.Service/Interfaces/IRescueMissionService.cs new file mode 100644 index 00000000..0a775445 --- /dev/null +++ b/SnakeAid.Service/Interfaces/IRescueMissionService.cs @@ -0,0 +1,21 @@ +using SnakeAid.Core.Domains; +using System.Threading.Tasks; + +namespace SnakeAid.Service.Interfaces +{ + public interface IRescueMissionService + { + // Tạo mission khi rescuer accept request + Task CreateMissionAsync(Guid incidentId, Guid rescuerId, decimal price); + + // Update mission status (e.g., EnRoute, Completed) + Task UpdateMissionStatusAsync(Guid missionId, RescueMissionStatus status); + + // User cancel mission: Set status to Cancelled, no new session + Task UserCancelMissionAsync(Guid missionId, string reason); + + // Rescuer abort mission: Set status to MissionAborted, create new session with increased radius + Task RescuerAbortMissionAsync(Guid missionId, string reason); + + } +} \ No newline at end of file diff --git a/SnakeAid.Service/Interfaces/IRescueNotificationService.cs b/SnakeAid.Service/Interfaces/IRescueNotificationService.cs new file mode 100644 index 00000000..a50e5847 --- /dev/null +++ b/SnakeAid.Service/Interfaces/IRescueNotificationService.cs @@ -0,0 +1,19 @@ +using System; +using System.Threading.Tasks; + +namespace SnakeAid.Service.Interfaces +{ + public interface IRescueNotificationService + { + + bool IsRescuerConnected(string rescuerId); + + Task SendNewRequestAsync(string rescuerId, object requestData); + + Task NotifyRequestTakenAsync(string rescuerId, Guid requestId); + + Task NotifyRequestCancelledAsync(string rescuerId, Guid requestId); + + Task NotifyRequestExpiredAsync(string rescuerId, Guid requestId); + } +} diff --git a/SnakeAid.Service/Interfaces/IRescueRequestSessionService.cs b/SnakeAid.Service/Interfaces/IRescueRequestSessionService.cs index 3205e8d8..7d62fd7b 100644 --- a/SnakeAid.Service/Interfaces/IRescueRequestSessionService.cs +++ b/SnakeAid.Service/Interfaces/IRescueRequestSessionService.cs @@ -2,6 +2,7 @@ using SnakeAid.Core.Requests; using SnakeAid.Core.Responses.Auth; using SnakeAid.Core.Responses.RescueRequestSession; +using SnakeAid.Core.Domains; using System; using System.Collections.Generic; using System.Linq; @@ -12,6 +13,31 @@ namespace SnakeAid.Service.Interfaces { public interface IRescueRequestSessionService { - + // Tạo session mới cho incident (initial hoặc expand) + Task CreateSessionAsync(Guid incidentId, int sessionNumber, int radiusKm, SessionTrigger trigger); + + // Broadcast requests đến rescuers online trong radius (fanout qua SignalR) + Task BroadcastRequestsAsync(Guid sessionId); + + // Handle timeout: Mark requests expired sau 60s, check nếu cần expand/create new session + Task HandleSessionTimeoutAsync(Guid sessionId); + + // Accept request: Update RescuerRequest, tạo RescueMission, mark others Taken + Task AcceptRequestAsync(Guid requestId, Guid rescuerId); + + // Reject request: Update status + Task RejectRequestAsync(Guid requestId); + + // Cancel session (user cancel incident) + Task CancelSessionAsync(Guid sessionId); + + // Expand radius và tạo session mới nếu cần (internal call từ HandleSessionTimeout) + Task TryExpandAndCreateNewSessionAsync(Guid incidentId); + + // Start initial rescue session for incident + Task StartRescueSessionAsync(Guid incidentId); + + // Handle mission abort: Create new session with increased radius + Task HandleMissionAbortAsync(Guid incidentId); } } diff --git a/SnakeAid.Service/Interfaces/ISessionTimeoutService.cs b/SnakeAid.Service/Interfaces/ISessionTimeoutService.cs new file mode 100644 index 00000000..62936576 --- /dev/null +++ b/SnakeAid.Service/Interfaces/ISessionTimeoutService.cs @@ -0,0 +1,25 @@ +using System; +using System.Threading.Tasks; + +namespace SnakeAid.Service.Interfaces +{ + + public interface ISessionTimeoutService + { + + /// Add session to monitoring queue with timeout + void ScheduleSessionTimeout(Guid sessionId, DateTime timeoutAt); + + + /// Remove session from monitoring (when session completed/cancelled) + void CancelSessionTimeout(Guid sessionId); + + + /// Get current queue status for monitoring + (int TotalSessions, int ExpiredCount, int PendingCount) GetQueueStatus(); + + + /// Health check for the service + bool IsHealthy(); + } +} \ No newline at end of file diff --git a/SnakeAid.Service/Interfaces/ISnakebiteIncidentService.cs b/SnakeAid.Service/Interfaces/ISnakebiteIncidentService.cs index 3837df8e..4aa2d8e8 100644 --- a/SnakeAid.Service/Interfaces/ISnakebiteIncidentService.cs +++ b/SnakeAid.Service/Interfaces/ISnakebiteIncidentService.cs @@ -1,8 +1,6 @@ -using SnakeAid.Core.Meta; -using SnakeAid.Core.Requests; +using SnakeAid.Core.Requests; using SnakeAid.Core.Requests.RescueRequestSession; using SnakeAid.Core.Requests.SnakebiteIncident; -using SnakeAid.Core.Responses.Auth; using SnakeAid.Core.Responses.SnakebiteIncident; using System; using System.Collections.Generic; @@ -14,12 +12,24 @@ namespace SnakeAid.Service.Interfaces { public interface ISnakebiteIncidentService { - Task> CreateIncidentAsync(CreateIncidentRequest request, Guid userId); + Task CreateIncidentAsync(CreateIncidentRequest request, Guid userId); - Task> RaiseSessionRangeAsync(RaiseSessionRangeRequest request); + Task RaiseSessionRangeAsync(RaiseSessionRangeRequest request); - Task> UpdateSymptomReportAsync(Guid incidentId, UpdateSymptomReportRequest request); + Task UpdateSymptomReportAsync(Guid incidentId, UpdateSymptomReportRequest request); - Task> CancelIncidentAsync(Guid incidentId); + Task CancelIncidentAsync(Guid incidentId); + + // Trigger rescue: Tạo session initial, broadcast requests + Task TriggerRescueAsync(Guid incidentId); + + // Start rescue session for existing incident (separated from CreateIncident) + Task StartRescueAsync(Guid incidentId); + + // Handle rescuer accept (từ SignalR callback) + Task AcceptRescueAsync(Guid requestId, Guid rescuerId); + + // Handle rescuer reject (từ SignalR callback) + Task RejectRescueAsync(Guid requestId); } } diff --git a/SnakeAid.Service/Interfaces/ServiceInterface.cs b/SnakeAid.Service/Interfaces/ServiceInterface.cs deleted file mode 100644 index fd0ac5a5..00000000 --- a/SnakeAid.Service/Interfaces/ServiceInterface.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace SnakeAid.Service.Interfaces -{ - public interface ServiceInterface - { - - } -} \ No newline at end of file From 4d74740166686086e5b83952dc62e3deadf23f08 Mon Sep 17 00:00:00 2001 From: DuongNManh Date: Fri, 6 Feb 2026 00:31:20 +0700 Subject: [PATCH 02/31] feat: add GetDetailIncidentAsync method to retrieve detailed snakebite incident information --- .../Implements/SnakebiteIncidentService.cs | 40 ++++++++++++++++--- .../Interfaces/ISnakebiteIncidentService.cs | 2 - 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/SnakeAid.Service/Implements/SnakebiteIncidentService.cs b/SnakeAid.Service/Implements/SnakebiteIncidentService.cs index c967127a..c60a14a0 100644 --- a/SnakeAid.Service/Implements/SnakebiteIncidentService.cs +++ b/SnakeAid.Service/Implements/SnakebiteIncidentService.cs @@ -37,7 +37,6 @@ public SnakebiteIncidentService( _sessionService = sessionService; } - public async Task CancelIncidentAsync(Guid incidentId) public async Task CancelIncidentAsync(Guid incidentId) { try @@ -70,7 +69,6 @@ public async Task CancelIncidentAsync(Guid incidentId) } } - public async Task CreateIncidentAsync(CreateIncidentRequest request, Guid userId) public async Task CreateIncidentAsync(CreateIncidentRequest request, Guid userId) { try @@ -125,7 +123,6 @@ public async Task CreateIncidentAsync(CreateIncidentRequ } } - public async Task RaiseSessionRangeAsync(RaiseSessionRangeRequest request) public async Task RaiseSessionRangeAsync(RaiseSessionRangeRequest request) { try @@ -228,7 +225,41 @@ await _unitOfWork.Context.Entry(existingIncident) } } - public async Task UpdateSymptomReportAsync(Guid incidentId, UpdateSymptomReportRequest request) + public async Task GetDetailIncidentAsync(Guid incidentId) + { + try + { + return await _unitOfWork.ExecuteInTransactionAsync(async () => + { + var existingIncident = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: s => s.Id == incidentId, + include: query => query + .Include(i => i.User) + .Include(i => i.AssignedRescuer) + .Include(i => i.Sessions) + .Include(i => i.AllRequests) + .ThenInclude(r => r.Rescuer) + .Include(i => i.RescueMission) + .Include(i => i.Media) + ); + + if (existingIncident == null) + { + throw new NotFoundException("Snakebite incident not found."); + } + + var responseData = existingIncident.Adapt(); + + return responseData; + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving snakebite incident details: {Message}", ex.Message); + throw; + } + } + public async Task UpdateSymptomReportAsync(Guid incidentId, UpdateSymptomReportRequest request) { try @@ -341,7 +372,6 @@ private int CalculateScoreByElapsedTime(List timeScoreList, int } // Find the TimeScorePoint where elapsedMinutes falls within MinMinutes and MaxMinutes - var matchingScore = timeScoreList.FirstOrDefault(ts => var matchingScore = timeScoreList.FirstOrDefault(ts => elapsedMinutes >= ts.MinMinutes && elapsedMinutes <= ts.MaxMinutes ); diff --git a/SnakeAid.Service/Interfaces/ISnakebiteIncidentService.cs b/SnakeAid.Service/Interfaces/ISnakebiteIncidentService.cs index b79070c4..75c03f90 100644 --- a/SnakeAid.Service/Interfaces/ISnakebiteIncidentService.cs +++ b/SnakeAid.Service/Interfaces/ISnakebiteIncidentService.cs @@ -11,10 +11,8 @@ public interface ISnakebiteIncidentService Task GetDetailIncidentAsync(Guid incidentId); - Task RaiseSessionRangeAsync(RaiseSessionRangeRequest request); Task RaiseSessionRangeAsync(RaiseSessionRangeRequest request); - Task UpdateSymptomReportAsync(Guid incidentId, UpdateSymptomReportRequest request); Task UpdateSymptomReportAsync(Guid incidentId, UpdateSymptomReportRequest request); Task CancelIncidentAsync(Guid incidentId); From 00e681f5d8466b34f45daf1b6522b10a8a589a81 Mon Sep 17 00:00:00 2001 From: the-khiem7 Date: Fri, 6 Feb 2026 01:15:53 +0700 Subject: [PATCH 03/31] chore: remove deploy workflow and add VSCode settings for dotnet solution --- .github/workflows/deploy-docsify.yml | 44 ---------------------------- .vscode/settings.json | 3 ++ SnakeAid.Docs | 2 +- 3 files changed, 4 insertions(+), 45 deletions(-) delete mode 100644 .github/workflows/deploy-docsify.yml create mode 100644 .vscode/settings.json diff --git a/.github/workflows/deploy-docsify.yml b/.github/workflows/deploy-docsify.yml deleted file mode 100644 index 4ab10e1a..00000000 --- a/.github/workflows/deploy-docsify.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Deploy Docsify to GitHub Pages - -on: - push: - branches: [main] - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Pages - uses: actions/configure-pages@v5 - - - name: Upload Docs artifact - uses: actions/upload-pages-artifact@v3 - with: - path: docs - - deploy: - runs-on: ubuntu-latest - needs: build - environment: - name: github-pages - url: ${{ steps.deploy.outputs.page_url }} - steps: - - name: Deploy to GitHub Pages - id: deploy - uses: actions/deploy-pages@v4 - - - name: Show deployed link - run: echo "Docs available at ${{ steps.deploy.outputs.page_url }}" diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..fe46a187 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "dotnet.defaultSolution": "SnakeAid.Api/SnakeAid.Api.sln" +} \ No newline at end of file diff --git a/SnakeAid.Docs b/SnakeAid.Docs index 7377ac31..1976c6f4 160000 --- a/SnakeAid.Docs +++ b/SnakeAid.Docs @@ -1 +1 @@ -Subproject commit 7377ac31de0541886c28e33772eaac8df30f1bc5 +Subproject commit 1976c6f4e921976550de5fba46f33197db52923c From 4603bfa8ad16a02f362824225bb1d123c642b43a Mon Sep 17 00:00:00 2001 From: the-khiem7 Date: Fri, 6 Feb 2026 02:36:04 +0700 Subject: [PATCH 04/31] feat: enhance Jenkins pipeline with improved Docker build and cleanup processes --- Jenkinsfile | 199 +++++++++++++++++++++++++++++----------------------- 1 file changed, 111 insertions(+), 88 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 9d4d8749..5da8bafa 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,6 +1,6 @@ // ---------- Helpers ---------- def utcCreated() { - return sh(script: "date -u +%Y-%m-%dT%H:%M:%SZ", returnStdout: true).trim() + sh(script: "date -u +%Y-%m-%dT%H:%M:%SZ", returnStdout: true).trim() } def normalizeRepoUrl(String rawRepo) { @@ -8,30 +8,108 @@ def normalizeRepoUrl(String rawRepo) { if (repoUrl.startsWith('git@github.com:')) { repoUrl = repoUrl.replace('git@github.com:', 'https://github.com/') } - return repoUrl.replaceAll(/\.git$/, '') + repoUrl.replaceAll(/\.git$/, '') } def commitUrl(String repoUrl, String commitSha) { - return (repoUrl && commitSha) ? "${repoUrl}/commit/${commitSha}" : (repoUrl ?: '') + (repoUrl && commitSha) ? "${repoUrl}/commit/${commitSha}" : (repoUrl ?: '') +} + +def guessRefName() { + env.CHANGE_ID ? "PR-${env.CHANGE_ID}" : (env.BRANCH_NAME ?: env.GIT_BRANCH ?: 'unknown') +} + +@NonCPS +Map resolveDeployContext(String branchName, String changeId, String changeTarget) { + final boolean isPr = (changeId != null) + + if (isPr) { + if (changeTarget == 'main') return [stage: 'staging', environment: 'preview'] + if (changeTarget == 'dev') return [stage: 'development', environment: 'pr'] + return [stage: 'development', environment: 'pr'] + } + + if (branchName == 'main') return [stage: 'production', environment: 'production'] + if (branchName == 'dev') return [stage: 'staging', environment: 'staging'] + return [stage: 'development', environment: 'development'] } def ociLabelArgs(String tag) { def created = utcCreated() def repoUrl = normalizeRepoUrl(env.GIT_URL ?: '') - def url = commitUrl(repoUrl, env.GIT_COMMIT ?: '') + def url = commitUrl(repoUrl, env.GIT_COMMIT ?: '') + def refName = guessRefName() + def ctx = resolveDeployContext(env.BRANCH_NAME, env.CHANGE_ID, env.CHANGE_TARGET) + + def baseImage = "mcr.microsoft.com/dotnet/aspnet:8.0" + def docsUrl = "https://snake-aid.github.io/SnakeAid.Docs" - // IMPORTANT: must be a SINGLE LINE to avoid Jenkins `sh` interpreting `--label` as a separate command def labels = [ "org.opencontainers.image.source=${repoUrl}", "org.opencontainers.image.revision=${env.GIT_COMMIT ?: ''}", "org.opencontainers.image.url=${url}", "org.opencontainers.image.created=${created}", - "org.opencontainers.image.version=${tag}" + "org.opencontainers.image.version=${tag}", + "org.opencontainers.image.ref.name=${refName}", + + "org.opencontainers.image.title=snakeaid-api", + "org.opencontainers.image.description=SnakeAid Backend API", + "org.opencontainers.image.vendor=SnakeAid", + "org.opencontainers.image.documentation=${docsUrl}", + "org.opencontainers.image.authors=thekhiem7", + + "org.opencontainers.image.base.name=${baseImage}", + "org.opencontainers.image.build.source=jenkins", + "org.opencontainers.image.build.version=${env.JENKINS_VERSION ?: 'unknown'}", + + "org.opencontainers.image.stage=${ctx.stage}", + "org.opencontainers.image.environment=${ctx.environment}", ].collect { "--label ${it}" }.join(' ') return "-f Dockerfile ${labels} ." } +def dockerBuildOnly(String tag) { + docker.build("${env.IMAGE}:${tag}", ociLabelArgs(tag)) +} + +def dockerBuildAndPush(String tag) { + def img = docker.build("${env.IMAGE}:${tag}", ociLabelArgs(tag)) + docker.withRegistry(env.REGISTRY_URL, env.REGISTRY_CREDENTIAL) { + img.push(tag) + } +} + +@NonCPS +int resolveCacheTtlHours(String branchName, String changeId) { + final int PR_TTL_HOURS = 24 + final int DEV_TTL_HOURS = 48 + final int MAIN_TTL_HOURS = 48 + final int DEFAULT_TTL_HOURS = 24 + + if (changeId != null) return PR_TTL_HOURS + if (branchName == 'main') return MAIN_TTL_HOURS + if (branchName == 'dev') return DEV_TTL_HOURS + return DEFAULT_TTL_HOURS +} + +void dockerCleanup(int cacheTtlHours) { + sh """ + set +e + + echo "[cleanup] docker image prune (dangling only)" + docker image prune -f + + echo "[cleanup] docker builder prune (until=${cacheTtlHours}h)" + docker builder prune -f --filter "until=${cacheTtlHours}h" + + echo "[cleanup] docker container prune (stopped)" + docker container prune -f + + exit 0 + """ +} + // ---------- Pipeline ---------- pipeline { agent any @@ -42,118 +120,63 @@ pipeline { } environment { - TIME_STAMP_FORMAT = "dd-MM-yyyy HH:mm:ss" - GITHUB_PR_URL = 'https://github.com/Snake-AID/SnakeAid.Backend/pull/' - IMAGE = 'thekhiem7/snakeaid-api' + IMAGE = 'thekhiem7/snakeaid-api' REGISTRY_CREDENTIAL = 'thekhiem7-dockerhub-credentials' - REGISTRY_URL = 'https://index.docker.io/v1/' + REGISTRY_URL = 'https://index.docker.io/v1/' } stages { stage('Checkout') { - steps { - checkout scm - } + steps { checkout scm } } - stage('Build Check (PR to dev)') { - when { - expression { env.CHANGE_ID != null && env.CHANGE_TARGET == 'dev' } - } - + stage('Build Check (PR -> dev)') { + when { expression { env.CHANGE_ID != null && env.CHANGE_TARGET == 'dev' } } steps { - script { - def tag = "pr-${env.CHANGE_ID}" - docker.build("${IMAGE}:${tag}", ociLabelArgs(tag)) - - // Cleanup: remove local image after build check - sh "docker rmi ${IMAGE}:${tag} --force || true" - } + script { dockerBuildOnly("pr-${env.CHANGE_ID}") } } } stage('Publish Dev (Merged to dev)') { - when { - allOf { - branch 'dev' - not { changeRequest() } - } - } - + when { allOf { branch 'dev'; not { changeRequest() } } } steps { - script { - def tag = "dev" - def img = docker.build("${IMAGE}:${tag}", ociLabelArgs(tag)) - - docker.withRegistry(REGISTRY_URL, REGISTRY_CREDENTIAL) { - img.push() - } - - // Cleanup - sh "docker rmi ${IMAGE}:${tag} --force || true" - } + script { dockerBuildAndPush('dev') } } } stage('Publish Preview (PR to main)') { - when { - expression { env.CHANGE_ID != null && env.CHANGE_TARGET == 'main' } - } - + when { expression { env.CHANGE_ID != null && env.CHANGE_TARGET == 'main' } } steps { - script { - def tag = "preview-${env.CHANGE_ID}" - def img = docker.build("${IMAGE}:${tag}", ociLabelArgs(tag)) - - docker.withRegistry(REGISTRY_URL, REGISTRY_CREDENTIAL) { - img.push() - } - - // Cleanup - sh "docker rmi ${IMAGE}:${tag} --force || true" - } + script { dockerBuildAndPush("preview-${env.CHANGE_ID}") } } } stage('Publish Latest (Merged to main)') { - when { - allOf { - branch 'main' - not { changeRequest() } - } - } - + when { allOf { branch 'main'; not { changeRequest() } } } steps { - script { - def tag = "latest" - def img = docker.build("${IMAGE}:${tag}", ociLabelArgs(tag)) - - docker.withRegistry(REGISTRY_URL, REGISTRY_CREDENTIAL) { - img.push() - } - - // Cleanup - sh "docker rmi ${IMAGE}:${tag} --force || true" - } + script { dockerBuildAndPush('latest') } } } stage('Deploy (Portainer Webhook)') { - when { - allOf { - branch 'main' - not { changeRequest() } + when { allOf { branch 'main'; not { changeRequest() } } } + steps { + withCredentials([string(credentialsId: 'portainer-snakeaid-webhook', variable: 'PORTAINER_WEBHOOK')]) { + sh 'curl -fsS -X POST "$PORTAINER_WEBHOOK"' } } + } + } - steps { - withCredentials([ - string(credentialsId: 'portainer-snakeaid-webhook', variable: 'PORTAINER_WEBHOOK') - ]) { - sh ''' - set -e - curl -fsS -X POST "$PORTAINER_WEBHOOK" - ''' + post { + always { + script { + stage('Docker Cleanup') { + catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') { + def ttl = resolveCacheTtlHours(env.BRANCH_NAME, env.CHANGE_ID) + echo "[cleanup] ttl=${ttl}h" + dockerCleanup(ttl) + } } } } From 75ecb0944f8ebcb6a4388e053d92f75975cc472a Mon Sep 17 00:00:00 2001 From: the-khiem7 Date: Fri, 6 Feb 2026 03:15:41 +0700 Subject: [PATCH 05/31] feat: refactor Docker build and cleanup processes in Jenkins pipeline --- Jenkinsfile | 118 ++++++++++++++++++++++++++-------------------------- 1 file changed, 58 insertions(+), 60 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 5da8bafa..4edf9b34 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -66,48 +66,31 @@ def ociLabelArgs(String tag) { "org.opencontainers.image.environment=${ctx.environment}", ].collect { "--label ${it}" }.join(' ') - return "-f Dockerfile ${labels} ." + return "-f Dockerfile ${labels}" } -def dockerBuildOnly(String tag) { - docker.build("${env.IMAGE}:${tag}", ociLabelArgs(tag)) -} - -def dockerBuildAndPush(String tag) { - def img = docker.build("${env.IMAGE}:${tag}", ociLabelArgs(tag)) - docker.withRegistry(env.REGISTRY_URL, env.REGISTRY_CREDENTIAL) { - img.push(tag) +// ---------- Docker wrapper (AUTO CLEANUP) ---------- +def withDockerImage(String tag, Closure body) { + try { + body() + } finally { + sh "docker rmi ${env.IMAGE}:${tag} --force || true" } } -@NonCPS -int resolveCacheTtlHours(String branchName, String changeId) { - final int PR_TTL_HOURS = 24 - final int DEV_TTL_HOURS = 48 - final int MAIN_TTL_HOURS = 48 - final int DEFAULT_TTL_HOURS = 24 - - if (changeId != null) return PR_TTL_HOURS - if (branchName == 'main') return MAIN_TTL_HOURS - if (branchName == 'dev') return DEV_TTL_HOURS - return DEFAULT_TTL_HOURS +def dockerBuildOnly(String tag) { + withDockerImage(tag) { + docker.build("${env.IMAGE}:${tag}","${ociLabelArgs(tag)} .") + } } -void dockerCleanup(int cacheTtlHours) { - sh """ - set +e - - echo "[cleanup] docker image prune (dangling only)" - docker image prune -f - - echo "[cleanup] docker builder prune (until=${cacheTtlHours}h)" - docker builder prune -f --filter "until=${cacheTtlHours}h" - - echo "[cleanup] docker container prune (stopped)" - docker container prune -f - - exit 0 - """ +def dockerBuildAndPush(String tag) { + withDockerImage(tag) { + def img = docker.build("${env.IMAGE}:${tag}","${ociLabelArgs(tag)} .") + docker.withRegistry(env.REGISTRY_URL, env.REGISTRY_CREDENTIAL) { + img.push(tag) + } + } } // ---------- Pipeline ---------- @@ -131,54 +114,69 @@ pipeline { } stage('Build Check (PR -> dev)') { - when { expression { env.CHANGE_ID != null && env.CHANGE_TARGET == 'dev' } } + when { + expression { env.CHANGE_ID != null && env.CHANGE_TARGET == 'dev' } + } steps { - script { dockerBuildOnly("pr-${env.CHANGE_ID}") } + script { + dockerBuildOnly("pr-${env.CHANGE_ID}") + } } } stage('Publish Dev (Merged to dev)') { - when { allOf { branch 'dev'; not { changeRequest() } } } + when { + allOf { + branch 'dev' + not { changeRequest() } + } + } steps { - script { dockerBuildAndPush('dev') } + script { + dockerBuildAndPush('dev') + } } } stage('Publish Preview (PR to main)') { - when { expression { env.CHANGE_ID != null && env.CHANGE_TARGET == 'main' } } + when { + expression { env.CHANGE_ID != null && env.CHANGE_TARGET == 'main' } + } steps { - script { dockerBuildAndPush("preview-${env.CHANGE_ID}") } + script { + dockerBuildAndPush("preview-${env.CHANGE_ID}") + } } } stage('Publish Latest (Merged to main)') { - when { allOf { branch 'main'; not { changeRequest() } } } + when { + allOf { + branch 'main' + not { changeRequest() } + } + } steps { - script { dockerBuildAndPush('latest') } + script { + dockerBuildAndPush('latest') + } } } stage('Deploy (Portainer Webhook)') { - when { allOf { branch 'main'; not { changeRequest() } } } - steps { - withCredentials([string(credentialsId: 'portainer-snakeaid-webhook', variable: 'PORTAINER_WEBHOOK')]) { - sh 'curl -fsS -X POST "$PORTAINER_WEBHOOK"' + when { + allOf { + branch 'main' + not { changeRequest() } } } - } - } - - post { - always { - script { - stage('Docker Cleanup') { - catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') { - def ttl = resolveCacheTtlHours(env.BRANCH_NAME, env.CHANGE_ID) - echo "[cleanup] ttl=${ttl}h" - dockerCleanup(ttl) - } + steps { + withCredentials([ + string(credentialsId: 'portainer-snakeaid-webhook', variable: 'PORTAINER_WEBHOOK') + ]) { + sh 'curl -fsS -X POST "$PORTAINER_WEBHOOK"' } } } } -} +} \ No newline at end of file From 7cc33420f297c1e0be9f32f248d4785730938d06 Mon Sep 17 00:00:00 2001 From: the-khiem7 Date: Fri, 6 Feb 2026 03:25:11 +0700 Subject: [PATCH 06/31] hotfix(Jenkins): Convert from docker plugin into sh command to avoud buildx problem --- Jenkinsfile | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 4edf9b34..7af9f9ed 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -80,15 +80,14 @@ def withDockerImage(String tag, Closure body) { def dockerBuildOnly(String tag) { withDockerImage(tag) { - docker.build("${env.IMAGE}:${tag}","${ociLabelArgs(tag)} .") + sh "docker buildx build -t ${env.IMAGE}:${tag} ${ociLabelArgs(tag)} --load ." } } def dockerBuildAndPush(String tag) { withDockerImage(tag) { - def img = docker.build("${env.IMAGE}:${tag}","${ociLabelArgs(tag)} .") docker.withRegistry(env.REGISTRY_URL, env.REGISTRY_CREDENTIAL) { - img.push(tag) + sh "docker buildx build -t ${env.IMAGE}:${tag} ${ociLabelArgs(tag)} --push ." } } } From 95449822b4aeafa772655c9b6d668d8e2b6e1c7a Mon Sep 17 00:00:00 2001 From: the-khiem7 Date: Fri, 6 Feb 2026 03:53:43 +0700 Subject: [PATCH 07/31] fix: update Docker build commands to use docker.build for improved compatibility --- Jenkinsfile | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 7af9f9ed..129b8d76 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -64,7 +64,7 @@ def ociLabelArgs(String tag) { "org.opencontainers.image.stage=${ctx.stage}", "org.opencontainers.image.environment=${ctx.environment}", - ].collect { "--label ${it}" }.join(' ') + ].collect { "--label \"${it}\"" }.join(' ') return "-f Dockerfile ${labels}" } @@ -80,14 +80,15 @@ def withDockerImage(String tag, Closure body) { def dockerBuildOnly(String tag) { withDockerImage(tag) { - sh "docker buildx build -t ${env.IMAGE}:${tag} ${ociLabelArgs(tag)} --load ." + docker.build("${env.IMAGE}:${tag}","${ociLabelArgs(tag)} .") } } def dockerBuildAndPush(String tag) { withDockerImage(tag) { + def img = docker.build("${env.IMAGE}:${tag}","${ociLabelArgs(tag)} .") docker.withRegistry(env.REGISTRY_URL, env.REGISTRY_CREDENTIAL) { - sh "docker buildx build -t ${env.IMAGE}:${tag} ${ociLabelArgs(tag)} --push ." + img.push(tag) } } } From 32e5707128ba570a25ac730acba28e7881e0320e Mon Sep 17 00:00:00 2001 From: DuongNManh Date: Fri, 6 Feb 2026 17:45:15 +0700 Subject: [PATCH 08/31] feat: add CancelIncident endpoint to allow cancellation of snakebite incidents --- .../SnakebiteIncidentController.cs | 11 + SnakeAid.Api/Hubs/RescuerHub.cs | 25 -- .../Implements/RescueRequestSessionService.cs | 111 +++--- .../SessionTimeoutBackgroundService.cs | 9 +- .../Implements/SnakebiteIncidentService.cs | 340 ++++++++---------- .../IRescueRequestSessionService.cs | 4 +- .../Interfaces/ISnakebiteIncidentService.cs | 2 - 7 files changed, 229 insertions(+), 273 deletions(-) diff --git a/SnakeAid.Api/Controllers/SnakebiteIncidentController.cs b/SnakeAid.Api/Controllers/SnakebiteIncidentController.cs index 2a817fdc..53635174 100644 --- a/SnakeAid.Api/Controllers/SnakebiteIncidentController.cs +++ b/SnakeAid.Api/Controllers/SnakebiteIncidentController.cs @@ -102,5 +102,16 @@ public async Task UpdateSymptomReport(Guid incidentId, [FromBody] var result = await _incidentService.UpdateSymptomReportAsync(incidentId, request); return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Symptom report updated successfully!")); } + + [HttpPut("{incidentId}/cancel")] + [SwaggerOperation(Summary = "Cancel Incident", Description = "Cancel a snakebite incident if it is in Pending or Assigned status")] + [SwaggerResponse(200, "Incident cancelled successfully", typeof(ApiResponse))] + [SwaggerResponse(404, "Incident not found")] + [SwaggerResponse(422, "Validation error")] + public async Task CancelIncident(Guid incidentId) + { + var result = await _incidentService.CancelIncidentAsync(incidentId); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Incident cancelled successfully!")); + } } } diff --git a/SnakeAid.Api/Hubs/RescuerHub.cs b/SnakeAid.Api/Hubs/RescuerHub.cs index 264bd95c..c1263fe1 100644 --- a/SnakeAid.Api/Hubs/RescuerHub.cs +++ b/SnakeAid.Api/Hubs/RescuerHub.cs @@ -70,31 +70,6 @@ public async Task AcceptRequest(Guid requestId, Guid rescuerId) } } - /// Rescuer reject request - public async Task RejectRequest(Guid requestId) - { - try - { - await _sessionService.RejectRequestAsync(requestId); - - await Clients.Caller.SendAsync("RequestRejected", new - { - RequestId = requestId, - Message = "Request rejected." - }); - - _logger.LogInformation("Request {RequestId} rejected", requestId); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error rejecting request {RequestId}: {Message}", requestId, ex.Message); - await Clients.Caller.SendAsync("RequestError", new - { - RequestId = requestId, - Error = ex.Message - }); - } - } public async Task UpdateLocation(string userId, double latitude, double longitude) { diff --git a/SnakeAid.Service/Implements/RescueRequestSessionService.cs b/SnakeAid.Service/Implements/RescueRequestSessionService.cs index 30184377..539a67b9 100644 --- a/SnakeAid.Service/Implements/RescueRequestSessionService.cs +++ b/SnakeAid.Service/Implements/RescueRequestSessionService.cs @@ -130,6 +130,14 @@ await _unitOfWork.ExecuteInTransactionAsync(async () => throw new NotFoundException("Session not found."); } + // Validate session is still active before broadcasting + if (session.Status != SessionStatus.Active) + { + _logger.LogWarning("Cannot broadcast requests for session {SessionId} with status {Status}", + sessionId, session.Status); + throw new BadRequestException($"Cannot broadcast requests for session with status: {session.Status}"); + } + var incident = session.Incident; // Query rescuers online trong radius bằng PostGIS @@ -145,18 +153,42 @@ await _unitOfWork.ExecuteInTransactionAsync(async () => return session; } + // Get rescuer IDs for filtering + var rescuerIds = rescuersInRadius.Select(r => r.AccountId).ToList(); + + // Query existing pending requests for these rescuers to avoid double-ping + var rescuersWithPending = await _unitOfWork.GetRepository() + .CreateBaseQuery() + .Where(r => rescuerIds.Contains(r.RescuerId) && r.Status == RescueRequestStatus.Pending) + .Select(r => r.RescuerId) + .Distinct() + .ToListAsync(); + + var rescuersWithPendingSet = new HashSet(rescuersWithPending); + var expiredAt = DateTime.UtcNow.AddSeconds(REQUEST_TIMEOUT_SECONDS); var requests = new List(); - // Build all RescuerRequest objects + // only for rescuers without any pending request foreach (var rescuer in rescuersInRadius) { + var rescuerId = rescuer.AccountId; + + // Skip if rescuer already has a pending request (preserve existing session) + if (rescuersWithPendingSet.Contains(rescuerId)) + { + _logger.LogDebug( + "Skipping rescuer {RescuerId} - already has pending request for another incident (preserving existing session)", + rescuerId); + continue; + } + requests.Add(new RescuerRequest { Id = Guid.NewGuid(), SessionId = sessionId, IncidentId = incident.Id, - RescuerId = rescuer.AccountId, + RescuerId = rescuerId, Status = RescueRequestStatus.Pending, RequestSentAt = DateTime.UtcNow, ExpiredAt = expiredAt, @@ -224,9 +256,9 @@ private async Task> GetRescuersInRadiusAsync(Point incident return connectedRescuers; } - /// + /// Push request đến rescuer qua notification service - /// + private async Task SendRequestToRescuerAsync(string userId, RescuerRequest request, RescueRequestSession session) { await _notificationService.SendNewRequestAsync(userId, new @@ -290,6 +322,15 @@ await _unitOfWork.ExecuteInTransactionAsync(async () => _logger.LogInformation("Session {SessionId} timed out, {Count} requests expired", sessionId, pendingRequests.Count); + // Notify all rescuers that their requests have expired (parallel notifications) + if (pendingRequests.Any()) + { + var expiredNotificationTasks = pendingRequests.Select(request => + NotifyRequestExpiredAsync(request.RescuerId.ToString(), request.Id) + ); + await Task.WhenAll(expiredNotificationTasks); + } + // Try expand and create new session await TryExpandAndCreateNewSessionAsync(session.IncidentId); @@ -418,59 +459,23 @@ await _unitOfWork.ExecuteInTransactionAsync(async () => } } - /// + /// Notify rescuer that request was taken by someone else - /// + private async Task NotifyRequestTakenAsync(string userId, Guid requestId) { await _notificationService.NotifyRequestTakenAsync(userId, requestId); } - /// - /// Reject request: Update status - /// - public async Task RejectRequestAsync(Guid requestId) - { - try - { - await _unitOfWork.ExecuteInTransactionAsync(async () => - { - var request = await _unitOfWork.GetRepository().FirstOrDefaultAsync( - predicate: r => r.Id == requestId - ); - - if (request == null) - { - throw new NotFoundException("Request not found."); - } - - if (request.Status != RescueRequestStatus.Pending) - { - throw new BadRequestException($"Cannot reject request with status: {request.Status}"); - } - - request.Status = RescueRequestStatus.Rejected; - request.ResponseAt = DateTime.UtcNow; - request.UpdatedAt = DateTime.UtcNow; - - _unitOfWork.GetRepository().Update(request); - await _unitOfWork.CommitAsync(); - - _logger.LogInformation("Request {RequestId} rejected", requestId); - return request; - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error rejecting request {RequestId}: {Message}", requestId, ex.Message); - throw; - } + /// Notify rescuer that request has expired + private async Task NotifyRequestExpiredAsync(string userId, Guid requestId) + { + await _notificationService.NotifyRequestExpiredAsync(userId, requestId); } - /// + /// Cancel session (user cancel incident) - /// public async Task CancelSessionAsync(Guid sessionId) { try @@ -519,17 +524,15 @@ await _unitOfWork.ExecuteInTransactionAsync(async () => } } - /// + /// Notify rescuer that request was cancelled - /// private async Task NotifyRequestCancelledAsync(string userId, Guid requestId) { await _notificationService.NotifyRequestCancelledAsync(userId, requestId); } - /// + /// Expand radius và tạo session mới nếu cần - /// public async Task TryExpandAndCreateNewSessionAsync(Guid incidentId) { try @@ -593,9 +596,8 @@ public async Task TryExpandAndCreateNewSessionAsync(Guid incidentId) } } - /// + /// Start initial rescue session for incident (called from SnakebiteIncidentService) - /// public async Task StartRescueSessionAsync(Guid incidentId) { var initialRadius = RADIUS_PROGRESSION[0]; // 10km @@ -603,10 +605,9 @@ public async Task StartRescueSessionAsync(Guid incidentId) await BroadcastRequestsAsync(session.Id); } - /// + /// Handle mission abort: Create new session with increased radius /// Called when rescuer aborts mission (after accepting request) - /// public async Task HandleMissionAbortAsync(Guid incidentId) { try diff --git a/SnakeAid.Service/Implements/SessionTimeoutBackgroundService.cs b/SnakeAid.Service/Implements/SessionTimeoutBackgroundService.cs index e8403d4f..69e31b0b 100644 --- a/SnakeAid.Service/Implements/SessionTimeoutBackgroundService.cs +++ b/SnakeAid.Service/Implements/SessionTimeoutBackgroundService.cs @@ -178,8 +178,13 @@ private async Task ProcessExpiredSessions() { try { - // Remove from monitoring queue first - _sessionTimeouts.TryRemove(sessionId, out _); + // Check if session was rescheduled after we selected it + if (_sessionTimeouts.TryGetValue(sessionId, out var newTimeout) && newTimeout > currentTime) + { + _logger.LogDebug("Session {SessionId} was rescheduled to {NewTimeout}; skipping timeout processing", + sessionId, newTimeout); + continue; + } // Handle the session timeout (includes expanding to new session if possible) await sessionService.HandleSessionTimeoutAsync(sessionId); diff --git a/SnakeAid.Service/Implements/SnakebiteIncidentService.cs b/SnakeAid.Service/Implements/SnakebiteIncidentService.cs index c60a14a0..25fac5ec 100644 --- a/SnakeAid.Service/Implements/SnakebiteIncidentService.cs +++ b/SnakeAid.Service/Implements/SnakebiteIncidentService.cs @@ -50,7 +50,7 @@ public async Task CancelIncidentAsync(Guid incidentId) { throw new NotFoundException("Snakebite incident not found."); } - // Validate incident status - only allow cancelling for Pending incidents + // only in Pending or Assigned status can be cancelled if (existingIncident.Status != SnakebiteIncidentStatus.Pending && existingIncident.Status != SnakebiteIncidentStatus.Assigned) { throw new BadRequestException($"Cannot cancel incident with status: {existingIncident.Status}"); @@ -133,90 +133,88 @@ public async Task RaiseSessionRangeAsync(RaiseSessionRan } return await _unitOfWork.ExecuteInTransactionAsync(async () => - { - // Load incident with all required navigation properties - var existingIncident = await _unitOfWork.GetRepository().FirstOrDefaultAsync( - predicate: s => s.Id == request.IncidentId, - include: query => query - .Include(i => i.Sessions) - .Include(i => i.User) - ); - - if (existingIncident == null) - { - throw new NotFoundException("Snakebite incident not found."); - } - - // Validate incident status - only allow raising range for Pending incidents - if (existingIncident.Status != SnakebiteIncidentStatus.Pending) { - throw new BadRequestException($"Cannot raise session range for incident with status: {existingIncident.Status}"); - } + // Load incident with all required navigation properties + var existingIncident = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: s => s.Id == request.IncidentId, + include: query => query + .Include(i => i.Sessions) + ); - // Close current session as Failed - var currentSession = existingIncident.Sessions - .FirstOrDefault(s => s.SessionNumber == existingIncident.CurrentSessionNumber); + if (existingIncident == null) + { + throw new NotFoundException("Snakebite incident not found."); + } - if (currentSession != null) - { - currentSession.Status = SessionStatus.Failed; - currentSession.CompletedAt = DateTime.UtcNow; - _unitOfWork.GetRepository().Update(currentSession); - } + // Validate incident status - only allow raising range for Pending incidents + if (existingIncident.Status != SnakebiteIncidentStatus.Pending) + { + throw new BadRequestException($"Cannot raise session range for incident with status: {existingIncident.Status}"); + } - // Check if maximum sessions reached (max 3 sessions) - // Check max session (trước khi tăng) - if (existingIncident.CurrentSessionNumber >= RADIUS_PROGRESSION.Length) - { - existingIncident.Status = SnakebiteIncidentStatus.NoRescuerFound; - existingIncident.LastSessionAt = DateTime.UtcNow; - _unitOfWork.GetRepository().Update(existingIncident); - await _unitOfWork.CommitAsync(); + // Close current session as Failed + var currentSession = existingIncident.Sessions + .FirstOrDefault(s => s.SessionNumber == existingIncident.CurrentSessionNumber); - throw new BadRequestException( - $"Maximum session range expansions reached ({RADIUS_PROGRESSION.Length} sessions). No rescuers found." - ); - } + if (currentSession != null) + { + currentSession.Status = SessionStatus.Failed; + currentSession.CompletedAt = DateTime.UtcNow; + _unitOfWork.GetRepository().Update(currentSession); + } - existingIncident.CurrentSessionNumber += 1; + // Check if maximum sessions reached (max 3 sessions) + // Check max session (trước khi tăng) + if (existingIncident.CurrentSessionNumber >= RADIUS_PROGRESSION.Length) + { + existingIncident.Status = SnakebiteIncidentStatus.NoRescuerFound; + existingIncident.LastSessionAt = DateTime.UtcNow; + _unitOfWork.GetRepository().Update(existingIncident); + await _unitOfWork.CommitAsync(); - int radiusIndex = existingIncident.CurrentSessionNumber - 1; - int newRadius = RADIUS_PROGRESSION[radiusIndex]; + throw new BadRequestException( + $"Maximum session range expansions reached ({RADIUS_PROGRESSION.Length} sessions). No rescuers found." + ); + } - existingIncident.CurrentRadiusKm = newRadius; - existingIncident.LastSessionAt = DateTime.UtcNow; + existingIncident.CurrentSessionNumber += 1; - // Create new session - var newSession = new RescueRequestSession - { - Id = Guid.NewGuid(), - IncidentId = existingIncident.Id, - SessionNumber = existingIncident.CurrentSessionNumber, - RadiusKm = existingIncident.CurrentRadiusKm, - Status = SessionStatus.Active, - CreatedAt = DateTime.UtcNow, - TriggerType = SessionTrigger.RadiusExpanded, - RescuersPinged = 0 - }; + int radiusIndex = existingIncident.CurrentSessionNumber - 1; + int newRadius = RADIUS_PROGRESSION[radiusIndex]; - await _unitOfWork.GetRepository().InsertAsync(newSession); + existingIncident.CurrentRadiusKm = newRadius; + existingIncident.LastSessionAt = DateTime.UtcNow; - await _unitOfWork.CommitAsync(); + // Create new session + var newSession = new RescueRequestSession + { + Id = Guid.NewGuid(), + IncidentId = existingIncident.Id, + SessionNumber = existingIncident.CurrentSessionNumber, + RadiusKm = existingIncident.CurrentRadiusKm, + Status = SessionStatus.Active, + CreatedAt = DateTime.UtcNow, + TriggerType = SessionTrigger.RadiusExpanded, + RescuersPinged = 0 + }; + + await _unitOfWork.GetRepository().InsertAsync(newSession); - // Reload sessions collection from DB to ensure consistency and proper order - await _unitOfWork.Context.Entry(existingIncident) - .Collection(i => i.Sessions) - .LoadAsync(); + await _unitOfWork.CommitAsync(); - var responseData = existingIncident.Adapt(); - responseData.Sessions = existingIncident.Sessions - .OrderBy(s => s.SessionNumber) - .Select(s => s.Adapt()) - .ToList(); + // Reload sessions collection from DB to ensure consistency and proper order + await _unitOfWork.Context.Entry(existingIncident) + .Collection(i => i.Sessions) + .LoadAsync(); - return responseData; - }); + var responseData = existingIncident.Adapt(); + responseData.Sessions = existingIncident.Sessions + .OrderBy(s => s.SessionNumber) + .Select(s => s.Adapt()) + .ToList(); + return responseData; + }); } catch (Exception ex) { @@ -230,28 +228,28 @@ public async Task GetDetailIncidentAsync(Guid in try { return await _unitOfWork.ExecuteInTransactionAsync(async () => - { - var existingIncident = await _unitOfWork.GetRepository().FirstOrDefaultAsync( - predicate: s => s.Id == incidentId, - include: query => query - .Include(i => i.User) - .Include(i => i.AssignedRescuer) - .Include(i => i.Sessions) - .Include(i => i.AllRequests) - .ThenInclude(r => r.Rescuer) - .Include(i => i.RescueMission) - .Include(i => i.Media) - ); - - if (existingIncident == null) { - throw new NotFoundException("Snakebite incident not found."); - } + var existingIncident = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: s => s.Id == incidentId, + include: query => query + .Include(i => i.User) + .Include(i => i.AssignedRescuer) + .Include(i => i.Sessions) + .Include(i => i.AllRequests) + .ThenInclude(r => r.Rescuer) + .Include(i => i.RescueMission) + .Include(i => i.Media) + ); + + if (existingIncident == null) + { + throw new NotFoundException("Snakebite incident not found."); + } - var responseData = existingIncident.Adapt(); + var responseData = existingIncident.Adapt(); - return responseData; - }); + return responseData; + }); } catch (Exception ex) { @@ -270,86 +268,86 @@ public async Task UpdateSymptomReportAsync(Guid inc } return await _unitOfWork.ExecuteInTransactionAsync(async () => - { - var existingIncident = await _unitOfWork.GetRepository().FirstOrDefaultAsync( - predicate: s => s.Id == incidentId - ); - if (existingIncident == null) { - throw new NotFoundException("Snakebite incident not found."); - } - - // Calculate elapsed time from incident occurrence - var currentTime = DateTime.UtcNow; - var elapsedMinutes = existingIncident.IncidentOccurredAt.HasValue - ? (int)(currentTime - existingIncident.IncidentOccurredAt.Value).TotalMinutes - : 0; + var existingIncident = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: s => s.Id == incidentId + ); + if (existingIncident == null) + { + throw new NotFoundException("Snakebite incident not found."); + } - // Collect symptom descriptions and calculate severity - var symptomDescriptions = new List(); - var coreSymptomScores = new List(); - var modifierSymptomScores = new List(); + // Calculate elapsed time from incident occurrence + var currentTime = DateTime.UtcNow; + var elapsedMinutes = existingIncident.IncidentOccurredAt.HasValue + ? (int)(currentTime - existingIncident.IncidentOccurredAt.Value).TotalMinutes + : 0; - foreach (var symptomId in request.SymptomIdList) - { - var symptom = await _unitOfWork.GetRepository().FirstOrDefaultAsync( - predicate: s => s.Id == symptomId - ); + // Collect symptom descriptions and calculate severity + var symptomDescriptions = new List(); + var coreSymptomScores = new List(); + var modifierSymptomScores = new List(); - if (symptom != null) + foreach (var symptomId in request.SymptomIdList) { - // Add symptom description - if (!string.IsNullOrEmpty(symptom.Description)) - { - symptomDescriptions.Add(symptom.Description); - } - - // Calculate score based on TimeScoreList - var score = CalculateScoreByElapsedTime(symptom.TimeScoreList, elapsedMinutes); + var symptom = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: s => s.Id == symptomId + ); - // Categorize by symptom category - if (symptom.Category == SymptomCategory.Core) + if (symptom != null) { - coreSymptomScores.Add(score); - } - else if (symptom.Category == SymptomCategory.Modifier) - { - modifierSymptomScores.Add(score); + // Add symptom description + if (!string.IsNullOrEmpty(symptom.Description)) + { + symptomDescriptions.Add(symptom.Description); + } + + // Calculate score based on TimeScoreList + var score = CalculateScoreByElapsedTime(symptom.TimeScoreList, elapsedMinutes); + + // Categorize by symptom category + if (symptom.Category == SymptomCategory.Core) + { + coreSymptomScores.Add(score); + } + else if (symptom.Category == SymptomCategory.Modifier) + { + modifierSymptomScores.Add(score); + } } } - } - // Calculate severity level - // Core: take maximum score - var severityLevel = 0; - if (coreSymptomScores.Any()) - { - severityLevel = coreSymptomScores.Max(); - } + // Calculate severity level + // Core: take maximum score + var severityLevel = 0; + if (coreSymptomScores.Any()) + { + severityLevel = coreSymptomScores.Max(); + } - // Modifier: sum all scores - if (modifierSymptomScores.Any()) - { - severityLevel += modifierSymptomScores.Sum(); - } + // Modifier: sum all scores + if (modifierSymptomScores.Any()) + { + severityLevel += modifierSymptomScores.Sum(); + } - if (severityLevel > 100) - severityLevel = 100; + if (severityLevel > 100) + severityLevel = 100; - // Update symptom report and severity level - var jsonOptions = new System.Text.Json.JsonSerializerOptions - { - Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - WriteIndented = false - }; - existingIncident.SymptomsReport = System.Text.Json.JsonSerializer.Serialize(symptomDescriptions, jsonOptions); - existingIncident.SeverityLevel = severityLevel; - _unitOfWork.GetRepository().Update(existingIncident); - await _unitOfWork.CommitAsync(); + // Update symptom report and severity level + var jsonOptions = new System.Text.Json.JsonSerializerOptions + { + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + WriteIndented = false + }; + existingIncident.SymptomsReport = System.Text.Json.JsonSerializer.Serialize(symptomDescriptions, jsonOptions); + existingIncident.SeverityLevel = severityLevel; + _unitOfWork.GetRepository().Update(existingIncident); + await _unitOfWork.CommitAsync(); - var responseData = existingIncident.Adapt(); - return responseData; - }); + var responseData = existingIncident.Adapt(); + return responseData; + }); } catch (Exception ex) { @@ -455,38 +453,6 @@ public async Task AcceptRescueAsync(Guid requestId, Guid r } } - /// - /// Handle rescuer reject - delegate to RescueRequestSessionService - /// - public async Task RejectRescueAsync(Guid requestId) - { - try - { - // Get request to validate - var request = await _unitOfWork.GetRepository().FirstOrDefaultAsync( - predicate: r => r.Id == requestId - ); - - if (request == null) - { - throw new NotFoundException("Request not found."); - } - - // Note: Actual reject logic will be handled by RescueRequestSessionService.RejectRequestAsync - - return new RejectRescueResponse - { - RequestId = requestId, - RejectedAt = DateTime.UtcNow, - Message = "Request rejected successfully." - }; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error rejecting rescue request {RequestId}: {Message}", requestId, ex.Message); - throw; - } - } /// Start rescue session for existing incident (tạo session và broadcast qua SignalR) public async Task StartRescueAsync(Guid incidentId) diff --git a/SnakeAid.Service/Interfaces/IRescueRequestSessionService.cs b/SnakeAid.Service/Interfaces/IRescueRequestSessionService.cs index 7d62fd7b..5b40e817 100644 --- a/SnakeAid.Service/Interfaces/IRescueRequestSessionService.cs +++ b/SnakeAid.Service/Interfaces/IRescueRequestSessionService.cs @@ -25,8 +25,8 @@ public interface IRescueRequestSessionService // Accept request: Update RescuerRequest, tạo RescueMission, mark others Taken Task AcceptRequestAsync(Guid requestId, Guid rescuerId); - // Reject request: Update status - Task RejectRequestAsync(Guid requestId); + // Reject removed: Rescuers cannot reject due to emergency nature - requests timeout automatically + // Task RejectRequestAsync(Guid requestId); // Cancel session (user cancel incident) Task CancelSessionAsync(Guid sessionId); diff --git a/SnakeAid.Service/Interfaces/ISnakebiteIncidentService.cs b/SnakeAid.Service/Interfaces/ISnakebiteIncidentService.cs index 75c03f90..382666f2 100644 --- a/SnakeAid.Service/Interfaces/ISnakebiteIncidentService.cs +++ b/SnakeAid.Service/Interfaces/ISnakebiteIncidentService.cs @@ -26,7 +26,5 @@ public interface ISnakebiteIncidentService // Handle rescuer accept (từ SignalR callback) Task AcceptRescueAsync(Guid requestId, Guid rescuerId); - // Handle rescuer reject (từ SignalR callback) - Task RejectRescueAsync(Guid requestId); } } From 305d914a5b07d465821a5b54027ad329053c1b11 Mon Sep 17 00:00:00 2001 From: the-khiem7 Date: Fri, 6 Feb 2026 20:54:47 +0700 Subject: [PATCH 09/31] chore: update subproject commit reference in SnakeAid.Docs --- SnakeAid.Docs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SnakeAid.Docs b/SnakeAid.Docs index 1976c6f4..0cc11121 160000 --- a/SnakeAid.Docs +++ b/SnakeAid.Docs @@ -1 +1 @@ -Subproject commit 1976c6f4e921976550de5fba46f33197db52923c +Subproject commit 0cc11121118d6417e32a180f42ac45340607b915 From c07587ebf5bb28d59c855360ff7e778413bd8176 Mon Sep 17 00:00:00 2001 From: the-khiem7 Date: Sat, 7 Feb 2026 15:14:44 +0700 Subject: [PATCH 10/31] feat: centralize Doppler configuration management and enhance app settings integration --- .vscode/settings.json | 40 ++++++- Directory.Packages.props | 1 + SnakeAid.Api/DI/DependencyInjection.cs | 111 ++++++++++++++++++++ SnakeAid.Api/Program.cs | 5 +- SnakeAid.Api/Properties/launchSettings.json | 21 ++-- SnakeAid.Api/SnakeAid.Api.csproj | 1 + SnakeAid.Core/Exceptions/ApiException.cs | 8 ++ SnakeAid.Core/Settings/AppSettings.cs | 9 +- 8 files changed, 187 insertions(+), 9 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index fe46a187..2937b7b8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,41 @@ { - "dotnet.defaultSolution": "SnakeAid.Api/SnakeAid.Api.sln" + "dotnet.defaultSolution": "SnakeAid.Backend.sln", + "files.exclude": { + "**/bin": false, + "**/obj": false, + "**/Properties": false, + ".vscode": false, + "**/node_modules": false, + "**/packages": false, + "**/.vs": false, + "**/dist": false, + "**/coverage": false, + "**/artifacts": false, + "**/logs": false, + "**/*.dll": false, + "**/*.pdb": false, + "**/*.exe": false, + "**/*.nupkg": false, + "**/*.zip": false, + "**/*.tar.gz": false, + "**/*.so": false, + "**/*.dylib": false, + "**/*.pyc": false, + "**/*.db": false, + "**/*.sqlite": false + }, + "search.exclude": { + "**/bin": true, + "**/obj": true, + "**/node_modules": true, + "**/.vs": true, + "**/dist": true + }, + "files.watcherExclude": { + "**/bin/**": true, + "**/obj/**": true, + "**/.git/**": true, + "**/node_modules/**": true, + "**/.vs/**": true + } } \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 68dcef3a..6d8c3c25 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,6 +3,7 @@ true + diff --git a/SnakeAid.Api/DI/DependencyInjection.cs b/SnakeAid.Api/DI/DependencyInjection.cs index 093f3551..ba3cc200 100644 --- a/SnakeAid.Api/DI/DependencyInjection.cs +++ b/SnakeAid.Api/DI/DependencyInjection.cs @@ -13,12 +13,121 @@ using System.Security.Claims; using System.Text; +using Serilog; +using Doppler.Extensions.Configuration; // Ensure this is available + namespace SnakeAid.Api.DI; public static class DependencyInjection { #region service he thong + #region AppSettings + public static WebApplicationBuilder AddAppsettings(this WebApplicationBuilder builder) + { + var dopplerAccessToken = LoadDopplerToken(); + + var configStrategy = new ConfigControl + { + UseDoppler = Environment.GetEnvironmentVariable("SNAKEAID_CONF_USE_DOPPLER") != "false", + UseAppSettings = Environment.GetEnvironmentVariable("SNAKEAID_CONF_USE_APPSETTINGS") != "false", + Priority = Environment.GetEnvironmentVariable("SNAKEAID_CONF_PRIORITY")?.ToUpper() ?? "DOPPLER" + }; + + ManageAppSettings(builder, configStrategy); + ManageDoppler(builder, configStrategy, dopplerAccessToken); + ValidateConfiguration(configStrategy); + + return builder; + } + + private static string? LoadDopplerToken() + { + var token = Environment.GetEnvironmentVariable("DOPPLER_TOKEN"); + if (!string.IsNullOrEmpty(token)) return token; + + token = Environment.GetEnvironmentVariable("DOPPLER_TOKEN", EnvironmentVariableTarget.User); + if (string.IsNullOrEmpty(token)) return null; + + Environment.SetEnvironmentVariable("DOPPLER_TOKEN", token, EnvironmentVariableTarget.Process); + + // Propagate other user env vars + foreach (System.Collections.DictionaryEntry userEnvVar in Environment.GetEnvironmentVariables(EnvironmentVariableTarget.User)) + { + if (userEnvVar.Key is string key && userEnvVar.Value is string value && key != "DOPPLER_TOKEN") + { + Environment.SetEnvironmentVariable(key, value, EnvironmentVariableTarget.Process); + } + } + + return token; + } + + private static void ManageAppSettings(WebApplicationBuilder builder, ConfigControl strategy) + { + if (strategy.UseAppSettings) + { + Serilog.Log.Information("✅ Configuration Source: AppSettings (ENABLED)"); + return; + } + + var jsonSourcesToDiscard = builder.Configuration.Sources + .Where(source => source is Microsoft.Extensions.Configuration.Json.JsonConfigurationSource) + .ToList(); + + foreach (var source in jsonSourcesToDiscard) + { + builder.Configuration.Sources.Remove(source); + } + Serilog.Log.Information("🚫 Configuration Source: AppSettings (DISABLED via Env Var)"); + } + + private static void ManageDoppler(WebApplicationBuilder builder, ConfigControl strategy, string? token) + { + if (!strategy.UseDoppler) + { + Serilog.Log.Information("🚫 Configuration Source: Doppler (DISABLED via Env Var)"); + return; + } + + if (string.IsNullOrEmpty(token)) + { + Serilog.Log.Warning("⚠️ Doppler enabled but DOPPLER_TOKEN is missing."); + return; + } + + builder.Configuration.AddDoppler(token); + + if (strategy.Priority == "APPSETTINGS" && strategy.UseAppSettings) + { + var jsonSourcesToReorder = builder.Configuration.Sources + .Where(source => source is Microsoft.Extensions.Configuration.Json.JsonConfigurationSource) + .ToList(); + + foreach (var source in jsonSourcesToReorder) + { + builder.Configuration.Sources.Remove(source); + builder.Configuration.Sources.Add(source); + } + Serilog.Log.Information("✅ Configuration Source: Doppler (ENABLED, Priority: LOW)"); + } + else + { + Serilog.Log.Information("✅ Configuration Source: Doppler (ENABLED, Priority: HIGH)"); + } + } + + private static void ValidateConfiguration(ConfigControl strategy) + { + if (!strategy.UseAppSettings && !strategy.UseDoppler) + { + throw new Core.Exceptions.ConfigurationException("❌ Critical Error: All configuration sources (Doppler and AppSettings) are DISABLED."); + } + } + + #endregion + + #region Services public static IServiceCollection AddServices(this IServiceCollection services, IConfiguration configuration) { var cloudinarySection = configuration.GetSection("Cloudinary"); @@ -77,6 +186,8 @@ private static IAsyncPolicy GetCircuitBreakerPolicy() #endregion + #endregion + #region authen vs author public static IServiceCollection AddAuthenticateAuthor(this IServiceCollection services, diff --git a/SnakeAid.Api/Program.cs b/SnakeAid.Api/Program.cs index 914685d8..bf871b81 100644 --- a/SnakeAid.Api/Program.cs +++ b/SnakeAid.Api/Program.cs @@ -17,6 +17,7 @@ using SQLitePCL; using Swashbuckle.AspNetCore.SwaggerUI; using System.Text.Json.Serialization; +using Doppler.Extensions.Configuration; namespace SnakeAid.Api { @@ -33,6 +34,8 @@ public static async Task Main(string[] args) { var builder = WebApplication.CreateBuilder(args); + builder.AddAppsettings(); + Batteries_V2.Init(); var sqliteLogPath = Path.Combine(builder.Environment.ContentRootPath, "logs", "logs.db"); @@ -149,7 +152,7 @@ public static async Task Main(string[] args) }); builder.Services.AddControllers(); - + // Add Razor Pages for lightweight UI admin pages builder.Services.AddRazorPages(); diff --git a/SnakeAid.Api/Properties/launchSettings.json b/SnakeAid.Api/Properties/launchSettings.json index 88511877..b8acf112 100644 --- a/SnakeAid.Api/Properties/launchSettings.json +++ b/SnakeAid.Api/Properties/launchSettings.json @@ -13,28 +13,37 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "swagger", + "launchUrl": "", "applicationUrl": "http://localhost:5009", "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" + "ASPNETCORE_ENVIRONMENT": "Development", + "SNAKEAID_CONF_USE_DOPPLER": "true", + "SNAKEAID_CONF_USE_APPSETTINGS": "true", + "SNAKEAID_CONF_PRIORITY": "DOPPLER" } }, "https": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "swagger", + "launchUrl": "", "applicationUrl": "https://localhost:7026;http://localhost:5009", "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" + "ASPNETCORE_ENVIRONMENT": "Development", + "SNAKEAID_CONF_USE_DOPPLER": "true", + "SNAKEAID_CONF_USE_APPSETTINGS": "true", + "SNAKEAID_CONF_PRIORITY": "DOPPLER" } }, "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, - "launchUrl": "swagger", + "launchUrl": "", "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" + "ASPNETCORE_ENVIRONMENT": "Development", + "SNAKEAID_CONF_USE_DOPPLER": "true", + "SNAKEAID_CONF_USE_APPSETTINGS": "true", + "SNAKEAID_CONF_PRIORITY": "DOPPLER" } } } diff --git a/SnakeAid.Api/SnakeAid.Api.csproj b/SnakeAid.Api/SnakeAid.Api.csproj index c6c4f4f6..63f99af7 100644 --- a/SnakeAid.Api/SnakeAid.Api.csproj +++ b/SnakeAid.Api/SnakeAid.Api.csproj @@ -9,6 +9,7 @@ + diff --git a/SnakeAid.Core/Exceptions/ApiException.cs b/SnakeAid.Core/Exceptions/ApiException.cs index 9c353ff5..9c9f8bea 100644 --- a/SnakeAid.Core/Exceptions/ApiException.cs +++ b/SnakeAid.Core/Exceptions/ApiException.cs @@ -111,4 +111,12 @@ public TooManyRequestsException(string reason, DateTime? retryAfter, int? limit Period = period; Endpoint = endpoint; } +} + +public class ConfigurationException : ApiException +{ + public ConfigurationException(string reason) + : base(reason, HttpStatusCode.InternalServerError) + { + } } \ No newline at end of file diff --git a/SnakeAid.Core/Settings/AppSettings.cs b/SnakeAid.Core/Settings/AppSettings.cs index 99b2df50..e0d78024 100644 --- a/SnakeAid.Core/Settings/AppSettings.cs +++ b/SnakeAid.Core/Settings/AppSettings.cs @@ -43,4 +43,11 @@ public class SnakeAISettings public bool SaveImage { get; set; } = false; public float Confidence { get; set; } = 0.25f; public int TimeoutSeconds { get; set; } = 30; -} \ No newline at end of file +} + +public class ConfigControl +{ + public bool UseDoppler { get; set; } = true; + public bool UseAppSettings { get; set; } = true; + public string Priority { get; set; } = "DOPPLER"; // "DOPPLER" or "APPSETTINGS" +} \ No newline at end of file From 73628791624ff00dfeeec43489415514aeb4050f Mon Sep 17 00:00:00 2001 From: the-khiem7 Date: Sun, 8 Feb 2026 20:16:07 +0700 Subject: [PATCH 11/31] refactor(config): simplify Doppler integration and remove complex configuration strategy --- SnakeAid.Api/DI/DependencyInjection.cs | 109 +++++-------------------- SnakeAid.Api/Program.cs | 2 +- SnakeAid.Core/Settings/AppSettings.cs | 6 -- 3 files changed, 22 insertions(+), 95 deletions(-) diff --git a/SnakeAid.Api/DI/DependencyInjection.cs b/SnakeAid.Api/DI/DependencyInjection.cs index ba3cc200..dd6045fa 100644 --- a/SnakeAid.Api/DI/DependencyInjection.cs +++ b/SnakeAid.Api/DI/DependencyInjection.cs @@ -22,109 +22,42 @@ public static class DependencyInjection { #region service he thong - #region AppSettings - public static WebApplicationBuilder AddAppsettings(this WebApplicationBuilder builder) - { - var dopplerAccessToken = LoadDopplerToken(); - - var configStrategy = new ConfigControl - { - UseDoppler = Environment.GetEnvironmentVariable("SNAKEAID_CONF_USE_DOPPLER") != "false", - UseAppSettings = Environment.GetEnvironmentVariable("SNAKEAID_CONF_USE_APPSETTINGS") != "false", - Priority = Environment.GetEnvironmentVariable("SNAKEAID_CONF_PRIORITY")?.ToUpper() ?? "DOPPLER" - }; - - ManageAppSettings(builder, configStrategy); - ManageDoppler(builder, configStrategy, dopplerAccessToken); - ValidateConfiguration(configStrategy); - - return builder; - } - - private static string? LoadDopplerToken() + #region Doppler + public static WebApplicationBuilder AddConfigurationFromDopplerCloud(this WebApplicationBuilder builder) { + // 1. Load Doppler Token (Process -> User) var token = Environment.GetEnvironmentVariable("DOPPLER_TOKEN"); - if (!string.IsNullOrEmpty(token)) return token; - - token = Environment.GetEnvironmentVariable("DOPPLER_TOKEN", EnvironmentVariableTarget.User); - if (string.IsNullOrEmpty(token)) return null; - Environment.SetEnvironmentVariable("DOPPLER_TOKEN", token, EnvironmentVariableTarget.Process); - - // Propagate other user env vars - foreach (System.Collections.DictionaryEntry userEnvVar in Environment.GetEnvironmentVariables(EnvironmentVariableTarget.User)) + if (string.IsNullOrEmpty(token)) { - if (userEnvVar.Key is string key && userEnvVar.Value is string value && key != "DOPPLER_TOKEN") + token = Environment.GetEnvironmentVariable("DOPPLER_TOKEN", EnvironmentVariableTarget.User); + if (!string.IsNullOrEmpty(token)) { - Environment.SetEnvironmentVariable(key, value, EnvironmentVariableTarget.Process); + // Propagate User token and other User env vars to Process + Environment.SetEnvironmentVariable("DOPPLER_TOKEN", token, EnvironmentVariableTarget.Process); + foreach (System.Collections.DictionaryEntry userEnvVar in Environment.GetEnvironmentVariables(EnvironmentVariableTarget.User)) + { + if (userEnvVar.Key is string key && userEnvVar.Value is string value && key != "DOPPLER_TOKEN") + { + Environment.SetEnvironmentVariable(key, value, EnvironmentVariableTarget.Process); + } + } } } - return token; - } - - private static void ManageAppSettings(WebApplicationBuilder builder, ConfigControl strategy) - { - if (strategy.UseAppSettings) - { - Serilog.Log.Information("✅ Configuration Source: AppSettings (ENABLED)"); - return; - } - - var jsonSourcesToDiscard = builder.Configuration.Sources - .Where(source => source is Microsoft.Extensions.Configuration.Json.JsonConfigurationSource) - .ToList(); - - foreach (var source in jsonSourcesToDiscard) - { - builder.Configuration.Sources.Remove(source); - } - Serilog.Log.Information("🚫 Configuration Source: AppSettings (DISABLED via Env Var)"); - } - - private static void ManageDoppler(WebApplicationBuilder builder, ConfigControl strategy, string? token) - { - if (!strategy.UseDoppler) - { - Serilog.Log.Information("🚫 Configuration Source: Doppler (DISABLED via Env Var)"); - return; - } - - if (string.IsNullOrEmpty(token)) - { - Serilog.Log.Warning("⚠️ Doppler enabled but DOPPLER_TOKEN is missing."); - return; - } - - builder.Configuration.AddDoppler(token); - - if (strategy.Priority == "APPSETTINGS" && strategy.UseAppSettings) + // 2. Register and Log results + if (!string.IsNullOrEmpty(token)) { - var jsonSourcesToReorder = builder.Configuration.Sources - .Where(source => source is Microsoft.Extensions.Configuration.Json.JsonConfigurationSource) - .ToList(); - - foreach (var source in jsonSourcesToReorder) - { - builder.Configuration.Sources.Remove(source); - builder.Configuration.Sources.Add(source); - } - Serilog.Log.Information("✅ Configuration Source: Doppler (ENABLED, Priority: LOW)"); + builder.Configuration.AddDoppler(token); + Serilog.Log.Information("🚀 Configuration: Doppler Cloud (ENABLED)"); } else { - Serilog.Log.Information("✅ Configuration Source: Doppler (ENABLED, Priority: HIGH)"); + Serilog.Log.Information("ℹ️ Configuration: Local Settings (AppSettings.json & Env Vars)"); } - } - private static void ValidateConfiguration(ConfigControl strategy) - { - if (!strategy.UseAppSettings && !strategy.UseDoppler) - { - throw new Core.Exceptions.ConfigurationException("❌ Critical Error: All configuration sources (Doppler and AppSettings) are DISABLED."); - } + return builder; } - #endregion #region Services diff --git a/SnakeAid.Api/Program.cs b/SnakeAid.Api/Program.cs index bf871b81..d76b4fec 100644 --- a/SnakeAid.Api/Program.cs +++ b/SnakeAid.Api/Program.cs @@ -34,7 +34,7 @@ public static async Task Main(string[] args) { var builder = WebApplication.CreateBuilder(args); - builder.AddAppsettings(); + builder.AddConfigurationFromDopplerCloud(); Batteries_V2.Init(); diff --git a/SnakeAid.Core/Settings/AppSettings.cs b/SnakeAid.Core/Settings/AppSettings.cs index e0d78024..acb77e17 100644 --- a/SnakeAid.Core/Settings/AppSettings.cs +++ b/SnakeAid.Core/Settings/AppSettings.cs @@ -45,9 +45,3 @@ public class SnakeAISettings public int TimeoutSeconds { get; set; } = 30; } -public class ConfigControl -{ - public bool UseDoppler { get; set; } = true; - public bool UseAppSettings { get; set; } = true; - public string Priority { get; set; } = "DOPPLER"; // "DOPPLER" or "APPSETTINGS" -} \ No newline at end of file From ff92c4d9e42af30d22d95b772aaff175c966d361 Mon Sep 17 00:00:00 2001 From: the-khiem7 Date: Sun, 8 Feb 2026 20:16:07 +0700 Subject: [PATCH 12/31] build: update launch configurations, environment variables, and docs subproject --- .vscode/launch.json | 8 ++++---- SnakeAid.Api/Properties/launchSettings.json | 17 +++-------------- SnakeAid.Docs | 2 +- 3 files changed, 8 insertions(+), 19 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 9ad0e4a1..da579d08 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,7 +11,7 @@ "cwd": "${workspaceFolder}/SnakeAid.Api", "stopAtEntry": false, "serverReadyAction": { - "pattern": "\\bNow listening on:\\s+(https?://\\S+)", + "pattern": "Now listening on:\\s+(https?://[^\\s]+)", "uriFormat": "%s", "action": "openExternally" }, @@ -33,7 +33,7 @@ "cwd": "${workspaceFolder}/SnakeAid.Api", "stopAtEntry": false, "serverReadyAction": { - "pattern": "\\bNow listening on:\\s+(https://.*:7026)", + "pattern": "Now listening on:\\s+(https://[^\\s]+:7026)", "uriFormat": "%s", "action": "openExternally" }, @@ -58,7 +58,7 @@ "cwd": "${workspaceFolder}/SnakeAid.Api", "stopAtEntry": false, "serverReadyAction": { - "pattern": "\\bNow listening on:\\s+(http://.*:5009)", + "pattern": "Now listening on:\\s+(http://[^\\s]+:5009)", "uriFormat": "%s", "action": "openExternally" }, @@ -83,7 +83,7 @@ "cwd": "${workspaceFolder}/SnakeAid.Api", "stopAtEntry": false, "serverReadyAction": { - "pattern": "\\bNow listening on:\\s+(https://.*:7026)", + "pattern": "Now listening on:\\s+(https://[^\\s]+:7026)", "uriFormat": "%s", "action": "openExternally" }, diff --git a/SnakeAid.Api/Properties/launchSettings.json b/SnakeAid.Api/Properties/launchSettings.json index b8acf112..97da9ec0 100644 --- a/SnakeAid.Api/Properties/launchSettings.json +++ b/SnakeAid.Api/Properties/launchSettings.json @@ -14,12 +14,8 @@ "dotnetRunMessages": true, "launchBrowser": true, "launchUrl": "", - "applicationUrl": "http://localhost:5009", "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "SNAKEAID_CONF_USE_DOPPLER": "true", - "SNAKEAID_CONF_USE_APPSETTINGS": "true", - "SNAKEAID_CONF_PRIORITY": "DOPPLER" + "ASPNETCORE_ENVIRONMENT": "Development" } }, "https": { @@ -27,12 +23,8 @@ "dotnetRunMessages": true, "launchBrowser": true, "launchUrl": "", - "applicationUrl": "https://localhost:7026;http://localhost:5009", "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "SNAKEAID_CONF_USE_DOPPLER": "true", - "SNAKEAID_CONF_USE_APPSETTINGS": "true", - "SNAKEAID_CONF_PRIORITY": "DOPPLER" + "ASPNETCORE_ENVIRONMENT": "Development" } }, "IIS Express": { @@ -40,10 +32,7 @@ "launchBrowser": true, "launchUrl": "", "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "SNAKEAID_CONF_USE_DOPPLER": "true", - "SNAKEAID_CONF_USE_APPSETTINGS": "true", - "SNAKEAID_CONF_PRIORITY": "DOPPLER" + "ASPNETCORE_ENVIRONMENT": "Development" } } } diff --git a/SnakeAid.Docs b/SnakeAid.Docs index 0cc11121..7377ac31 160000 --- a/SnakeAid.Docs +++ b/SnakeAid.Docs @@ -1 +1 @@ -Subproject commit 0cc11121118d6417e32a180f42ac45340607b915 +Subproject commit 7377ac31de0541886c28e33772eaac8df30f1bc5 From 737432acde35b07124669db8a177a2a27c67575d Mon Sep 17 00:00:00 2001 From: DuongNManh Date: Mon, 9 Feb 2026 17:48:14 +0700 Subject: [PATCH 13/31] Enhance session timeout service with detailed logging and monitoring capabilities; add demo data seeder for testing - Improved logging in SessionTimeoutBackgroundService for better traceability of session management. - Added GetMonitoringInfo method to ISessionTimeoutService for detailed session tracking. - Implemented DemoDataSeeder to populate the database with demo users and rescuers for testing purposes. - Included cleanup methods to remove demo data from the database. --- .../Controllers/MonitoringController.cs | 2 +- .../Controllers/RescueDemoController.cs | 1107 +++++------- SnakeAid.Api/DI/DependencyInjection.cs | 3 + SnakeAid.Api/Hubs/RescuerHub.cs | 79 +- SnakeAid.Api/Pages/Demo/RescueDemo.cshtml | 1523 +++++++++-------- SnakeAid.Api/Program.cs | 43 +- SnakeAid.Api/Services/DemoDataSeeder.cs | 303 ++++ .../SignalRRescueNotificationService.cs | 18 +- SnakeAid.Core/Domains/SnakebiteIncident.cs | 4 +- .../Implements/GenericRepository.cs | 34 +- .../Implements/RescueRequestSessionService.cs | 485 +++--- .../SessionTimeoutBackgroundService.cs | 110 +- .../Interfaces/ISessionTimeoutService.cs | 10 + 13 files changed, 2098 insertions(+), 1623 deletions(-) create mode 100644 SnakeAid.Api/Services/DemoDataSeeder.cs diff --git a/SnakeAid.Api/Controllers/MonitoringController.cs b/SnakeAid.Api/Controllers/MonitoringController.cs index 50791803..125ac938 100644 --- a/SnakeAid.Api/Controllers/MonitoringController.cs +++ b/SnakeAid.Api/Controllers/MonitoringController.cs @@ -8,7 +8,7 @@ namespace SnakeAid.Api.Controllers { [Route("api/monitoring")] [ApiController] - [Authorize] // Only authenticated users can access monitoring + // [Authorize] // Only authenticated users can access monitoring public class MonitoringController : ControllerBase { private readonly ISessionTimeoutService _timeoutService; diff --git a/SnakeAid.Api/Controllers/RescueDemoController.cs b/SnakeAid.Api/Controllers/RescueDemoController.cs index 92ab0f7f..d3af85d2 100644 --- a/SnakeAid.Api/Controllers/RescueDemoController.cs +++ b/SnakeAid.Api/Controllers/RescueDemoController.cs @@ -1,14 +1,21 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; using SnakeAid.Api.Hubs; using SnakeAid.Api.Services; using SnakeAid.Core.Domains; -using System.Collections.Concurrent; +using SnakeAid.Core.Requests.RescueRequestSession; +using SnakeAid.Core.Requests.SnakebiteIncident; +using SnakeAid.Repository.Data; +using SnakeAid.Repository.Interfaces; +using SnakeAid.Service.Interfaces; namespace SnakeAid.Api.Controllers { /// - /// Demo controller for testing rescue SignalR flow with mock data (no database required) + /// Demo controller for testing rescue flow with REAL services but DEMO data + /// Uses DemoDataSeeder to create test users/rescuers in actual database + /// All service logic (session, broadcast, timeout, notifications) works exactly as production /// [Route("api/[controller]")] [ApiController] @@ -16,470 +23,320 @@ public class RescueDemoController : ControllerBase { private readonly IHubContext _hubContext; private readonly ILogger _logger; - - // ====== MOCK DATA STORAGE (thay vì database) ====== - - // Mock Users - public static ConcurrentDictionary MockUsers { get; } = new() - { - [Guid.Parse("11111111-1111-1111-1111-111111111111")] = new MockUser - { - Id = Guid.Parse("11111111-1111-1111-1111-111111111111"), - Name = "Demo User", - Role = "Member", - Lat = 10.762622, // HCM City center - Lng = 106.660172 - } - }; - - // Mock Rescuers (4 rescuers at different locations around HCM) - public static ConcurrentDictionary MockRescuers { get; } = new() - { - [Guid.Parse("22222222-2222-2222-2222-222222222221")] = new MockRescuer - { - Id = Guid.Parse("22222222-2222-2222-2222-222222222221"), - Name = "Rescuer A - Quận 1", - Lat = 10.7700, - Lng = 106.6980, - IsOnline = false, - DistanceFromUserKm = 4.5 - }, - [Guid.Parse("22222222-2222-2222-2222-222222222222")] = new MockRescuer - { - Id = Guid.Parse("22222222-2222-2222-2222-222222222222"), - Name = "Rescuer B - Quận 3", - Lat = 10.7831, - Lng = 106.6859, - IsOnline = false, - DistanceFromUserKm = 3.2 - }, - [Guid.Parse("22222222-2222-2222-2222-222222222223")] = new MockRescuer - { - Id = Guid.Parse("22222222-2222-2222-2222-222222222223"), - Name = "Rescuer C - Quận 7", - Lat = 10.7295, - Lng = 106.7215, - IsOnline = false, - DistanceFromUserKm = 8.0 - }, - [Guid.Parse("22222222-2222-2222-2222-222222222224")] = new MockRescuer - { - Id = Guid.Parse("22222222-2222-2222-2222-222222222224"), - Name = "Rescuer D - Tân Bình", - Lat = 10.8053, - Lng = 106.6482, - IsOnline = false, - DistanceFromUserKm = 6.5 - } - }; - - // Mock Incidents - public static ConcurrentDictionary MockIncidents { get; } = new(); - - // Mock Sessions - public static ConcurrentDictionary MockSessions { get; } = new(); - - // Mock Requests (per rescuer per session) - public static ConcurrentDictionary MockRequests { get; } = new(); - - // Mock Missions - public static ConcurrentDictionary MockMissions { get; } = new(); - - // Session timeout timers - private static ConcurrentDictionary SessionTimers { get; } = new(); - - public RescueDemoController(IHubContext hubContext, ILogger logger) + private readonly DemoDataSeeder _demoDataSeeder; + private readonly ISnakebiteIncidentService _incidentService; + private readonly IRescueRequestSessionService _sessionService; + private readonly ISessionTimeoutService _timeoutService; + private readonly IUnitOfWork _unitOfWork; + + // Track current demo incident for UI convenience + private static Guid? _currentDemoIncidentId = null; + + public RescueDemoController( + IHubContext hubContext, + ILogger logger, + DemoDataSeeder demoDataSeeder, + ISnakebiteIncidentService incidentService, + IRescueRequestSessionService sessionService, + ISessionTimeoutService timeoutService, + IUnitOfWork unitOfWork) { _hubContext = hubContext; _logger = logger; + _demoDataSeeder = demoDataSeeder; + _incidentService = incidentService; + _sessionService = sessionService; + _timeoutService = timeoutService; + _unitOfWork = unitOfWork; } - #region User Actions + #region Demo Data Management /// - /// User tạo incident mới + /// Seed demo users and rescuers into database /// - [HttpPost("incident/create")] - public async Task CreateIncident([FromBody] CreateIncidentDto dto) + [HttpPost("seed")] + public async Task SeedDemoData() { - var userId = Guid.Parse("11111111-1111-1111-1111-111111111111"); - - var incident = new MockIncident + var success = await _demoDataSeeder.SeedDemoDataAsync(); + if (!success) { - Id = Guid.NewGuid(), - UserId = userId, - Lat = dto.Lat, - Lng = dto.Lng, - Status = "Pending", - CurrentSessionNumber = 0, - CurrentRadiusKm = 0, - CreatedAt = DateTime.UtcNow - }; - - MockIncidents[incident.Id] = incident; - - _logger.LogInformation("Created mock incident {IncidentId}", incident.Id); - - // Notify all connected clients about new incident - await _hubContext.Clients.All.SendAsync("IncidentCreated", incident); + return BadRequest("Failed to seed demo data. Check if data already exists or see logs."); + } - return Ok(new { incident, message = "Incident created successfully. Ready to trigger rescue." }); + return Ok(new + { + message = "Demo data seeded successfully", + userId = DemoDataSeeder.DEMO_USER_ID, + rescuers = new[] + { + new { id = DemoDataSeeder.DEMO_RESCUER_A_ID, name = "Rescuer A - Quận 1" }, + new { id = DemoDataSeeder.DEMO_RESCUER_B_ID, name = "Rescuer B - Quận 3" }, + new { id = DemoDataSeeder.DEMO_RESCUER_C_ID, name = "Rescuer C - Quận 7" }, + new { id = DemoDataSeeder.DEMO_RESCUER_D_ID, name = "Rescuer D - Tân Bình" } + } + }); } /// - /// User trigger rescue session (bắt đầu tìm rescuer) + /// Clean up all demo data (incidents, sessions, requests, missions, users) /// - [HttpPost("incident/{incidentId}/trigger")] - public async Task TriggerRescue(Guid incidentId) + [HttpPost("cleanup")] + public async Task CleanupDemoData() { - if (!MockIncidents.TryGetValue(incidentId, out var incident)) - { - return NotFound("Incident not found"); - } - - if (incident.Status != "Pending") + var success = await _demoDataSeeder.CleanupDemoDataAsync(); + if (!success) { - return BadRequest($"Cannot trigger rescue for incident with status: {incident.Status}"); + return BadRequest("Failed to cleanup demo data. See logs for details."); } - // Create first session - var session = await CreateNewSessionAsync(incident, 1, 5, "Initial"); + _currentDemoIncidentId = null; - // Start timeout timer (60 seconds) - StartSessionTimer(session.Id, 60); + await _hubContext.Clients.All.SendAsync("DemoDataCleanedUp", new { Message = "Demo data cleaned up" }); - return Ok(new { session, message = "Rescue triggered. Broadcasting to nearby rescuers..." }); + return Ok(new { message = "Demo data cleaned up successfully" }); } /// - /// User cancel incident + /// Get demo data status /// - [HttpPost("incident/{incidentId}/cancel")] - public async Task CancelIncident(Guid incidentId) + [HttpGet("status")] + public async Task GetDemoStatus() { - if (!MockIncidents.TryGetValue(incidentId, out var incident)) - { - return NotFound("Incident not found"); - } - - if (incident.Status != "Pending" && incident.Status != "Assigned") + var status = await _demoDataSeeder.GetStatusAsync(); + return Ok(new { - return BadRequest($"Cannot cancel incident with status: {incident.Status}"); - } + status, + currentIncidentId = _currentDemoIncidentId, + connectedRescuers = SignalRRescueNotificationService.ConnectedRescuers.Keys.ToList() + }); + } - incident.Status = "Cancelled"; + #endregion - // Cancel all active sessions and requests - var activeSessions = MockSessions.Values.Where(s => s.IncidentId == incidentId && s.Status == "Active"); - foreach (var session in activeSessions) - { - await CancelSessionAsync(session.Id); - } + #region User Actions (Using Real Services) - // Cancel mission if exists - var mission = MockMissions.Values.FirstOrDefault(m => m.IncidentId == incidentId); - if (mission != null && (mission.Status == "Preparing" || mission.Status == "EnRoute")) + /// + /// Create incident + Start rescue session (matches real flow from SnakebiteIncidentController) + /// This combines: + /// 1. CreateIncidentAsync - Creates incident record + /// 2. StartRescueAsync - Creates initial session + broadcasts to rescuers + /// + [HttpPost("incident/create")] + public async Task CreateIncident([FromBody] CreateIncidentDto dto) + { + try { - mission.Status = "Cancelled"; - mission.UpdatedAt = DateTime.UtcNow; - - // Notify rescuer - var rescuerId = mission.RescuerId.ToString(); - if (SignalRRescueNotificationService.ConnectedRescuers.ContainsKey(rescuerId)) + // Check if demo data is seeded + var status = await _demoDataSeeder.GetStatusAsync(); + if (!status.IsSeeded) { - await _hubContext.Clients.Client(SignalRRescueNotificationService.ConnectedRescuers[rescuerId]) - .SendAsync("MissionCancelled", new { MissionId = mission.Id, Reason = "User cancelled incident" }); + return BadRequest("Demo data not seeded. Call POST /api/rescuedemo/seed first."); } - } - await _hubContext.Clients.All.SendAsync("IncidentCancelled", incident); + // Step 1: Create incident using REAL service (matches SnakebiteIncidentController line 55) + var request = new CreateIncidentRequest + { + Lat = dto.Lat, + Lng = dto.Lng + }; - return Ok(new { incident, message = "Incident cancelled" }); - } + var response = await _incidentService.CreateIncidentAsync(request, DemoDataSeeder.DEMO_USER_ID); + _currentDemoIncidentId = response.Id; - /// - /// User raise session range (mở rộng bán kính tìm kiếm) - /// - [HttpPost("incident/{incidentId}/raise-range")] - public async Task RaiseSessionRange(Guid incidentId) - { - if (!MockIncidents.TryGetValue(incidentId, out var incident)) - { - return NotFound("Incident not found"); - } + _logger.LogInformation("Demo incident created: {IncidentId}", response.Id); - if (incident.Status != "Pending") - { - return BadRequest($"Cannot raise range for incident with status: {incident.Status}"); - } + // Step 2: Start rescue session and broadcast to rescuers (matches line 58) + var rescueResult = await _incidentService.StartRescueAsync(response.Id); - // Close current session as Failed - var currentSession = MockSessions.Values - .FirstOrDefault(s => s.IncidentId == incidentId && s.SessionNumber == incident.CurrentSessionNumber); + // Combine response data (matches line 61-65) + response.SessionId = rescueResult.SessionId; + response.SessionNumber = rescueResult.SessionNumber; + response.RadiusKm = rescueResult.RadiusKm; + response.RescuersPinged = rescueResult.RescuersPinged; - if (currentSession != null) - { - // Stop timer - StopSessionTimer(currentSession.Id); + _logger.LogInformation("Rescue session started: SessionId={SessionId}, Radius={Radius}km, Rescuers={Count}", + rescueResult.SessionId, rescueResult.RadiusKm, rescueResult.RescuersPinged); - currentSession.Status = "Failed"; - currentSession.CompletedAt = DateTime.UtcNow; - - // Mark all pending requests as expired - var pendingRequests = MockRequests.Values.Where(r => r.SessionId == currentSession.Id && r.Status == "Pending"); - foreach (var req in pendingRequests) + // Notify all clients + await _hubContext.Clients.All.SendAsync("IncidentCreated", new { - req.Status = "Expired"; - req.UpdatedAt = DateTime.UtcNow; + IncidentId = response.Id, + SessionId = response.SessionId, + SessionNumber = response.SessionNumber, + RadiusKm = response.RadiusKm, + RescuersPinged = response.RescuersPinged, + UserId = DemoDataSeeder.DEMO_USER_ID, + Lat = dto.Lat, + Lng = dto.Lng, + Status = "Pending" + }); - // Notify rescuer - await NotifyRescuerAsync(req.RescuerId.ToString(), "RequestExpired", new { RequestId = req.Id }); - } + return Ok(new + { + incidentId = response.Id, + sessionId = response.SessionId, + sessionNumber = response.SessionNumber, + radiusKm = response.RadiusKm, + rescuersPinged = response.RescuersPinged, + message = $"Incident created and rescue session started! {response.RescuersPinged} rescuers pinged within {response.RadiusKm}km radius." + }); } - - // Check max sessions - if (incident.CurrentSessionNumber >= 3) + catch (Exception ex) { - incident.Status = "NoRescuerFound"; - await _hubContext.Clients.All.SendAsync("IncidentNoRescuerFound", incident); - return Ok(new { incident, message = "Maximum session range expansions reached. No rescuers found." }); + _logger.LogError(ex, "Failed to create demo incident"); + return BadRequest(ex.Message); } - - // Calculate new radius - int newRadius = incident.CurrentRadiusKm switch - { - 5 => 7, - 7 => 10, - _ => incident.CurrentRadiusKm + 5 - }; - - // Create new session - var newSession = await CreateNewSessionAsync(incident, incident.CurrentSessionNumber + 1, newRadius, "RadiusExpanded"); - - // Start timeout timer - StartSessionTimer(newSession.Id, 60); - - return Ok(new { session = newSession, message = $"Range expanded to {newRadius}km" }); } - #endregion - - #region Rescuer Actions - /// - /// Rescuer accept request + /// Trigger rescue using REAL session service (creates session, broadcasts to rescuers) + /// This will: + /// 1. Create initial session (sessionNumber=1, radius=5km) + /// 2. Query rescuers within radius + /// 3. Create RescuerRequest records + /// 4. Broadcast via SignalR using IRescueNotificationService + /// 5. Register 60s timeout in SessionTimeoutBackgroundService /// - [HttpPost("request/{requestId}/accept")] - public async Task AcceptRequest(Guid requestId, [FromQuery] Guid rescuerId) + [HttpPost("incident/{incidentId}/trigger")] + public async Task TriggerRescue(Guid incidentId) { - if (!MockRequests.TryGetValue(requestId, out var request)) + try { - return NotFound("Request not found"); - } + // Call REAL service + var response = await _incidentService.TriggerRescueAsync(incidentId); - if (request.RescuerId != rescuerId) - { - return BadRequest("This request is not assigned to you"); - } + _logger.LogInformation("Rescue triggered for incident {IncidentId}, session {SessionId}", + incidentId, response.SessionId); - if (request.Status != "Pending") - { - return BadRequest($"Cannot accept request with status: {request.Status}"); + return Ok(new + { + sessionId = response.SessionId, + sessionNumber = response.SessionNumber, + radiusKm = response.RadiusKm, + rescuersPinged = response.RescuersPinged, + message = $"Rescue triggered! {response.RescuersPinged} rescuers notified within {response.RadiusKm}km. Real background timeout (60s) is active." + }); } - - // Check if expired - if (DateTime.UtcNow > request.ExpiredAt) + catch (Exception ex) { - request.Status = "Expired"; - request.UpdatedAt = DateTime.UtcNow; - return BadRequest("Request has expired"); + _logger.LogError(ex, "Failed to trigger rescue"); + return BadRequest(ex.Message); } + } - // Check if session already completed - var session = MockSessions[request.SessionId]; - if (session.Status == "Completed") + /// + /// Raise session range (expand radius) using REAL service + /// This will: + /// 1. Mark current session as Failed + /// 2. Expire all pending requests + /// 3. Create new session with expanded radius + /// 4. Broadcast to new rescuers + /// 5. Register new 60s timeout + /// + [HttpPost("incident/{incidentId}/raise-range")] + public async Task RaiseRange(Guid incidentId) + { + try { - request.Status = "Taken"; - request.UpdatedAt = DateTime.UtcNow; - return BadRequest("Another rescuer has already accepted this incident"); - } - - // Accept this request - request.Status = "Accepted"; - request.ResponseAt = DateTime.UtcNow; - request.UpdatedAt = DateTime.UtcNow; - - // Stop session timer - StopSessionTimer(session.Id); + var response = await _incidentService.RaiseSessionRangeAsync(new RaiseSessionRangeRequest + { + IncidentId = incidentId + }); - // Mark all other requests in session as Taken - var otherRequests = MockRequests.Values.Where(r => r.SessionId == session.Id && r.Id != requestId && r.Status == "Pending").ToList(); - foreach (var otherReq in otherRequests) - { - otherReq.Status = "Taken"; - otherReq.UpdatedAt = DateTime.UtcNow; + _logger.LogInformation("Range raised for incident {IncidentId}, new session {SessionId}", + incidentId, response.SessionId); - // Notify other rescuers - await NotifyRescuerAsync(otherReq.RescuerId.ToString(), "RequestTaken", new + return Ok(new { - RequestId = otherReq.Id, - Message = "This request has been taken by another rescuer." + sessionId = response.SessionId, + sessionNumber = response.SessionNumber, + radiusKm = response.RadiusKm, + rescuersPinged = response.RescuersPinged, + message = $"Range expanded to {response.RadiusKm}km! {response.RescuersPinged} new rescuers notified." }); } - - // Mark session as completed - session.Status = "Completed"; - session.CompletedAt = DateTime.UtcNow; - - // Create mission - var incident = MockIncidents[request.IncidentId]; - incident.Status = "Assigned"; - incident.AssignedRescuerId = rescuerId; - incident.AssignedAt = DateTime.UtcNow; - - var mission = new MockMission - { - Id = Guid.NewGuid(), - IncidentId = request.IncidentId, - RescuerId = rescuerId, - Status = "Preparing", - CreatedAt = DateTime.UtcNow - }; - MockMissions[mission.Id] = mission; - - // Notify caller - await NotifyRescuerAsync(rescuerId.ToString(), "RequestAccepted", new + catch (Exception ex) { - RequestId = requestId, - MissionId = mission.Id, - Message = "Request accepted! You have been assigned to this rescue mission." - }); - - // Notify user - await _hubContext.Clients.All.SendAsync("IncidentAssigned", new - { - IncidentId = incident.Id, - RescuerId = rescuerId, - RescuerName = MockRescuers[rescuerId].Name, - MissionId = mission.Id - }); - - _logger.LogInformation("Rescuer {RescuerId} accepted request {RequestId}, mission {MissionId} created", - rescuerId, requestId, mission.Id); - - return Ok(new { request, mission, message = "Request accepted successfully" }); + _logger.LogError(ex, "Failed to raise range"); + return BadRequest(ex.Message); + } } /// - /// Rescuer reject request + /// Cancel incident using REAL service + /// This will: + /// 1. Mark incident as Cancelled + /// 2. Cancel all active sessions + /// 3. Expire all pending requests + /// 4. Cancel mission if exists + /// 5. Notify all involved rescuers /// - [HttpPost("request/{requestId}/reject")] - public async Task RejectRequest(Guid requestId) + [HttpPost("incident/{incidentId}/cancel")] + public async Task CancelIncident(Guid incidentId) { - if (!MockRequests.TryGetValue(requestId, out var request)) - { - return NotFound("Request not found"); - } - - if (request.Status != "Pending") + try { - return BadRequest($"Cannot reject request with status: {request.Status}"); - } - - request.Status = "Rejected"; - request.ResponseAt = DateTime.UtcNow; - request.UpdatedAt = DateTime.UtcNow; + var response = await _incidentService.CancelIncidentAsync(incidentId); - await NotifyRescuerAsync(request.RescuerId.ToString(), "RequestRejected", new { RequestId = requestId }); + _logger.LogInformation("Incident {IncidentId} cancelled", incidentId); - return Ok(new { request, message = "Request rejected" }); - } + if (incidentId == _currentDemoIncidentId) + { + _currentDemoIncidentId = null; + } - /// - /// Cancel mission by rescuer - /// - [HttpPost("mission/{missionId}/cancel")] - public async Task CancelMission(Guid missionId, [FromQuery] string reason = "Rescuer cancelled") - { - if (!MockMissions.TryGetValue(missionId, out var mission)) - { - return NotFound("Mission not found"); + return Ok(new + { + incidentId = response.Id, + message = "Incident cancelled successfully. All sessions and requests terminated." + }); } - - if (mission.Status != "Preparing" && mission.Status != "EnRoute") + catch (Exception ex) { - return BadRequest($"Cannot cancel mission with status: {mission.Status}"); + _logger.LogError(ex, "Failed to cancel incident"); + return BadRequest(ex.Message); } + } - mission.Status = "Cancelled"; - mission.CancellationReason = reason; - mission.UpdatedAt = DateTime.UtcNow; - - var incident = MockIncidents[mission.IncidentId]; - - // Reset incident to Pending for retry - incident.Status = "Pending"; - incident.AssignedRescuerId = null; - incident.AssignedAt = null; - - // Create new session triggered by mission cancellation - var newSession = await CreateNewSessionAsync(incident, incident.CurrentSessionNumber + 1, incident.CurrentRadiusKm, "MissionCancelled"); - - // Start timeout timer - StartSessionTimer(newSession.Id, 60); + #endregion - // Notify user - await _hubContext.Clients.All.SendAsync("MissionCancelledByRescuer", new - { - MissionId = missionId, - IncidentId = incident.Id, - Reason = reason, - NewSessionId = newSession.Id, - Message = "Rescuer cancelled. Looking for another rescuer..." - }); - - return Ok(new { mission, newSession, message = "Mission cancelled, new session created" }); - } + #region Rescuer Actions (Using Real Services) /// - /// Update mission status + /// Rescuer accept request using REAL service + /// This will: + /// 1. Validate request is Pending and not expired + /// 2. Mark request as Accepted + /// 3. Mark all other requests in session as Taken + /// 4. Mark session as Completed + /// 5. Create RescueMission + /// 6. Update incident status to Assigned + /// 7. Notify all rescuers (accepted, taken) + /// 8. Cancel timeout in background service /// - [HttpPost("mission/{missionId}/status")] - public async Task UpdateMissionStatus(Guid missionId, [FromQuery] string status) + [HttpPost("request/{requestId}/accept")] + public async Task AcceptRequest(Guid requestId, [FromQuery] Guid rescuerId) { - if (!MockMissions.TryGetValue(missionId, out var mission)) + try { - return NotFound("Mission not found"); - } + var response = await _incidentService.AcceptRescueAsync(requestId, rescuerId); - var oldStatus = mission.Status; - mission.Status = status; - mission.UpdatedAt = DateTime.UtcNow; + _logger.LogInformation("Rescuer {RescuerId} accepted request {RequestId}, mission {MissionId}", + rescuerId, requestId, response.MissionId); - switch (status) - { - case "EnRoute": - mission.StartedAt = DateTime.UtcNow; - break; - case "RescuerArrived": - mission.ArrivedAt = DateTime.UtcNow; - break; - case "MissionCompleted": - mission.CompletedAt = DateTime.UtcNow; - var incident = MockIncidents[mission.IncidentId]; - incident.Status = "Finished"; - break; + return Ok(new + { + requestId, + missionId = response.MissionId, + message = "Request accepted! Mission created. You have been assigned to this rescue." + }); } - - await _hubContext.Clients.All.SendAsync("MissionStatusUpdated", new + catch (Exception ex) { - MissionId = missionId, - OldStatus = oldStatus, - NewStatus = status, - Mission = mission - }); - - return Ok(new { mission, message = $"Mission status updated to {status}" }); + _logger.LogError(ex, "Failed to accept request"); + return BadRequest(ex.Message); + } } #endregion @@ -487,393 +344,199 @@ public async Task UpdateMissionStatus(Guid missionId, [FromQuery] #region Query APIs /// - /// Get all mock data state + /// Get current demo incident details (from real database) /// - [HttpGet("state")] - public IActionResult GetState() + [HttpGet("incident/current")] + public async Task GetCurrentIncident() { - return Ok(new + if (_currentDemoIncidentId == null) { - users = MockUsers.Values.ToList(), - rescuers = MockRescuers.Values.Select(r => new - { - r.Id, - r.Name, - r.Lat, - r.Lng, - r.IsOnline, - r.DistanceFromUserKm, - IsConnected = SignalRRescueNotificationService.ConnectedRescuers.ContainsKey(r.Id.ToString()) - }).ToList(), - incidents = MockIncidents.Values.ToList(), - sessions = MockSessions.Values.ToList(), - requests = MockRequests.Values.ToList(), - missions = MockMissions.Values.ToList(), - connectedRescuers = SignalRRescueNotificationService.ConnectedRescuers.Keys.ToList() - }); - } - - /// - /// Get incident details - /// - [HttpGet("incident/{incidentId}")] - public IActionResult GetIncident(Guid incidentId) - { - if (!MockIncidents.TryGetValue(incidentId, out var incident)) - { - return NotFound("Incident not found"); + return NotFound("No active demo incident"); } - var sessions = MockSessions.Values.Where(s => s.IncidentId == incidentId).OrderBy(s => s.SessionNumber).ToList(); - var requests = MockRequests.Values.Where(r => r.IncidentId == incidentId).ToList(); - var mission = MockMissions.Values.FirstOrDefault(m => m.IncidentId == incidentId); - - return Ok(new { incident, sessions, requests, mission }); - } - - /// - /// Get requests for a rescuer - /// - [HttpGet("rescuer/{rescuerId}/requests")] - public IActionResult GetRescuerRequests(Guid rescuerId) - { - var requests = MockRequests.Values.Where(r => r.RescuerId == rescuerId).ToList(); - return Ok(requests); - } - - /// - /// Reset all mock data - /// - [HttpPost("reset")] - public async Task ResetMockData() - { - // Stop all timers - foreach (var timer in SessionTimers.Values) + try { - timer.Stop(); - timer.Dispose(); + var incident = await _incidentService.GetDetailIncidentAsync(_currentDemoIncidentId.Value); + return Ok(incident); } - SessionTimers.Clear(); - - MockIncidents.Clear(); - MockSessions.Clear(); - MockRequests.Clear(); - MockMissions.Clear(); - - // Reset rescuer online status - foreach (var rescuer in MockRescuers.Values) + catch (Exception ex) { - rescuer.IsOnline = SignalRRescueNotificationService.ConnectedRescuers.ContainsKey(rescuer.Id.ToString()); + _logger.LogError(ex, "Failed to get incident details"); + return NotFound(ex.Message); } - - await _hubContext.Clients.All.SendAsync("DataReset", new { Message = "All mock data has been reset" }); - - return Ok(new { message = "Mock data reset successfully" }); } /// - /// Simulate session timeout manually (for testing) + /// Get comprehensive monitoring data for current incident (incident + sessions + requests + mission) /// - [HttpPost("session/{sessionId}/timeout")] - public async Task SimulateTimeout(Guid sessionId) - { - await HandleSessionTimeoutAsync(sessionId); - return Ok(new { message = "Session timeout handled" }); - } - - #endregion - - #region Private Helper Methods - - private async Task CreateNewSessionAsync(MockIncident incident, int sessionNumber, int radiusKm, string trigger) + [HttpGet("incident/monitor")] + public async Task GetIncidentMonitoring() { - var session = new MockSession - { - Id = Guid.NewGuid(), - IncidentId = incident.Id, - SessionNumber = sessionNumber, - RadiusKm = radiusKm, - Status = "Active", - TriggerType = trigger, - RescuersPinged = 0, - CreatedAt = DateTime.UtcNow - }; - - MockSessions[session.Id] = session; - - // Update incident - incident.CurrentSessionNumber = sessionNumber; - incident.CurrentRadiusKm = radiusKm; - incident.LastSessionAt = DateTime.UtcNow; - - // Find and ping connected rescuers within radius - var connectedRescuers = MockRescuers.Values - .Where(r => SignalRRescueNotificationService.ConnectedRescuers.ContainsKey(r.Id.ToString())) - .Where(r => r.DistanceFromUserKm <= radiusKm) - .ToList(); - - session.RescuersPinged = connectedRescuers.Count; - - var expiredAt = DateTime.UtcNow.AddSeconds(60); - - foreach (var rescuer in connectedRescuers) - { - var request = new MockRescuerRequest + if (_currentDemoIncidentId == null) + { + return Ok(new { hasIncident = false, message = "No active demo incident" }); + } + + try + { + // Get incident details + var incident = await _incidentService.GetDetailIncidentAsync(_currentDemoIncidentId.Value); + + // Query all sessions for this incident + var sessions = await _unitOfWork.GetRepository() + .CreateBaseQuery() + .Where(s => s.IncidentId == _currentDemoIncidentId.Value) + .OrderBy(s => s.SessionNumber) + .Select(s => new + { + s.Id, + s.SessionNumber, + s.RadiusKm, + s.Status, + s.TriggerType, + s.RescuersPinged, + s.CreatedAt + }) + .ToListAsync(); + + // Query all requests for this incident + var requests = await _unitOfWork.GetRepository() + .CreateBaseQuery() + .Where(r => r.IncidentId == _currentDemoIncidentId.Value) + .OrderByDescending(r => r.CreatedAt) + .Select(r => new + { + r.Id, + r.SessionId, + r.RescuerId, + RescuerName = r.Rescuer.Account.FullName, + r.Status, + r.RequestSentAt, + r.ExpiredAt, + r.ResponseAt + }) + .ToListAsync(); + + // Query mission if exists + var mission = await _unitOfWork.GetRepository() + .CreateBaseQuery() + .Where(m => m.IncidentId == _currentDemoIncidentId.Value) + .Select(m => new + { + m.Id, + m.RescuerId, + RescuerName = m.Rescuer.Account.FullName, + m.Status, + m.StartedAt, + m.ArrivedAt, + m.CompletedAt, + m.Price + }) + .FirstOrDefaultAsync(); + + return Ok(new { - Id = Guid.NewGuid(), - SessionId = session.Id, - IncidentId = incident.Id, - RescuerId = rescuer.Id, - Status = "Pending", - RequestSentAt = DateTime.UtcNow, - ExpiredAt = expiredAt, - CreatedAt = DateTime.UtcNow - }; - - MockRequests[request.Id] = request; - - // Send request to rescuer via SignalR - await NotifyRescuerAsync(rescuer.Id.ToString(), "NewRescueRequest", new - { - RequestId = request.Id, - SessionId = session.Id, - IncidentId = incident.Id, - IncidentLat = incident.Lat, - IncidentLng = incident.Lng, - RadiusKm = radiusKm, - SessionNumber = sessionNumber, - ExpiredAt = expiredAt, - RequestSentAt = request.RequestSentAt, - TimeoutSeconds = 60 + hasIncident = true, + incident = new + { + incident.Id, + incident.Status, + incident.CurrentSessionNumber, + incident.CurrentRadiusKm, + incident.LastSessionAt, + incident.AssignedAt, + incident.AssignedRescuerId + }, + sessions = sessions, + requests = requests.Select(r => new + { + r.Id, + r.SessionId, + r.RescuerId, + r.RescuerName, + r.Status, + r.RequestSentAt, + r.ExpiredAt, + r.ResponseAt, + SessionNumber = sessions.FirstOrDefault(s => s.Id == r.SessionId)?.SessionNumber + }), + mission = mission, + timestamp = DateTime.UtcNow }); } - - // Notify all about session creation - await _hubContext.Clients.All.SendAsync("SessionCreated", new + catch (Exception ex) { - Session = session, - RescuersPinged = connectedRescuers.Count, - RescuerNames = connectedRescuers.Select(r => r.Name).ToList() - }); - - _logger.LogInformation("Session {SessionId} created with {Count} rescuers pinged in {RadiusKm}km", - session.Id, connectedRescuers.Count, radiusKm); - - return session; - } - - private async Task CancelSessionAsync(Guid sessionId) - { - if (!MockSessions.TryGetValue(sessionId, out var session)) - return; - - StopSessionTimer(sessionId); - - session.Status = "Cancelled"; - session.CompletedAt = DateTime.UtcNow; - - // Cancel all pending requests - var pendingRequests = MockRequests.Values.Where(r => r.SessionId == sessionId && r.Status == "Pending"); - foreach (var req in pendingRequests) - { - req.Status = "Cancelled"; - req.UpdatedAt = DateTime.UtcNow; - - await NotifyRescuerAsync(req.RescuerId.ToString(), "RequestCancelled", new - { - RequestId = req.Id, - Message = "Request cancelled by user" - }); + _logger.LogError(ex, "Failed to get incident monitoring data"); + return StatusCode(500, new { error = ex.Message }); } - - await _hubContext.Clients.All.SendAsync("SessionCancelled", session); } - private async Task HandleSessionTimeoutAsync(Guid sessionId) + /// + /// Get all demo rescuers with connection status + /// + [HttpGet("rescuers")] + public IActionResult GetRescuers() { - if (!MockSessions.TryGetValue(sessionId, out var session)) - return; - - if (session.Status != "Active") - return; - - // Mark all pending requests as expired - var pendingRequests = MockRequests.Values.Where(r => r.SessionId == sessionId && r.Status == "Pending").ToList(); - foreach (var req in pendingRequests) + var rescuers = new[] { - req.Status = "Expired"; - req.UpdatedAt = DateTime.UtcNow; - - await NotifyRescuerAsync(req.RescuerId.ToString(), "RequestExpired", new - { - RequestId = req.Id, - Message = "Request has expired" - }); - } - - session.Status = "Failed"; - session.CompletedAt = DateTime.UtcNow; + new { id = DemoDataSeeder.DEMO_RESCUER_A_ID, name = "Rescuer A - Quận 1", distanceKm = 4.5 }, + new { id = DemoDataSeeder.DEMO_RESCUER_B_ID, name = "Rescuer B - Quận 3", distanceKm = 3.2 }, + new { id = DemoDataSeeder.DEMO_RESCUER_C_ID, name = "Rescuer C - Quận 7", distanceKm = 8.0 }, + new { id = DemoDataSeeder.DEMO_RESCUER_D_ID, name = "Rescuer D - Tân Bình", distanceKm = 6.5 } + }; - await _hubContext.Clients.All.SendAsync("SessionTimeout", new + var result = rescuers.Select(r => new { - SessionId = sessionId, - ExpiredRequests = pendingRequests.Count, - Message = "Session timed out. All pending requests expired." + r.id, + r.name, + r.distanceKm, + isConnected = SignalRRescueNotificationService.ConnectedRescuers.ContainsKey(r.id.ToString()) }); - // Try to expand and create new session - var incident = MockIncidents[session.IncidentId]; - - if (incident.Status == "Pending" && incident.CurrentSessionNumber < 3) - { - int nextRadius = session.RadiusKm switch - { - 5 => 7, - 7 => 10, - _ => session.RadiusKm + 5 - }; - - var newSession = await CreateNewSessionAsync(incident, incident.CurrentSessionNumber + 1, nextRadius, "RadiusExpanded"); - StartSessionTimer(newSession.Id, 60); - - await _hubContext.Clients.All.SendAsync("SessionAutoExpanded", new - { - OldSessionId = sessionId, - NewSession = newSession, - Message = $"Auto-expanded to {nextRadius}km" - }); - } - else if (incident.CurrentSessionNumber >= 3) - { - incident.Status = "NoRescuerFound"; - await _hubContext.Clients.All.SendAsync("IncidentNoRescuerFound", incident); - } + return Ok(result); } - private void StartSessionTimer(Guid sessionId, int seconds) - { - var timer = new System.Timers.Timer(seconds * 1000); - timer.Elapsed += async (sender, e) => - { - timer.Stop(); - await HandleSessionTimeoutAsync(sessionId); - }; - timer.AutoReset = false; - timer.Start(); - - SessionTimers[sessionId] = timer; - _logger.LogInformation("Started {Seconds}s timer for session {SessionId}", seconds, sessionId); - } - - private void StopSessionTimer(Guid sessionId) + /// + /// Monitor background service sessions with real-time timeout tracking + /// + [HttpGet("sessions/monitor")] + public IActionResult GetSessionMonitoring() { - if (SessionTimers.TryRemove(sessionId, out var timer)) - { - timer.Stop(); - timer.Dispose(); - _logger.LogInformation("Stopped timer for session {SessionId}", sessionId); - } - } + var monitoringInfo = _timeoutService.GetMonitoringInfo(); + var (totalSessions, expiredCount, pendingCount) = _timeoutService.GetQueueStatus(); - private async Task NotifyRescuerAsync(string rescuerId, string method, object data) - { - if (SignalRRescueNotificationService.ConnectedRescuers.TryGetValue(rescuerId, out var connectionId)) + return Ok(new { - try + summary = new { - await _hubContext.Clients.Client(connectionId).SendAsync(method, data); - } - catch (Exception ex) + totalTracked = totalSessions, + expired = expiredCount, + pending = pendingCount, + healthy = _timeoutService.IsHealthy() + }, + sessions = monitoringInfo.Select(s => new { - _logger.LogError(ex, "Error sending {Method} to rescuer {RescuerId}", method, rescuerId); - } - } + sessionId = s.SessionId, + timeoutAt = s.TimeoutAt, + timeRemainingSeconds = (int)s.TimeRemaining.TotalSeconds, + isExpired = s.IsExpired, + status = s.IsExpired ? "Expired" : + s.TimeRemaining.TotalSeconds < 10 ? "Expiring Soon" : "Active" + }), + timestamp = DateTime.UtcNow + }); } #endregion } - #region Mock DTOs + #region DTOs public class CreateIncidentDto { public double Lat { get; set; } = 10.762622; public double Lng { get; set; } = 106.660172; - } - - public class MockUser - { - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public string Role { get; set; } = "Member"; - public double Lat { get; set; } - public double Lng { get; set; } - } - - public class MockRescuer - { - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public double Lat { get; set; } - public double Lng { get; set; } - public bool IsOnline { get; set; } - public double DistanceFromUserKm { get; set; } - } - - public class MockIncident - { - public Guid Id { get; set; } - public Guid UserId { get; set; } - public double Lat { get; set; } - public double Lng { get; set; } - public string Status { get; set; } = "Pending"; - public int CurrentSessionNumber { get; set; } - public int CurrentRadiusKm { get; set; } - public DateTime? LastSessionAt { get; set; } - public Guid? AssignedRescuerId { get; set; } - public DateTime? AssignedAt { get; set; } - public DateTime CreatedAt { get; set; } - } - - public class MockSession - { - public Guid Id { get; set; } - public Guid IncidentId { get; set; } - public int SessionNumber { get; set; } - public int RadiusKm { get; set; } - public string Status { get; set; } = "Active"; - public string TriggerType { get; set; } = "Initial"; - public int RescuersPinged { get; set; } - public DateTime CreatedAt { get; set; } - public DateTime? CompletedAt { get; set; } - } - - public class MockRescuerRequest - { - public Guid Id { get; set; } - public Guid SessionId { get; set; } - public Guid IncidentId { get; set; } - public Guid RescuerId { get; set; } - public string Status { get; set; } = "Pending"; - public DateTime RequestSentAt { get; set; } - public DateTime? ResponseAt { get; set; } - public DateTime ExpiredAt { get; set; } - public DateTime CreatedAt { get; set; } - public DateTime? UpdatedAt { get; set; } - } - - public class MockMission - { - public Guid Id { get; set; } - public Guid IncidentId { get; set; } - public Guid RescuerId { get; set; } - public string Status { get; set; } = "Preparing"; - public string? CancellationReason { get; set; } - public DateTime CreatedAt { get; set; } - public DateTime? StartedAt { get; set; } - public DateTime? ArrivedAt { get; set; } - public DateTime? CompletedAt { get; set; } - public DateTime? UpdatedAt { get; set; } + public string? SymptomsReport { get; set; } } #endregion diff --git a/SnakeAid.Api/DI/DependencyInjection.cs b/SnakeAid.Api/DI/DependencyInjection.cs index 093f3551..b831288a 100644 --- a/SnakeAid.Api/DI/DependencyInjection.cs +++ b/SnakeAid.Api/DI/DependencyInjection.cs @@ -57,6 +57,9 @@ public static IServiceCollection AddServices(this IServiceCollection services, I services.AddScoped(); + // Register Demo Data Seeder for testing + services.AddScoped(); + return services; } diff --git a/SnakeAid.Api/Hubs/RescuerHub.cs b/SnakeAid.Api/Hubs/RescuerHub.cs index c1263fe1..52cc0cb2 100644 --- a/SnakeAid.Api/Hubs/RescuerHub.cs +++ b/SnakeAid.Api/Hubs/RescuerHub.cs @@ -8,12 +8,15 @@ using SnakeAid.Core.Domains; using SnakeAid.Api.Services; using SnakeAid.Service.Interfaces; +using SnakeAid.Repository.Interfaces; +using SnakeAid.Repository.Data; namespace SnakeAid.Api.Hubs { public class RescuerHub : Hub { private readonly IRescueRequestSessionService _sessionService; + private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; // Static dictionary để track connected rescuers: userId -> connectionId @@ -21,9 +24,11 @@ public class RescuerHub : Hub public RescuerHub( IRescueRequestSessionService sessionService, + IUnitOfWork unitOfWork, ILogger logger) { _sessionService = sessionService; + _unitOfWork = unitOfWork; _logger = logger; } @@ -32,9 +37,43 @@ public RescuerHub( ///
public async Task JoinAsRescuer(string userId) { + _logger.LogInformation("JoinAsRescuer called for userId: {UserId}, ConnectionId: {ConnectionId}, Current dictionary size: {DictSize}", + userId, Context.ConnectionId, SignalRRescueNotificationService.ConnectedRescuers.Count); + // Add connection to notification service SignalRRescueNotificationService.AddConnection(userId, Context.ConnectionId); + _logger.LogInformation("After AddConnection: Dictionary size: {DictSize}, Contains {UserId}: {Contains}", + SignalRRescueNotificationService.ConnectedRescuers.Count, + userId, + SignalRRescueNotificationService.ConnectedRescuers.ContainsKey(userId)); + + // Update RescuerProfile IsOnline status in database + if (Guid.TryParse(userId, out var rescuerGuid)) + { + var rescuerProfile = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: r => r.AccountId == rescuerGuid, + asNoTracking: false + ); + + if (rescuerProfile != null) + { + rescuerProfile.IsOnline = true; + rescuerProfile.UpdatedAt = DateTime.UtcNow; + _unitOfWork.GetRepository().Update(rescuerProfile); + await _unitOfWork.CommitAsync(); + _logger.LogInformation("Rescuer {UserId} set to ONLINE in database", userId); + } + else + { + _logger.LogWarning("RescuerProfile not found for userId: {UserId}", userId); + } + } + else + { + _logger.LogWarning("Invalid GUID format for userId: {UserId}", userId); + } + _logger.LogInformation("Rescuer {UserId} joined with connectionId {ConnectionId}", userId, Context.ConnectionId); await Clients.Caller.SendAsync("Joined", new { @@ -85,13 +124,51 @@ public async Task UpdateLocation(string userId, double latitude, double longitud public override async Task OnDisconnectedAsync(Exception? exception) { + var connectionId = Context.ConnectionId; + + _logger.LogWarning("⚠️ OnDisconnectedAsync START. ConnectionId: {ConnectionId}, Dictionary size: {DictSize}, Exception: {Exception}", + connectionId, SignalRRescueNotificationService.ConnectedRescuers.Count, exception?.Message ?? "None"); + + // Log all current connections + _logger.LogWarning("Current connections: {Connections}", + string.Join(", ", SignalRRescueNotificationService.ConnectedRescuers.Select(kvp => $"{kvp.Key}→{kvp.Value}"))); + var userId = ConnectedRescuers.FirstOrDefault(x => x.Value == Context.ConnectionId).Key; if (userId != null) { + _logger.LogWarning("Found userId {UserId} for disconnected ConnectionId {ConnectionId}", userId, connectionId); + SignalRRescueNotificationService.RemoveConnection(userId); - _logger.LogInformation("Rescuer {UserId} disconnected", userId); + + _logger.LogWarning("After removal, Dictionary size: {DictSize}", SignalRRescueNotificationService.ConnectedRescuers.Count); + + // Update RescuerProfile IsOnline status in database + if (Guid.TryParse(userId, out var rescuerGuid)) + { + var rescuerProfile = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: r => r.AccountId == rescuerGuid, + asNoTracking: false + ); + + if (rescuerProfile != null) + { + rescuerProfile.IsOnline = false; + rescuerProfile.UpdatedAt = DateTime.UtcNow; + rescuerProfile.LastLocationUpdate = DateTime.UtcNow; + _unitOfWork.GetRepository().Update(rescuerProfile); + await _unitOfWork.CommitAsync(); + _logger.LogWarning("✅ Rescuer {UserId} set to OFFLINE in database due to disconnection", userId); + } + } + } + else + { + _logger.LogWarning("⚠️ OnDisconnectedAsync called for UNKNOWN connection {ConnectionId} (never called JoinAsRescuer?)", Context.ConnectionId); } + await base.OnDisconnectedAsync(exception); + + _logger.LogWarning("⚠️ OnDisconnectedAsync END. Final dictionary size: {DictSize}", SignalRRescueNotificationService.ConnectedRescuers.Count); } public async Task GetConnectedRescuers() diff --git a/SnakeAid.Api/Pages/Demo/RescueDemo.cshtml b/SnakeAid.Api/Pages/Demo/RescueDemo.cshtml index 0988d86f..71851e2e 100644 --- a/SnakeAid.Api/Pages/Demo/RescueDemo.cshtml +++ b/SnakeAid.Api/Pages/Demo/RescueDemo.cshtml @@ -14,504 +14,504 @@ @@ -519,8 +519,14 @@
-

🐍 SnakeAid - Rescue Demo

-

Test SignalR rescue flow với 4 rescuers giả lập và 1 user

+

🐍 SnakeAid - Rescue Demo (Real Services + Real Background Timeout)

+

Test với services thật, background timeout thật (60s), database thật - chỉ data là demo

+
+ +
@@ -540,6 +546,9 @@ + @@ -555,8 +564,15 @@
- - + + +
+ +
+
@@ -645,557 +661,694 @@ let timerSeconds = 60; const mockRescuers = [ - { id: '22222222-2222-2222-2222-222222222221', name: 'Rescuer A - Quận 1', distance: 4.5 }, - { id: '22222222-2222-2222-2222-222222222222', name: 'Rescuer B - Quận 3', distance: 3.2 }, - { id: '22222222-2222-2222-2222-222222222223', name: 'Rescuer C - Quận 7', distance: 8.0 }, - { id: '22222222-2222-2222-2222-222222222224', name: 'Rescuer D - Tân Bình', distance: 6.5 } + { id: '22222222-2222-2222-2222-222222222221', name: 'Rescuer A - Quận 1', distance: 4.5 }, + { id: '22222222-2222-2222-2222-222222222222', name: 'Rescuer B - Quận 3', distance: 3.2 }, + { id: '22222222-2222-2222-2222-222222222223', name: 'Rescuer C - Quận 7', distance: 8.0 }, + { id: '22222222-2222-2222-2222-222222222224', name: 'Rescuer D - Tân Bình', distance: 6.5 } ]; const rescuerRequests = {}; // rescuerId -> requestData // ====== INIT ====== - document.addEventListener('DOMContentLoaded', () => { - renderRescuers(); - refreshState(); + document.addEventListener('DOMContentLoaded', async () => { + renderRescuers(); + await checkDemoStatus(); + startMonitoring(); // Start real-time monitoring }); + // ====== DEMO DATA MANAGEMENT ====== + async function checkDemoStatus() { + try { + const response = await fetch('/api/rescuedemo/status'); + const data = await response.json(); + + const isSeeded = data.status.isSeeded; + currentIncidentId = data.currentIncidentId; + + if (!isSeeded) { + document.getElementById('demoAlert').style.display = 'block'; + document.getElementById('btnSeedData').disabled = false; + document.getElementById('btnCreateIncident').disabled = true; + } else { + document.getElementById('demoAlert').style.display = 'none'; + document.getElementById('btnSeedData').disabled = true; + document.getElementById('btnCreateIncident').disabled = false; + } + + log('Demo status checked: ' + (isSeeded ? 'Data seeded ✅' : 'Data not seeded ❌'), 'info'); + } catch (err) { + log('Failed to check demo status: ' + err.message, 'error'); + } + } + + async function seedDemoData() { + try { + log('Seeding demo data into database...', 'info'); + const response = await fetch('/api/rescuedemo/seed', { method: 'POST' }); + + if (!response.ok) { + throw new Error(await response.text()); + } + + const data = await response.json(); + log('✅ Demo data seeded: 1 user + 4 rescuers', 'success'); + + document.getElementById('demoAlert').style.display = 'none'; + document.getElementById('btnSeedData').disabled = true; + document.getElementById('btnCreateIncident').disabled = false; + } catch (err) { + log('❌ Failed to seed demo data: ' + err.message, 'error'); + } + } + + async function cleanupDemoData() { + if (!confirm('This will delete all demo data (incidents, sessions, requests, missions, users) from database. Continue?')) { + return; + } + + try { + log('Cleaning up demo data...', 'info'); + const response = await fetch('/api/rescuedemo/cleanup', { method: 'POST' }); + + if (!response.ok) { + throw new Error(await response.text()); + } + + log('✅ Demo data cleaned up from database', 'success'); + + currentIncidentId = null; + Object.keys(rescuerRequests).forEach(k => delete rescuerRequests[k]); + + document.getElementById('demoAlert').style.display = 'block'; + document.getElementById('btnSeedData').disabled = false; + document.getElementById('btnCreateIncident').disabled = true; + document.getElementById('btnTriggerRescue').disabled = true; + document.getElementById('btnRaiseRange').disabled = true; + document.getElementById('btnCancelIncident').disabled = true; + + renderRescuers(); + } catch (err) { + log('❌ Failed to cleanup: ' + err.message, 'error'); + } + } + // ====== USER CONNECTION ====== async function connectUser() { - try { - userConnection = new signalR.HubConnectionBuilder() - .withUrl('/rescuer-hub') - .withAutomaticReconnect() - .build(); - - // Register event handlers - userConnection.on('IncidentCreated', handleIncidentCreated); - userConnection.on('IncidentAssigned', handleIncidentAssigned); - userConnection.on('IncidentCancelled', handleIncidentCancelled); - userConnection.on('IncidentNoRescuerFound', handleNoRescuerFound); - userConnection.on('SessionCreated', handleSessionCreated); - userConnection.on('SessionTimeout', handleSessionTimeout); - userConnection.on('SessionAutoExpanded', handleSessionAutoExpanded); - userConnection.on('MissionStatusUpdated', handleMissionStatusUpdated); - userConnection.on('MissionCancelledByRescuer', handleMissionCancelledByRescuer); - userConnection.on('DataReset', handleDataReset); - - await userConnection.start(); - - document.getElementById('userStatus').classList.add('online'); - document.getElementById('userStatusText').textContent = 'Connected'; - document.getElementById('btnConnect').disabled = true; - document.getElementById('btnCreateIncident').disabled = false; - - log('User connected to SignalR', 'success'); - } catch (err) { - log('Failed to connect: ' + err.message, 'error'); - } + try { + userConnection = new signalR.HubConnectionBuilder() + .withUrl('/rescuer-hub', { + // Server gửi ping mỗi 15s, client timeout sau 60s không nhận được message + timeout: 60000 // 60 seconds - PHẢI > server KeepAliveInterval (15s) + }) + .withAutomaticReconnect() + .build(); + + // Register event handlers + userConnection.on('IncidentCreated', handleIncidentCreated); + userConnection.on('IncidentAssigned', handleIncidentAssigned); + userConnection.on('IncidentCancelled', handleIncidentCancelled); + userConnection.on('IncidentNoRescuerFound', handleNoRescuerFound); + userConnection.on('SessionCreated', handleSessionCreated); + userConnection.on('SessionTimeout', handleSessionTimeout); + userConnection.on('SessionAutoExpanded', handleSessionAutoExpanded); + userConnection.on('MissionStatusUpdated', handleMissionStatusUpdated); + userConnection.on('MissionCancelledByRescuer', handleMissionCancelledByRescuer); + userConnection.on('DataReset', handleDataReset); + + await userConnection.start(); + + document.getElementById('userStatus').classList.add('online'); + document.getElementById('userStatusText').textContent = 'Connected'; + document.getElementById('btnConnect').disabled = true; + document.getElementById('btnCreateIncident').disabled = false; + + log('User connected to SignalR', 'success'); + } catch (err) { + log('Failed to connect: ' + err.message, 'error'); + } } // ====== RESCUER CONNECTIONS ====== async function connectRescuer(rescuerId) { - if (rescuerConnections[rescuerId]) { - log(`Rescuer ${rescuerId.slice(-1)} already connected`, 'warning'); - return; - } - - try { - const connection = new signalR.HubConnectionBuilder() - .withUrl('/rescuer-hub') - .withAutomaticReconnect() - .build(); - - // Register rescuer-specific events - connection.on('Joined', (data) => { - log(`Rescuer ${rescuerId.slice(-1)} joined: ${data.message}`, 'success'); - }); - - connection.on('NewRescueRequest', (data) => { - handleNewRequest(rescuerId, data); - }); - - connection.on('RequestAccepted', (data) => { - log(`✅ Request accepted! Mission ${data.missionId?.slice(0, 8)}...`, 'success'); - rescuerRequests[rescuerId] = { ...rescuerRequests[rescuerId], status: 'Accepted' }; - renderRescuers(); - }); - - connection.on('RequestTaken', (data) => { - log(`🔵 Request taken by another rescuer`, 'info'); - rescuerRequests[rescuerId] = { ...rescuerRequests[rescuerId], status: 'Taken' }; - renderRescuers(); - }); - - connection.on('RequestExpired', (data) => { - log(`⏰ Request expired for Rescuer ${rescuerId.slice(-1)}`, 'warning'); - rescuerRequests[rescuerId] = { ...rescuerRequests[rescuerId], status: 'Expired' }; - renderRescuers(); - }); - - connection.on('RequestCancelled', (data) => { - log(`❌ Request cancelled for Rescuer ${rescuerId.slice(-1)}`, 'warning'); - rescuerRequests[rescuerId] = { ...rescuerRequests[rescuerId], status: 'Cancelled' }; - renderRescuers(); - }); - - connection.on('MissionCancelled', (data) => { - log(`Mission cancelled: ${data.reason}`, 'warning'); - }); - - await connection.start(); - await connection.invoke('JoinAsRescuer', rescuerId); - - rescuerConnections[rescuerId] = connection; - log(`Rescuer ${rescuerId.slice(-1)} connected`, 'success'); - renderRescuers(); - - } catch (err) { - log(`Failed to connect Rescuer ${rescuerId.slice(-1)}: ${err.message}`, 'error'); - } + if (rescuerConnections[rescuerId]) { + log(`Rescuer ${rescuerId.slice(-1)} already connected`, 'warning'); + return; + } + + try { + const connection = new signalR.HubConnectionBuilder() + .withUrl('/rescuer-hub', { + // Server gửi ping mỗi 15s, client timeout sau 60s không nhận được message + timeout: 60000 // 60 seconds - PHẢI > server KeepAliveInterval (15s) + }) + .withAutomaticReconnect() + .build(); + + // Register rescuer-specific events + connection.on('Joined', (data) => { + log(`Rescuer ${rescuerId.slice(-1)} joined: ${data.message}`, 'success'); + }); + + connection.on('NewRescueRequest', (data) => { + handleNewRequest(rescuerId, data); + }); + + connection.on('RequestAccepted', (data) => { + log(`✅ Request accepted! Mission ${data.missionId?.slice(0, 8)}...`, 'success'); + rescuerRequests[rescuerId] = { ...rescuerRequests[rescuerId], status: 'Accepted' }; + renderRescuers(); + }); + + connection.on('RequestTaken', (data) => { + log(`🔵 Request taken by another rescuer`, 'info'); + rescuerRequests[rescuerId] = { ...rescuerRequests[rescuerId], status: 'Taken' }; + renderRescuers(); + }); + + connection.on('RequestExpired', (data) => { + log(`⏰ Request expired for Rescuer ${rescuerId.slice(-1)}`, 'warning'); + rescuerRequests[rescuerId] = { ...rescuerRequests[rescuerId], status: 'Expired' }; + renderRescuers(); + }); + + connection.on('RequestCancelled', (data) => { + log(`❌ Request cancelled for Rescuer ${rescuerId.slice(-1)}`, 'warning'); + rescuerRequests[rescuerId] = { ...rescuerRequests[rescuerId], status: 'Cancelled' }; + renderRescuers(); + }); + + connection.on('MissionCancelled', (data) => { + log(`Mission cancelled: ${data.reason}`, 'warning'); + }); + + await connection.start(); + await connection.invoke('JoinAsRescuer', rescuerId); + + rescuerConnections[rescuerId] = connection; + log(`Rescuer ${rescuerId.slice(-1)} connected`, 'success'); + renderRescuers(); + + } catch (err) { + log(`Failed to connect Rescuer ${rescuerId.slice(-1)}: ${err.message}`, 'error'); + } } async function disconnectRescuer(rescuerId) { - if (rescuerConnections[rescuerId]) { - await rescuerConnections[rescuerId].stop(); - delete rescuerConnections[rescuerId]; - delete rescuerRequests[rescuerId]; - log(`Rescuer ${rescuerId.slice(-1)} disconnected`, 'info'); - renderRescuers(); - } + if (rescuerConnections[rescuerId]) { + await rescuerConnections[rescuerId].stop(); + delete rescuerConnections[rescuerId]; + delete rescuerRequests[rescuerId]; + log(`Rescuer ${rescuerId.slice(-1)} disconnected`, 'info'); + renderRescuers(); + } } // ====== USER ACTIONS ====== async function createIncident() { - try { - const response = await fetch('/api/rescuedemo/incident/create', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ lat: 10.762622, lng: 106.660172 }) - }); - const data = await response.json(); - currentIncidentId = data.incident.id; + try { + log('Creating incident with REAL SnakebiteIncidentService...', 'info'); + const response = await fetch('/api/rescuedemo/incident/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ lat: 10.762622, lng: 106.660172 }) + }); - document.getElementById('btnCreateIncident').disabled = true; - document.getElementById('btnTriggerRescue').disabled = false; + if (!response.ok) { + throw new Error(await response.text()); + } + + const data = await response.json(); + currentIncidentId = data.incidentId; - log(`Incident created: ${currentIncidentId.slice(0, 8)}...`, 'success'); - updateIncidentUI(data.incident); - } catch (err) { - log('Failed to create incident: ' + err.message, 'error'); - } + document.getElementById('btnCreateIncident').disabled = true; + document.getElementById('btnTriggerRescue').disabled = false; + + log(`✅ Incident created in database: ${currentIncidentId.slice(0, 8)}...`, 'success'); + } catch (err) { + log('❌ Failed to create incident: ' + err.message, 'error'); + } } async function triggerRescue() { - if (!currentIncidentId) return; - try { - const response = await fetch(`/api/rescuedemo/incident/${currentIncidentId}/trigger`, { - method: 'POST' - }); - const data = await response.json(); + if (!currentIncidentId) return; + try { + log('Triggering rescue with REAL RescueRequestSessionService...', 'info'); + log('This will: 1) Create session, 2) Query rescuers from DB, 3) Broadcast via SignalR, 4) Register 60s timeout', 'info'); - document.getElementById('btnTriggerRescue').disabled = true; - document.getElementById('btnRaiseRange').disabled = false; - document.getElementById('btnCancelIncident').disabled = false; + const response = await fetch(`/api/rescuedemo/incident/${currentIncidentId}/trigger`, { + method: 'POST' + }); + + if (!response.ok) { + throw new Error(await response.text()); + } + + const data = await response.json(); - log(`Rescue triggered, Session ${data.session.sessionNumber}`, 'success'); - startTimer(60); - } catch (err) { - log('Failed to trigger rescue: ' + err.message, 'error'); - } + document.getElementById('btnTriggerRescue').disabled = true; + document.getElementById('btnRaiseRange').disabled = false; + document.getElementById('btnCancelIncident').disabled = false; + + log(`✅ ${data.message}`, 'success'); + log(`📡 Session ${data.sessionNumber} created, ${data.rescuersPinged} rescuers pinged`, 'info'); + log(`⏰ Real SessionTimeoutBackgroundService will handle timeout in 60s`, 'warning'); + } catch (err) { + log('❌ Failed to trigger rescue: ' + err.message, 'error'); + } } async function raiseRange() { - if (!currentIncidentId) return; - try { - const response = await fetch(`/api/rescuedemo/incident/${currentIncidentId}/raise-range`, { - method: 'POST' - }); - const data = await response.json(); - - if (data.incident?.status === 'NoRescuerFound') { - log('Max sessions reached. No rescuers found.', 'error'); - stopTimer(); - } else { - log(`Range expanded to ${data.session.radiusKm}km`, 'info'); - startTimer(60); - } - refreshState(); - } catch (err) { - log('Failed to raise range: ' + err.message, 'error'); - } + if (!currentIncidentId) return; + try { + const response = await fetch(`/api/rescuedemo/incident/${currentIncidentId}/raise-range`, { + method: 'POST' + }); + const data = await response.json(); + + if (data.incident?.status === 'NoRescuerFound') { + log('Max sessions reached. No rescuers found.', 'error'); + stopTimer(); + } else { + log(`Range expanded to ${data.session.radiusKm}km`, 'info'); + startTimer(60); + } + refreshState(); + } catch (err) { + log('Failed to raise range: ' + err.message, 'error'); + } } async function cancelIncident() { - if (!currentIncidentId) return; - try { - const response = await fetch(`/api/rescuedemo/incident/${currentIncidentId}/cancel`, { - method: 'POST' - }); - const data = await response.json(); + if (!currentIncidentId) return; + try { + const response = await fetch(`/api/rescuedemo/incident/${currentIncidentId}/cancel`, { + method: 'POST' + }); + const data = await response.json(); - document.getElementById('btnRaiseRange').disabled = true; - document.getElementById('btnCancelIncident').disabled = true; - stopTimer(); + document.getElementById('btnRaiseRange').disabled = true; + document.getElementById('btnCancelIncident').disabled = true; + stopTimer(); - log('Incident cancelled', 'warning'); - refreshState(); - } catch (err) { - log('Failed to cancel incident: ' + err.message, 'error'); - } + log('Incident cancelled', 'warning'); + refreshState(); + } catch (err) { + log('Failed to cancel incident: ' + err.message, 'error'); + } } // ====== RESCUER ACTIONS ====== async function acceptRequest(rescuerId) { - const request = rescuerRequests[rescuerId]; - if (!request) { - log('No active request to accept', 'warning'); - return; - } - - try { - const response = await fetch(`/api/rescuedemo/request/${request.requestId}/accept?rescuerId=${rescuerId}`, { - method: 'POST' - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.message || 'Accept failed'); - } - - const data = await response.json(); - currentMissionId = data.mission.id; - log(`Rescuer ${rescuerId.slice(-1)} accepted request!`, 'success'); - stopTimer(); - refreshState(); - } catch (err) { - log('Accept failed: ' + err.message, 'error'); - } + const request = rescuerRequests[rescuerId]; + if (!request) { + log('No active request to accept', 'warning'); + return; + } + + try { + const response = await fetch(`/api/rescuedemo/request/${request.requestId}/accept?rescuerId=${rescuerId}`, { + method: 'POST' + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Accept failed'); + } + + const data = await response.json(); + currentMissionId = data.mission.id; + log(`Rescuer ${rescuerId.slice(-1)} accepted request!`, 'success'); + stopTimer(); + refreshState(); + } catch (err) { + log('Accept failed: ' + err.message, 'error'); + } } async function rejectRequest(rescuerId) { - const request = rescuerRequests[rescuerId]; - if (!request) return; + const request = rescuerRequests[rescuerId]; + if (!request) return; - try { - await fetch(`/api/rescuedemo/request/${request.requestId}/reject`, { - method: 'POST' - }); + try { + await fetch(`/api/rescuedemo/request/${request.requestId}/reject`, { + method: 'POST' + }); - rescuerRequests[rescuerId] = { ...request, status: 'Rejected' }; - log(`Rescuer ${rescuerId.slice(-1)} rejected request`, 'info'); - renderRescuers(); - } catch (err) { - log('Reject failed: ' + err.message, 'error'); - } + rescuerRequests[rescuerId] = { ...request, status: 'Rejected' }; + log(`Rescuer ${rescuerId.slice(-1)} rejected request`, 'info'); + renderRescuers(); + } catch (err) { + log('Reject failed: ' + err.message, 'error'); + } } async function cancelMission(rescuerId) { - if (!currentMissionId) return; + if (!currentMissionId) return; - try { - const response = await fetch(`/api/rescuedemo/mission/${currentMissionId}/cancel?reason=Rescuer%20cancelled`, { - method: 'POST' - }); - const data = await response.json(); + try { + const response = await fetch(`/api/rescuedemo/mission/${currentMissionId}/cancel?reason=Rescuer%20cancelled`, { + method: 'POST' + }); + const data = await response.json(); - log('Mission cancelled by rescuer, new session created', 'warning'); - currentMissionId = null; - startTimer(60); - refreshState(); - } catch (err) { - log('Cancel mission failed: ' + err.message, 'error'); - } + log('Mission cancelled by rescuer, new session created', 'warning'); + currentMissionId = null; + startTimer(60); + refreshState(); + } catch (err) { + log('Cancel mission failed: ' + err.message, 'error'); + } } async function updateMissionStatus() { - const select = document.getElementById('missionStatusSelect'); - const status = select.value; - if (!status || !currentMissionId) return; - - try { - await fetch(`/api/rescuedemo/mission/${currentMissionId}/status?status=${status}`, { - method: 'POST' - }); - log(`Mission status updated to ${status}`, 'success'); - select.value = ''; - refreshState(); - } catch (err) { - log('Update status failed: ' + err.message, 'error'); - } + const select = document.getElementById('missionStatusSelect'); + const status = select.value; + if (!status || !currentMissionId) return; + + try { + await fetch(`/api/rescuedemo/mission/${currentMissionId}/status?status=${status}`, { + method: 'POST' + }); + log(`Mission status updated to ${status}`, 'success'); + select.value = ''; + refreshState(); + } catch (err) { + log('Update status failed: ' + err.message, 'error'); + } } // ====== EVENT HANDLERS ====== function handleNewRequest(rescuerId, data) { - rescuerRequests[rescuerId] = { - requestId: data.requestId, - sessionId: data.sessionId, - expiredAt: new Date(data.expiredAt), - status: 'Pending' - }; - log(`📨 New request for Rescuer ${rescuerId.slice(-1)} (${data.radiusKm}km)`, 'info'); - renderRescuers(); + rescuerRequests[rescuerId] = { + requestId: data.requestId, + sessionId: data.sessionId, + expiredAt: new Date(data.expiredAt), + status: 'Pending' + }; + log(`📨 New request for Rescuer ${rescuerId.slice(-1)} (${data.radiusKm}km)`, 'info'); + renderRescuers(); } function handleIncidentCreated(incident) { - log(`Incident ${incident.id.slice(0, 8)}... created`, 'info'); + log(`Incident ${incident.id.slice(0, 8)}... created`, 'info'); } function handleIncidentAssigned(data) { - log(`✅ Incident assigned to ${data.rescuerName}`, 'success'); - document.getElementById('btnRaiseRange').disabled = true; - refreshState(); + log(`✅ Incident assigned to ${data.rescuerName}`, 'success'); + document.getElementById('btnRaiseRange').disabled = true; + refreshState(); } function handleIncidentCancelled(incident) { - log('Incident cancelled', 'warning'); - stopTimer(); - refreshState(); + log('Incident cancelled', 'warning'); + stopTimer(); + refreshState(); } function handleNoRescuerFound(incident) { - log('❌ No rescuer found in area', 'error'); - stopTimer(); - refreshState(); + log('❌ No rescuer found in area', 'error'); + stopTimer(); + refreshState(); } function handleSessionCreated(data) { - log(`Session ${data.session.sessionNumber} created, ${data.rescuersPinged} rescuers pinged`, 'info'); - refreshState(); + log(`Session ${data.session.sessionNumber} created, ${data.rescuersPinged} rescuers pinged`, 'info'); + refreshState(); } function handleSessionTimeout(data) { - log(`⏰ Session timed out, ${data.expiredRequests} requests expired`, 'warning'); - refreshState(); + log(`⏰ Session timed out, ${data.expiredRequests} requests expired`, 'warning'); + refreshState(); } function handleSessionAutoExpanded(data) { - log(`📍 Auto-expanded to ${data.newSession.radiusKm}km`, 'info'); - startTimer(60); - refreshState(); + log(`📍 Auto-expanded to ${data.newSession.radiusKm}km`, 'info'); + startTimer(60); + refreshState(); } function handleMissionStatusUpdated(data) { - log(`Mission status: ${data.newStatus}`, 'success'); - refreshState(); + log(`Mission status: ${data.newStatus}`, 'success'); + refreshState(); } function handleMissionCancelledByRescuer(data) { - log('Mission cancelled by rescuer, finding new rescuer...', 'warning'); - currentMissionId = null; - startTimer(60); - refreshState(); + log('Mission cancelled by rescuer, finding new rescuer...', 'warning'); + currentMissionId = null; + startTimer(60); + refreshState(); } function handleDataReset(data) { - log('🗑️ All data reset', 'info'); - currentIncidentId = null; - currentMissionId = null; - Object.keys(rescuerRequests).forEach(k => delete rescuerRequests[k]); - stopTimer(); + log('🗑️ All data reset', 'info'); + currentIncidentId = null; + currentMissionId = null; + Object.keys(rescuerRequests).forEach(k => delete rescuerRequests[k]); + stopTimer(); - document.getElementById('btnCreateIncident').disabled = false; - document.getElementById('btnTriggerRescue').disabled = true; - document.getElementById('btnRaiseRange').disabled = true; - document.getElementById('btnCancelIncident').disabled = true; + document.getElementById('btnCreateIncident').disabled = false; + document.getElementById('btnTriggerRescue').disabled = true; + document.getElementById('btnRaiseRange').disabled = true; + document.getElementById('btnCancelIncident').disabled = true; - refreshState(); + refreshState(); } // ====== UI RENDERING ====== function renderRescuers() { - const grid = document.getElementById('rescuerGrid'); - grid.innerHTML = mockRescuers.map(r => { - const isConnected = !!rescuerConnections[r.id]; - const request = rescuerRequests[r.id]; - const hasRequest = request && request.status === 'Pending'; - - return ` -
-
${r.name}
-
📍 ${r.distance} km from user
-
- ${!isConnected ? ` - - ` : ` - - `} -
- ${request ? ` -
- Request: ${request.status} - ${request.status === 'Pending' ? ` -
- - -
- ` : ''} - ${request.status === 'Accepted' ? ` -
- -
- ` : ''} -
- ` : ''} -
- `; - }).join(''); + const grid = document.getElementById('rescuerGrid'); + grid.innerHTML = mockRescuers.map(r => { + const isConnected = !!rescuerConnections[r.id]; + const request = rescuerRequests[r.id]; + const hasRequest = request && request.status === 'Pending'; + + return ` +
+
${r.name}
+
📍 ${r.distance} km from user
+
+ ${!isConnected ? ` + + ` : ` + + `} +
+ ${request ? ` +
+ Request: ${request.status} + ${request.status === 'Pending' ? ` +
+ + +
+ ` : ''} + ${request.status === 'Accepted' ? ` +
+ +
+ ` : ''} +
+ ` : ''} +
+ `; + }).join(''); } function updateIncidentUI(incident) { - document.getElementById('incidentInfo').style.display = 'block'; - document.getElementById('incidentStatus').textContent = incident.status; - document.getElementById('incidentStatus').className = 'badge badge-' + incident.status.toLowerCase().replace(/\s+/g, '-'); - document.getElementById('currentSession').textContent = incident.currentSessionNumber || '-'; - document.getElementById('currentRadius').textContent = incident.currentRadiusKm || '-'; + document.getElementById('incidentInfo').style.display = 'block'; + document.getElementById('incidentStatus').textContent = incident.status; + document.getElementById('incidentStatus').className = 'badge badge-' + incident.status.toLowerCase().replace(/\s+/g, '-'); + document.getElementById('currentSession').textContent = incident.currentSessionNumber || '-'; + document.getElementById('currentRadius').textContent = incident.currentRadiusKm || '-'; } async function refreshState() { - try { - const response = await fetch('/api/rescuedemo/state'); - const state = await response.json(); - - // Update rescuer online status - mockRescuers.forEach(r => { - const stateRescuer = state.rescuers.find(sr => sr.id === r.id); - if (stateRescuer) { - r.isOnline = stateRescuer.isConnected; - } - }); - - // Update incident info - const incident = state.incidents[0]; - if (incident) { - currentIncidentId = incident.id; - updateIncidentUI(incident); - - // Update buttons based on status - const isPending = incident.status === 'Pending'; - const hasSession = incident.currentSessionNumber > 0; - - document.getElementById('btnCreateIncident').disabled = true; - document.getElementById('btnTriggerRescue').disabled = hasSession; - document.getElementById('btnRaiseRange').disabled = !isPending || !hasSession; - document.getElementById('btnCancelIncident').disabled = !isPending && incident.status !== 'Assigned'; - } else { - document.getElementById('incidentInfo').style.display = 'none'; - } - - // Update mission info - const mission = state.missions[0]; - if (mission) { - currentMissionId = mission.id; - const rescuer = state.rescuers.find(r => r.id === mission.rescuerId); - document.getElementById('missionInfo').style.display = 'block'; - document.getElementById('missionRescuer').textContent = rescuer?.name || mission.rescuerId; - document.getElementById('missionStatus').textContent = mission.status; - } else { - document.getElementById('missionInfo').style.display = 'none'; - } - - // Update sessions - renderSessions(state.sessions, state.requests); - - renderRescuers(); - } catch (err) { - console.error('Failed to refresh state:', err); - } - } - - function renderSessions(sessions, requests) { - const container = document.getElementById('sessionsContainer'); - - if (!sessions || sessions.length === 0) { - container.innerHTML = ` -
- No sessions yet. Create an incident to start. -
- `; - return; - } - - container.innerHTML = sessions - .sort((a, b) => b.sessionNumber - a.sessionNumber) - .map(session => { - const sessionRequests = requests.filter(r => r.sessionId === session.id); - const statusClass = session.status.toLowerCase(); - - return ` -
-
- Session #${session.sessionNumber} - ${session.triggerType} -
-
- Radius: ${session.radiusKm}km | Status: ${session.status} | Pinged: ${session.rescuersPinged} -
-
- ${sessionRequests.map(r => ` - ${r.status} - `).join('')} -
-
- `; - }).join(''); + try { + const response = await fetch('/api/rescuedemo/incident/monitor'); + const data = await response.json(); + + if (!data.hasIncident) { + // No active incident + document.getElementById('incidentInfo').style.display = 'none'; + document.getElementById('missionInfo').style.display = 'none'; + document.getElementById('sessionsContainer').innerHTML = ` +
+ No active incident. Create one to start monitoring. +
+ `; + return; + } + + // Update incident info + updateIncidentUI(data.incident); + + // Update mission info + if (data.mission) { + currentMissionId = data.mission.id; + document.getElementById('missionInfo').style.display = 'block'; + document.getElementById('missionRescuer').textContent = data.mission.rescuerName || data.mission.rescuerId; + document.getElementById('missionStatus').textContent = data.mission.status; + } else { + document.getElementById('missionInfo').style.display = 'none'; + } + + // Update buttons based on status + const isPending = data.incident.status === 'Pending'; + const hasSession = data.incident.currentSessionNumber > 0; + + document.getElementById('btnCreateIncident').disabled = true; + document.getElementById('btnTriggerRescue').disabled = hasSession; + document.getElementById('btnRaiseRange').disabled = !isPending || !hasSession; + document.getElementById('btnCancelIncident').disabled = !isPending && data.incident.status !== 'Assigned'; + + // Render sessions with requests + renderSessionsWithRequests(data.sessions, data.requests); + + renderRescuers(); + } catch (err) { + console.error('Failed to refresh state:', err); + } + } + + function renderSessionsWithRequests(sessions, requests) { + const container = document.getElementById('sessionsContainer'); + + if (!sessions || sessions.length === 0) { + container.innerHTML = ` +
+ No sessions yet. Create an incident to start. +
+ `; + return; + } + + container.innerHTML = sessions + .sort((a, b) => b.sessionNumber - a.sessionNumber) + .map(session => { + const sessionRequests = requests.filter(r => r.sessionId === session.id); + const statusClass = session.status.toLowerCase(); + + return ` +
+
+ Session #${session.sessionNumber} + ${session.triggerType} +
+
+ Radius: ${session.radiusKm}km | Status: ${session.status} | Pinged: ${session.rescuersPinged} +
+ ${sessionRequests.length > 0 ? ` +
+ Requests (${sessionRequests.length}): +
+ ${sessionRequests.map(r => ` + + ${r.rescuerName?.split(' - ')[0] || 'Rescuer'}: ${r.status} + + `).join('')} +
+
+ ` : ''} +
+ `; + }).join(''); + } + + // Auto-refresh monitoring data every 10 seconds + let monitoringInterval = null; + + function startMonitoring() { + if (monitoringInterval) return; + + // Initial fetch + refreshState(); + + // Poll every 10 seconds + monitoringInterval = setInterval(() => { + refreshState(); + }, 10000); + + log('📊 Real-time monitoring started (polling every 10s)', 'info'); + } + + function stopMonitoring() { + if (monitoringInterval) { + clearInterval(monitoringInterval); + monitoringInterval = null; + log('📊 Monitoring stopped', 'info'); + } + } + + function toggleMonitoring() { + const toggleText = document.getElementById('monitoringToggleText'); + if (monitoringInterval) { + stopMonitoring(); + toggleText.textContent = '▶️ Resume Monitoring'; + } else { + startMonitoring(); + toggleText.textContent = '⏸️ Pause Monitoring'; + } } // ====== TIMER ====== function startTimer(seconds) { - stopTimer(); - timerSeconds = seconds; + stopTimer(); + timerSeconds = seconds; - const timerEl = document.getElementById('sessionTimer'); - const valueEl = document.getElementById('timerValue'); - const progressEl = document.getElementById('timerProgress'); + const timerEl = document.getElementById('sessionTimer'); + const valueEl = document.getElementById('timerValue'); + const progressEl = document.getElementById('timerProgress'); - timerEl.style.display = 'block'; - timerEl.classList.remove('warning'); - valueEl.textContent = timerSeconds; - progressEl.style.width = '100%'; + timerEl.style.display = 'block'; + timerEl.classList.remove('warning'); + valueEl.textContent = timerSeconds; + progressEl.style.width = '100%'; - timerInterval = setInterval(() => { - timerSeconds--; - valueEl.textContent = timerSeconds; - progressEl.style.width = (timerSeconds / 60 * 100) + '%'; + timerInterval = setInterval(() => { + timerSeconds--; + valueEl.textContent = timerSeconds; + progressEl.style.width = (timerSeconds / 60 * 100) + '%'; - if (timerSeconds <= 10) { - timerEl.classList.add('warning'); - } + if (timerSeconds <= 10) { + timerEl.classList.add('warning'); + } - if (timerSeconds <= 0) { - stopTimer(); - } - }, 1000); + if (timerSeconds <= 0) { + stopTimer(); + } + }, 1000); } function stopTimer() { - if (timerInterval) { - clearInterval(timerInterval); - timerInterval = null; - } - document.getElementById('sessionTimer').style.display = 'none'; + if (timerInterval) { + clearInterval(timerInterval); + timerInterval = null; + } + document.getElementById('sessionTimer').style.display = 'none'; } // ====== UTILITIES ====== async function resetData() { - try { - await fetch('/api/rescuedemo/reset', { method: 'POST' }); - } catch (err) { - log('Reset failed: ' + err.message, 'error'); - } + try { + await fetch('/api/rescuedemo/reset', { method: 'POST' }); + } catch (err) { + log('Reset failed: ' + err.message, 'error'); + } } function log(message, type = 'info') { - const logEl = document.getElementById('eventLog'); - const time = new Date().toLocaleTimeString(); - const entry = document.createElement('div'); - entry.className = `log-entry ${type}`; - entry.innerHTML = `[${time}] ${message}`; - logEl.insertBefore(entry, logEl.firstChild); - - // Keep only last 50 entries - while (logEl.children.length > 50) { - logEl.removeChild(logEl.lastChild); - } + const logEl = document.getElementById('eventLog'); + const time = new Date().toLocaleTimeString(); + const entry = document.createElement('div'); + entry.className = `log-entry ${type}`; + entry.innerHTML = `[${time}] ${message}`; + logEl.insertBefore(entry, logEl.firstChild); + + // Keep only last 50 entries + while (logEl.children.length > 50) { + logEl.removeChild(logEl.lastChild); + } } diff --git a/SnakeAid.Api/Program.cs b/SnakeAid.Api/Program.cs index 27a73f99..e0053e21 100644 --- a/SnakeAid.Api/Program.cs +++ b/SnakeAid.Api/Program.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Scrutor; using Serilog; using Serilog.Ui.Core.Extensions; @@ -104,22 +105,25 @@ public static async Task Main(string[] args) builder.Services.AddServices(builder.Configuration); - // Register services using Scrutor + // Register services using Scrutor (excluding background services) builder.Services.Scan(scan => scan .FromAssemblies( typeof(Program).Assembly, // SnakeAid.Api typeof(SnakeAid.Core.Domains.BaseEntity).Assembly, // SnakeAid.Core typeof(SnakeAid.Service.Interfaces.IAuthService).Assembly, // SnakeAid.Service typeof(SnakeAid.Repository.Interfaces.IGenericRepository<>).Assembly) // SnakeAid.Repository - .AddClasses(classes => classes.Where(type => type.Name.EndsWith("Service") || type.Name.EndsWith("Repository"))) + .AddClasses(classes => classes + .Where(type => (type.Name.EndsWith("Service") || type.Name.EndsWith("Repository")) + && !type.Name.Contains("BackgroundService"))) // Exclude background services .AsImplementedInterfaces() .WithScopedLifetime()); - // Register SessionTimeoutBackgroundService manually as singleton for background service + // Register SessionTimeoutBackgroundService as singleton + // It implements both IHostedService and ISessionTimeoutService builder.Services.AddSingleton(); builder.Services.AddSingleton(provider => provider.GetRequiredService()); - builder.Services.AddHostedService(provider => + builder.Services.AddSingleton(provider => provider.GetRequiredService()); builder.Services.AddMemoryCache(); @@ -165,7 +169,7 @@ public static async Task Main(string[] args) builder.Services.AddSignalR(options => { options.EnableDetailedErrors = builder.Environment.IsDevelopment(); - options.KeepAliveInterval = TimeSpan.FromMinutes(1); + options.KeepAliveInterval = TimeSpan.FromSeconds(15); options.ClientTimeoutInterval = TimeSpan.FromMinutes(2); options.HandshakeTimeout = TimeSpan.FromSeconds(30); options.MaximumReceiveMessageSize = 64 * 1024; // 64KB @@ -325,6 +329,35 @@ public static async Task Main(string[] args) // Health checks endpoint app.MapHealthChecks("/health"); + // Test database connection endpoint + app.MapGet("/api/test/db", async (SnakeAidDbContext dbContext) => + { + try + { + var canConnect = await dbContext.Database.CanConnectAsync(); + if (canConnect) + { + var accountCount = await dbContext.MemberProfiles.CountAsync(); + return Results.Ok(new + { + status = "Connected", + message = "Database connection successful", + accountCount, + timestamp = DateTime.UtcNow + }); + } + return Results.Problem("Cannot connect to database"); + } + catch (Exception ex) + { + return Results.Problem( + detail: ex.Message, + title: "Database Connection Failed", + statusCode: 500 + ); + } + }).WithTags("Diagnostics"); + app.Run(); } catch (Exception ex) diff --git a/SnakeAid.Api/Services/DemoDataSeeder.cs b/SnakeAid.Api/Services/DemoDataSeeder.cs new file mode 100644 index 00000000..c5ae6753 --- /dev/null +++ b/SnakeAid.Api/Services/DemoDataSeeder.cs @@ -0,0 +1,303 @@ +using Bogus.Extensions.UnitedKingdom; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using NetTopologySuite.Geometries; +using SnakeAid.Core.Domains; +using SnakeAid.Repository.Data; + +namespace SnakeAid.Api.Services +{ + /// + /// Seeds demo data into database for testing rescue flow with real services + /// Creates Account + MemberProfile/RescuerProfile records + /// + public class DemoDataSeeder + { + private readonly UserManager _userManager; + private readonly SnakeAidDbContext _dbContext; + private readonly ILogger _logger; + + // Demo user IDs (fixed GUIDs for easy reference) + public static readonly Guid DEMO_USER_ID = Guid.Parse("11111111-1111-1111-1111-111111111111"); + public static readonly Guid DEMO_RESCUER_A_ID = Guid.Parse("22222222-2222-2222-2222-222222222221"); + public static readonly Guid DEMO_RESCUER_B_ID = Guid.Parse("22222222-2222-2222-2222-222222222222"); + public static readonly Guid DEMO_RESCUER_C_ID = Guid.Parse("22222222-2222-2222-2222-222222222223"); + public static readonly Guid DEMO_RESCUER_D_ID = Guid.Parse("22222222-2222-2222-2222-222222222224"); + + // Demo locations (HCMC area) + private static readonly GeometryFactory _geometryFactory = NetTopologySuite.NtsGeometryServices.Instance.CreateGeometryFactory(srid: 4326); + + public DemoDataSeeder( + UserManager userManager, + SnakeAidDbContext dbContext, + ILogger logger) + { + _userManager = userManager; + _dbContext = dbContext; + _logger = logger; + } + + /// + /// Seed demo users and rescuers into database + /// + public async Task SeedDemoDataAsync() + { + try + { + // Check if demo data already exists + var existingUser = await _userManager.FindByIdAsync(DEMO_USER_ID.ToString()); + if (existingUser != null) + { + _logger.LogInformation("Demo data already exists, skipping seed"); + return true; + } + + // Create demo victim user + var demoUser = new Account + { + Id = DEMO_USER_ID, + UserName = "demo_user", + Email = "demo.user@snakeaid.test", + FullName = "Demo User (Victim)", + PhoneNumber = "0901111111", + Role = AccountRole.User, + IsActive = true, + EmailConfirmed = true, + PhoneNumberConfirmed = true, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + var userResult = await _userManager.CreateAsync(demoUser, "Demo@123"); + if (!userResult.Succeeded) + { + _logger.LogError("Failed to create demo user: {Errors}", string.Join(", ", userResult.Errors.Select(e => e.Description))); + return false; + } + + // Create MemberProfile for demo user + var memberProfile = new MemberProfile + { + + AccountId = DEMO_USER_ID, + Rating = 0, + RatingCount = 0, + EmergencyContacts = new List { "0909999999" }, + HasUnderlyingDisease = false, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + await _dbContext.MemberProfiles.AddAsync(memberProfile); + await _dbContext.SaveChangesAsync(); + + // Create demo rescuers with locations + var rescuers = new[] + { + new { Id = DEMO_RESCUER_A_ID, Name = "Rescuer A - Quận 1", Phone = "0902222221", Lng = 106.699800, Lat = 10.775400 }, // Bến Thành + new { Id = DEMO_RESCUER_B_ID, Name = "Rescuer B - Quận 3", Phone = "0902222222", Lng = 106.682166, Lat = 10.776889 }, // Lý Thái Tổ + new { Id = DEMO_RESCUER_C_ID, Name = "Rescuer C - Quận 7", Phone = "0902222223", Lng = 106.722550, Lat = 10.733200 }, // Phú Mỹ Hưng + new { Id = DEMO_RESCUER_D_ID, Name = "Rescuer D - Tân Bình", Phone = "0902222224", Lng = 106.652344, Lat = 10.799862 } // Sân bay TSN + }; + + foreach (var r in rescuers) + { + var rescuerAccount = new Account + { + Id = r.Id, + UserName = r.Phone, + Email = $"{r.Phone}@snakeaid.test", + FullName = r.Name, + PhoneNumber = r.Phone, + Role = AccountRole.Rescuer, + IsActive = true, + EmailConfirmed = true, + PhoneNumberConfirmed = true, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + var rescuerResult = await _userManager.CreateAsync(rescuerAccount, "Demo@123"); + if (!rescuerResult.Succeeded) + { + _logger.LogError("Failed to create {Name}: {Errors}", r.Name, string.Join(", ", rescuerResult.Errors.Select(e => e.Description))); + continue; + } + + // Create RescuerProfile with location + var rescuerProfile = new RescuerProfile + { + AccountId = r.Id, + IsOnline = false, // Will be set to true when they connect via SignalR + Rating = 0, + RatingCount = 0, + Type = RescuerType.Emergency, + LastLocation = _geometryFactory.CreatePoint(new Coordinate(r.Lng, r.Lat)), + LastLocationUpdate = DateTime.UtcNow, + TotalMissions = 0, + CompletedMissions = 0, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + await _dbContext.RescuerProfiles.AddAsync(rescuerProfile); + } + + await _dbContext.SaveChangesAsync(); + + _logger.LogInformation("Demo data seeded successfully: 1 user + 4 rescuers with profiles"); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to seed demo data"); + return false; + } + } + + /// + /// Clean up all demo data (missions, incidents, sessions, requests, profiles, accounts) + /// + public async Task CleanupDemoDataAsync() + { + try + { + var demoUserIds = new[] { DEMO_USER_ID, DEMO_RESCUER_A_ID, DEMO_RESCUER_B_ID, DEMO_RESCUER_C_ID, DEMO_RESCUER_D_ID }; + + _logger.LogInformation("Starting cleanup of demo data..."); + + // 1. Get all demo incidents (created by demo user) + var demoIncidentIds = await _dbContext.SnakebiteIncidents + .Where(i => demoUserIds.Contains(i.UserId)) + .Select(i => i.Id) + .ToListAsync(); + + _logger.LogInformation("Found {Count} demo incidents", demoIncidentIds.Count); + + // 2. Delete RescuerRequests (child of RescueRequestSession) + var rescuerRequests = await _dbContext.RescuerRequests + .Where(r => demoIncidentIds.Contains(r.IncidentId) || demoUserIds.Contains(r.RescuerId)) + .ToListAsync(); + _dbContext.RescuerRequests.RemoveRange(rescuerRequests); + _logger.LogInformation("Removing {Count} rescuer requests", rescuerRequests.Count); + + // 3. Delete RescueRequestSessions (child of SnakebiteIncident) + var sessions = await _dbContext.RescueRequestSessions + .Where(s => demoIncidentIds.Contains(s.IncidentId)) + .ToListAsync(); + _dbContext.RescueRequestSessions.RemoveRange(sessions); + _logger.LogInformation("Removing {Count} rescue sessions", sessions.Count); + + // 4. Delete ConsultationPingRequests (if any related to rescue missions) + var missionIds = await _dbContext.RescueMissions + .Where(m => demoUserIds.Contains(m.RescuerId) || demoIncidentIds.Contains(m.IncidentId)) + .Select(m => m.Id) + .ToListAsync(); + + var consultationPings = await _dbContext.ConsultationPingRequests + .Where(c => c.RescueMissionId.HasValue && missionIds.Contains(c.RescueMissionId.Value)) + .ToListAsync(); + _dbContext.ConsultationPingRequests.RemoveRange(consultationPings); + _logger.LogInformation("Removing {Count} consultation pings", consultationPings.Count); + + // 5. Delete RescueMissions + var missions = await _dbContext.RescueMissions + .Where(m => missionIds.Contains(m.Id)) + .ToListAsync(); + _dbContext.RescueMissions.RemoveRange(missions); + _logger.LogInformation("Removing {Count} rescue missions", missions.Count); + + // 6. Delete SnakebiteIncidents + var incidents = await _dbContext.SnakebiteIncidents + .Where(i => demoIncidentIds.Contains(i.Id)) + .ToListAsync(); + _dbContext.SnakebiteIncidents.RemoveRange(incidents); + _logger.LogInformation("Removing {Count} snakebite incidents", incidents.Count); + + // 7. Delete AppNotifications for demo users + var notifications = await _dbContext.AppNotifications + .Where(n => demoUserIds.Contains(n.UserId)) + .ToListAsync(); + _dbContext.AppNotifications.RemoveRange(notifications); + _logger.LogInformation("Removing {Count} notifications", notifications.Count); + + // 8. Delete Transactions for demo users (if any) + var transactions = await _dbContext.Transactions + .Where(t => demoUserIds.Contains(t.UserId)) + .ToListAsync(); + _dbContext.Transactions.RemoveRange(transactions); + _logger.LogInformation("Removing {Count} transactions", transactions.Count); + + // 9. Delete Profiles + var memberProfiles = await _dbContext.MemberProfiles + .Where(m => demoUserIds.Contains(m.AccountId)) + .ToListAsync(); + _dbContext.MemberProfiles.RemoveRange(memberProfiles); + _logger.LogInformation("Removing {Count} member profiles", memberProfiles.Count); + + var rescuerProfiles = await _dbContext.RescuerProfiles + .Where(r => demoUserIds.Contains(r.AccountId)) + .ToListAsync(); + _dbContext.RescuerProfiles.RemoveRange(rescuerProfiles); + _logger.LogInformation("Removing {Count} rescuer profiles", rescuerProfiles.Count); + + // Save all deletions + await _dbContext.SaveChangesAsync(); + _logger.LogInformation("All related data deleted from database"); + + // 10. Delete accounts (through UserManager for proper cleanup) + foreach (var userId in demoUserIds) + { + var account = await _userManager.FindByIdAsync(userId.ToString()); + if (account != null) + { + var result = await _userManager.DeleteAsync(account); + if (result.Succeeded) + { + _logger.LogInformation("Deleted account {UserId}", userId); + } + else + { + _logger.LogWarning("Failed to delete account {UserId}: {Errors}", + userId, string.Join(", ", result.Errors.Select(e => e.Description))); + } + } + } + + _logger.LogInformation("Demo data cleanup completed successfully"); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to clean up demo data"); + return false; + } + } + + /// + /// Get demo data status + /// + public async Task GetStatusAsync() + { + var status = new DemoDataStatus(); + + // Check users + status.UserExists = await _userManager.FindByIdAsync(DEMO_USER_ID.ToString()) != null; + status.RescuerAExists = await _userManager.FindByIdAsync(DEMO_RESCUER_A_ID.ToString()) != null; + status.RescuerBExists = await _userManager.FindByIdAsync(DEMO_RESCUER_B_ID.ToString()) != null; + status.RescuerCExists = await _userManager.FindByIdAsync(DEMO_RESCUER_C_ID.ToString()) != null; + status.RescuerDExists = await _userManager.FindByIdAsync(DEMO_RESCUER_D_ID.ToString()) != null; + + return status; + } + } + + public class DemoDataStatus + { + public bool UserExists { get; set; } + public bool RescuerAExists { get; set; } + public bool RescuerBExists { get; set; } + public bool RescuerCExists { get; set; } + public bool RescuerDExists { get; set; } + + public bool IsSeeded => UserExists && RescuerAExists && RescuerBExists && RescuerCExists && RescuerDExists; + } +} diff --git a/SnakeAid.Api/Services/SignalRRescueNotificationService.cs b/SnakeAid.Api/Services/SignalRRescueNotificationService.cs index 0d1d88b4..8ce6d6f6 100644 --- a/SnakeAid.Api/Services/SignalRRescueNotificationService.cs +++ b/SnakeAid.Api/Services/SignalRRescueNotificationService.cs @@ -97,29 +97,45 @@ public async Task NotifyRequestExpiredAsync(string rescuerId, Guid requestId) { try { + _logger.LogInformation("Sending RequestExpired notification to rescuer {RescuerId} (connectionId: {ConnectionId})", + rescuerId, connectionId); + await _hubContext.Clients.Client(connectionId).SendAsync("RequestExpired", new { RequestId = requestId, Message = "This request has expired." }); + + _logger.LogInformation("Successfully sent RequestExpired notification to rescuer {RescuerId}", rescuerId); } catch (Exception ex) { _logger.LogError(ex, "Error notifying rescuer {RescuerId} about expired request: {Message}", rescuerId, ex.Message); } } + else + { + _logger.LogWarning("Cannot send RequestExpired to rescuer {RescuerId} - not in ConnectedRescuers dictionary. Current connections: {Count}", + rescuerId, ConnectedRescuers.Count); + } } #region Static methods for Hub to manage connections public static void AddConnection(string userId, string connectionId) { + var sizeBefore = ConnectedRescuers.Count; ConnectedRescuers[userId] = connectionId; + var sizeAfter = ConnectedRescuers.Count; + Console.WriteLine($"[SignalR] AddConnection: userId={userId}, connId={connectionId}, size: {sizeBefore}→{sizeAfter}"); } public static void RemoveConnection(string userId) { - ConnectedRescuers.TryRemove(userId, out _); + var sizeBefore = ConnectedRescuers.Count; + var removed = ConnectedRescuers.TryRemove(userId, out var removedConnId); + var sizeAfter = ConnectedRescuers.Count; + Console.WriteLine($"[SignalR] RemoveConnection: userId={userId}, removed={removed}, connId={removedConnId}, size: {sizeBefore}→{sizeAfter}"); } public static string? GetConnectionId(string userId) diff --git a/SnakeAid.Core/Domains/SnakebiteIncident.cs b/SnakeAid.Core/Domains/SnakebiteIncident.cs index c6072df2..1b9a50e3 100644 --- a/SnakeAid.Core/Domains/SnakebiteIncident.cs +++ b/SnakeAid.Core/Domains/SnakebiteIncident.cs @@ -17,7 +17,7 @@ public class SnakebiteIncident : BaseEntity [ForeignKey(nameof(User))] public Guid UserId { get; set; } // FK to MemberProfile - [Required] + [Required] [Column(TypeName = "geometry(Point, 4326)")] public Point LocationCoordinates { get; set; } @@ -32,7 +32,7 @@ public class SnakebiteIncident : BaseEntity public int CurrentSessionNumber { get; set; } = 0; // Track session hiện tại [Required] - [Range(1, 50)] + [Range(0, 50)] public int CurrentRadiusKm { get; set; } = 5; // Radius hiện tại public DateTime? LastSessionAt { get; set; } // Tránh spam sessions diff --git a/SnakeAid.Repository/Implements/GenericRepository.cs b/SnakeAid.Repository/Implements/GenericRepository.cs index 454a784a..89e07ab6 100644 --- a/SnakeAid.Repository/Implements/GenericRepository.cs +++ b/SnakeAid.Repository/Implements/GenericRepository.cs @@ -211,14 +211,28 @@ public virtual bool Update(T entity) try { - var entityEntry = _dbContext.Entry(entity); + // Get primary key values + var keyValues = GetKeyValues(entity); + if (keyValues == null || keyValues.Length == 0) + return false; - // If entity is detached (not being tracked), attach it - if (entityEntry.State == EntityState.Detached) + // Check if entity with same key is already tracked + var tracked = _dbSet.Local.FirstOrDefault(e => + GetKeyValues(e)?.SequenceEqual(keyValues) == true); + + if (tracked != null) + { + // Update the tracked entity's values instead of attaching new instance + _dbContext.Entry(tracked).CurrentValues.SetValues(entity); + return true; + } + + // Not tracked - safe to attach and mark as modified + var entry = _dbContext.Entry(entity); + if (entry.State == EntityState.Detached) _dbSet.Attach(entity); - // Mark entity as modified - entityEntry.State = EntityState.Modified; + entry.State = EntityState.Modified; return true; } catch (Exception) @@ -227,6 +241,16 @@ public virtual bool Update(T entity) } } + private object[]? GetKeyValues(T entity) + { + var key = _dbContext.Model.FindEntityType(typeof(T))?.FindPrimaryKey(); + if (key == null) return null; + + return key.Properties + .Select(p => _dbContext.Entry(entity).Property(p.Name).CurrentValue!) + .ToArray(); + } + public virtual bool UpdateProperties( T entity, params Expression>[] propertiesToUpdate) diff --git a/SnakeAid.Service/Implements/RescueRequestSessionService.cs b/SnakeAid.Service/Implements/RescueRequestSessionService.cs index 539a67b9..ad4914e0 100644 --- a/SnakeAid.Service/Implements/RescueRequestSessionService.cs +++ b/SnakeAid.Service/Implements/RescueRequestSessionService.cs @@ -26,85 +26,87 @@ public class RescueRequestSessionService : IRescueRequestSessionService private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly IConfiguration _configuration; - private readonly IRescueMissionService _missionService; private readonly IRescueNotificationService _notificationService; private readonly ISessionTimeoutService _timeoutService; // Configuration constants (sau này lấy từ SystemSetting) private const int MAX_SESSIONS = 3; private const int REQUEST_TIMEOUT_SECONDS = 60; + private const decimal DEFAULT_RESCUE_PRICE = 500000m; private static readonly int[] RADIUS_PROGRESSION = { 10, 20, 30 }; // km public RescueRequestSessionService( IUnitOfWork unitOfWork, ILogger logger, IConfiguration configuration, - IRescueMissionService missionService, IRescueNotificationService notificationService, ISessionTimeoutService timeoutService) { _unitOfWork = unitOfWork; _logger = logger; _configuration = configuration; - _missionService = missionService; _notificationService = notificationService; _timeoutService = timeoutService; } - /// Tạo session mới cho incident (initial hoặc expand) + /// Tạo session mới cho incident (initial hoặc expand) - Internal version without transaction + private async Task CreateSessionInternalAsync(Guid incidentId, int sessionNumber, int radiusKm, SessionTrigger trigger) + { + var incident = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: i => i.Id == incidentId + ); + + if (incident == null) + { + throw new NotFoundException("Incident not found."); + } + + // Validate max sessions + if (sessionNumber > MAX_SESSIONS) + { + incident.Status = SnakebiteIncidentStatus.NoRescuerFound; + _unitOfWork.GetRepository().Update(incident); + throw new BadRequestException($"Maximum sessions ({MAX_SESSIONS}) reached. No rescuers found."); + } + + var session = new RescueRequestSession + { + Id = Guid.NewGuid(), + IncidentId = incidentId, + SessionNumber = sessionNumber, + RadiusKm = radiusKm, + Status = SessionStatus.Active, + TriggerType = trigger, + RescuersPinged = 0, + CreatedAt = DateTime.UtcNow + }; + + // Update incident tracking + incident.CurrentSessionNumber = sessionNumber; + incident.CurrentRadiusKm = radiusKm; + incident.LastSessionAt = DateTime.UtcNow; + + await _unitOfWork.GetRepository().InsertAsync(session); + _unitOfWork.GetRepository().Update(incident); + + // Schedule timeout monitoring for this session + var timeoutAt = DateTime.UtcNow.AddSeconds(REQUEST_TIMEOUT_SECONDS); + _timeoutService.ScheduleSessionTimeout(session.Id, timeoutAt); + + _logger.LogInformation("Created session {SessionId} for incident {IncidentId}, radius {RadiusKm}km, trigger {Trigger}, timeout at {TimeoutAt}", + session.Id, incidentId, radiusKm, trigger, timeoutAt); + + return session; + } + + /// Tạo session mới cho incident (initial hoặc expand) - Public version with transaction public async Task CreateSessionAsync(Guid incidentId, int sessionNumber, int radiusKm, SessionTrigger trigger) { try { return await _unitOfWork.ExecuteInTransactionAsync(async () => { - var incident = await _unitOfWork.GetRepository().FirstOrDefaultAsync( - predicate: i => i.Id == incidentId - ); - - if (incident == null) - { - throw new NotFoundException("Incident not found."); - } - - // Validate max sessions - if (sessionNumber > MAX_SESSIONS) - { - incident.Status = SnakebiteIncidentStatus.NoRescuerFound; - _unitOfWork.GetRepository().Update(incident); - await _unitOfWork.CommitAsync(); - throw new BadRequestException($"Maximum sessions ({MAX_SESSIONS}) reached. No rescuers found."); - } - - var session = new RescueRequestSession - { - Id = Guid.NewGuid(), - IncidentId = incidentId, - SessionNumber = sessionNumber, - RadiusKm = radiusKm, - Status = SessionStatus.Active, - TriggerType = trigger, - RescuersPinged = 0, - CreatedAt = DateTime.UtcNow - }; - - // Update incident tracking - incident.CurrentSessionNumber = sessionNumber; - incident.CurrentRadiusKm = radiusKm; - incident.LastSessionAt = DateTime.UtcNow; - - await _unitOfWork.GetRepository().InsertAsync(session); - _unitOfWork.GetRepository().Update(incident); - await _unitOfWork.CommitAsync(); - - // Schedule timeout monitoring for this session - var timeoutAt = DateTime.UtcNow.AddSeconds(REQUEST_TIMEOUT_SECONDS); - _timeoutService.ScheduleSessionTimeout(session.Id, timeoutAt); - - _logger.LogInformation("Created session {SessionId} for incident {IncidentId}, radius {RadiusKm}km, trigger {Trigger}, timeout at {TimeoutAt}", - session.Id, incidentId, radiusKm, trigger, timeoutAt); - - return session; + return await CreateSessionInternalAsync(incidentId, sessionNumber, radiusKm, trigger); }); } catch (Exception ex) @@ -114,114 +116,158 @@ public async Task CreateSessionAsync(Guid incidentId, int } } - public async Task BroadcastRequestsAsync(Guid sessionId) + /// Broadcast requests to rescuers - Internal version without transaction (accepts session object) + private async Task BroadcastRequestsInternalAsync(RescueRequestSession session) { - try + if (session == null) { - await _unitOfWork.ExecuteInTransactionAsync(async () => + throw new ArgumentNullException(nameof(session)); + } + + // Ensure incident is loaded + if (session.Incident == null) + { + var sessionIncident = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: i => i.Id == session.IncidentId + ); + if (sessionIncident == null) { - var session = await _unitOfWork.GetRepository().FirstOrDefaultAsync( - predicate: s => s.Id == sessionId, - include: q => q.Include(s => s.Incident) - ); + throw new NotFoundException("Incident not found."); + } + session.Incident = sessionIncident; + } - if (session == null) - { - throw new NotFoundException("Session not found."); - } + // Validate session is still active before broadcasting + if (session.Status != SessionStatus.Active) + { + _logger.LogWarning("Cannot broadcast requests for session {SessionId} with status {Status}", + session.Id, session.Status); + throw new BadRequestException($"Cannot broadcast requests for session with status: {session.Status}"); + } - // Validate session is still active before broadcasting - if (session.Status != SessionStatus.Active) - { - _logger.LogWarning("Cannot broadcast requests for session {SessionId} with status {Status}", - sessionId, session.Status); - throw new BadRequestException($"Cannot broadcast requests for session with status: {session.Status}"); - } + var incident = session.Incident; - var incident = session.Incident; + // Query rescuers online trong radius bằng PostGIS + _logger.LogInformation("Querying rescuers for session {SessionId}, incident {IncidentId}, radius {RadiusKm}km", + session.Id, session.IncidentId, session.RadiusKm); - // Query rescuers online trong radius bằng PostGIS - var rescuersInRadius = await GetRescuersInRadiusAsync( - incident.LocationCoordinates, - session.RadiusKm - ); + var rescuersInRadius = await GetRescuersInRadiusAsync( + incident.LocationCoordinates, + session.RadiusKm + ); - if (!rescuersInRadius.Any()) - { - _logger.LogWarning("No rescuers found in {RadiusKm}km radius for session {SessionId}", - session.RadiusKm, sessionId); - return session; - } + _logger.LogInformation("Found {Count} rescuers in {RadiusKm}km radius for session {SessionId}", + rescuersInRadius.Count, session.RadiusKm, session.Id); - // Get rescuer IDs for filtering - var rescuerIds = rescuersInRadius.Select(r => r.AccountId).ToList(); + if (!rescuersInRadius.Any()) + { + _logger.LogWarning("No rescuers found in {RadiusKm}km radius for session {SessionId}", + session.RadiusKm, session.Id); + return session; + } - // Query existing pending requests for these rescuers to avoid double-ping - var rescuersWithPending = await _unitOfWork.GetRepository() - .CreateBaseQuery() - .Where(r => rescuerIds.Contains(r.RescuerId) && r.Status == RescueRequestStatus.Pending) - .Select(r => r.RescuerId) - .Distinct() - .ToListAsync(); + // Get rescuer IDs for filtering + var rescuerIds = rescuersInRadius.Select(r => r.AccountId).ToList(); + + _logger.LogInformation("Checking for existing pending requests (excluding current incident {IncidentId}) for {Count} rescuers", + session.IncidentId, rescuerIds.Count); + + // Query existing pending requests for these rescuers to avoid double-ping + // IMPORTANT: Exclude pending requests for the CURRENT incident (allow re-ping in new session) + // Only skip rescuers who have pending requests for OTHER incidents + var rescuersWithPending = await _unitOfWork.GetRepository() + .CreateBaseQuery() + .Where(r => rescuerIds.Contains(r.RescuerId) + && r.Status == RescueRequestStatus.Pending + && r.IncidentId != session.IncidentId) // Exclude current incident's requests + .Select(r => r.RescuerId) + .Distinct() + .ToListAsync(); - var rescuersWithPendingSet = new HashSet(rescuersWithPending); + _logger.LogInformation("Found {Count} rescuers with pending requests for OTHER incidents", + rescuersWithPending.Count); - var expiredAt = DateTime.UtcNow.AddSeconds(REQUEST_TIMEOUT_SECONDS); - var requests = new List(); + var rescuersWithPendingSet = new HashSet(rescuersWithPending); - // only for rescuers without any pending request - foreach (var rescuer in rescuersInRadius) - { - var rescuerId = rescuer.AccountId; + var expiredAt = DateTime.UtcNow.AddSeconds(REQUEST_TIMEOUT_SECONDS); + var requests = new List(); - // Skip if rescuer already has a pending request (preserve existing session) - if (rescuersWithPendingSet.Contains(rescuerId)) - { - _logger.LogDebug( - "Skipping rescuer {RescuerId} - already has pending request for another incident (preserving existing session)", - rescuerId); - continue; - } + // only for rescuers without any pending request + foreach (var rescuer in rescuersInRadius) + { + var rescuerId = rescuer.AccountId; - requests.Add(new RescuerRequest - { - Id = Guid.NewGuid(), - SessionId = sessionId, - IncidentId = incident.Id, - RescuerId = rescuerId, - Status = RescueRequestStatus.Pending, - RequestSentAt = DateTime.UtcNow, - ExpiredAt = expiredAt, - CreatedAt = DateTime.UtcNow - }); - } + // Skip if rescuer already has a pending request (preserve existing session) + if (rescuersWithPendingSet.Contains(rescuerId)) + { + _logger.LogDebug( + "Skipping rescuer {RescuerId} - already has pending request for ANOTHER incident (preserving existing session)", + rescuerId); + continue; + } - // Bulk insert all requests at once - await _unitOfWork.GetRepository().InsertRangeAsync(requests); + requests.Add(new RescuerRequest + { + Id = Guid.NewGuid(), + SessionId = session.Id, + IncidentId = incident.Id, + RescuerId = rescuerId, + Status = RescueRequestStatus.Pending, + RequestSentAt = DateTime.UtcNow, + ExpiredAt = expiredAt, + CreatedAt = DateTime.UtcNow + }); + } - // Create lookup dictionary for O(1) rescuer lookup (optimization) - var rescuerLookup = rescuersInRadius.ToDictionary(r => r.AccountId, r => r.AccountId.ToString()); + _logger.LogInformation("Created {RequestCount} requests for session {SessionId} ({SkippedCount} rescuers skipped due to pending requests for other incidents)", + requests.Count, session.Id, rescuersInRadius.Count - requests.Count); - // Push notifications to all connected rescuers (parallel execution) - var notificationTasks = requests.Select(request => - SendRequestToRescuerAsync( - rescuerLookup[request.RescuerId], // O(1) lookup instead of O(n) - request, - session - ) - ); - await Task.WhenAll(notificationTasks); + // Bulk insert all requests at once + await _unitOfWork.GetRepository().InsertRangeAsync(requests); - // Update session tracking and commit - session.RescuersPinged = requests.Count; - _unitOfWork.GetRepository().Update(session); - await _unitOfWork.CommitAsync(); + // Create lookup dictionary for O(1) rescuer lookup (optimization) + var rescuerLookup = rescuersInRadius.ToDictionary(r => r.AccountId, r => r.AccountId.ToString()); - _logger.LogInformation("Successfully broadcasted {Count} requests for session {SessionId}, radius {RadiusKm}km. " + - "All data committed to database.", - requests.Count, sessionId, session.RadiusKm); + // Push notifications to all connected rescuers (parallel execution) + var notificationTasks = requests.Select(request => + SendRequestToRescuerAsync( + rescuerLookup[request.RescuerId], // O(1) lookup instead of O(n) + request, + session + ) + ); + await Task.WhenAll(notificationTasks); - return session; + // Update session tracking + session.RescuersPinged = requests.Count; + _unitOfWork.GetRepository().Update(session); + + _logger.LogInformation("Successfully broadcasted {Count} requests for session {SessionId}, radius {RadiusKm}km. " + + "All data committed to database.", + requests.Count, session.Id, session.RadiusKm); + + return session; + } + + /// Broadcast requests to rescuers - Public version with transaction + public async Task BroadcastRequestsAsync(Guid sessionId) + { + try + { + await _unitOfWork.ExecuteInTransactionAsync(async () => + { + // Query session from database + var session = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: s => s.Id == sessionId, + include: q => q.Include(s => s.Incident) + ); + + if (session == null) + { + throw new NotFoundException("Session not found."); + } + + return await BroadcastRequestsInternalAsync(session); }); } catch (Exception ex) @@ -238,6 +284,22 @@ private async Task> GetRescuersInRadiusAsync(Point incident // Convert km to meters for PostGIS distance calculation var radiusMeters = radiusKm * 1000; + _logger.LogDebug("Querying rescuers: radius {RadiusKm}km ({RadiusMeters}m), location ({Lng}, {Lat})", + radiusKm, radiusMeters, incidentLocation.X, incidentLocation.Y); + + // First, get ALL rescuer profiles to see their IsOnline status + var allRescuers = await _unitOfWork.GetRepository() + .CreateBaseQuery(asNoTracking: true) + .Where(r => r.LastLocation != null) + .Where(r => r.Type == RescuerType.Emergency || r.Type == RescuerType.Both) + .Select(r => new { r.AccountId, r.IsOnline }) + .ToListAsync(); + + _logger.LogWarning("DEBUG: All rescuer profiles in DB - Total: {Count}, Online: {OnlineCount}, IDs: {RescuerStatuses}", + allRescuers.Count, + allRescuers.Count(r => r.IsOnline), + string.Join(", ", allRescuers.Select(r => $"{r.AccountId}:{(r.IsOnline ? "ONLINE" : "OFFLINE")}"))); + // Sử dụng CreateBaseQuery() theo pattern của GenericRepository var rescuers = await _unitOfWork.GetRepository() .CreateBaseQuery(asNoTracking: true) @@ -248,11 +310,24 @@ private async Task> GetRescuersInRadiusAsync(Point incident .OrderBy(r => r.LastLocation!.Distance(incidentLocation)) .ToListAsync(); + _logger.LogInformation("Database query found {Count} rescuers (IsOnline=true, within {RadiusKm}km)", + rescuers.Count, radiusKm); + + // Check SignalR connection status + var connectedCount = rescuers.Count(r => _notificationService.IsRescuerConnected(r.AccountId.ToString())); + _logger.LogWarning("DEBUG: SignalR connections - {ConnectedCount} of {TotalCount} rescuers are connected: {ConnectedIds}", + connectedCount, + rescuers.Count, + string.Join(", ", rescuers.Select(r => $"{r.AccountId}:{(_notificationService.IsRescuerConnected(r.AccountId.ToString()) ? "CONNECTED" : "NOT-CONNECTED")}"))); + // Filter chỉ những rescuer đang connected tới hub (via notification service) var connectedRescuers = rescuers .Where(r => _notificationService.IsRescuerConnected(r.AccountId.ToString())) .ToList(); + _logger.LogInformation("After SignalR connection filter: {ConnectedCount} of {TotalCount} rescuers are connected to hub", + connectedRescuers.Count, rescuers.Count); + return connectedRescuers; } @@ -279,26 +354,30 @@ public async Task HandleSessionTimeoutAsync(Guid sessionId) { try { - await _unitOfWork.ExecuteInTransactionAsync(async () => - { - var session = await _unitOfWork.GetRepository().FirstOrDefaultAsync( - predicate: s => s.Id == sessionId, - include: q => q.Include(s => s.Requests).Include(s => s.Incident) - ); + var session = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: s => s.Id == sessionId, + include: q => q.Include(s => s.Requests).Include(s => s.Incident) + ); - if (session == null) - { - throw new NotFoundException("Session not found."); - } + if (session == null) + { + // Session may have been deleted (incident cancelled, already processed, etc.) + // This is normal for background cleanup - just log and skip + _logger.LogWarning("Session {SessionId} not found during timeout handling - may have been cancelled or already processed", + sessionId); + return; + } - // Skip nếu session đã complete hoặc cancelled - if (session.Status != SessionStatus.Active) - { - _logger.LogInformation("Session {SessionId} already {Status}, skipping timeout handling", - sessionId, session.Status); - return session; - } + // Skip nếu session đã complete hoặc cancelled + if (session.Status != SessionStatus.Active) + { + _logger.LogInformation("Session {SessionId} already {Status}, skipping timeout handling", + sessionId, session.Status); + return; + } + await _unitOfWork.ExecuteInTransactionAsync(async () => + { // Mark all pending requests as expired (bulk update for better performance) var pendingRequests = session.Requests.Where(r => r.Status == RescueRequestStatus.Pending).ToList(); if (pendingRequests.Any()) @@ -317,7 +396,6 @@ await _unitOfWork.ExecuteInTransactionAsync(async () => session.Status = SessionStatus.Failed; session.CompletedAt = DateTime.UtcNow; _unitOfWork.GetRepository().Update(session); - await _unitOfWork.CommitAsync(); _logger.LogInformation("Session {SessionId} timed out, {Count} requests expired", sessionId, pendingRequests.Count); @@ -327,16 +405,24 @@ await _unitOfWork.ExecuteInTransactionAsync(async () => { var expiredNotificationTasks = pendingRequests.Select(request => NotifyRequestExpiredAsync(request.RescuerId.ToString(), request.Id) - ); + ).ToArray(); await Task.WhenAll(expiredNotificationTasks); } // Try expand and create new session + _logger.LogInformation("Attempting to expand session for incident {IncidentId} (from session {SessionId})", + session.IncidentId, sessionId); + await TryExpandAndCreateNewSessionAsync(session.IncidentId); return session; }); } + catch (NotFoundException ex) + { + // Session or incident was deleted - this is expected behavior when incidents are cancelled + _logger.LogInformation("Session timeout processing skipped: {Message}", ex.Message); + } catch (Exception ex) { _logger.LogError(ex, "Error handling session timeout {SessionId}: {Message}", sessionId, ex.Message); @@ -372,7 +458,6 @@ await _unitOfWork.ExecuteInTransactionAsync(async () => { request.Status = RescueRequestStatus.Expired; _unitOfWork.GetRepository().Update(request); - await _unitOfWork.CommitAsync(); throw new BadRequestException("Request has expired."); } @@ -381,7 +466,6 @@ await _unitOfWork.ExecuteInTransactionAsync(async () => { request.Status = RescueRequestStatus.Taken; _unitOfWork.GetRepository().Update(request); - await _unitOfWork.CommitAsync(); throw new BadRequestException("Another rescuer has already accepted this incident."); } @@ -419,35 +503,47 @@ await _unitOfWork.ExecuteInTransactionAsync(async () => // Cancel timeout monitoring since session is completed _timeoutService.CancelSessionTimeout(request.Session.Id); - // Commit critical data first (request acceptance, session completion) - await _unitOfWork.CommitAsync(); + // Create rescue mission inline (to avoid circular dependency with IRescueMissionService) + var incident = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: i => i.Id == request.IncidentId + ); - // Create rescue mission (post-commit operation) - try + if (incident == null) { - await _missionService.CreateMissionAsync(request.IncidentId, rescuerId, 0); - _logger.LogInformation("Mission created successfully for request {RequestId}", requestId); + throw new NotFoundException("Incident not found."); } - catch (Exception missionEx) + + // Verify rescuer profile exists + var rescuer = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: r => r.AccountId == rescuerId + ); + + if (rescuer == null) { - // CRITICAL: Data already committed, log for manual intervention - _logger.LogError(missionEx, - "CRITICAL: Failed to create mission after accepting request {RequestId}. " + - "Data inconsistency detected. Manual intervention required. " + - "IncidentId: {IncidentId}, RescuerId: {RescuerId}", - requestId, request.IncidentId, rescuerId); - - // TODO: Add to failed operations queue for retry - // TODO: Send alert to admin/ops team - - // Re-throw để caller biết có issue (nhưng data đã committed) - throw new InvalidOperationException( - $"Request accepted but mission creation failed. RequestId: {requestId}", - missionEx); + throw new NotFoundException("Rescuer not found."); } - _logger.LogInformation("Rescuer {RescuerId} accepted request {RequestId} for incident {IncidentId}", - rescuerId, requestId, request.IncidentId); + // Create new mission + var mission = new RescueMission + { + Id = Guid.NewGuid(), + IncidentId = request.IncidentId, + RescuerId = rescuerId, + Status = RescueMissionStatus.Preparing, + Price = DEFAULT_RESCUE_PRICE, + CreatedAt = DateTime.UtcNow + }; + + // Update incident status + incident.Status = SnakebiteIncidentStatus.Assigned; + incident.AssignedRescuerId = rescuerId; + incident.AssignedAt = DateTime.UtcNow; + + await _unitOfWork.GetRepository().InsertAsync(mission); + _unitOfWork.GetRepository().Update(incident); + + _logger.LogInformation("Rescuer {RescuerId} accepted request {RequestId} for incident {IncidentId}, mission {MissionId} created", + rescuerId, requestId, request.IncidentId, mission.Id); return request; }); @@ -510,7 +606,6 @@ await _unitOfWork.ExecuteInTransactionAsync(async () => _timeoutService.CancelSessionTimeout(sessionId); _unitOfWork.GetRepository().Update(session); - await _unitOfWork.CommitAsync(); _logger.LogInformation("Session {SessionId} cancelled", sessionId); @@ -543,7 +638,10 @@ public async Task TryExpandAndCreateNewSessionAsync(Guid incidentId) if (incident == null) { - throw new NotFoundException("Incident not found."); + // Incident may have been deleted (user cancelled, etc.) + _logger.LogWarning("Incident {IncidentId} not found during session expansion - may have been cancelled", + incidentId); + return false; } // Check if incident is still pending @@ -562,7 +660,6 @@ public async Task TryExpandAndCreateNewSessionAsync(Guid incidentId) incident.Status = SnakebiteIncidentStatus.NoRescuerFound; _unitOfWork.GetRepository().Update(incident); - await _unitOfWork.CommitAsync(); return false; } @@ -573,16 +670,16 @@ public async Task TryExpandAndCreateNewSessionAsync(Guid incidentId) ? RADIUS_PROGRESSION[nextRadiusIndex] : RADIUS_PROGRESSION[^1]; // Use last value if exceeded - // Create new session - var newSession = await CreateSessionAsync( + // Create new session (use internal version - already in transaction) + var newSession = await CreateSessionInternalAsync( incidentId, nextSessionNumber, nextRadius, SessionTrigger.RadiusExpanded ); - // Broadcast requests for new session - await BroadcastRequestsAsync(newSession.Id); + // Broadcast requests for new session (pass session object - already in transaction) + await BroadcastRequestsInternalAsync(newSession); _logger.LogInformation("Expanded to session {SessionNumber} with radius {RadiusKm}km for incident {IncidentId}", nextSessionNumber, nextRadius, incidentId); @@ -635,9 +732,13 @@ public async Task HandleMissionAbortAsync(Guid incidentId) _logger.LogWarning("Max sessions ({MaxSessions}) reached for incident {IncidentId}, marking as NoRescuerFound", MAX_SESSIONS, incidentId); - incident.Status = SnakebiteIncidentStatus.NoRescuerFound; - _unitOfWork.GetRepository().Update(incident); - await _unitOfWork.CommitAsync(); + // Wrap in transaction to ensure status update is committed + await _unitOfWork.ExecuteInTransactionAsync(async () => + { + incident.Status = SnakebiteIncidentStatus.NoRescuerFound; + _unitOfWork.GetRepository().Update(incident); + return await Task.FromResult(0); + }); return; } diff --git a/SnakeAid.Service/Implements/SessionTimeoutBackgroundService.cs b/SnakeAid.Service/Implements/SessionTimeoutBackgroundService.cs index 69e31b0b..4d1f4c1b 100644 --- a/SnakeAid.Service/Implements/SessionTimeoutBackgroundService.cs +++ b/SnakeAid.Service/Implements/SessionTimeoutBackgroundService.cs @@ -48,18 +48,23 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try { + _logger.LogDebug("[SessionTimeout] Starting new iteration. Current sessions: {Count}, Schedule slots: {Slots}", + _sessionTimeouts.Count, _timeoutSchedule.Count); + // Calculate next optimal delay based on earliest timeout var nextDelay = CalculateNextDelay(); - _logger.LogDebug("Next timeout check in {Delay}ms. Monitoring {Count} sessions", - nextDelay.TotalMilliseconds, _sessionTimeouts.Count); + _logger.LogInformation("[SessionTimeout] Next timeout check in {Delay}s ({DelayMs}ms). Monitoring {Count} sessions", + Math.Round(nextDelay.TotalSeconds, 2), nextDelay.TotalMilliseconds, _sessionTimeouts.Count); // Wait until next scheduled timeout or cancellation using var timerCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); _currentTimerCancellation = timerCts; + _logger.LogDebug("[SessionTimeout] Waiting for {Delay}ms before next check...", nextDelay.TotalMilliseconds); await Task.Delay(nextDelay, timerCts.Token); + _logger.LogDebug("[SessionTimeout] Timer elapsed, checking for expired sessions..."); // Process any expired sessions await ProcessExpiredSessions(); } @@ -67,7 +72,11 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { // Expected when cancellation is requested or timer is reset if (stoppingToken.IsCancellationRequested) + { + _logger.LogInformation("[SessionTimeout] Stopping token cancelled, exiting loop"); break; + } + _logger.LogDebug("[SessionTimeout] Timer was reset/cancelled, rescheduling..."); // Continue if it was just a timer reset } catch (Exception ex) @@ -85,11 +94,16 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) /// Add session to monitoring with precise timeout scheduling public void ScheduleSessionTimeout(Guid sessionId, DateTime timeoutAt) { + var now = DateTime.UtcNow; + var timeUntilTimeout = timeoutAt - now; + lock (_scheduleLock) { // Remove existing if present if (_sessionTimeouts.TryGetValue(sessionId, out var existingTimeout)) { + _logger.LogDebug("[SessionTimeout] Rescheduling session {SessionId} from {OldTimeout} to {NewTimeout}", + sessionId, existingTimeout, timeoutAt); RemoveFromSchedule(sessionId, existingTimeout); } @@ -102,7 +116,8 @@ public void ScheduleSessionTimeout(Guid sessionId, DateTime timeoutAt) } _timeoutSchedule[timeoutAt].Add(sessionId); - _logger.LogDebug("Scheduled precise timeout for session {SessionId} at {TimeoutAt}", sessionId, timeoutAt); + _logger.LogInformation("[SessionTimeout] ✅ Scheduled timeout for session {SessionId} at {TimeoutAt} (in {Minutes}m {Seconds}s). Total sessions: {Total}", + sessionId, timeoutAt, (int)timeUntilTimeout.TotalMinutes, (int)timeUntilTimeout.Seconds % 60, _sessionTimeouts.Count); } // Reset timer để reschedule với timeout mới @@ -118,7 +133,12 @@ public void CancelSessionTimeout(Guid sessionId) if (_sessionTimeouts.TryRemove(sessionId, out var timeoutAt)) { RemoveFromSchedule(sessionId, timeoutAt); - _logger.LogDebug("Cancelled timeout monitoring for session {SessionId} (was scheduled for {TimeoutAt})", sessionId, timeoutAt); + _logger.LogInformation("[SessionTimeout] ❌ Cancelled timeout monitoring for session {SessionId} (was scheduled for {TimeoutAt}). Remaining sessions: {Count}", + sessionId, timeoutAt, _sessionTimeouts.Count); + } + else + { + _logger.LogDebug("[SessionTimeout] Attempted to cancel session {SessionId} but it was not found in monitoring", sessionId); } } @@ -136,16 +156,24 @@ private async Task ProcessExpiredSessions() lock (_scheduleLock) { + _logger.LogDebug("[SessionTimeout] Checking for expired sessions at {CurrentTime}. Total schedule slots: {Slots}", + currentTime, _timeoutSchedule.Count); + // Get all time slots that have expired foreach (var timeSlot in _timeoutSchedule.Keys.ToList()) { if (currentTime >= timeSlot) { - expiredSessions.AddRange(_timeoutSchedule[timeSlot]); + var sessionsInSlot = _timeoutSchedule[timeSlot]; + _logger.LogDebug("[SessionTimeout] Time slot {TimeSlot} has expired with {Count} sessions", + timeSlot, sessionsInSlot.Count); + expiredSessions.AddRange(sessionsInSlot); expiredTimeSlots.Add(timeSlot); } else { + _logger.LogDebug("[SessionTimeout] Next time slot {TimeSlot} has not expired yet (in {Seconds}s)", + timeSlot, (timeSlot - currentTime).TotalSeconds); break; // SortedDictionary is ordered, so we can break early } } @@ -165,38 +193,52 @@ private async Task ProcessExpiredSessions() if (!expiredSessions.Any()) { + _logger.LogDebug("[SessionTimeout] No expired sessions found at this check"); return; // No expired sessions } - _logger.LogInformation("Processing {Count} expired sessions", expiredSessions.Count); + _logger.LogInformation("[SessionTimeout] ⏰ Processing {Count} expired sessions: {SessionIds}", + expiredSessions.Count, string.Join(", ", expiredSessions)); // Process each expired session using var scope = _serviceScopeFactory.CreateScope(); var sessionService = scope.ServiceProvider.GetRequiredService(); + var successCount = 0; + var errorCount = 0; + var skippedCount = 0; + foreach (var sessionId in expiredSessions) { try { + _logger.LogDebug("[SessionTimeout] Processing expired session {SessionId}...", sessionId); + // Check if session was rescheduled after we selected it if (_sessionTimeouts.TryGetValue(sessionId, out var newTimeout) && newTimeout > currentTime) { - _logger.LogDebug("Session {SessionId} was rescheduled to {NewTimeout}; skipping timeout processing", + _logger.LogInformation("[SessionTimeout] ⚠️ Session {SessionId} was rescheduled to {NewTimeout}; skipping timeout processing", sessionId, newTimeout); + skippedCount++; continue; } // Handle the session timeout (includes expanding to new session if possible) await sessionService.HandleSessionTimeoutAsync(sessionId); - _logger.LogInformation("Successfully processed timeout for session {SessionId}", sessionId); + _logger.LogInformation("[SessionTimeout] ✅ Successfully processed timeout for session {SessionId}", sessionId); + successCount++; } catch (Exception ex) { - _logger.LogError(ex, "Error handling timeout for session {SessionId}: {Message}", sessionId, ex.Message); + _logger.LogError(ex, "[SessionTimeout] ❌ Error handling timeout for session {SessionId}: {Message}", sessionId, ex.Message); + errorCount++; // Continue processing other sessions even if one fails } } + + _logger.LogInformation("[SessionTimeout] Completed processing expired sessions. Success: {Success}, Errors: {Errors}, Skipped: {Skipped}", + successCount, errorCount, skippedCount); } @@ -220,6 +262,7 @@ private TimeSpan CalculateNextDelay() { if (_timeoutSchedule.Count == 0) { + _logger.LogDebug("[SessionTimeout] No sessions in schedule, using default delay: {Delay}s", DEFAULT_DELAY.TotalSeconds); // No sessions scheduled, use default delay return DEFAULT_DELAY; } @@ -230,6 +273,8 @@ private TimeSpan CalculateNextDelay() if (earliestTimeout <= now) { + _logger.LogDebug("[SessionTimeout] ⚡ Earliest timeout {EarliestTimeout} has already passed! Processing immediately with min delay", + earliestTimeout); // Already have expired sessions, process immediately return MIN_DELAY; } @@ -238,9 +283,18 @@ private TimeSpan CalculateNextDelay() // Clamp between min and max delays if (calculatedDelay < MIN_DELAY) + { + _logger.LogDebug("[SessionTimeout] Calculated delay {Delay}ms too small, using MIN_DELAY", calculatedDelay.TotalMilliseconds); return MIN_DELAY; + } if (calculatedDelay > MAX_DELAY) + { + _logger.LogDebug("[SessionTimeout] Calculated delay {Delay}s too large, using MAX_DELAY", calculatedDelay.TotalSeconds); return MAX_DELAY; + } + + _logger.LogDebug("[SessionTimeout] ⏱️ Next timeout at {EarliestTimeout}, delay: {DelaySeconds}s ({DelayMs}ms)", + earliestTimeout, Math.Round(calculatedDelay.TotalSeconds, 2), calculatedDelay.TotalMilliseconds); return calculatedDelay; } @@ -252,11 +306,13 @@ private void RescheduleTimer() { try { + _logger.LogDebug("[SessionTimeout] 🔄 Rescheduling timer due to schedule change"); // Cancel current timer to trigger reschedule _currentTimerCancellation?.Cancel(); } catch (ObjectDisposedException) { + _logger.LogDebug("[SessionTimeout] Timer cancellation already disposed, ignoring"); // Ignore if already disposed } } @@ -268,11 +324,19 @@ private void RemoveFromSchedule(Guid sessionId, DateTime timeoutAt) if (_timeoutSchedule.TryGetValue(timeoutAt, out var sessionList)) { sessionList.Remove(sessionId); + _logger.LogDebug("[SessionTimeout] Removed session {SessionId} from schedule at {TimeoutAt}. Remaining in slot: {Count}", + sessionId, timeoutAt, sessionList.Count); if (sessionList.Count == 0) { _timeoutSchedule.Remove(timeoutAt); + _logger.LogDebug("[SessionTimeout] Removed empty time slot {TimeoutAt}", timeoutAt); } } + else + { + _logger.LogDebug("[SessionTimeout] Time slot {TimeoutAt} not found when trying to remove session {SessionId}", + timeoutAt, sessionId); + } } @@ -283,6 +347,34 @@ public bool IsHealthy() return _sessionTimeouts.Count < 1000; // Reasonable limit } + /// Get detailed monitoring info for all tracked sessions + public List GetMonitoringInfo() + { + lock (_scheduleLock) + { + var result = new List(); + var now = DateTime.UtcNow; + + foreach (var kvp in _sessionTimeouts) + { + var sessionId = kvp.Key; + var timeoutAt = kvp.Value; + var timeRemaining = timeoutAt - now; + var isExpired = timeRemaining < TimeSpan.Zero; + + result.Add(new SessionMonitorInfo + { + SessionId = sessionId, + TimeoutAt = timeoutAt, + TimeRemaining = timeRemaining, + IsExpired = isExpired + }); + } + + return result.OrderBy(s => s.TimeoutAt).ToList(); + } + } + public override async Task StopAsync(CancellationToken cancellationToken) { _logger.LogInformation("SessionTimeoutBackgroundService is stopping..."); diff --git a/SnakeAid.Service/Interfaces/ISessionTimeoutService.cs b/SnakeAid.Service/Interfaces/ISessionTimeoutService.cs index 62936576..e1263700 100644 --- a/SnakeAid.Service/Interfaces/ISessionTimeoutService.cs +++ b/SnakeAid.Service/Interfaces/ISessionTimeoutService.cs @@ -18,8 +18,18 @@ public interface ISessionTimeoutService /// Get current queue status for monitoring (int TotalSessions, int ExpiredCount, int PendingCount) GetQueueStatus(); + /// Get detailed monitoring info for all tracked sessions + List GetMonitoringInfo(); /// Health check for the service bool IsHealthy(); } + + public class SessionMonitorInfo + { + public Guid SessionId { get; set; } + public DateTime TimeoutAt { get; set; } + public TimeSpan TimeRemaining { get; set; } + public bool IsExpired { get; set; } + } } \ No newline at end of file From 661eb1a57130dfb99338797baf417c4f165b575e Mon Sep 17 00:00:00 2001 From: NhanNPSE184696 Date: Tue, 10 Feb 2026 18:04:21 +0700 Subject: [PATCH 14/31] feat: implement RescueMissionController and service for managing rescue mission statuses and details --- .../Controllers/RescueMissionController.cs | 186 +++++++++++++ .../UpdateRescueMissionStatusRequest.cs | 23 ++ .../RescueMissionStatusResponse.cs | 27 ++ .../Implements/RescueMissionService.cs | 251 ++++++++++++++++++ SnakeAid.Service/Interfaces/IRescueMission.cs | 21 ++ 5 files changed, 508 insertions(+) create mode 100644 SnakeAid.Api/Controllers/RescueMissionController.cs create mode 100644 SnakeAid.Core/Requests/RescueMission/UpdateRescueMissionStatusRequest.cs create mode 100644 SnakeAid.Core/Responses/RescueMission/RescueMissionStatusResponse.cs create mode 100644 SnakeAid.Service/Implements/RescueMissionService.cs create mode 100644 SnakeAid.Service/Interfaces/IRescueMission.cs diff --git a/SnakeAid.Api/Controllers/RescueMissionController.cs b/SnakeAid.Api/Controllers/RescueMissionController.cs new file mode 100644 index 00000000..2f8f86b0 --- /dev/null +++ b/SnakeAid.Api/Controllers/RescueMissionController.cs @@ -0,0 +1,186 @@ +using MapsterMapper; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using SnakeAid.Core.Domains; +using SnakeAid.Core.Meta; +using SnakeAid.Core.Requests.RescueMission; +using SnakeAid.Core.Responses.RescueMission; +using SnakeAid.Service.Interfaces; +using Swashbuckle.AspNetCore.Annotations; +using System; +using System.Threading.Tasks; + +namespace SnakeAid.Api.Controllers +{ + [Route("api/rescue-missions")] + [ApiController] + [Authorize] + public class RescueMissionController : BaseController + { + private readonly IRescueMissionService _missionService; + + public RescueMissionController( + ILogger logger, + IHttpContextAccessor httpContextAccessor, + IMapper mapper, + IRescueMissionService missionService) + : base(logger, httpContextAccessor, mapper) + { + _missionService = missionService; + } + + /// + /// Get rescue mission details + /// + [HttpGet("{missionId}")] + [SwaggerOperation(Summary = "Get Mission Details", Description = "Retrieve detailed information about a rescue mission")] + [SwaggerResponse(200, "Mission details retrieved successfully", typeof(ApiResponse))] + [SwaggerResponse(404, "Mission not found")] + public async Task GetMissionDetails(Guid missionId) + { + var result = await _missionService.GetMissionDetailsAsync(missionId); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Mission details retrieved successfully!")); + } + + /// + /// Update rescue mission status + /// When status is set to MissionCompleted, the corresponding incident is automatically updated to Finished + /// + [HttpPatch("{missionId}/status")] + [SwaggerOperation( + Summary = "Update Mission Status", + Description = @"Update the status of a rescue mission. + +Valid status transitions: +- Preparing → EnRoute, Cancelled +- EnRoute → RescuerArrived, MissionAborted +- RescuerArrived → MissionCompleted, MissionUncompleted, MissionAborted + +When transitioning to MissionCompleted: +- At least one verification image is required +- The corresponding SnakebiteIncident status is automatically updated to Finished")] + [SwaggerResponse(200, "Mission status updated successfully", typeof(ApiResponse))] + [SwaggerResponse(400, "Invalid status transition or missing verification images")] + [SwaggerResponse(403, "Not authorized to update this mission")] + [SwaggerResponse(404, "Mission not found")] + [SwaggerResponse(422, "Validation error")] + public async Task UpdateMissionStatus( + Guid missionId, + [FromBody] UpdateRescueMissionStatusRequest request) + { + var rescuerId = GetCurrentUserId(); + var result = await _missionService.UpdateMissionStatusAsync(missionId, request, rescuerId); + + var message = request.Status == RescueMissionStatus.MissionCompleted + ? "Mission completed successfully! Incident marked as finished." + : $"Mission status updated to {request.Status} successfully!"; + + return Ok(ApiResponseBuilder.BuildSuccessResponse(result, message)); + } + + /// + /// Start mission - transition to EnRoute + /// + [HttpPatch("{missionId}/start")] + [SwaggerOperation( + Summary = "Start Mission", + Description = "Start the rescue mission (Preparing → EnRoute). Rescuer begins heading to the incident location.")] + [SwaggerResponse(200, "Mission started successfully", typeof(ApiResponse))] + [SwaggerResponse(400, "Invalid status transition")] + [SwaggerResponse(403, "Not authorized")] + [SwaggerResponse(404, "Mission not found")] + public async Task StartMission(Guid missionId) + { + var rescuerId = GetCurrentUserId(); + var request = new UpdateRescueMissionStatusRequest { Status = RescueMissionStatus.EnRoute }; + var result = await _missionService.UpdateMissionStatusAsync(missionId, request, rescuerId); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Mission started! En route to location.")); + } + + /// + /// Mark arrival at location - transition to RescuerArrived + /// + [HttpPatch("{missionId}/arrive")] + [SwaggerOperation( + Summary = "Arrive at Location", + Description = "Mark rescuer's arrival at the incident location (EnRoute → RescuerArrived).")] + [SwaggerResponse(200, "Arrival marked successfully", typeof(ApiResponse))] + [SwaggerResponse(400, "Invalid status transition")] + [SwaggerResponse(403, "Not authorized")] + [SwaggerResponse(404, "Mission not found")] + public async Task ArriveAtLocation(Guid missionId) + { + var rescuerId = GetCurrentUserId(); + var request = new UpdateRescueMissionStatusRequest { Status = RescueMissionStatus.RescuerArrived }; + var result = await _missionService.UpdateMissionStatusAsync(missionId, request, rescuerId); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Arrival marked successfully!")); + } + + /// + /// Complete mission - transition to MissionCompleted + /// Requires verification images and updates incident to Finished + /// + [HttpPatch("{missionId}/complete")] + [SwaggerOperation( + Summary = "Complete Mission", + Description = "Complete the rescue mission with verification images (RescuerArrived → MissionCompleted). Automatically updates incident status to Finished. Requires at least one verification image.")] + [SwaggerResponse(200, "Mission completed successfully", typeof(ApiResponse))] + [SwaggerResponse(400, "Invalid status transition or missing verification images")] + [SwaggerResponse(403, "Not authorized")] + [SwaggerResponse(404, "Mission not found")] + [SwaggerResponse(422, "Validation error")] + public async Task CompleteMission( + Guid missionId, + [FromBody] UpdateRescueMissionStatusRequest request) + { + var rescuerId = GetCurrentUserId(); + request.Status = RescueMissionStatus.MissionCompleted; + var result = await _missionService.UpdateMissionStatusAsync(missionId, request, rescuerId); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Mission completed successfully! Incident marked as finished.")); + } + + /// + /// Abort mission - transition to MissionAborted + /// + [HttpPatch("{missionId}/abort")] + [SwaggerOperation( + Summary = "Abort Mission", + Description = "Abort the mission with a reason (EnRoute/RescuerArrived → MissionAborted).")] + [SwaggerResponse(200, "Mission aborted", typeof(ApiResponse))] + [SwaggerResponse(400, "Invalid status transition")] + [SwaggerResponse(403, "Not authorized")] + [SwaggerResponse(404, "Mission not found")] + public async Task AbortMission( + Guid missionId, + [FromBody] UpdateRescueMissionStatusRequest request) + { + var rescuerId = GetCurrentUserId(); + request.Status = RescueMissionStatus.MissionAborted; + var result = await _missionService.UpdateMissionStatusAsync(missionId, request, rescuerId); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Mission aborted.")); + } + + /// + /// Cancel mission - transition to Cancelled + /// + [HttpPatch("{missionId}/cancel")] + [SwaggerOperation( + Summary = "Cancel Mission", + Description = "Cancel the mission before it starts (Preparing → Cancelled).")] + [SwaggerResponse(200, "Mission cancelled", typeof(ApiResponse))] + [SwaggerResponse(400, "Invalid status transition")] + [SwaggerResponse(403, "Not authorized")] + [SwaggerResponse(404, "Mission not found")] + public async Task CancelMission( + Guid missionId, + [FromBody] UpdateRescueMissionStatusRequest request) + { + var rescuerId = GetCurrentUserId(); + request.Status = RescueMissionStatus.Cancelled; + var result = await _missionService.UpdateMissionStatusAsync(missionId, request, rescuerId); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Mission cancelled.")); + } + } +} diff --git a/SnakeAid.Core/Requests/RescueMission/UpdateRescueMissionStatusRequest.cs b/SnakeAid.Core/Requests/RescueMission/UpdateRescueMissionStatusRequest.cs new file mode 100644 index 00000000..e46b83c3 --- /dev/null +++ b/SnakeAid.Core/Requests/RescueMission/UpdateRescueMissionStatusRequest.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using SnakeAid.Core.Domains; + +namespace SnakeAid.Core.Requests.RescueMission +{ + public class UpdateRescueMissionStatusRequest + { + [Required(ErrorMessage = "Status is required")] + public RescueMissionStatus Status { get; set; } + + public string? Notes { get; set; } + + [MaxLength(500, ErrorMessage = "Cancellation reason cannot exceed 500 characters")] + public string? CancellationReason { get; set; } + + public decimal? ActualCost { get; set; } + + // Required when completing mission + public List? VerificationImageIds { get; set; } + } +} diff --git a/SnakeAid.Core/Responses/RescueMission/RescueMissionStatusResponse.cs b/SnakeAid.Core/Responses/RescueMission/RescueMissionStatusResponse.cs new file mode 100644 index 00000000..130496ea --- /dev/null +++ b/SnakeAid.Core/Responses/RescueMission/RescueMissionStatusResponse.cs @@ -0,0 +1,27 @@ +using System; +using SnakeAid.Core.Domains; + +namespace SnakeAid.Core.Responses.RescueMission +{ + public class RescueMissionStatusResponse + { + public Guid Id { get; set; } + public Guid IncidentId { get; set; } + public Guid RescuerId { get; set; } + public RescueMissionStatus Status { get; set; } + public RescueMissionStatus PreviousStatus { get; set; } + public decimal Price { get; set; } + public DateTime? StartedAt { get; set; } + public DateTime? ArrivedAt { get; set; } + public DateTime? CompletedAt { get; set; } + public string? Notes { get; set; } + public string? CancellationReason { get; set; } + public decimal? EstimatedCost { get; set; } + public decimal? ActualCost { get; set; } + public DateTime UpdatedAt { get; set; } + + // Related incident status + public SnakebiteIncidentStatus? IncidentStatus { get; set; } + public int VerificationImageCount { get; set; } + } +} diff --git a/SnakeAid.Service/Implements/RescueMissionService.cs b/SnakeAid.Service/Implements/RescueMissionService.cs new file mode 100644 index 00000000..9934f0c0 --- /dev/null +++ b/SnakeAid.Service/Implements/RescueMissionService.cs @@ -0,0 +1,251 @@ +using Mapster; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using SnakeAid.Core.Domains; +using SnakeAid.Core.Exceptions; +using SnakeAid.Core.Requests.RescueMission; +using SnakeAid.Core.Responses.RescueMission; +using SnakeAid.Repository.Data; +using SnakeAid.Repository.Interfaces; +using SnakeAid.Service.Interfaces; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace SnakeAid.Service.Implements +{ + public class RescueMissionService : IRescueMissionService + { + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + + public RescueMissionService( + IUnitOfWork unitOfWork, + ILogger logger) + { + _unitOfWork = unitOfWork; + _logger = logger; + } + + public async Task UpdateMissionStatusAsync( + Guid missionId, + UpdateRescueMissionStatusRequest request, + Guid rescuerId) + { + try + { + return await _unitOfWork.ExecuteInTransactionAsync(async () => + { + // Get mission with incident + var mission = await _unitOfWork.GetRepository() + .FirstOrDefaultAsync( + predicate: m => m.Id == missionId, + include: m => m.Include(x => x.Incident)); + + if (mission == null) + { + throw new NotFoundException($"Mission {missionId} not found"); + } + + // Validate that the rescuer owns this mission + if (mission.RescuerId != rescuerId) + { + throw new ForbiddenException("You are not authorized to update this mission"); + } + + var previousStatus = mission.Status; + + // Validate status transition + ValidateStatusTransition(previousStatus, request.Status); + + // Special handling for MissionCompleted status + if (request.Status == RescueMissionStatus.MissionCompleted) + { + // Require verification images + await ValidateVerificationImagesAsync(missionId, request.VerificationImageIds); + + mission.CompletedAt = DateTime.UtcNow; + mission.ActualCost = request.ActualCost; + + // Update incident status to Finished + if (mission.Incident != null) + { + mission.Incident.Status = SnakebiteIncidentStatus.Finished; + _unitOfWork.GetRepository().Update(mission.Incident); + + _logger.LogInformation( + "Mission {MissionId} completed. Incident {IncidentId} status changed to Finished", + missionId, mission.IncidentId); + } + } + + // Validate cancellation reason for Cancelled and Aborted statuses + if (request.Status == RescueMissionStatus.Cancelled || request.Status == RescueMissionStatus.MissionAborted) + { + if (string.IsNullOrWhiteSpace(request.CancellationReason)) + { + throw new BadRequestException($"CancellationReason is required when status is {request.Status}"); + } + } + + // Update status-specific timestamps + UpdateStatusTimestamps(mission, request.Status); + + // Update mission + mission.Status = request.Status; + if (!string.IsNullOrWhiteSpace(request.Notes)) + { + mission.Notes = request.Notes; + } + + // Update cancellation reason for Cancelled and Aborted statuses + if (request.Status == RescueMissionStatus.Cancelled || request.Status == RescueMissionStatus.MissionAborted) + { + mission.CancellationReason = request.CancellationReason; + } + + _unitOfWork.GetRepository().Update(mission); + await _unitOfWork.CommitAsync(); + + _logger.LogInformation( + "Mission {MissionId} status updated from {PreviousStatus} to {NewStatus} by rescuer {RescuerId}", + missionId, previousStatus, request.Status, rescuerId); + + return await BuildResponseAsync(mission, previousStatus); + }); + } + catch (Exception ex) when (ex is not BadRequestException && ex is not ForbiddenException && ex is not NotFoundException) + { + _logger.LogError(ex, "Error updating mission {MissionId} status", missionId); + throw; + } + } + + public async Task GetMissionDetailsAsync(Guid missionId) + { + var mission = await _unitOfWork.GetRepository() + .FirstOrDefaultAsync( + predicate: m => m.Id == missionId, + include: m => m.Include(x => x.Incident)); + + if (mission == null) + { + throw new NotFoundException($"Mission {missionId} not found"); + } + + return await BuildResponseAsync(mission, mission.Status); + } + + #region Private Helper Methods + + private void ValidateStatusTransition(RescueMissionStatus currentStatus, RescueMissionStatus newStatus) + { + // Allow same status (idempotent) + if (currentStatus == newStatus) + { + return; + } + + // Define valid transitions + var validTransitions = currentStatus switch + { + RescueMissionStatus.Preparing => new[] { + RescueMissionStatus.EnRoute, + RescueMissionStatus.Cancelled + }, + RescueMissionStatus.EnRoute => new[] { + RescueMissionStatus.RescuerArrived, + RescueMissionStatus.MissionAborted + }, + RescueMissionStatus.RescuerArrived => new[] { + RescueMissionStatus.MissionCompleted, + RescueMissionStatus.MissionUncompleted, + RescueMissionStatus.MissionAborted + }, + // Terminal states - no transitions allowed + RescueMissionStatus.MissionCompleted => Array.Empty(), + RescueMissionStatus.MissionUncompleted => Array.Empty(), + RescueMissionStatus.MissionAborted => Array.Empty(), + RescueMissionStatus.Cancelled => Array.Empty(), + _ => Array.Empty() + }; + + if (!validTransitions.Contains(newStatus)) + { + throw new BadRequestException( + $"Invalid status transition from {currentStatus} to {newStatus}. " + + $"Valid transitions: {string.Join(", ", validTransitions)}"); + } + } + + private void UpdateStatusTimestamps(RescueMission mission, RescueMissionStatus newStatus) + { + switch (newStatus) + { + case RescueMissionStatus.EnRoute: + mission.StartedAt = DateTime.UtcNow; + break; + case RescueMissionStatus.RescuerArrived: + mission.ArrivedAt = DateTime.UtcNow; + break; + case RescueMissionStatus.MissionCompleted: + case RescueMissionStatus.MissionUncompleted: + case RescueMissionStatus.MissionAborted: + if (!mission.CompletedAt.HasValue) + { + mission.CompletedAt = DateTime.UtcNow; + } + break; + } + } + + private async Task ValidateVerificationImagesAsync(Guid missionId, System.Collections.Generic.List? imageIds) + { + if (imageIds == null || !imageIds.Any()) + { + throw new BadRequestException("At least one verification image is required to complete the mission"); + } + + var mediaRepo = _unitOfWork.GetRepository(); + var verificationImages = await mediaRepo.GetListAsync( + predicate: m => imageIds.Contains(m.Id) && + m.ReferenceId == missionId && + m.ReferenceType == MediaReferenceType.RescueMission && + m.Purpose == MediaPurpose.Evidence); + + if (verificationImages.Count != imageIds.Count) + { + throw new BadRequestException( + $"Invalid verification images. Expected {imageIds.Count} valid images, found {verificationImages.Count}"); + } + } + + private async Task BuildResponseAsync( + RescueMission mission, + RescueMissionStatus previousStatus) + { + var response = mission.Adapt(); + response.PreviousStatus = previousStatus; + response.UpdatedAt = DateTime.UtcNow; + + // Get incident status if loaded + if (mission.Incident != null) + { + response.IncidentStatus = mission.Incident.Status; + } + + // Get verification image count + var imageCount = await _unitOfWork.GetRepository() + .CountAsync(predicate: m => + m.ReferenceId == mission.Id && + m.ReferenceType == MediaReferenceType.RescueMission && + m.Purpose == MediaPurpose.Evidence); + + response.VerificationImageCount = imageCount; + + return response; + } + + #endregion + } +} diff --git a/SnakeAid.Service/Interfaces/IRescueMission.cs b/SnakeAid.Service/Interfaces/IRescueMission.cs new file mode 100644 index 00000000..6f6fe396 --- /dev/null +++ b/SnakeAid.Service/Interfaces/IRescueMission.cs @@ -0,0 +1,21 @@ +using System; +using System.Threading.Tasks; +using SnakeAid.Core.Requests.RescueMission; +using SnakeAid.Core.Responses.RescueMission; + +namespace SnakeAid.Service.Interfaces +{ + public interface IRescueMissionService + { + /// + /// Update rescue mission status + /// When status is set to MissionCompleted, also updates the corresponding incident to Finished + /// + Task UpdateMissionStatusAsync(Guid missionId, UpdateRescueMissionStatusRequest request, Guid rescuerId); + + /// + /// Get mission details + /// + Task GetMissionDetailsAsync(Guid missionId); + } +} From 1d6c04d5274dd86593274342959d9d09e3adcd21 Mon Sep 17 00:00:00 2001 From: NeoNgyn Date: Tue, 10 Feb 2026 18:11:45 +0700 Subject: [PATCH 15/31] update: optimize status validation logic Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- SnakeAid.Service/Implements/RescueMissionService.cs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/SnakeAid.Service/Implements/RescueMissionService.cs b/SnakeAid.Service/Implements/RescueMissionService.cs index 9934f0c0..8a9d64df 100644 --- a/SnakeAid.Service/Implements/RescueMissionService.cs +++ b/SnakeAid.Service/Implements/RescueMissionService.cs @@ -80,12 +80,10 @@ public async Task UpdateMissionStatusAsync( } // Validate cancellation reason for Cancelled and Aborted statuses - if (request.Status == RescueMissionStatus.Cancelled || request.Status == RescueMissionStatus.MissionAborted) + if ((request.Status == RescueMissionStatus.Cancelled || request.Status == RescueMissionStatus.MissionAborted) + && string.IsNullOrWhiteSpace(request.CancellationReason)) { - if (string.IsNullOrWhiteSpace(request.CancellationReason)) - { - throw new BadRequestException($"CancellationReason is required when status is {request.Status}"); - } + throw new BadRequestException($"CancellationReason is required when status is {request.Status}"); } // Update status-specific timestamps From 0911b55175baf1b084257cdc132eab4154b9264c Mon Sep 17 00:00:00 2001 From: NhanNPSE184696 Date: Tue, 10 Feb 2026 18:16:28 +0700 Subject: [PATCH 16/31] fix: update RescueMissionStatusResponse to use mission's UpdatedAt timestamp --- SnakeAid.Service/Implements/RescueMissionService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SnakeAid.Service/Implements/RescueMissionService.cs b/SnakeAid.Service/Implements/RescueMissionService.cs index 9934f0c0..77a2ed7c 100644 --- a/SnakeAid.Service/Implements/RescueMissionService.cs +++ b/SnakeAid.Service/Implements/RescueMissionService.cs @@ -226,7 +226,7 @@ private async Task BuildResponseAsync( { var response = mission.Adapt(); response.PreviousStatus = previousStatus; - response.UpdatedAt = DateTime.UtcNow; + response.UpdatedAt = mission.UpdatedAt; // Get incident status if loaded if (mission.Incident != null) From 7e583e358cb0abbe5074d618797fe67781004c87 Mon Sep 17 00:00:00 2001 From: DuongNManh Date: Wed, 11 Feb 2026 00:05:38 +0700 Subject: [PATCH 17/31] Refactor rescue mission handling and incident management - Updated RescueMissionService to allow multiple active missions per incident for retry scenarios. - Enhanced RescuerAbortMissionAsync to ensure proper transaction handling and incident status updates. - Modified RescueRequestSessionService to exclude rescuers who previously aborted missions for the same incident. - Improved SnakeAIService to handle first aid guideline content more robustly. - Fixed response type naming in SnakebiteIncidentService and ISnakebiteIncidentService for consistency. --- .../Controllers/RescueDemoController.cs | 124 +- .../SnakebiteIncidentController.cs | 2 +- SnakeAid.Api/Pages/Demo/RescueDemo.cshtml | 130 +- SnakeAid.Api/Program.cs | 12 +- SnakeAid.Core/Domains/SnakeSpecies.cs | 3 +- SnakeAid.Core/Domains/SnakebiteIncident.cs | 2 +- .../Mappings/SnakebiteIncidentMapper.cs | 5 + ....cs => DetailSnakebiteIncidentResponse.cs} | 4 +- .../RescueMissionConfiguration.cs | 2 +- .../SnakebiteIncidentConfiguration.cs | 7 +- SnakeAid.Repository/Implements/UnitOfWork.cs | 5 + SnakeAid.Repository/Interfaces/IUnitOfWork.cs | 1 + .../20260210164830_UpdateSchemaV1.Designer.cs | 3501 +++++++++++++++++ .../20260210164830_UpdateSchemaV1.cs | 41 + .../SnakeAidDbContextModelSnapshot.cs | 9 +- SnakeAid.Repository/Seeds/DataSeeder.cs | 1446 +++---- .../Implements/RescueMissionService.cs | 71 +- .../Implements/RescueRequestSessionService.cs | 72 +- SnakeAid.Service/Implements/SnakeAIService.cs | 4 +- .../Implements/SnakebiteIncidentService.cs | 6 +- .../Interfaces/ISnakebiteIncidentService.cs | 2 +- 21 files changed, 4644 insertions(+), 805 deletions(-) rename SnakeAid.Core/Responses/SnakebiteIncident/{DetailSnakebiteIncidentReposne.cs => DetailSnakebiteIncidentResponse.cs} (97%) create mode 100644 SnakeAid.Repository/Migrations/20260210164830_UpdateSchemaV1.Designer.cs create mode 100644 SnakeAid.Repository/Migrations/20260210164830_UpdateSchemaV1.cs diff --git a/SnakeAid.Api/Controllers/RescueDemoController.cs b/SnakeAid.Api/Controllers/RescueDemoController.cs index d3af85d2..0af1955c 100644 --- a/SnakeAid.Api/Controllers/RescueDemoController.cs +++ b/SnakeAid.Api/Controllers/RescueDemoController.cs @@ -4,6 +4,7 @@ using SnakeAid.Api.Hubs; using SnakeAid.Api.Services; using SnakeAid.Core.Domains; +using SnakeAid.Core.Exceptions; using SnakeAid.Core.Requests.RescueRequestSession; using SnakeAid.Core.Requests.SnakebiteIncident; using SnakeAid.Repository.Data; @@ -320,22 +321,137 @@ public async Task AcceptRequest(Guid requestId, [FromQuery] Guid { try { - var response = await _incidentService.AcceptRescueAsync(requestId, rescuerId); + // Call the REAL session service to accept request (not just validation) + await _sessionService.AcceptRequestAsync(requestId, rescuerId); + + // Query created mission + var request = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: r => r.Id == requestId + ); + + if (request == null) + { + throw new NotFoundException("Request not found"); + } + + var mission = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: m => m.IncidentId == request.IncidentId + ); _logger.LogInformation("Rescuer {RescuerId} accepted request {RequestId}, mission {MissionId}", - rescuerId, requestId, response.MissionId); + rescuerId, requestId, mission?.Id); return Ok(new { requestId, - missionId = response.MissionId, + missionId = mission?.Id, + incidentId = request.IncidentId, message = "Request accepted! Mission created. You have been assigned to this rescue." }); } catch (Exception ex) { _logger.LogError(ex, "Failed to accept request"); - return BadRequest(ex.Message); + return BadRequest(new { error = ex.Message }); + } + } + + /// + /// Rescuer abort/cancel mission using REAL service + /// This will: + /// 1. Mark mission as MissionAborted + /// 2. Reset incident to Pending + /// 3. Create new session with increased radius + /// 4. Exclude this rescuer from new session + /// 5. Broadcast to other rescuers + /// + [HttpPost("mission/{missionId}/abort")] + public async Task AbortMission(Guid missionId, [FromQuery] string? reason = "Rescuer cancelled") + { + try + { + // Query mission to get rescuer and incident info + // IMPORTANT: Use asNoTracking to avoid polluting DbContext with tracked entities + // This ensures the mission service gets fresh data + var mission = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: m => m.Id == missionId, + include: q => q.Include(m => m.Incident), + asNoTracking: true + ); + + if (mission == null) + { + throw new NotFoundException("Mission not found"); + } + + var rescuerId = mission.RescuerId; + var incidentId = mission.IncidentId; + + // Use MissionService to abort (it will call SessionService.HandleMissionAbortAsync) + var missionService = HttpContext.RequestServices.GetRequiredService(); + await missionService.RescuerAbortMissionAsync(missionId, reason ?? "Rescuer cancelled"); + + _logger.LogInformation("Rescuer {RescuerId} aborted mission {MissionId}, new session created for incident {IncidentId}", + rescuerId, missionId, incidentId); + + // Get updated incident info + var updatedIncident = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: i => i.Id == incidentId, + include: q => q.Include(i => i.Sessions.OrderByDescending(s => s.SessionNumber).Take(1)) + ); + + var latestSession = updatedIncident?.Sessions?.FirstOrDefault(); + + return Ok(new + { + message = "Mission aborted. Creating new session with expanded radius...", + incidentId, + newSession = new + { + sessionId = latestSession?.Id, + sessionNumber = latestSession?.SessionNumber, + radiusKm = latestSession?.RadiusKm, + rescuersPinged = latestSession?.RescuersPinged + }, + excludedRescuerId = rescuerId + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to abort mission"); + return BadRequest(new { error = ex.Message }); + } + } + + /// + /// Update mission status (for demo: EnRoute, Arrived, Completed) + /// + [HttpPost("mission/{missionId}/status")] + public async Task UpdateMissionStatus(Guid missionId, [FromQuery] string status) + { + try + { + if (!Enum.TryParse(status, true, out var missionStatus)) + { + return BadRequest(new { error = "Invalid mission status" }); + } + + var missionService = HttpContext.RequestServices.GetRequiredService(); + await missionService.UpdateMissionStatusAsync(missionId, missionStatus); + + _logger.LogInformation("Mission {MissionId} status updated to {Status}", missionId, status); + + return Ok(new + { + missionId, + newStatus = status, + message = $"Mission status updated to {status}" + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update mission status"); + return BadRequest(new { error = ex.Message }); } } diff --git a/SnakeAid.Api/Controllers/SnakebiteIncidentController.cs b/SnakeAid.Api/Controllers/SnakebiteIncidentController.cs index 53635174..a5fcff0a 100644 --- a/SnakeAid.Api/Controllers/SnakebiteIncidentController.cs +++ b/SnakeAid.Api/Controllers/SnakebiteIncidentController.cs @@ -81,7 +81,7 @@ public async Task RaiseSessionRange(Guid incidentId) ///
[HttpGet("{incidentId}")] [SwaggerOperation(Summary = "Get Incident Detail", Description = "Retrieve detailed information about a snakebite incident including user, rescuer, sessions, and media")] - [SwaggerResponse(200, "Incident details retrieved successfully", typeof(ApiResponse))] + [SwaggerResponse(200, "Incident details retrieved successfully", typeof(ApiResponse))] [SwaggerResponse(404, "Incident not found")] public async Task GetIncidentDetail(Guid incidentId) { diff --git a/SnakeAid.Api/Pages/Demo/RescueDemo.cshtml b/SnakeAid.Api/Pages/Demo/RescueDemo.cshtml index 71851e2e..5292c2e6 100644 --- a/SnakeAid.Api/Pages/Demo/RescueDemo.cshtml +++ b/SnakeAid.Api/Pages/Demo/RescueDemo.cshtml @@ -275,6 +275,12 @@ color: #9d174d; } + .request-status.aborted { + background: #fee2e2; + color: #991b1b; + font-weight: 600; + } + /* Status Panel */ .status-panel { height: calc(100vh - 160px); @@ -627,15 +633,23 @@ @@ -659,6 +673,7 @@ let currentMissionId = null; let timerInterval = null; let timerSeconds = 60; + const excludedRescuers = new Set(); // Track rescuers who aborted missions const mockRescuers = [ { id: '22222222-2222-2222-2222-222222222221', name: 'Rescuer A - Quận 1', distance: 4.5 }, @@ -737,6 +752,8 @@ log('✅ Demo data cleaned up from database', 'success'); currentIncidentId = null; + currentMissionId = null; + excludedRescuers.clear(); // Clear excluded rescuers Object.keys(rescuerRequests).forEach(k => delete rescuerRequests[k]); document.getElementById('demoAlert').style.display = 'block'; @@ -810,7 +827,12 @@ }); connection.on('NewRescueRequest', (data) => { - handleNewRequest(rescuerId, data); + // Only process if not excluded (backend should already filter, but double check) + if (!excludedRescuers.has(rescuerId)) { + handleNewRequest(rescuerId, data); + } else { + log(`⚠️ Rescuer ${rescuerId.slice(-1)} is excluded - should not receive request`, 'warning'); + } }); connection.on('RequestAccepted', (data) => { @@ -866,6 +888,11 @@ // ====== USER ACTIONS ====== async function createIncident() { try { + // Clear previous state + currentMissionId = null; + excludedRescuers.clear(); + Object.keys(rescuerRequests).forEach(k => delete rescuerRequests[k]); + log('Creating incident with REAL SnakebiteIncidentService...', 'info'); const response = await fetch('/api/rescuedemo/incident/create', { method: 'POST', @@ -884,6 +911,7 @@ document.getElementById('btnTriggerRescue').disabled = false; log(`✅ Incident created in database: ${currentIncidentId.slice(0, 8)}...`, 'success'); + renderRescuers(); // Update UI to clear excluded badges } catch (err) { log('❌ Failed to create incident: ' + err.message, 'error'); } @@ -976,9 +1004,18 @@ } const data = await response.json(); - currentMissionId = data.mission.id; - log(`Rescuer ${rescuerId.slice(-1)} accepted request!`, 'success'); + currentMissionId = data.missionId; + + // Update state immediately for instant UI feedback + rescuerRequests[rescuerId] = { ...request, status: 'Accepted' }; + + log(`Rescuer ${rescuerId.slice(-1)} accepted request! Mission ${data.missionId?.slice(0, 8)}... created`, 'success'); stopTimer(); + + // Render UI immediately before async refreshState + renderRescuers(); + + // Then fetch full state from server refreshState(); } catch (err) { log('Accept failed: ' + err.message, 'error'); @@ -1003,34 +1040,79 @@ } async function cancelMission(rescuerId) { - if (!currentMissionId) return; + if (!currentMissionId) { + log('No active mission to cancel', 'warning'); + return; + } + + if (!confirm('Cancel this mission? This will create a new session with expanded radius (excluding you).')) { + return; + } try { - const response = await fetch(`/api/rescuedemo/mission/${currentMissionId}/cancel?reason=Rescuer%20cancelled`, { + log('Aborting mission...', 'info'); + const response = await fetch(`/api/rescuedemo/mission/${currentMissionId}/abort?reason=Rescuer%20cancelled`, { method: 'POST' }); - const data = await response.json(); - log('Mission cancelled by rescuer, new session created', 'warning'); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Abort failed'); + } + + const data = await response.json(); + log(`🔄 Mission aborted! New session #${data.newSession.sessionNumber} created with ${data.newSession.radiusKm}km radius`, 'warning'); + log(`📡 ${data.newSession.rescuersPinged} rescuers notified (excluding Rescuer ${rescuerId.slice(-1)})`, 'info'); + log(`🚫 Rescuer ${rescuerId.slice(-1)} is now EXCLUDED from this incident`, 'error'); + currentMissionId = null; + // Mark this rescuer as aborted/excluded + excludedRescuers.add(rescuerId); + rescuerRequests[rescuerId] = { status: 'Aborted', reason: 'Mission cancelled by rescuer' }; + startTimer(60); refreshState(); + renderRescuers(); } catch (err) { - log('Cancel mission failed: ' + err.message, 'error'); + log('❌ Abort mission failed: ' + err.message, 'error'); } } async function updateMissionStatus() { const select = document.getElementById('missionStatusSelect'); const status = select.value; - if (!status || !currentMissionId) return; + if (!status || !currentMissionId) { + log('Please select a status', 'warning'); + return; + } try { - await fetch(`/api/rescuedemo/mission/${currentMissionId}/status?status=${status}`, { + const response = await fetch(`/api/rescuedemo/mission/${currentMissionId}/status?status=${status}`, { method: 'POST' }); - log(`Mission status updated to ${status}`, 'success'); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Update failed'); + } + + const data = await response.json(); + log(`✅ Mission status updated to ${data.newStatus}`, 'success'); select.value = ''; + + // Update mission status in UI immediately + const missionStatusEl = document.getElementById('missionStatus'); + if (missionStatusEl) { + missionStatusEl.textContent = data.newStatus; + } + + // Stop timer if mission completed + if (status === 'MissionCompleted') { + stopTimer(); + log('🎉 Mission completed! Incident resolved.', 'success'); + } + + // Then fetch full state refreshState(); } catch (err) { log('Update status failed: ' + err.message, 'error'); @@ -1121,10 +1203,11 @@ const isConnected = !!rescuerConnections[r.id]; const request = rescuerRequests[r.id]; const hasRequest = request && request.status === 'Pending'; + const isExcluded = excludedRescuers.has(r.id); return `
-
${r.name}
+
${r.name} ${isExcluded ? '🚫' : ''}
📍 ${r.distance} km from user
${!isConnected ? ` @@ -1144,9 +1227,19 @@ ` : ''} ${request.status === 'Accepted' ? `
- + +
+ ` : ''} + ${request.status === 'Aborted' ? ` +
+ ⚠️ Excluded from this incident +
+ ` : ''}
` : ''} + ${isExcluded && !request ? ` +
+ 🚫 Excluded - Aborted previous mission
` : ''}
@@ -1188,6 +1281,7 @@ document.getElementById('missionInfo').style.display = 'block'; document.getElementById('missionRescuer').textContent = data.mission.rescuerName || data.mission.rescuerId; document.getElementById('missionStatus').textContent = data.mission.status; + document.getElementById('missionPrice').textContent = (data.mission.price || 0).toLocaleString('vi-VN'); } else { document.getElementById('missionInfo').style.display = 'none'; } diff --git a/SnakeAid.Api/Program.cs b/SnakeAid.Api/Program.cs index e0053e21..bc47864f 100644 --- a/SnakeAid.Api/Program.cs +++ b/SnakeAid.Api/Program.cs @@ -281,12 +281,12 @@ public static async Task Main(string[] args) // app.ApplyMigrations(); } - // // Seed data (mở ra nếu seed lại dữ liệu) - // using (var scope = app.Services.CreateScope()) - // { - // var context = scope.ServiceProvider.GetRequiredService(); - // await DataSeeder.SeedAsync(context); - // } + // Seed data (mở ra nếu seed lại dữ liệu) + using (var scope = app.Services.CreateScope()) + { + var context = scope.ServiceProvider.GetRequiredService(); + await DataSeeder.SeedAsync(context); + } } app.UseSwagger(); app.UseSwaggerUI(c => diff --git a/SnakeAid.Core/Domains/SnakeSpecies.cs b/SnakeAid.Core/Domains/SnakeSpecies.cs index 7b213b58..84a8381e 100644 --- a/SnakeAid.Core/Domains/SnakeSpecies.cs +++ b/SnakeAid.Core/Domains/SnakeSpecies.cs @@ -98,7 +98,6 @@ public enum OverrideMode public class FirstAidOverride { public OverrideMode Mode { get; set; } = OverrideMode.Append; - public List Steps { get; set; } = new(); - // public FirstAidContent Content { get; set; } = new(); + public FirstAidContent Content { get; set; } = new(); } } \ No newline at end of file diff --git a/SnakeAid.Core/Domains/SnakebiteIncident.cs b/SnakeAid.Core/Domains/SnakebiteIncident.cs index 1b9a50e3..837b13de 100644 --- a/SnakeAid.Core/Domains/SnakebiteIncident.cs +++ b/SnakeAid.Core/Domains/SnakebiteIncident.cs @@ -55,7 +55,7 @@ public class SnakebiteIncident : BaseEntity public RescuerProfile? AssignedRescuer { get; set; } public ICollection Sessions { get; set; } = new List(); public ICollection AllRequests { get; set; } = new List(); // Denormalized for easy query - public RescueMission? RescueMission { get; set; } + public ICollection Missions { get; set; } = new List(); public ICollection Media { get; set; } = new List(); } diff --git a/SnakeAid.Core/Mappings/SnakebiteIncidentMapper.cs b/SnakeAid.Core/Mappings/SnakebiteIncidentMapper.cs index 86adf337..ef947a84 100644 --- a/SnakeAid.Core/Mappings/SnakebiteIncidentMapper.cs +++ b/SnakeAid.Core/Mappings/SnakebiteIncidentMapper.cs @@ -1,6 +1,7 @@ using Mapster; using NetTopologySuite.Geometries; using SnakeAid.Core.Domains; +using SnakeAid.Core.Responses; using SnakeAid.Core.Responses.SnakebiteIncident; using System; using System.Collections.Generic; @@ -22,6 +23,10 @@ public void Register(TypeAdapterConfig config) // Map Incident → Response config.NewConfig() .Map(dest => dest.LocationCoordinates, src => src.LocationCoordinates); + + config.NewConfig() + .Map(dest => dest.RescueMission, src => + src.Missions.OrderByDescending(m => m.CreatedAt).FirstOrDefault()); } } } diff --git a/SnakeAid.Core/Responses/SnakebiteIncident/DetailSnakebiteIncidentReposne.cs b/SnakeAid.Core/Responses/SnakebiteIncident/DetailSnakebiteIncidentResponse.cs similarity index 97% rename from SnakeAid.Core/Responses/SnakebiteIncident/DetailSnakebiteIncidentReposne.cs rename to SnakeAid.Core/Responses/SnakebiteIncident/DetailSnakebiteIncidentResponse.cs index bfd660c5..55f4a7b0 100644 --- a/SnakeAid.Core/Responses/SnakebiteIncident/DetailSnakebiteIncidentReposne.cs +++ b/SnakeAid.Core/Responses/SnakebiteIncident/DetailSnakebiteIncidentResponse.cs @@ -14,11 +14,11 @@ namespace SnakeAid.Core.Responses.SnakebiteIncident { - public class DetailSnakebiteIncidentReposne + public class DetailSnakebiteIncidentResponse { public Guid Id { get; set; } - + public Guid UserId { get; set; } // FK to MemberProfile [Column(TypeName = "geometry(Point, 4326)")] diff --git a/SnakeAid.Repository/Data/Configurations/RescueMissionConfiguration.cs b/SnakeAid.Repository/Data/Configurations/RescueMissionConfiguration.cs index 816331a4..a8b29316 100644 --- a/SnakeAid.Repository/Data/Configurations/RescueMissionConfiguration.cs +++ b/SnakeAid.Repository/Data/Configurations/RescueMissionConfiguration.cs @@ -28,8 +28,8 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(m => m.RescuerId) .HasDatabaseName("IX_RescueMissions_RescuerId"); + // Not unique anymore - an incident can have multiple missions (due to abort/retry) builder.HasIndex(m => m.IncidentId) - .IsUnique() .HasDatabaseName("IX_RescueMissions_IncidentId"); } } diff --git a/SnakeAid.Repository/Data/Configurations/SnakebiteIncidentConfiguration.cs b/SnakeAid.Repository/Data/Configurations/SnakebiteIncidentConfiguration.cs index be9e54d2..b60d8eaa 100644 --- a/SnakeAid.Repository/Data/Configurations/SnakebiteIncidentConfiguration.cs +++ b/SnakeAid.Repository/Data/Configurations/SnakebiteIncidentConfiguration.cs @@ -23,10 +23,11 @@ public void Configure(EntityTypeBuilder builder) // Relationship với MemberProfile (User) đã config tại MemberProfileConfiguration - // Relationship: Incident -> RescueMission (1-1) - builder.HasOne(i => i.RescueMission) + // Relationship: Incident -> Missions (1-N) + // An incident can have multiple missions due to rescuer abort and retry + builder.HasMany(i => i.Missions) .WithOne(m => m.Incident) - .HasForeignKey(m => m.IncidentId) + .HasForeignKey(m => m.IncidentId) .OnDelete(DeleteBehavior.Cascade); // Relationship: Incident -> Sessions (1-N) diff --git a/SnakeAid.Repository/Implements/UnitOfWork.cs b/SnakeAid.Repository/Implements/UnitOfWork.cs index 8e83b742..2d4f8831 100644 --- a/SnakeAid.Repository/Implements/UnitOfWork.cs +++ b/SnakeAid.Repository/Implements/UnitOfWork.cs @@ -91,6 +91,11 @@ public Task RollbackAsync() return Task.CompletedTask; } + public void ClearChangeTracker() + { + Context.ChangeTracker.Clear(); + } + private void TrackChanges() { diff --git a/SnakeAid.Repository/Interfaces/IUnitOfWork.cs b/SnakeAid.Repository/Interfaces/IUnitOfWork.cs index 89d74a46..500efa13 100644 --- a/SnakeAid.Repository/Interfaces/IUnitOfWork.cs +++ b/SnakeAid.Repository/Interfaces/IUnitOfWork.cs @@ -9,6 +9,7 @@ public interface IUnitOfWork : IGenericRepositoryFactory, IDisposable int Commit(); Task CommitAsync(); Task RollbackAsync(); + void ClearChangeTracker(); } diff --git a/SnakeAid.Repository/Migrations/20260210164830_UpdateSchemaV1.Designer.cs b/SnakeAid.Repository/Migrations/20260210164830_UpdateSchemaV1.Designer.cs new file mode 100644 index 00000000..2e8c073b --- /dev/null +++ b/SnakeAid.Repository/Migrations/20260210164830_UpdateSchemaV1.Designer.cs @@ -0,0 +1,3501 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetTopologySuite.Geometries; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SnakeAid.Repository.Data; + +#nullable disable + +namespace SnakeAid.Repository.Migrations +{ + [DbContext(typeof(SnakeAidDbContext))] + [Migration("20260210164830_UpdateSchemaV1")] + partial class UpdateSchemaV1 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("SnakeAid") + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", "AspNetIdentity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", "AspNetIdentity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", "AspNetIdentity"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.AIModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeployedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDefault") + .HasColumnType("boolean"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("IsActive") + .HasDatabaseName("IX_AIModels_IsActive"); + + b.HasIndex("IsDefault") + .HasDatabaseName("IX_AIModels_IsDefault"); + + b.HasIndex("Version") + .IsUnique() + .HasDatabaseName("IX_AIModels_Version"); + + b.ToTable("AIModels", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.AISnakeClassMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AIModelId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("SnakeSpeciesId") + .HasColumnType("integer"); + + b.Property("YoloClassId") + .HasColumnType("integer"); + + b.Property("YoloClassName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("IsActive") + .HasDatabaseName("IX_AISnakeClassMappings_IsActive"); + + b.HasIndex("SnakeSpeciesId"); + + b.HasIndex("AIModelId", "YoloClassId") + .IsUnique() + .HasDatabaseName("IX_AISnakeClassMappings_AIModelId_YoloClassId"); + + b.ToTable("AISnakeClassMappings", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("AvatarUrl") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("ReputationPoints") + .HasColumnType("integer"); + + b.Property("ReputationStatus") + .HasColumnType("integer"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("SuspendedUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("SuspensionReason") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("IsActive") + .HasDatabaseName("IX_Accounts_IsActive"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("Role") + .HasDatabaseName("IX_Accounts_Role"); + + b.ToTable("Accounts", "AspNetIdentity"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.Antivenom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Manufacturer") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TreatmentFacilityId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TreatmentFacilityId"); + + b.ToTable("Antivenoms", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.AppNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("IsRead") + .HasDatabaseName("IX_AppNotifications_IsRead"); + + b.HasIndex("UserId") + .HasDatabaseName("IX_AppNotifications_UserId"); + + b.HasIndex("UserId", "IsRead") + .HasDatabaseName("IX_AppNotifications_UserId_IsRead"); + + b.ToTable("AppNotifications", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.Blog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RejectionReason") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId") + .HasDatabaseName("IX_Blogs_AuthorId"); + + b.HasIndex("Status") + .HasDatabaseName("IX_Blogs_Status"); + + b.ToTable("Blogs", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.CatchingEnvironment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Price") + .HasColumnType("numeric(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("CatchingEnvironments", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.CatchingMissionDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("SnakeCatchingMissionId") + .HasColumnType("uuid"); + + b.Property("SnakeSpeciesId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SnakeCatchingMissionId") + .HasDatabaseName("IX_CatchingMissionDetails_SnakeCatchingMissionId"); + + b.HasIndex("SnakeSpeciesId") + .HasDatabaseName("IX_CatchingMissionDetails_SnakeSpeciesId"); + + b.ToTable("CatchingMissionDetails", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.CatchingRequestDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("SnakeCatchingRequestId") + .HasColumnType("uuid"); + + b.Property("SnakeSpeciesId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SnakeCatchingRequestId") + .HasDatabaseName("IX_CatchingRequestDetails_SnakeCatchingRequestId"); + + b.HasIndex("SnakeSpeciesId") + .HasDatabaseName("IX_CatchingRequestDetails_SnakeSpeciesId"); + + b.ToTable("CatchingRequestDetails", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.ChatMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttachmentUrl") + .HasColumnType("text"); + + b.Property("ConsultationId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("SenderId") + .HasColumnType("uuid"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ConsultationId") + .HasDatabaseName("IX_ChatMessages_ConsultationId"); + + b.HasIndex("SenderId") + .HasDatabaseName("IX_ChatMessages_SenderId"); + + b.HasIndex("SentAt") + .HasDatabaseName("IX_ChatMessages_SentAt"); + + b.ToTable("ChatMessages", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.CommunityReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdditionalDetails") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LocationCoordinates") + .IsRequired() + .HasColumnType("geometry"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .HasDatabaseName("IX_CommunityReports_UserId"); + + b.ToTable("CommunityReports", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.Consultation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CalleeId") + .HasColumnType("uuid"); + + b.Property("CallerId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpertTimeSlotId") + .HasColumnType("uuid"); + + b.Property("RoomId") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CalleeId") + .HasDatabaseName("IX_Consultations_CalleeId"); + + b.HasIndex("CallerId") + .HasDatabaseName("IX_Consultations_CallerId"); + + b.HasIndex("ExpertTimeSlotId"); + + b.HasIndex("RoomId") + .HasDatabaseName("IX_Consultations_RoomId"); + + b.HasIndex("Status") + .HasDatabaseName("IX_Consultations_Status"); + + b.ToTable("Consultations", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.ConsultationBooking", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BookedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CancellationReason") + .HasColumnType("text"); + + b.Property("CancelledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsultationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpertId") + .HasColumnType("uuid"); + + b.Property("PaymentDeadline") + .IsRequired() + .HasColumnType("timestamp with time zone"); + + b.Property("Price") + .HasColumnType("numeric"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TimeSlotId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("ConsultationId"); + + b.HasIndex("ExpertId") + .HasDatabaseName("IX_ConsultationBookings_ExpertId"); + + b.HasIndex("Status") + .HasDatabaseName("IX_ConsultationBookings_Status"); + + b.HasIndex("TimeSlotId") + .HasDatabaseName("IX_ConsultationBookings_TimeSlotId"); + + b.HasIndex("UserId") + .HasDatabaseName("IX_ConsultationBookings_UserId"); + + b.ToTable("ConsultationBookings", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.ConsultationPingRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsultationId") + .HasColumnType("uuid"); + + b.Property("ExpertId") + .HasColumnType("uuid"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RescueMissionId") + .HasColumnType("uuid"); + + b.Property("RescuerId") + .HasColumnType("uuid"); + + b.Property("RespondedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ConsultationId"); + + b.HasIndex("ExpertId") + .HasDatabaseName("IX_ConsultationPingRequests_ExpertId"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("IX_ConsultationPingRequests_ExpiresAt"); + + b.HasIndex("RescueMissionId"); + + b.HasIndex("RescuerId") + .HasDatabaseName("IX_ConsultationPingRequests_RescuerId"); + + b.HasIndex("Status") + .HasDatabaseName("IX_ConsultationPingRequests_Status"); + + b.ToTable("ConsultationPingRequests", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.ExpertCertificate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CertificateName") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("CertificateUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpertId") + .HasColumnType("uuid"); + + b.Property("ExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuingOrganization") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("RejectionReason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VerificationStatus") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ExpertCertificates", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.ExpertProfile", b => + { + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Biography") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ConsultationFee") + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsOnline") + .HasColumnType("boolean"); + + b.Property("Rating") + .HasColumnType("numeric(3,2)"); + + b.Property("RatingCount") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("AccountId"); + + b.ToTable("ExpertProfiles", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.ExpertSpecialization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpertId") + .HasColumnType("uuid"); + + b.Property("SpecializationId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SpecializationId"); + + b.HasIndex("ExpertId", "SpecializationId") + .IsUnique() + .HasDatabaseName("IX_ExpertSpecializations_ExpertId_SpecializationId"); + + b.ToTable("ExpertSpecializations", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.ExpertTimeSlot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpertId") + .HasColumnType("uuid"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("ExpertId") + .HasDatabaseName("IX_ExpertTimeSlots_ExpertId"); + + b.HasIndex("Status") + .HasDatabaseName("IX_ExpertTimeSlots_Status"); + + b.HasIndex("ExpertId", "StartTime") + .HasDatabaseName("IX_ExpertTimeSlots_ExpertId_StartTime"); + + b.ToTable("ExpertTimeSlots", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.FilterOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("OptionImageUrl") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("OptionText") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("QuestionId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("IsActive") + .HasDatabaseName("IX_FilterOptions_IsActive"); + + b.HasIndex("QuestionId") + .HasDatabaseName("IX_FilterOptions_QuestionId"); + + b.ToTable("FilterOptions", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.FilterQuestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Question") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("IsActive") + .HasDatabaseName("IX_FilterQuestions_IsActive"); + + b.ToTable("FilterQuestions", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.FilterSnakeMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FilterOptionId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SnakeSpeciesId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IsActive") + .HasDatabaseName("IX_FilterSnakeMappings_IsActive"); + + b.HasIndex("SnakeSpeciesId"); + + b.HasIndex("FilterOptionId", "SnakeSpeciesId") + .IsUnique() + .HasDatabaseName("IX_FilterSnakeMappings_FilterOptionId_SnakeSpeciesId"); + + b.ToTable("FilterSnakeMappings", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.FirstAidGuideline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Type") + .HasDatabaseName("IX_FirstAidGuidelines_Type"); + + b.ToTable("FirstAidGuidelines", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.Lesson", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Lessons", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.LibraryMedia", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsPublic") + .HasColumnType("boolean"); + + b.Property("MediaType") + .HasColumnType("integer"); + + b.Property("MediaUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("SnakeSpeciesId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UploadedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UploadedById") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("IsActive") + .HasDatabaseName("IX_LibraryMedias_IsActive"); + + b.HasIndex("IsPublic") + .HasDatabaseName("IX_LibraryMedias_IsPublic"); + + b.HasIndex("MediaType") + .HasDatabaseName("IX_LibraryMedias_MediaType"); + + b.HasIndex("SnakeSpeciesId") + .HasDatabaseName("IX_LibraryMedias_SnakeSpeciesId"); + + b.HasIndex("UploadedById"); + + b.ToTable("LibraryMedias", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.LocationEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Heading") + .HasColumnType("real"); + + b.Property("Location") + .IsRequired() + .HasColumnType("geometry"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("SessionId") + .HasColumnType("uuid"); + + b.Property("SessionType") + .HasColumnType("integer"); + + b.Property("Speed") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.HasIndex("AccountId") + .HasDatabaseName("IX_LocationEvents_AccountId"); + + b.HasIndex("RecordedAt") + .HasDatabaseName("IX_LocationEvents_RecordedAt"); + + b.HasIndex("SessionId") + .HasDatabaseName("IX_LocationEvents_SessionId"); + + b.HasIndex("SessionId", "RecordedAt") + .HasDatabaseName("IX_LocationEvents_SessionId_RecordedAt"); + + b.ToTable("LocationEvents", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.MemberProfile", b => + { + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmergencyContacts") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("HasUnderlyingDisease") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Rating") + .HasColumnType("real"); + + b.Property("RatingCount") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("AccountId"); + + b.ToTable("MemberProfiles", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.Otp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptLeft") + .HasColumnType("integer"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("OtpCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Otp", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.PaymentCard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CardHolderName") + .IsRequired() + .HasColumnType("text"); + + b.Property("CardNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Cvv") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDefault") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("PaymentCards", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.ReportMedia", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("IsProcessed") + .HasColumnType("boolean"); + + b.Property("MediaUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Purpose") + .HasColumnType("integer"); + + b.Property("ReferenceId") + .HasColumnType("uuid"); + + b.Property("ReferenceType") + .HasColumnType("integer"); + + b.Property("RequiresAIProcessing") + .HasColumnType("boolean"); + + b.Property("SequenceOrder") + .HasColumnType("integer"); + + b.Property("SnakeCatchingRequestId") + .HasColumnType("uuid"); + + b.Property("SnakebiteIncidentId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UploadBatchId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReferenceId") + .HasDatabaseName("IX_ReportMedias_ReferenceId"); + + b.HasIndex("ReferenceType") + .HasDatabaseName("IX_ReportMedias_ReferenceType"); + + b.HasIndex("RequiresAIProcessing") + .HasDatabaseName("IX_ReportMedias_RequiresAIProcessing"); + + b.HasIndex("SnakeCatchingRequestId"); + + b.HasIndex("SnakebiteIncidentId"); + + b.HasIndex("ReferenceId", "ReferenceType") + .HasDatabaseName("IX_ReportMedias_ReferenceId_ReferenceType"); + + b.ToTable("ReportMedias", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.ReputationRawEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsProcessed") + .HasColumnType("boolean"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("PointsChange") + .HasColumnType("integer"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProcessingError") + .HasColumnType("text"); + + b.Property("ReferenceId") + .HasColumnType("uuid"); + + b.Property("ReferenceType") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EventType") + .HasDatabaseName("IX_ReputationRawEvents_EventType"); + + b.HasIndex("IsProcessed") + .HasDatabaseName("IX_ReputationRawEvents_IsProcessed"); + + b.HasIndex("UserId") + .HasDatabaseName("IX_ReputationRawEvents_UserId"); + + b.HasIndex("ReferenceId", "ReferenceType") + .HasDatabaseName("IX_ReputationRawEvents_ReferenceId_ReferenceType"); + + b.ToTable("ReputationRawEvents", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.ReputationRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("IX_ReputationRules_Name"); + + b.ToTable("ReputationRules", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.ReputationTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NewPoints") + .HasColumnType("integer"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("PointsChanged") + .HasColumnType("integer"); + + b.Property("PreviousPoints") + .HasColumnType("integer"); + + b.Property("RawEventId") + .HasColumnType("uuid"); + + b.Property("ReputationRuleId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RawEventId") + .HasDatabaseName("IX_ReputationTransactions_RawEventId"); + + b.HasIndex("ReputationRuleId"); + + b.HasIndex("UserId") + .HasDatabaseName("IX_ReputationTransactions_UserId"); + + b.ToTable("ReputationTransactions", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.RescueMission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActualCost") + .HasColumnType("numeric(18,2)"); + + b.Property("ArrivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CancellationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EstimatedCost") + .HasColumnType("numeric(18,2)"); + + b.Property("IncidentId") + .HasColumnType("uuid"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Price") + .HasColumnType("numeric(18,2)"); + + b.Property("RescuerId") + .HasColumnType("uuid"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("IncidentId") + .HasDatabaseName("IX_RescueMissions_IncidentId"); + + b.HasIndex("RescuerId") + .HasDatabaseName("IX_RescueMissions_RescuerId"); + + b.HasIndex("Status") + .HasDatabaseName("IX_RescueMissions_Status"); + + b.ToTable("RescueMissions", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.RescueRequestSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IncidentId") + .HasColumnType("uuid"); + + b.Property("RadiusKm") + .HasColumnType("integer"); + + b.Property("RescuersPinged") + .HasColumnType("integer"); + + b.Property("SessionNumber") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TriggerType") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("IncidentId") + .HasDatabaseName("IX_RescueRequestSessions_IncidentId"); + + b.HasIndex("Status") + .HasDatabaseName("IX_RescueRequestSessions_Status"); + + b.HasIndex("IncidentId", "SessionNumber") + .IsUnique() + .HasDatabaseName("IX_RescueRequestSessions_IncidentId_SessionNumber"); + + b.ToTable("RescueRequestSessions", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.RescuerProfile", b => + { + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CompletedMissions") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsOnline") + .HasColumnType("boolean"); + + b.Property("LastLocation") + .HasColumnType("geometry(Point, 4326)"); + + b.Property("LastLocationUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("Rating") + .HasColumnType("numeric(3,2)"); + + b.Property("RatingCount") + .HasColumnType("integer"); + + b.Property("TotalMissions") + .HasColumnType("integer"); + + b.Property("Type") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("AccountId"); + + b.HasIndex("IsOnline") + .HasDatabaseName("IX_RescuerProfiles_IsOnline"); + + b.HasIndex("Type") + .HasDatabaseName("IX_RescuerProfiles_Type"); + + b.ToTable("RescuerProfiles", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.RescuerRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IncidentId") + .HasColumnType("uuid"); + + b.Property("RequestSentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RescuerId") + .HasColumnType("uuid"); + + b.Property("ResponseAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SessionId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ExpiredAt") + .HasDatabaseName("IX_RescuerRequests_ExpiredAt"); + + b.HasIndex("IncidentId") + .HasDatabaseName("IX_RescuerRequests_IncidentId"); + + b.HasIndex("RescuerId") + .HasDatabaseName("IX_RescuerRequests_RescuerId"); + + b.HasIndex("SessionId") + .HasDatabaseName("IX_RescuerRequests_SessionId"); + + b.HasIndex("Status") + .HasDatabaseName("IX_RescuerRequests_Status"); + + b.ToTable("RescuerRequests", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.SnakeAIRecognitionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AIModelId") + .HasColumnType("integer"); + + b.Property("AllDetections") + .HasColumnType("jsonb"); + + b.Property("ClassMappingId") + .HasColumnType("uuid"); + + b.Property("Confidence") + .HasColumnType("numeric(5,4)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DetectedSpeciesId") + .HasColumnType("integer"); + + b.Property("ExpertCorrectedSpeciesId") + .HasColumnType("integer"); + + b.Property("ExpertId") + .HasColumnType("uuid"); + + b.Property("ExpertNotes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExpertVerifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsMapped") + .HasColumnType("boolean"); + + b.Property("ReportMediaId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("YoloClassName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("AIModelId") + .HasDatabaseName("IX_SnakeAIRecognitionResults_AIModelId"); + + b.HasIndex("ClassMappingId"); + + b.HasIndex("DetectedSpeciesId"); + + b.HasIndex("ExpertCorrectedSpeciesId"); + + b.HasIndex("ExpertId"); + + b.HasIndex("IsMapped") + .HasDatabaseName("IX_SnakeAIRecognitionResults_IsMapped"); + + b.HasIndex("ReportMediaId") + .HasDatabaseName("IX_SnakeAIRecognitionResults_ReportMediaId"); + + b.HasIndex("Status") + .HasDatabaseName("IX_SnakeAIRecognitionResults_Status"); + + b.ToTable("SnakeAIRecognitionResults", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.SnakeCatchingMission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActualCost") + .HasColumnType("numeric(18,2)"); + + b.Property("ArrivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CancellationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EstimatedCost") + .HasColumnType("numeric(18,2)"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Price") + .HasColumnType("numeric(18,2)"); + + b.Property("RescuerId") + .HasColumnType("uuid"); + + b.Property("SnakeCatchingRequestId") + .HasColumnType("uuid"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("RescuerId") + .HasDatabaseName("IX_SnakeCatchingMissions_RescuerId"); + + b.HasIndex("SnakeCatchingRequestId") + .IsUnique() + .HasDatabaseName("IX_SnakeCatchingMissions_RequestId"); + + b.HasIndex("Status") + .HasDatabaseName("IX_SnakeCatchingMissions_Status"); + + b.ToTable("SnakeCatchingMissions", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.SnakeCatchingRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdditionalDetails") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Address") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AssignedRescuerId") + .HasColumnType("uuid"); + + b.Property("CancellationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EstimatedPrice") + .HasColumnType("numeric(18,2)"); + + b.Property("LocationCoordinates") + .IsRequired() + .HasColumnType("geometry(Point, 4326)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("PreferredTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RequestDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AssignedRescuerId") + .HasDatabaseName("IX_SnakeCatchingRequests_AssignedRescuerId"); + + b.HasIndex("RequestDate") + .HasDatabaseName("IX_SnakeCatchingRequests_RequestDate"); + + b.HasIndex("Status") + .HasDatabaseName("IX_SnakeCatchingRequests_Status"); + + b.HasIndex("UserId") + .HasDatabaseName("IX_SnakeCatchingRequests_UserId"); + + b.ToTable("SnakeCatchingRequests", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.SnakeCatchingTariff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BasePrice") + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("SizeCategory") + .HasColumnType("integer"); + + b.Property("SnakeSpeciesId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("IsActive") + .HasDatabaseName("IX_SnakeCatchingTariffs_IsActive"); + + b.HasIndex("SnakeSpeciesId", "SizeCategory") + .IsUnique() + .HasDatabaseName("IX_SnakeCatchingTariffs_SnakeSpeciesId_SizeCategory"); + + b.ToTable("SnakeCatchingTariffs", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.SnakeSpecies", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CommonName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FirstAidGuidelineOverride") + .HasColumnType("jsonb"); + + b.Property("Identification") + .HasColumnType("jsonb"); + + b.Property("IdentificationSummary") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsVenomous") + .HasColumnType("boolean"); + + b.Property("PrimaryVenomType") + .HasColumnType("integer"); + + b.Property("RiskLevel") + .HasColumnType("real"); + + b.Property("ScientificName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SymptomsByTime") + .HasColumnType("jsonb"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("IsActive") + .HasDatabaseName("IX_SnakeSpecies_IsActive"); + + b.HasIndex("IsVenomous") + .HasDatabaseName("IX_SnakeSpecies_IsVenomous"); + + b.HasIndex("ScientificName") + .IsUnique() + .HasDatabaseName("IX_SnakeSpecies_ScientificName"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("IX_SnakeSpecies_Slug"); + + b.ToTable("SnakeSpecies", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.SnakeSpeciesName", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SnakeSpeciesId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .HasDatabaseName("IX_SnakeSpeciesNames_Name"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("IX_SnakeSpeciesNames_Slug"); + + b.HasIndex("SnakeSpeciesId") + .HasDatabaseName("IX_SnakeSpeciesNames_SnakeSpeciesId"); + + b.ToTable("SnakeSpeciesNames", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.SnakebiteIncident", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AssignedRescuerId") + .HasColumnType("uuid"); + + b.Property("CancellationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentRadiusKm") + .HasColumnType("integer"); + + b.Property("CurrentSessionNumber") + .HasColumnType("integer"); + + b.Property("IncidentOccurredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSessionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LocationCoordinates") + .IsRequired() + .HasColumnType("geometry(Point, 4326)"); + + b.Property("SeverityLevel") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("SymptomsReport") + .HasColumnType("jsonb"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AssignedRescuerId") + .HasDatabaseName("IX_SnakebiteIncidents_AssignedRescuerId"); + + b.HasIndex("Status") + .HasDatabaseName("IX_SnakebiteIncidents_Status"); + + b.HasIndex("UserId") + .HasDatabaseName("IX_SnakebiteIncidents_UserId"); + + b.ToTable("SnakebiteIncidents", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.Specialization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("IX_Specializations_Name"); + + b.ToTable("Specializations", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.SpeciesAntivenom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AntivenomId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("SnakeSpeciesId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AntivenomId"); + + b.HasIndex("SnakeSpeciesId", "AntivenomId") + .IsUnique() + .HasDatabaseName("IX_SpeciesAntivenoms_SnakeSpeciesId_AntivenomId"); + + b.ToTable("SpeciesAntivenoms", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.SpeciesVenom", b => + { + b.Property("SnakeSpeciesId") + .HasColumnType("integer"); + + b.Property("VenomTypeId") + .HasColumnType("integer"); + + b.HasKey("SnakeSpeciesId", "VenomTypeId"); + + b.HasIndex("VenomTypeId"); + + b.ToTable("SpeciesVenoms", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.SymptomConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AlertMessage") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("AttributeKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("AttributeLabel") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsCritical") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("TimeScoresJson") + .HasColumnType("jsonb"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VenomTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AttributeKey") + .HasDatabaseName("IX_SymptomConfigs_AttributeKey"); + + b.HasIndex("DisplayOrder") + .HasDatabaseName("IX_SymptomConfigs_DisplayOrder"); + + b.HasIndex("IsActive") + .HasDatabaseName("IX_SymptomConfigs_IsActive"); + + b.HasIndex("VenomTypeId") + .HasDatabaseName("IX_SymptomConfigs_VenomTypeId"); + + b.ToTable("SymptomConfigs", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.SystemSetting", b => + { + b.Property("SettingKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ValueType") + .HasColumnType("integer"); + + b.HasKey("SettingKey"); + + b.ToTable("SystemSettings", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.TrackingSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DistanceMeters") + .HasColumnType("double precision"); + + b.Property("EtaMinutes") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("MemberLastUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("MemberLocation") + .HasColumnType("geometry(Point, 4326)"); + + b.Property("RescuerLastUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("RescuerLocation") + .HasColumnType("geometry(Point, 4326)"); + + b.Property("SessionId") + .HasColumnType("uuid"); + + b.Property("SessionType") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("IsActive") + .HasDatabaseName("IX_TrackingSessions_IsActive"); + + b.HasIndex("SessionId") + .HasDatabaseName("IX_TrackingSessions_SessionId"); + + b.HasIndex("SessionType") + .HasDatabaseName("IX_TrackingSessions_SessionType"); + + b.HasIndex("SessionId", "SessionType") + .IsUnique() + .HasDatabaseName("IX_TrackingSessions_SessionId_SessionType"); + + b.ToTable("TrackingSessions", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ExternalTransactionId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PaymentMethod") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ReferenceId") + .HasColumnType("uuid"); + + b.Property("TransactionType") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("IX_Transactions_CreatedAt"); + + b.HasIndex("ReferenceId") + .HasDatabaseName("IX_Transactions_ReferenceId"); + + b.HasIndex("TransactionType") + .HasDatabaseName("IX_Transactions_TransactionType"); + + b.HasIndex("UserId") + .HasDatabaseName("IX_Transactions_UserId"); + + b.ToTable("Transactions", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.TreatmentFacility", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ContactNumber") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Location") + .IsRequired() + .HasColumnType("geometry(Point, 4326)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("IsActive") + .HasDatabaseName("IX_TreatmentFacilities_IsActive"); + + b.HasIndex("Name") + .HasDatabaseName("IX_TreatmentFacilities_Name"); + + b.ToTable("TreatmentFacilities", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.UserFeedback", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comments") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RaterId") + .HasColumnType("uuid"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.Property("ReferenceId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("RaterId") + .HasDatabaseName("IX_UserFeedbacks_RaterId"); + + b.HasIndex("TargetUserId") + .HasDatabaseName("IX_UserFeedbacks_TargetUserId"); + + b.HasIndex("Type") + .HasDatabaseName("IX_UserFeedbacks_Type"); + + b.HasIndex("ReferenceId", "Type") + .HasDatabaseName("IX_UserFeedbacks_ReferenceId_Type"); + + b.ToTable("UserFeedbacks", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.VenomType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("FirstAidGuidelineId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ScientificName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SeverityIndex") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FirstAidGuidelineId") + .IsUnique(); + + b.HasIndex("IsActive") + .HasDatabaseName("IX_VenomTypes_IsActive"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("IX_VenomTypes_Name"); + + b.ToTable("VenomTypes", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.Wallet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Balance") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique() + .HasDatabaseName("IX_Wallets_UserId"); + + b.ToTable("Wallets", "SnakeAid"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.WalletWithdraw", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric(18,2)"); + + b.Property("BankAccount") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BankName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("WalletId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Status") + .HasDatabaseName("IX_WalletWithdraws_Status"); + + b.HasIndex("UserId") + .HasDatabaseName("IX_WalletWithdraws_UserId"); + + b.HasIndex("WalletId") + .HasDatabaseName("IX_WalletWithdraws_WalletId"); + + b.ToTable("WalletWithdraws", "SnakeAid"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("SnakeAid.Core.Domains.Account", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("SnakeAid.Core.Domains.Account", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("SnakeAid.Core.Domains.Account", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.AISnakeClassMapping", b => + { + b.HasOne("SnakeAid.Core.Domains.AIModel", "AIModel") + .WithMany("ClassMappings") + .HasForeignKey("AIModelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SnakeAid.Core.Domains.SnakeSpecies", "SnakeSpecies") + .WithMany() + .HasForeignKey("SnakeSpeciesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AIModel"); + + b.Navigation("SnakeSpecies"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.Antivenom", b => + { + b.HasOne("SnakeAid.Core.Domains.TreatmentFacility", null) + .WithMany("AntivenomStocks") + .HasForeignKey("TreatmentFacilityId"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.AppNotification", b => + { + b.HasOne("SnakeAid.Core.Domains.Account", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.Blog", b => + { + b.HasOne("SnakeAid.Core.Domains.Account", "Author") + .WithMany() + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.CatchingMissionDetail", b => + { + b.HasOne("SnakeAid.Core.Domains.SnakeCatchingMission", "SnakeCatchingMission") + .WithMany() + .HasForeignKey("SnakeCatchingMissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SnakeAid.Core.Domains.SnakeSpecies", "SnakeSpecies") + .WithMany() + .HasForeignKey("SnakeSpeciesId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("SnakeCatchingMission"); + + b.Navigation("SnakeSpecies"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.CatchingRequestDetail", b => + { + b.HasOne("SnakeAid.Core.Domains.SnakeCatchingRequest", "SnakeCatchingRequest") + .WithMany() + .HasForeignKey("SnakeCatchingRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SnakeAid.Core.Domains.SnakeSpecies", "SnakeSpecies") + .WithMany() + .HasForeignKey("SnakeSpeciesId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("SnakeCatchingRequest"); + + b.Navigation("SnakeSpecies"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.ChatMessage", b => + { + b.HasOne("SnakeAid.Core.Domains.Consultation", "Consultation") + .WithMany() + .HasForeignKey("ConsultationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SnakeAid.Core.Domains.Account", "Sender") + .WithMany() + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Consultation"); + + b.Navigation("Sender"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.CommunityReport", b => + { + b.HasOne("SnakeAid.Core.Domains.Account", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.Consultation", b => + { + b.HasOne("SnakeAid.Core.Domains.Account", "Callee") + .WithMany() + .HasForeignKey("CalleeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SnakeAid.Core.Domains.Account", "Caller") + .WithMany() + .HasForeignKey("CallerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SnakeAid.Core.Domains.ExpertTimeSlot", null) + .WithMany("Consultations") + .HasForeignKey("ExpertTimeSlotId"); + + b.Navigation("Callee"); + + b.Navigation("Caller"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.ConsultationBooking", b => + { + b.HasOne("SnakeAid.Core.Domains.Consultation", "Consultation") + .WithMany() + .HasForeignKey("ConsultationId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SnakeAid.Core.Domains.Account", "Expert") + .WithMany() + .HasForeignKey("ExpertId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SnakeAid.Core.Domains.ExpertTimeSlot", "TimeSlot") + .WithMany("RescueMissions") + .HasForeignKey("TimeSlotId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SnakeAid.Core.Domains.Account", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Consultation"); + + b.Navigation("Expert"); + + b.Navigation("TimeSlot"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.ConsultationPingRequest", b => + { + b.HasOne("SnakeAid.Core.Domains.Consultation", "Consultation") + .WithMany() + .HasForeignKey("ConsultationId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SnakeAid.Core.Domains.Account", "Expert") + .WithMany() + .HasForeignKey("ExpertId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SnakeAid.Core.Domains.RescueMission", "RescueMission") + .WithMany() + .HasForeignKey("RescueMissionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SnakeAid.Core.Domains.Account", "Rescuer") + .WithMany() + .HasForeignKey("RescuerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Consultation"); + + b.Navigation("Expert"); + + b.Navigation("RescueMission"); + + b.Navigation("Rescuer"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.ExpertProfile", b => + { + b.HasOne("SnakeAid.Core.Domains.Account", "Account") + .WithOne("ExpertProfile") + .HasForeignKey("SnakeAid.Core.Domains.ExpertProfile", "AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.ExpertSpecialization", b => + { + b.HasOne("SnakeAid.Core.Domains.ExpertProfile", "Expert") + .WithMany("Specializations") + .HasForeignKey("ExpertId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SnakeAid.Core.Domains.Specialization", "Specialization") + .WithMany("ExpertSpecializations") + .HasForeignKey("SpecializationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Expert"); + + b.Navigation("Specialization"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.ExpertTimeSlot", b => + { + b.HasOne("SnakeAid.Core.Domains.Account", "Expert") + .WithMany() + .HasForeignKey("ExpertId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Expert"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.FilterOption", b => + { + b.HasOne("SnakeAid.Core.Domains.FilterQuestion", "Question") + .WithMany("FilterOptions") + .HasForeignKey("QuestionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Question"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.FilterSnakeMapping", b => + { + b.HasOne("SnakeAid.Core.Domains.FilterOption", "FilterOption") + .WithMany("FilterSnakeMappings") + .HasForeignKey("FilterOptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SnakeAid.Core.Domains.SnakeSpecies", "SnakeSpecies") + .WithMany("FilterSnakeMappings") + .HasForeignKey("SnakeSpeciesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FilterOption"); + + b.Navigation("SnakeSpecies"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.LibraryMedia", b => + { + b.HasOne("SnakeAid.Core.Domains.SnakeSpecies", "SnakeSpecies") + .WithMany("LibraryMedias") + .HasForeignKey("SnakeSpeciesId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("SnakeAid.Core.Domains.Account", "UploadedBy") + .WithMany() + .HasForeignKey("UploadedById") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("SnakeSpecies"); + + b.Navigation("UploadedBy"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.MemberProfile", b => + { + b.HasOne("SnakeAid.Core.Domains.Account", "Account") + .WithOne("MemberProfile") + .HasForeignKey("SnakeAid.Core.Domains.MemberProfile", "AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.Otp", b => + { + b.HasOne("SnakeAid.Core.Domains.Account", "User") + .WithMany("Otps") + .HasForeignKey("UserId"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.ReportMedia", b => + { + b.HasOne("SnakeAid.Core.Domains.SnakeCatchingRequest", null) + .WithMany("Media") + .HasForeignKey("SnakeCatchingRequestId"); + + b.HasOne("SnakeAid.Core.Domains.SnakebiteIncident", null) + .WithMany("Media") + .HasForeignKey("SnakebiteIncidentId"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.ReputationRawEvent", b => + { + b.HasOne("SnakeAid.Core.Domains.Account", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.ReputationTransaction", b => + { + b.HasOne("SnakeAid.Core.Domains.ReputationRawEvent", "RawEvent") + .WithMany() + .HasForeignKey("RawEventId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SnakeAid.Core.Domains.ReputationRule", "ReputationRule") + .WithMany() + .HasForeignKey("ReputationRuleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SnakeAid.Core.Domains.Account", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("RawEvent"); + + b.Navigation("ReputationRule"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.RescueMission", b => + { + b.HasOne("SnakeAid.Core.Domains.SnakebiteIncident", "Incident") + .WithMany("Missions") + .HasForeignKey("IncidentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SnakeAid.Core.Domains.RescuerProfile", "Rescuer") + .WithMany("Missions") + .HasForeignKey("RescuerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Incident"); + + b.Navigation("Rescuer"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.RescueRequestSession", b => + { + b.HasOne("SnakeAid.Core.Domains.SnakebiteIncident", "Incident") + .WithMany("Sessions") + .HasForeignKey("IncidentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Incident"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.RescuerProfile", b => + { + b.HasOne("SnakeAid.Core.Domains.Account", "Account") + .WithOne("RescuerProfile") + .HasForeignKey("SnakeAid.Core.Domains.RescuerProfile", "AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.RescuerRequest", b => + { + b.HasOne("SnakeAid.Core.Domains.SnakebiteIncident", "Incident") + .WithMany("AllRequests") + .HasForeignKey("IncidentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SnakeAid.Core.Domains.RescuerProfile", "Rescuer") + .WithMany("RescuerRequests") + .HasForeignKey("RescuerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SnakeAid.Core.Domains.RescueRequestSession", "Session") + .WithMany("Requests") + .HasForeignKey("SessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Incident"); + + b.Navigation("Rescuer"); + + b.Navigation("Session"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.SnakeAIRecognitionResult", b => + { + b.HasOne("SnakeAid.Core.Domains.AIModel", "AIModel") + .WithMany("RecognitionResults") + .HasForeignKey("AIModelId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SnakeAid.Core.Domains.AISnakeClassMapping", "ClassMapping") + .WithMany() + .HasForeignKey("ClassMappingId"); + + b.HasOne("SnakeAid.Core.Domains.SnakeSpecies", "DetectedSpecies") + .WithMany() + .HasForeignKey("DetectedSpeciesId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SnakeAid.Core.Domains.SnakeSpecies", "ExpertCorrectedSpecies") + .WithMany() + .HasForeignKey("ExpertCorrectedSpeciesId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SnakeAid.Core.Domains.Account", "Expert") + .WithMany() + .HasForeignKey("ExpertId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SnakeAid.Core.Domains.ReportMedia", "ReportMedia") + .WithMany("AIRecognitionResults") + .HasForeignKey("ReportMediaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AIModel"); + + b.Navigation("ClassMapping"); + + b.Navigation("DetectedSpecies"); + + b.Navigation("Expert"); + + b.Navigation("ExpertCorrectedSpecies"); + + b.Navigation("ReportMedia"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.SnakeCatchingMission", b => + { + b.HasOne("SnakeAid.Core.Domains.RescuerProfile", "Rescuer") + .WithMany("CatchingMissions") + .HasForeignKey("RescuerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SnakeAid.Core.Domains.SnakeCatchingRequest", "SnakeCatchingRequest") + .WithOne("Mission") + .HasForeignKey("SnakeAid.Core.Domains.SnakeCatchingMission", "SnakeCatchingRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Rescuer"); + + b.Navigation("SnakeCatchingRequest"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.SnakeCatchingRequest", b => + { + b.HasOne("SnakeAid.Core.Domains.RescuerProfile", "AssignedRescuer") + .WithMany() + .HasForeignKey("AssignedRescuerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SnakeAid.Core.Domains.MemberProfile", "User") + .WithMany("SnakeCatchingRequests") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AssignedRescuer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.SnakeCatchingTariff", b => + { + b.HasOne("SnakeAid.Core.Domains.SnakeSpecies", "SnakeSpecies") + .WithMany("SnakeCatchingTariffs") + .HasForeignKey("SnakeSpeciesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SnakeSpecies"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.SnakeSpeciesName", b => + { + b.HasOne("SnakeAid.Core.Domains.SnakeSpecies", "SnakeSpecies") + .WithMany("AlternativeNames") + .HasForeignKey("SnakeSpeciesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SnakeSpecies"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.SnakebiteIncident", b => + { + b.HasOne("SnakeAid.Core.Domains.RescuerProfile", "AssignedRescuer") + .WithMany() + .HasForeignKey("AssignedRescuerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SnakeAid.Core.Domains.MemberProfile", "User") + .WithMany("SnakebiteIncidents") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AssignedRescuer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.SpeciesAntivenom", b => + { + b.HasOne("SnakeAid.Core.Domains.Antivenom", "Antivenom") + .WithMany("SpeciesAntivenoms") + .HasForeignKey("AntivenomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SnakeAid.Core.Domains.SnakeSpecies", "SnakeSpecies") + .WithMany("SpeciesAntivenoms") + .HasForeignKey("SnakeSpeciesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Antivenom"); + + b.Navigation("SnakeSpecies"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.SpeciesVenom", b => + { + b.HasOne("SnakeAid.Core.Domains.SnakeSpecies", "SnakeSpecies") + .WithMany("SpeciesVenoms") + .HasForeignKey("SnakeSpeciesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SnakeAid.Core.Domains.VenomType", "VenomType") + .WithMany("SpeciesVenoms") + .HasForeignKey("VenomTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SnakeSpecies"); + + b.Navigation("VenomType"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.SymptomConfig", b => + { + b.HasOne("SnakeAid.Core.Domains.VenomType", "VenomType") + .WithMany("SymptomConfigs") + .HasForeignKey("VenomTypeId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("VenomType"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.Transaction", b => + { + b.HasOne("SnakeAid.Core.Domains.Account", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.UserFeedback", b => + { + b.HasOne("SnakeAid.Core.Domains.Account", "Rater") + .WithMany() + .HasForeignKey("RaterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SnakeAid.Core.Domains.Account", "TargetUser") + .WithMany() + .HasForeignKey("TargetUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Rater"); + + b.Navigation("TargetUser"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.VenomType", b => + { + b.HasOne("SnakeAid.Core.Domains.FirstAidGuideline", "FirstAidGuideline") + .WithOne() + .HasForeignKey("SnakeAid.Core.Domains.VenomType", "FirstAidGuidelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("FirstAidGuideline"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.Wallet", b => + { + b.HasOne("SnakeAid.Core.Domains.Account", "Account") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.WalletWithdraw", b => + { + b.HasOne("SnakeAid.Core.Domains.Account", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SnakeAid.Core.Domains.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.AIModel", b => + { + b.Navigation("ClassMappings"); + + b.Navigation("RecognitionResults"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.Account", b => + { + b.Navigation("ExpertProfile"); + + b.Navigation("MemberProfile"); + + b.Navigation("Otps"); + + b.Navigation("RescuerProfile"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.Antivenom", b => + { + b.Navigation("SpeciesAntivenoms"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.ExpertProfile", b => + { + b.Navigation("Specializations"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.ExpertTimeSlot", b => + { + b.Navigation("Consultations"); + + b.Navigation("RescueMissions"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.FilterOption", b => + { + b.Navigation("FilterSnakeMappings"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.FilterQuestion", b => + { + b.Navigation("FilterOptions"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.MemberProfile", b => + { + b.Navigation("SnakeCatchingRequests"); + + b.Navigation("SnakebiteIncidents"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.ReportMedia", b => + { + b.Navigation("AIRecognitionResults"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.RescueRequestSession", b => + { + b.Navigation("Requests"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.RescuerProfile", b => + { + b.Navigation("CatchingMissions"); + + b.Navigation("Missions"); + + b.Navigation("RescuerRequests"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.SnakeCatchingRequest", b => + { + b.Navigation("Media"); + + b.Navigation("Mission"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.SnakeSpecies", b => + { + b.Navigation("AlternativeNames"); + + b.Navigation("FilterSnakeMappings"); + + b.Navigation("LibraryMedias"); + + b.Navigation("SnakeCatchingTariffs"); + + b.Navigation("SpeciesAntivenoms"); + + b.Navigation("SpeciesVenoms"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.SnakebiteIncident", b => + { + b.Navigation("AllRequests"); + + b.Navigation("Media"); + + b.Navigation("Missions"); + + b.Navigation("Sessions"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.Specialization", b => + { + b.Navigation("ExpertSpecializations"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.TreatmentFacility", b => + { + b.Navigation("AntivenomStocks"); + }); + + modelBuilder.Entity("SnakeAid.Core.Domains.VenomType", b => + { + b.Navigation("SpeciesVenoms"); + + b.Navigation("SymptomConfigs"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SnakeAid.Repository/Migrations/20260210164830_UpdateSchemaV1.cs b/SnakeAid.Repository/Migrations/20260210164830_UpdateSchemaV1.cs new file mode 100644 index 00000000..f80b0dc4 --- /dev/null +++ b/SnakeAid.Repository/Migrations/20260210164830_UpdateSchemaV1.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SnakeAid.Repository.Migrations +{ + /// + public partial class UpdateSchemaV1 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_RescueMissions_IncidentId", + schema: "SnakeAid", + table: "RescueMissions"); + + migrationBuilder.CreateIndex( + name: "IX_RescueMissions_IncidentId", + schema: "SnakeAid", + table: "RescueMissions", + column: "IncidentId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_RescueMissions_IncidentId", + schema: "SnakeAid", + table: "RescueMissions"); + + migrationBuilder.CreateIndex( + name: "IX_RescueMissions_IncidentId", + schema: "SnakeAid", + table: "RescueMissions", + column: "IncidentId", + unique: true); + } + } +} diff --git a/SnakeAid.Repository/Migrations/SnakeAidDbContextModelSnapshot.cs b/SnakeAid.Repository/Migrations/SnakeAidDbContextModelSnapshot.cs index 3b21037e..dfa23dd8 100644 --- a/SnakeAid.Repository/Migrations/SnakeAidDbContextModelSnapshot.cs +++ b/SnakeAid.Repository/Migrations/SnakeAidDbContextModelSnapshot.cs @@ -19,7 +19,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("SnakeAid") - .HasAnnotation("ProductVersion", "8.0.23") + .HasAnnotation("ProductVersion", "8.0.11") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); @@ -1538,7 +1538,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); b.HasIndex("IncidentId") - .IsUnique() .HasDatabaseName("IX_RescueMissions_IncidentId"); b.HasIndex("RescuerId") @@ -3066,8 +3065,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("SnakeAid.Core.Domains.RescueMission", b => { b.HasOne("SnakeAid.Core.Domains.SnakebiteIncident", "Incident") - .WithOne("RescueMission") - .HasForeignKey("SnakeAid.Core.Domains.RescueMission", "IncidentId") + .WithMany("Missions") + .HasForeignKey("IncidentId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); @@ -3472,7 +3471,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Media"); - b.Navigation("RescueMission"); + b.Navigation("Missions"); b.Navigation("Sessions"); }); diff --git a/SnakeAid.Repository/Seeds/DataSeeder.cs b/SnakeAid.Repository/Seeds/DataSeeder.cs index dfcce63d..6fe1ae78 100644 --- a/SnakeAid.Repository/Seeds/DataSeeder.cs +++ b/SnakeAid.Repository/Seeds/DataSeeder.cs @@ -240,729 +240,729 @@ public static async Task SeedAsync(SnakeAidDbContext context) }; context.FilterOptions.AddRange(filterOptions); - // var snakes = new List - // { - // // 1. RẮN CẠP NIA BẮC (Bungarus multicinctus) - // new SnakeSpecies - // { - // Id = 1, - // ScientificName = "Bungarus multicinctus", - // CommonName = "Rắn Cạp Nia Bắc", - // Slug = "ran-cap-nia-bac", - // Description = "Một trong những loài rắn độc nhất châu Á, thường gặp ở vùng đồng bằng và trung du Bắc Bộ.", - // IdentificationSummary = "Chiều dài trung bình 1.0m - 1.5m. Thân có các khoanh trắng và đen rõ rệt, vảy trơn bóng.", - // PrimaryVenomType = PrimaryVenomType.Neurotoxic, - // RiskLevel = 9.5f, - // IsVenomous = true, - // ImageUrl = "https://e.khoahoc.tv/photos/image/2020/09/19/ran-cap-nia-1.jpg", - // Identification = new IdentificationFeature - // { - // PhysicalTraits = new List { "Chiều dài: 100 - 150 cm", "Khoanh trắng đen rõ rệt", "Đầu bầu dục", "Vảy bóng", "Thân hình tam giác nhẹ" }, - // Behaviors = new List { "Hoạt động mạnh về đêm", "Thích nơi ẩm ướt", "Thường chui vào nhà dân tìm mồi" }, - // Habitat = "Cánh đồng, ven sông, khu dân cư miền Bắc" - // }, - // SymptomsByTime = new List - // { - // new SymptomTimeline { TimeRange = "0 - 30 phút", Signs = new List { "Đau nhẹ tại vết cắn, ít cảm giác", "Tê nhẹ" }, IsCritical = false }, - // new SymptomTimeline { TimeRange = "1 - 3 giờ", Signs = new List { "Sụp mí mắt", "Khó nói", "Khó nuốt", "Yếu cơ" }, IsCritical = true }, - // new SymptomTimeline { TimeRange = "3 - 6 giờ", Signs = new List { "Liệt cơ hô hấp", "Ngừng thở" }, IsCritical = true } - // }, - // FirstAidGuidelineOverride = new FirstAidOverride - // { - // Mode = OverrideMode.Append, - // Content = new FirstAidContent - // { - // Steps = new List - // { - // new FirstAidStep { Text = "Đặc biệt chú ý hỗ trợ hô hấp nhân tạo nếu nạn nhân có dấu hiệu ngưng thở." } - // } - // } - // } - // }, - - // // 2. RẮN LỤC ĐUÔI ĐỎ (Trimeresurus albolabris) - // new SnakeSpecies - // { - // Id = 2, - // ScientificName = "Trimeresurus albolabris", - // CommonName = "Rắn Lục Đuôi Đỏ", - // Slug = "ran-luc-duoi-do", - // Description = "Loài rắn lục phổ biến nhất, thường sống trên cây và gây ra nhiều vụ tai nạn tại Việt Nam.", - // IdentificationSummary = "Chiều dài trung bình 60cm - 90cm. Thân màu xanh lá cây, đầu hình tam giác rõ rệt, chót đuôi có màu đỏ cam.", - // PrimaryVenomType = PrimaryVenomType.Hemotoxic, - // RiskLevel = 7.5f, - // IsVenomous = true, - // ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/32/1736395426_0.jpg", - // Identification = new IdentificationFeature - // { - // PhysicalTraits = new List { "Chiều dài: 60 - 90 cm", "Thân xanh lá", "Đuôi màu đỏ", "Đầu tam giác phình to", "Vảy nhám" }, - // Behaviors = new List { "Sống trên cây", "Ngụy trang cực tốt trong lá cây", "Hay xuất hiện ở bụi hoa, vườn nhà" }, - // Habitat = "Vườn cây, bụi rậm, rừng thưa trên toàn quốc" - // }, - // SymptomsByTime = new List - // { - // new SymptomTimeline { TimeRange = "0 - 15 phút", Signs = new List { "Đau nhức dữ dội", "Sưng nề nhanh chóng" }, IsCritical = false }, - // new SymptomTimeline { TimeRange = "1 - 6 giờ", Signs = new List { "Xuất hiện bọng nước", "Chảy máu không cầm", "Bầm tím diện rộng" }, IsCritical = true } - // }, - // FirstAidGuidelineOverride = new FirstAidOverride - // { - // Mode = OverrideMode.Replace, - // Content = new FirstAidContent - // { - // Steps = new List - // { - // new FirstAidStep { Text = "Rửa sạch vết thương." }, - // new FirstAidStep { Text = "Bất động lỏng chi." }, - // new FirstAidStep { Text = "TUYỆT ĐỐI KHÔNG BĂNG ÉP CHẶT vì gây hoại tử nhanh." } - // } - // } - // } - // }, - - // // 3. RẮN HỔ MANG CHÚA (Ophiophagus hannah) - // new SnakeSpecies - // { - // Id = 3, - // ScientificName = "Ophiophagus hannah", - // CommonName = "Rắn Hổ Mang Chúa", - // Slug = "ran-ho-mang-chua", - // Description = "Loài rắn độc dài nhất thế giới, cực kỳ nguy hiểm với lượng nọc độc khổng lồ.", - // IdentificationSummary = "Kích thước khổng lồ (4-6m), Vảy đầu lớn, Cổ phình mang hẹp, vân chữ V ngược ở cổ.", - // PrimaryVenomType = PrimaryVenomType.Neurotoxic, - // RiskLevel = 10.0f, - // IsVenomous = true, - // ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/55/1737107747_0.jpg", - // Identification = new IdentificationFeature - // { - // PhysicalTraits = new List { "Kích thước khổng lồ (4-6m)", - // "Cặp vảy chẩm hình cánh bướm, nằm ở ngay phía sau vảy đầu", - // "Phình mang hẹp dài", "Màu đen, nâu hoặc vàng chì" }, - // Behaviors = new List { "Chủ động tấn công nếu bị kích động", "Có khả năng rướn cao thân mình" ,"Là một loài rắn thông minh, sẽ quan sát và phản ứng." }, - // Habitat = "Rừng rậm, nương rẫy, gần nguồn nước" - // }, - // SymptomsByTime = new List - // { - // new SymptomTimeline { TimeRange = "0 - 15 phút", Signs = new List { "Đau nhức", "Chóng mặt", "Hoa mắt" }, IsCritical = true }, - // new SymptomTimeline { TimeRange = "30 - 60 phút", Signs = new List { "Hôn mê", "Suy hô hấp cấp", "Tử vong nhanh nếu không cấp cứu" }, IsCritical = true } - // }, - // FirstAidGuidelineOverride = new FirstAidOverride - // { - // Mode = OverrideMode.Append, - // Content = new FirstAidContent - // { - // Steps = new List - // { - // new FirstAidStep { Text = "Vận chuyển nạn nhân bằng phương tiện nhanh nhất có thể đến bệnh viện lớn." } - // } - // } - // } - // }, - - // // 4. RẮN RÁO (Ptyas korros) - KHÔNG ĐỘC - // new SnakeSpecies - // { - // Id = 4, - // ScientificName = "Ptyas korros", - // CommonName = "Rắn Ráo", - // Slug = "ran-rao", - // Description = "Loài rắn không độc phổ biến, thường bị nhầm lẫn với rắn hổ mang.", - // IdentificationSummary = "Dài trung bình 1,2 - 2,0m. Mắt rất lớn, thân thon dài màu nâu đất hoặc xám chì, di chuyển cực nhanh.", - // PrimaryVenomType = PrimaryVenomType.None, - // RiskLevel = 1.0f, - // IsVenomous = false, - // ImageUrl = "https://www.cakhotranluan.com/images/2022/2ran-rao1.jpg", - // Identification = new IdentificationFeature - // { - // PhysicalTraits = new List { "Chiều dài: 120 - 200 cm", "Mắt to", "Thân dài thon", "Vảy trơn bóng", "Đầu bầu dục" }, - // Behaviors = new List { "Di chuyển rất nhanh", "Hoạt động ban ngày", "Thường chạy trốn khi gặp người" }, - // Habitat = "Đồng ruộng, bụi rậm, vườn nhà" - // }, - // SymptomsByTime = new List - // { - // new SymptomTimeline { TimeRange = "Sau khi cắn", Signs = new List { "Chảy máu nhẹ", "Vết xước li ti", "Không sưng nề" }, IsCritical = false } - // }, - // FirstAidGuidelineOverride = new FirstAidOverride - // { - // Mode = OverrideMode.Append, - // Content = new FirstAidContent - // { - // Steps = new List - // { - // new FirstAidStep { Text = "Sát trùng vết thương bằng cồn hoặc nước sạch." } - // } - // } - // } - // }, - - // new SnakeSpecies - // { - // Id = 5, - // ScientificName = "Bungarus fasciatus", - // CommonName = "Rắn Cạp Nong", - // Slug = "ran-cap-nong", - // Description = "Loài rắn độc thần kinh nguy hiểm, dễ nhận biết với các khoanh vàng đen xen kẽ đều nhau.", - // IdentificationSummary = "Kích thước 1.8 - 2.3m. Thân hình tam giác với sống lưng gồ cao, đầu có vệt vàng hình mũi tên.", - // PrimaryVenomType = PrimaryVenomType.Neurotoxic, - // RiskLevel = 9.0f, - // IsVenomous = true, - // ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/46/1736402914_0.jpg", - // Identification = new IdentificationFeature - // { - // PhysicalTraits = new List { "Khoanh đen và vàng xen kẽ đều nhau", "Thân hình tam giác, sống lưng gồ", "Vệt vàng hai bên má tạo hình mũi tên" }, - // Behaviors = new List { "Săn mồi ban đêm", "Bị thu hút bởi ánh lửa", "Tính tình thường nhút nhát nhưng độc tính rất mạnh" }, - // Habitat = "Rừng núi, bụi rậm, ven nguồn nước" - // }, - // SymptomsByTime = new List - // { - // new SymptomTimeline { TimeRange = "0 - 30 phút", Signs = new List { "Ngứa nhẹ hoặc tê rát tại vết cắn", "Ít đau khiến nạn nhân chủ quan" }, IsCritical = false }, - // new SymptomTimeline { TimeRange = "1 - 2 giờ", Signs = new List { "Mệt mỏi bất thường", "Tức ngực nhẹ", "Sụp mí mắt nhẹ" }, IsCritical = true }, - // new SymptomTimeline { TimeRange = "2 - 6 giờ", Signs = new List { "Đau nhức toàn thân", "Yếu liệt cơ tiến triển", "Suy hô hấp cấp" }, IsCritical = true } - // }, - // FirstAidGuidelineOverride = new FirstAidOverride - // { - // Mode = OverrideMode.Append, - // Content = new FirstAidContent - // { - // Steps = new List - // { - // new FirstAidStep { Text = "Cảnh giác cao độ nếu bị cắn khi đang cắm trại hoặc đi rừng ban đêm." } - // } - // } - // } - // }, - - // new SnakeSpecies - // { - // Id = 6, - // ScientificName = "Bungarus candidus", - // CommonName = "Rắn Cạp Nia Nam", - // Slug = "ran-cap-nia-nam", - // Description = "Loài rắn độc thần kinh cực mạnh ở miền Nam, có tập tính lẻn vào nhà người dân.", - // IdentificationSummary = "Khoanh đen và trắng có độ rộng gần bằng nhau, thân tròn bóng, đầu nhỏ.", - // PrimaryVenomType = PrimaryVenomType.Neurotoxic, - // RiskLevel = 9.8f, - // IsVenomous = true, - // ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/38/1736401130_0.jpg", - // Identification = new IdentificationFeature - // { - // PhysicalTraits = new List { "Khoanh đen và trắng/vàng nhạt đều nhau", "Thân tròn, vảy trơn bóng", "Đầu nhỏ không phân biệt rõ với cổ" }, - // Behaviors = new List { "Hoạt động đêm", "Hay chui vào nhà dân", "Cắn người khi đang ngủ" }, - // Habitat = "Vùng đồng bằng, khu dân cư miền Nam Việt Nam" - // }, - // SymptomsByTime = new List - // { - // new SymptomTimeline { TimeRange = "0 - 15 phút", Signs = new List { "Vết cắn rất nhẹ, không sưng đau", "Khó thấy dấu răng" }, IsCritical = false }, - // new SymptomTimeline { TimeRange = "2 - 3 giờ", Signs = new List { "Sụp mí mắt nặng", "Đồng tử giãn", "Nói ngọng", "Yếu chi" }, IsCritical = true }, - // new SymptomTimeline { TimeRange = "3 - 6 giờ", Signs = new List { "Liệt cơ toàn thân", "Suy hô hấp hoàn toàn" }, IsCritical = true } - // }, - // FirstAidGuidelineOverride = new FirstAidOverride - // { - // Mode = OverrideMode.Append, - // Content = new FirstAidContent - // { - // Steps = new List - // { - // new FirstAidStep { Text = "Tuyệt đối không chờ triệu chứng đau mới đi viện vì nọc cạp nia không gây đau." } - // } - // } - // } - // }, - - // new SnakeSpecies - // { - // Id = 7, - // ScientificName = "Naja kaouthia", - // CommonName = "Rắn Hổ Mang Xiêm", - // Slug = "ran-ho-mang-xiem", - // Description = "Loài rắn hổ mang có nọc độc hỗn hợp, gây hoại tử mô nghiêm trọng và liệt thần kinh.", - // IdentificationSummary = "Màu nâu xám hoặc đen, có bành cổ với một hình tròn đơn (hình kính mắt) ở mặt sau.", - // PrimaryVenomType = PrimaryVenomType.Neurotoxic, // Lưu ý: Hỗn hợp thần kinh và tế bào - // RiskLevel = 9.0f, - // IsVenomous = true, - // ImageUrl = "https://photo.znews.vn/w660/Uploaded/rotnrz/2023_07_04/ho_meo_1.jpg", - // Identification = new IdentificationFeature - // { - // PhysicalTraits = new List { "Bành cổ rộng", "Hình kính mắt một vòng tròn sau cổ", "Màu nâu hoặc xám đen" }, - // Behaviors = new List { "Có thể phun nọc độc xa và chuẩn", "Ngóc đầu cao và phình mang khi tấn công" }, - // Habitat = "Ruộng lúa, vườn tược, khu dân cư gần nguồn nước" - // }, - // SymptomsByTime = new List - // { - // new SymptomTimeline { TimeRange = "0 - 10 phút", Signs = new List { "Đau rõ rệt", "Chảy máu tại vết cắn" }, IsCritical = false }, - // new SymptomTimeline { TimeRange = "10 - 60 phút", Signs = new List { "Sưng nhanh", "Đau tăng dần", "Có thể phồng rộp da" }, IsCritical = false }, - // new SymptomTimeline { TimeRange = "1 - 3 giờ", Signs = new List { "Sụp mí mắt", "Yếu cơ", "Khó nuốt" }, IsCritical = true }, - // new SymptomTimeline { TimeRange = "6 - 24 giờ", Signs = new List { "Hoại tử mô tại chỗ", "Nhiễm trùng vết thương" }, IsCritical = true } - // }, - // FirstAidGuidelineOverride = new FirstAidOverride - // { - // Mode = OverrideMode.Append, - // Content = new FirstAidContent - // { - // Steps = new List - // { - // new FirstAidStep { Text = "Nếu bị nọc phun vào mắt, phải rửa bằng nước sạch liên tục 15-20 phút." } - // } - // } - // } - // }, - - // new SnakeSpecies - // { - // Id = 8, - // ScientificName = "Rhabdophis subminiatus", - // CommonName = "Rắn Hoa Cỏ Cổ Đỏ", - // Slug = "ran-hoa-co-co-do", - // Description = "Loài rắn có độc nguy hiểm điều kiện (độc nanh sau và độc da vùng cổ), gây rối loạn đông máu nặng và chưa có huyết thanh đặc hiệu.", - // IdentificationSummary = "Cổ màu đỏ rực hoặc cam vàng đặc trưng, thân xanh ô liu, mắt tròn lớn.", - // PrimaryVenomType = PrimaryVenomType.Hemotoxic, - // RiskLevel = 8.5f, - // IsVenomous = true, - // ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/93/1737024030_0.jpeg", - // Identification = new IdentificationFeature - // { - // PhysicalTraits = new List { "Vòng cổ màu đỏ rực hoặc vàng cam", "Đầu thuôn không hình tam giác", "Mắt tròn, đồng tử tròn" }, - // Behaviors = new List { "Ngóc đầu, tiết độc trắng đục và bẹt cổ giống rắn hổ khi bị đe dọa", "Tính khí không ổn định (lúc hiền lúc dữ)" }, - // Habitat = "Rừng, nương rẫy, gần nguồn nước" - // }, - // SymptomsByTime = new List - // { - // new SymptomTimeline { - // TimeRange = "0 - 1 giờ", - // Signs = new List { "Vết cắn đau nhẹ", "Sưng nhẹ cục bộ", "Có thể không có cảm giác bị nhiễm độc ngay" }, - // IsCritical = false - // }, - // new SymptomTimeline { - // TimeRange = "1 - 6 giờ", - // Signs = new List { "Máu rỉ rả không cầm tại vết cắn", "Bầm tím lan rộng", "Đau bụng, buồn nôn" }, - // IsCritical = true - // }, - // new SymptomTimeline { - // TimeRange = "6 - 24 giờ", - // Signs = new List { "Chảy máu chân răng, máu cam", "Tiểu ra máu", "Nôn ra máu", "Dấu hiệu suy thận" }, - // IsCritical = true - // } - // }, - // FirstAidGuidelineOverride = new FirstAidOverride - // { - // Mode = OverrideMode.Replace, // Thay thế hoàn toàn vì cách tiếp cận điều trị rất khác - // Content = new FirstAidContent - // { - // Steps = new List { - // new FirstAidStep { Text = "Đặt nạn nhân nằm yên, bất động hoàn toàn chi bị cắn.", MediaUrl = "https://assets.snakeaid.vn/aid/immobilize.gif" }, - // new FirstAidStep { Text = "Băng ép nhẹ bằng băng vải rộng để bảo vệ vết thương.", MediaUrl = "https://assets.snakeaid.vn/aid/light-bandage.jpg" }, - // new FirstAidStep { Text = "Nhanh chóng chuyển nạn nhân đến bệnh viện tuyến tỉnh hoặc trung ương có khả năng lọc máu và truyền máu.", MediaUrl = "" } - // }, - // Dos = new List { - // new FirstAidStep { Text = "Báo cho bác sĩ đây là rắn 'Rhabdophis subminiatus' (Hoa cỏ cổ đỏ).", MediaUrl = "" }, - // new FirstAidStep { Text = "Theo dõi sát màu nước tiểu và tình trạng chảy máu.", MediaUrl = "" } - // }, - // Donts = new List { - // new FirstAidStep { Text = "KHÔNG ĐƯỢC CHỦ QUAN nếu thấy vết cắn không sưng đau nhiều lúc đầu.", MediaUrl = "" }, - // new FirstAidStep { Text = "KHÔNG dùng ga-rô chặt (làm tăng hoại tử và rối loạn đông máu tại chỗ).", MediaUrl = "" }, - // new FirstAidStep { Text = "KHÔNG rạch hoặc hút máu tại vết cắn.", MediaUrl = "" } - // }, - // Notes = new List { - // "Lưu ý quan trọng: Việt Nam chưa có huyết thanh kháng độc cho loài này. Việc điều trị chủ yếu là hỗ trợ, truyền máu và lọc thận.", - // "Loài này có răng độc nằm sâu phía sau hàm (Hậu nha), nọc độc chỉ tiết ra khi rắn nhai hoặc cắn sâu." - // } - // } - // } - // }, - - // new SnakeSpecies - // { - // Id = 9, - // ScientificName = "Coelognathus radiatus", - // CommonName = "Rắn Hổ Ngựa", - // Slug = "ran-ho-ngua", - // Description = "Loài rắn không độc, di chuyển cực nhanh và thường có hành vi tự vệ hung dữ.", - // IdentificationSummary = "Màu nâu vàng, có 4 sọc đen chạy dọc phần trước thân, đầu dài.", - // PrimaryVenomType = PrimaryVenomType.None, - // RiskLevel = 1.0f, - // IsVenomous = false, - // ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/122/1737364008_0.jpg", - // Identification = new IdentificationFeature - // { - // PhysicalTraits = new List { "Có thể dài đến 2m", "4 sọc đen trên thân trước", "Đầu dài bầu dục", "Mắt lớn" }, - // Behaviors = new List { "Ngóc cao thân mình, bẹt cổ để hù dọa giống rắn hổ", " Miệng há rộng, hung hăng, doạ nạt, dữ tợn khi bị đe dọa", "Giả chết nếu cảm thấy nguy hiểm trước đối phương" }, - // Habitat = "Đồng ruộng, bụi cây, khu vực nông nghiệp" - // }, - // SymptomsByTime = new List - // { - // new SymptomTimeline { TimeRange = "Sau khi cắn", Signs = new List { "Vết xước li ti", "Chảy máu nhẹ", "Không có triệu chứng thần kinh hay sưng nề lớn" }, IsCritical = false } - // }, - // FirstAidGuidelineOverride = new FirstAidOverride - // { - // Mode = OverrideMode.Append, - // Content = new FirstAidContent - // { - // Steps = new List - // { - // new FirstAidStep { Text = "Chỉ cần rửa sạch vết thương bằng xà phòng để tránh nhiễm trùng." } - // } - // } - // } - // }, - - // new SnakeSpecies - // { - // Id = 10, - // ScientificName = "Elaphe carinata", - // CommonName = "Rắn Chuột Vua", - // Slug = "ran-chuot-vua", - // Description = "Loài rắn không độc nhưng cực kỳ hung dữ, có kích thước lớn và mùi hôi đặc trưng để xua đuổi kẻ thù. Dễ bị nhầm lẫn với rắn hổ mang chúa do kích thước lớn và họa tiết đầu gần giống nhau.", - // IdentificationSummary = "Thân màu nâu vàng hoặc xám đen với các vảy có gờ nổi rất mạnh (nhám). Không có nanh độc, không phình mang.", - // PrimaryVenomType = PrimaryVenomType.None, - // RiskLevel = 2.0f, - // IsVenomous = false, - // ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/123/1737365069_2.jpeg", - // Identification = new IdentificationFeature - // { - // PhysicalTraits = new List { "Vảy có gờ nổi rất rõ (thân nhám)", "Mắt lớn, đầu thuôn dài", "Kích thước có thể lên tới 2.4m", "Màu sắc pha trộn vàng - đen - nâu ô liu" }, - // Behaviors = new List { "Cực kỳ hung dữ, sẵn sàng tấn công khi bị kích động", "Phát ra mùi hôi thối nồng nặc từ tuyến sau", "Ăn thịt các loài rắn khác (kể cả rắn độc)" }, - // Habitat = "Vùng đồi núi, bụi rậm, trang trại chăn nuôi" - // }, - // SymptomsByTime = new List - // { - // new SymptomTimeline { TimeRange = "Sau khi cắn", Signs = new List { "Vết cắn hình vòng cung", "Chảy máu khá nhiều do răng sắc nhọn", "Đau rát cục bộ" }, IsCritical = false } - // }, - // FirstAidGuidelineOverride = new FirstAidOverride - // { - // Mode = OverrideMode.Append, - // Content = new FirstAidContent - // { - // Steps = new List - // { - // new FirstAidStep { Text = "Sát trùng kỹ vết thương vì miệng loài này chứa nhiều vi khuẩn do ăn chuột và thịt thối." } - // } - // } - // } - // }, - - // // 11. RẮN LỤC CƯỜM (LỤC GẤM) - Protobothrops mucrosquamatus - // new SnakeSpecies - // { - // Id = 11, - // ScientificName = "Protobothrops mucrosquamatus", - // CommonName = "Rắn Lục Cườm", - // Slug = "ran-luc-cuom", - // Description = "Loài rắn độc máu nguy hiểm, đầu hình tam giác lớn, hoa văn đốm sâm so le nhau ở sống lưng.", - // IdentificationSummary = "Đầu tam giác rõ rệt, thân có các vệt hoa văn màu nâu đen trên nền xám/vàng đất, vảy nhám.", - // PrimaryVenomType = PrimaryVenomType.Hemotoxic, - // RiskLevel = 8.5f, - // IsVenomous = true, - // ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/54/1736524361_0.jpg", - // Identification = new IdentificationFeature { - // PhysicalTraits = new List { "Đầu tam giác lớn", "Hoa văn vện gấm đốm nâu", "Vảy nhám", "Mắt có con ngươi dọc" }, - // Behaviors = new List { "Hoạt động đêm",", Chuyên phục kích săn mồi", "Tính hung dữ, sẵn sàng tấn công", "Thường ở hốc đá, bụi rậm, lá khô" }, - // Habitat = "Rừng núi, vùng đồi gò, hang hốc" - // }, - // SymptomsByTime = new List { - // new SymptomTimeline { TimeRange = "0 - 15 phút", Signs = new List { "Đau rát dữ dội", "Sưng nề tức thì" }, IsCritical = false }, - // new SymptomTimeline { TimeRange = "1 - 6 giờ", Signs = new List { "Xuất huyết dưới da", "Máu chảy không cầm tại vết cắn", "Bầm tím nặng" }, IsCritical = true } - // }, - // FirstAidGuidelineOverride = new FirstAidOverride { - // Mode = OverrideMode.Replace, Content = new FirstAidContent { Steps = new List { new FirstAidStep { Text = "KHÔNG garô/băng ép." }, new FirstAidStep { Text = "Bất động chi bằng nẹp lỏng." }, new FirstAidStep { Text = "Chuyển viện gấp." } } } } - // }, - - // // 12. RẮN LỤC NƯA (CHÀM QUẠP) - Calloselasma rhodostoma - // new SnakeSpecies - // { - // Id = 12, - // ScientificName = "Calloselasma rhodostoma", - // CommonName = "Rắn Lục Nưa", - // Slug = "ran-luc-nua", - // Description = "Loài rắn cực nguy hiểm ở miền Nam/Tây Nguyên, ngụy trang hoàn hảo dưới lá khô.", - // IdentificationSummary = "Thân mập, đầu tam giác, hoa văn hình tam giác sẫm màu dọc hai bên thân.", - // PrimaryVenomType = PrimaryVenomType.Hemotoxic, // và có độc tế bào cytotoxic - // RiskLevel = 9.5f, - // IsVenomous = true, - // ImageUrl = "https://cdn.kienthuc.net.vn/images/cf739f51f3276a5be16e9cbb75eb670590e4e1a049c04ce64210426ce976f3c08b805acba731385fd614eebaf46f4c3502d128915c73af35a8698059b85f0f9824f61d459aaa6ca7ad4acb289d18b91958ebfab71a3d1d5ad62d6dd95e9bd598a65c32335617c1b43812b9de6f4caea5/thot-tim-loai-ran-cuc-doc-nam-im-lim-cho-can-nguoi-o-viet-nam-Hinh-9.png", - // Identification = new IdentificationFeature { - // PhysicalTraits = new List { "Thân mập, ngắn", "Hoa văn hình tam giác đối xứng", "Màu nâu lá khô", "Đầu tam giác rất nhọn" }, - // Behaviors = new List { "Nằm bất động dưới lá khô", "Không bỏ chạy khi có người, chủ động cắn", "Tấn công bất ngờ cực nhanh" }, - // Habitat = "Rừng cao su, vườn điều, rừng khộp" - // }, - // SymptomsByTime = new List { - // new SymptomTimeline { TimeRange = "0 - 30 phút", Signs = new List { "Sưng nề cực nhanh", "Đau buốt như lửa đốt" }, IsCritical = true }, - // new SymptomTimeline { TimeRange = "6 - 12 giờ", Signs = new List { "Hoại tử mô diện rộng", "Xuất huyết toàn thân", "Phồng rộp máu" }, IsCritical = true } - // }, - // FirstAidGuidelineOverride = new FirstAidOverride { Mode = OverrideMode.Replace, Content = new FirstAidContent { Steps = new List { new FirstAidStep { Text = "Tuyệt đối không rạch vết thương vì nọc gây rối loạn đông máu cực nặng." }, new FirstAidStep { Text = "Băng ép nhẹ bằng băng thun (không chặt)." } } } } - // }, - - // // 13. RẮN LỤC XANH - Trimeresurus stejnegeri - // new SnakeSpecies - // { - // Id = 13, - // ScientificName = "Trimeresurus stejnegeri", - // CommonName = "Rắn Lục Xanh (Lục Vẻ)", - // Slug = "ran-luc-xanh", - // Description = "Thường bị nhầm với lục đuôi đỏ nhưng không có màu đỏ ở đuôi, độc tính tương tự.", - // IdentificationSummary = "Toàn thân xanh lá, đầu tam giác, có hố nhiệt giữa mắt và mũi.", - // PrimaryVenomType = PrimaryVenomType.Hemotoxic, - // RiskLevel = 7.0f, - // IsVenomous = true, - // ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/34/1736396208_0.jpg", - // Identification = new IdentificationFeature { - // PhysicalTraits = new List { "Thân xanh mướt", "Đầu tam giác", "Mắt vàng/cam", "Không có đuôi đỏ" }, - // Behaviors = new List { "Sống hoàn toàn trên cây", "Ngụy trang trong lá", "Hoạt động ban đêm" }, - // Habitat = "Bụi rậm, vườn cây trái, rừng rậm" - // }, - // SymptomsByTime = new List { - // new SymptomTimeline { TimeRange = "0 - 15 phút", Signs = new List { "Sưng đau cục bộ", "Buồn nôn" }, IsCritical = false }, - // new SymptomTimeline { TimeRange = "2 - 12 giờ", Signs = new List { "Vết thương thâm đen", "Chảy máu chân răng" }, IsCritical = true } - // } - // }, - - // // 14. RẮN KHIẾM VẠCH - Oligodon fasciolatus - // new SnakeSpecies - // { - // Id = 14, - // ScientificName = "Oligodon fasciolatus", - // CommonName = "Rắn Khiếm Vạch", - // Slug = "ran-khiem-vach", - // Description = "Loài rắn không độc nhưng có răng sắc nhọn để ăn trứng chim/bò sát.", - // IdentificationSummary = "Kích thước nhỏ, tối đa 45 cm. Màu nâu/xám, có các vạch ngang mờ và hình chữ V trên đỉnh đầu.", - // PrimaryVenomType = PrimaryVenomType.None, - // RiskLevel = 1.5f, - // IsVenomous = false, - // ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/154/1737700148_0.jpg", - // Identification = new IdentificationFeature { - // PhysicalTraits = new List { "Hình chữ V trên đầu", "Vảy trơn bóng", "Đầu bầu dục", "Kích thước nhỏ (tối đa 45 cm)", "Có 2 sọc đen dọc thân" }, - // Behaviors = new List { "Săn mồi ban ngày", "Khá nhút nhát", "Thường gặp dưới đống gạch đá" }, - // Habitat = "Vườn nhà, khu vực nông nghiệp, rừng thưa" - // }, - // SymptomsByTime = new List { - // new SymptomTimeline { TimeRange = "Sau khi cắn", Signs = new List { "Vết thương lớn", "Chảy máu nhiều", "Không sưng nề" }, IsCritical = false } - // }, - // FirstAidGuidelineOverride = new FirstAidOverride - // { - // Mode = OverrideMode.Replace, - // Content = new FirstAidContent - // { - // Steps = new List - // { - // new FirstAidStep { Text = "Rửa sạch vết thương bằng xà phòng hoặc dung dịch sát khuẩn." }, - // new FirstAidStep { Text = "Cầm máu nếu cần thiết." }, - // new FirstAidStep { Text = "Theo dõi dấu hiệu nhiễm trùng (sưng, đỏ, mưng mủ)." }, - // new FirstAidStep { Text = "Đến cơ sở y tế nếu vết thương không lành hoặc có dấu hiệu nhiễm trùng." } - // } - // } - // } - // }, - - // // 15. RẮN CƯỜM (HẢI XÀ) - Chrysopelea ornata - // new SnakeSpecies - // { - // Id = 15, - // ScientificName = "Chrysopelea ornata", - // CommonName = "Rắn Cườm (Rắn Bay)", - // Slug = "ran-cuom", - // Description = "Loài rắn nước có khả năng 'bay' bằng cách hóp bụng để lượn qua các cành cây.", - // IdentificationSummary = "Màu xanh vàng nhạt với các họa tiết viền đen chi tiết trên từng vảy.", - // PrimaryVenomType = PrimaryVenomType.None, - // RiskLevel = 1.0f, - // IsVenomous = false, - // ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/3/1737361056_0.jpg", - // Identification = new IdentificationFeature { - // PhysicalTraits = new List { "Vảy màu vàng chanh viền đen", "Thân thon dài", "Mắt to tròn" }, - // Behaviors = new List { "Leo trèo cực giỏi", "Nhảy từ trên cây cao xuống", "Rất hiền lành" }, - // Habitat = "Cây cao, vườn nhà, rừng rậm" - // } - // }, - - // // 16. RẮN RÁO TRÂU - Ptyas mucosa - // new SnakeSpecies - // { - // Id = 16, - // ScientificName = "Ptyas mucosa", - // CommonName = "Rắn Ráo Trâu (Rắn Lãi Lớn)", - // Slug = "ran-rao-trau", - // Description = "Loài rắn không độc có kích thước lớn, di chuyển tốc độ rất nhanh.", - // IdentificationSummary = "Màu nâu/vàng đất, nửa thân sau có các vạch đen ngang rõ rệt như vằn hổ. Mắt rất to. Họa tiet đầu giống rắn hổ mang.", - // PrimaryVenomType = PrimaryVenomType.None, - // RiskLevel = 2.0f, - // IsVenomous = false, - // ImageUrl = "https://sgaqua.vn/wp-content/uploads/2026/01/ran-rao-trau-co-doc-khong-1.jpg", - // Identification = new IdentificationFeature { - // PhysicalTraits = new List { "Kích thước lớn (tới 3m)", "Vằn ngang zig zag trắng nửa thân trước chuyển đen nửa thân sau", "Mắt rất to, tròn", "Vảy trơn, óng ánh, xếp đều", "Họa tiết vảy đầu giống rắn hổ mang" }, - // Behaviors = new List { "Chạy trốn cực nhanh", "Hung dữ khi bị dồn vào đường cùng", "Bị đe dọa sẽ mở rộng vùng cổ và tạo âm thanh rít liên tục" }, - // Habitat = "Đồng ruộng, bụi rậm, hang hốc" - // } - // }, - - // // 17. RẮN HOA CÂN VÂN ĐỐM - Sinonatrix aequifasciata - // new SnakeSpecies - // { - // Id = 17, - // ScientificName = "Sinonatrix aequifasciata", - // CommonName = "Rắn Hoa Cân Vân Đốm", - // Slug = "ran-hoa-can-van-dom", - // Description = "Rắn nước không độc, thường sống gần các khe suối.", - // IdentificationSummary = "Kích thước trung bình từ 0.7-1.4m. Thân mập, có các hoa văn hình mắt màu vàng đen chạy dọc thân.", - // PrimaryVenomType = PrimaryVenomType.None, - // RiskLevel = 1.0f, - // IsVenomous = false, - // ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/171/trimerodytes-aequifasciatus_1740063874_0.jpg", - // Identification = new IdentificationFeature { - // PhysicalTraits = new List { "Thân mập hình trụ", "Hoa văn hình mắt màu vàng đen chạy dọc thân", "Đầu bầu dục" }, - // Behaviors = new List { "Sống bán thủy sinh", "Ăn cá và ếch nhái" }, - // Habitat = "Suối, ao hồ, đầm lầy vùng núi" - // } - // }, - - // // 18. RẮN RI CÁ - Homalopsis buccata - // new SnakeSpecies - // { - // Id = 18, - // ScientificName = "Homalopsis buccata", - // CommonName = "Rắn Ri Cá", - // Slug = "ran-ri-ca", - // Description = "Rắn nước phổ biến ở Nam Bộ, thịt ngon nhưng không có độc.", - // IdentificationSummary = "Kích thước trung bình khoảng 70cm.Đầu to, có hình mặt nạ trắng trên đầu, thân có nhiều khoanh màu nâu đỏ nhạt.", - // PrimaryVenomType = PrimaryVenomType.None, - // RiskLevel = 1.0f, - // IsVenomous = false, - // ImageUrl = "https://upload.wikimedia.org/wikipedia/commons/c/c1/Homalopsis_buccata.png", - // Identification = new IdentificationFeature { - // PhysicalTraits = new List { "Kích thước trung bình khoảng 70cm", "Đầu to rộng", "Hoa văn mặt nạ trên đỉnh đầu", "Thân chắc, vảy gồ" }, - // Behaviors = new List { "Ăn đêm", "Sống dưới nước", "Nhút nhát" }, - // Habitat = "Kênh rạch, ao hồ, đầm lầy bùn" - // } - // }, - - // // 19. RẮN ROI - Ahaetulla prasina - // new SnakeSpecies - // { - // Id = 19, - // ScientificName = "Ahaetulla prasina", - // CommonName = "Rắn Roi (Rắn Sinh Viên)", - // Slug = "ran-roi", - // Description = "Thân mảnh như sợi dây thừng, đầu rất nhọn, độc nhẹ, không gây nguy hiểm cho người.", - // IdentificationSummary = "Màu xanh lá huỳnh quang nổi bật, mõm dài và nhọn, con ngươi nằm ngang.", - // PrimaryVenomType = PrimaryVenomType.None, - // RiskLevel = 1.0f, - // IsVenomous = false, - // ImageUrl = "https://cdn-i.vtcnews.vn/files/f2/2015/01/21/nhung-loai-ran-ky-di-nhat-the-gioi-tai-viet-nam-0.jpg", - // Identification = new IdentificationFeature { - // PhysicalTraits = new List { "Thân cực mảnh", "Mõm nhọn dài", "Con ngươi ngang đặc trưng", "Màu xanh lá hoặc nâu nhạt" }, - // Behaviors = new List { "Sống trên cây", "Di chuyển chậm chạp", "Hay thò thụt lưỡi đánh hơi" }, - // Habitat = "Vườn cây, rừng thưa, bụi rậm" - // } - // }, - - // // 20. RẮN TRUN - Cylindrophis ruffus - // new SnakeSpecies - // { - // Id = 20, - // ScientificName = "Cylindrophis ruffus", - // CommonName = "Rắn Trun", - // Slug = "ran-trun", - // Description = "Loài rắn không độc, thân hình trụ tròn, thường bị nhầm với rắn độc do màu sắc.", - // IdentificationSummary = "Thân đen bóng có vạch vàng/trắng, đuôi ngắn tịt và có màu đỏ dưới mặt đuôi.", - // PrimaryVenomType = PrimaryVenomType.None, - // RiskLevel = 1.0f, - // IsVenomous = false, - // ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/131/1737365540_0.jpg", - // Identification = new IdentificationFeature { - // PhysicalTraits = new List { "Thân hình trụ đồng nhất", "Đuôi ngắn giống đầu", "Mặt dưới đuôi màu đỏ" }, - // Behaviors = new List { "Chui rúc trong bùn đất", "Khi gặp nguy hiểm sẽ cuộn tròn và giơ đuôi đỏ lên để lừa kẻ thù" }, - // Habitat = "Đầm lầy, ruộng lúa, nơi đất ẩm" - // } - // }, - - // // 22. RẮN ĐAI LỚN - Lycodon fasciatus - // new SnakeSpecies - // { - // Id = 21, - // ScientificName = "Ptyas major", // Tên khoa học chính xác của Rắn Đại Lớn - // CommonName = "Rắn Đai Lớn (Rắn Xanh Lớn)", - // Slug = "ran-dai-lon-xanh", - // Description = "Loài rắn hoàn toàn không độc, hiền lành, thường bị nhầm với rắn lục do màu xanh lục toàn thân.", - // IdentificationSummary = "Kích thước có thể đạt 1,5-2,5m. Không độc, toàn thân màu xanh lá mượt mà, mắt rất to và tròn, đuôi thuôn dài.", - // PrimaryVenomType = PrimaryVenomType.None, - // RiskLevel = 1.0f, - // IsVenomous = false, - // ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/101/ptyas-major_1743324730_0.jpg", - // Identification = new IdentificationFeature { - // PhysicalTraits = new List { - // "Thân dài, có thể đạt 1,5-2,5 mét", - // "Toàn thân màu xanh lá cây đồng nhất", - // "Bụng màu vàng nhạt hoặc trắng xanh", - // "Mắt rất to, con ngươi tròn đen", - // "Vảy trơn mịn, bóng mượt" - // }, - // Behaviors = new List { - // "Hoạt động chủ yếu ban ngày", - // "Rất hiền lành, hiếm khi cắn người kể cả khi bị bắt", - // "Di chuyển nhanh nhẹn trên mặt đất và cây cỏ" - // }, - // Habitat = "Vườn tược, bụi rậm, rừng thưa, thường gặp ở vùng đồi núi và trung du" - // }, - // SymptomsByTime = new List { - // new SymptomTimeline { - // TimeRange = "Sau khi cắn", - // Signs = new List { "Vết xước rất nhỏ", "Hầu như không đau", "Không sưng nề" }, - // IsCritical = false - // } - // }, - // FirstAidGuidelineOverride = new FirstAidOverride { - // Mode = OverrideMode.Replace, - // Content = new FirstAidContent { - // Steps = new List { - // new FirstAidStep { Text = "Rửa sạch vết thương bằng nước hoặc xà phòng." }, - // new FirstAidStep { Text = "Bình tĩnh vì đây là loài rắn ích lợi, chuyên ăn côn trùng và sâu bọ." } - // } - // } - // } - // }, - // new SnakeSpecies - // { - // Id = 22, - // ScientificName = "Amphiesma stolatum", - // CommonName = "Rắn Sãi Cỏ", - // Slug = "ran-sai-co", - // Description = "Loài rắn nước không độc, hiền lành và có ích cho nông nghiệp. Chúng thường bị nhầm lẫn với một số loài rắn khác do hoa văn phức tạp.", - // IdentificationSummary = "Không độc, dài trung bình 40cm - 80cm. Thân có 2 sọc sáng màu song song, nối với nhau bởi các vạch ngang tối màu trông như chiếc thang.", - // PrimaryVenomType = PrimaryVenomType.None, - // RiskLevel = 1.0f, - // IsVenomous = false, - // ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/27/1736340349_0.jpg", - // Identification = new IdentificationFeature { - // PhysicalTraits = new List { - // "Chiều dài trung bình 40 - 80 cm", - // "2 sọc sáng màu chạy dọc song song trên lưng", - // "Các vạch ngang tối màu nối 2 sọc giống hình chiếc thang", - // "Bụng màu kem nhạt với đốm đen nhỏ hai bên thân", - // "Mép miệng màu vàng nhạt với vạch đen trước và sau mắt" - // }, - // Behaviors = new List { - // "Hoạt động chủ yếu vào ban ngày (nhật hành)", - // "Tính tình nhút nhát, thường bỏ chạy khi gặp người", - // "Săn các sinh vật nhỏ như cá, giun đất và tắc kè" - // }, - // Habitat = "Vùng đồng bằng và đồi núi, thường ở gần nguồn nước (ao, hồ, suối)" - // }, - // SymptomsByTime = new List { - // new SymptomTimeline { - // TimeRange = "Sau khi cắn", - // Signs = new List { "Vết xước nhỏ hình vòng cung", "Chảy máu nhẹ", "Không sưng nề, không gây độc" }, - // IsCritical = false - // } - // }, - // FirstAidGuidelineOverride = new FirstAidOverride { - // Mode = OverrideMode.Replace, - // Content = new FirstAidContent { - // Steps = new List { - // new FirstAidStep { Text = "Rửa vết thương bằng xà phòng và nước sạch để tránh nhiễm trùng." }, - // new FirstAidStep { Text = "Bình tĩnh vì đây là loài rắn hoàn toàn vô hại." } - // } - // } - // } - // } - // }; - - // context.SnakeSpecies.AddRange(snakes); + var snakes = new List + { + // 1. RẮN CẠP NIA BẮC (Bungarus multicinctus) + new SnakeSpecies + { + Id = 1, + ScientificName = "Bungarus multicinctus", + CommonName = "Rắn Cạp Nia Bắc", + Slug = "ran-cap-nia-bac", + Description = "Một trong những loài rắn độc nhất châu Á, thường gặp ở vùng đồng bằng và trung du Bắc Bộ.", + IdentificationSummary = "Chiều dài trung bình 1.0m - 1.5m. Thân có các khoanh trắng và đen rõ rệt, vảy trơn bóng.", + PrimaryVenomType = PrimaryVenomType.Neurotoxic, + RiskLevel = 9.5f, + IsVenomous = true, + ImageUrl = "https://e.khoahoc.tv/photos/image/2020/09/19/ran-cap-nia-1.jpg", + Identification = new IdentificationFeature + { + PhysicalTraits = new List { "Chiều dài: 100 - 150 cm", "Khoanh trắng đen rõ rệt", "Đầu bầu dục", "Vảy bóng", "Thân hình tam giác nhẹ" }, + Behaviors = new List { "Hoạt động mạnh về đêm", "Thích nơi ẩm ướt", "Thường chui vào nhà dân tìm mồi" }, + Habitat = "Cánh đồng, ven sông, khu dân cư miền Bắc" + }, + SymptomsByTime = new List + { + new SymptomTimeline { TimeRange = "0 - 30 phút", Signs = new List { "Đau nhẹ tại vết cắn, ít cảm giác", "Tê nhẹ" }, IsCritical = false }, + new SymptomTimeline { TimeRange = "1 - 3 giờ", Signs = new List { "Sụp mí mắt", "Khó nói", "Khó nuốt", "Yếu cơ" }, IsCritical = true }, + new SymptomTimeline { TimeRange = "3 - 6 giờ", Signs = new List { "Liệt cơ hô hấp", "Ngừng thở" }, IsCritical = true } + }, + FirstAidGuidelineOverride = new FirstAidOverride + { + Mode = OverrideMode.Append, + Content = new FirstAidContent + { + Steps = new List + { + new FirstAidStep { Text = "Đặc biệt chú ý hỗ trợ hô hấp nhân tạo nếu nạn nhân có dấu hiệu ngưng thở." } + } + } + } + }, + + // 2. RẮN LỤC ĐUÔI ĐỎ (Trimeresurus albolabris) + new SnakeSpecies + { + Id = 2, + ScientificName = "Trimeresurus albolabris", + CommonName = "Rắn Lục Đuôi Đỏ", + Slug = "ran-luc-duoi-do", + Description = "Loài rắn lục phổ biến nhất, thường sống trên cây và gây ra nhiều vụ tai nạn tại Việt Nam.", + IdentificationSummary = "Chiều dài trung bình 60cm - 90cm. Thân màu xanh lá cây, đầu hình tam giác rõ rệt, chót đuôi có màu đỏ cam.", + PrimaryVenomType = PrimaryVenomType.Hemotoxic, + RiskLevel = 7.5f, + IsVenomous = true, + ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/32/1736395426_0.jpg", + Identification = new IdentificationFeature + { + PhysicalTraits = new List { "Chiều dài: 60 - 90 cm", "Thân xanh lá", "Đuôi màu đỏ", "Đầu tam giác phình to", "Vảy nhám" }, + Behaviors = new List { "Sống trên cây", "Ngụy trang cực tốt trong lá cây", "Hay xuất hiện ở bụi hoa, vườn nhà" }, + Habitat = "Vườn cây, bụi rậm, rừng thưa trên toàn quốc" + }, + SymptomsByTime = new List + { + new SymptomTimeline { TimeRange = "0 - 15 phút", Signs = new List { "Đau nhức dữ dội", "Sưng nề nhanh chóng" }, IsCritical = false }, + new SymptomTimeline { TimeRange = "1 - 6 giờ", Signs = new List { "Xuất hiện bọng nước", "Chảy máu không cầm", "Bầm tím diện rộng" }, IsCritical = true } + }, + FirstAidGuidelineOverride = new FirstAidOverride + { + Mode = OverrideMode.Replace, + Content = new FirstAidContent + { + Steps = new List + { + new FirstAidStep { Text = "Rửa sạch vết thương." }, + new FirstAidStep { Text = "Bất động lỏng chi." }, + new FirstAidStep { Text = "TUYỆT ĐỐI KHÔNG BĂNG ÉP CHẶT vì gây hoại tử nhanh." } + } + } + } + }, + + // 3. RẮN HỔ MANG CHÚA (Ophiophagus hannah) + new SnakeSpecies + { + Id = 3, + ScientificName = "Ophiophagus hannah", + CommonName = "Rắn Hổ Mang Chúa", + Slug = "ran-ho-mang-chua", + Description = "Loài rắn độc dài nhất thế giới, cực kỳ nguy hiểm với lượng nọc độc khổng lồ.", + IdentificationSummary = "Kích thước khổng lồ (4-6m), Vảy đầu lớn, Cổ phình mang hẹp, vân chữ V ngược ở cổ.", + PrimaryVenomType = PrimaryVenomType.Neurotoxic, + RiskLevel = 10.0f, + IsVenomous = true, + ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/55/1737107747_0.jpg", + Identification = new IdentificationFeature + { + PhysicalTraits = new List { "Kích thước khổng lồ (4-6m)", + "Cặp vảy chẩm hình cánh bướm, nằm ở ngay phía sau vảy đầu", + "Phình mang hẹp dài", "Màu đen, nâu hoặc vàng chì" }, + Behaviors = new List { "Chủ động tấn công nếu bị kích động", "Có khả năng rướn cao thân mình" ,"Là một loài rắn thông minh, sẽ quan sát và phản ứng." }, + Habitat = "Rừng rậm, nương rẫy, gần nguồn nước" + }, + SymptomsByTime = new List + { + new SymptomTimeline { TimeRange = "0 - 15 phút", Signs = new List { "Đau nhức", "Chóng mặt", "Hoa mắt" }, IsCritical = true }, + new SymptomTimeline { TimeRange = "30 - 60 phút", Signs = new List { "Hôn mê", "Suy hô hấp cấp", "Tử vong nhanh nếu không cấp cứu" }, IsCritical = true } + }, + FirstAidGuidelineOverride = new FirstAidOverride + { + Mode = OverrideMode.Append, + Content = new FirstAidContent + { + Steps = new List + { + new FirstAidStep { Text = "Vận chuyển nạn nhân bằng phương tiện nhanh nhất có thể đến bệnh viện lớn." } + } + } + } + }, + + // 4. RẮN RÁO (Ptyas korros) - KHÔNG ĐỘC + new SnakeSpecies + { + Id = 4, + ScientificName = "Ptyas korros", + CommonName = "Rắn Ráo", + Slug = "ran-rao", + Description = "Loài rắn không độc phổ biến, thường bị nhầm lẫn với rắn hổ mang.", + IdentificationSummary = "Dài trung bình 1,2 - 2,0m. Mắt rất lớn, thân thon dài màu nâu đất hoặc xám chì, di chuyển cực nhanh.", + PrimaryVenomType = PrimaryVenomType.None, + RiskLevel = 1.0f, + IsVenomous = false, + ImageUrl = "https://www.cakhotranluan.com/images/2022/2ran-rao1.jpg", + Identification = new IdentificationFeature + { + PhysicalTraits = new List { "Chiều dài: 120 - 200 cm", "Mắt to", "Thân dài thon", "Vảy trơn bóng", "Đầu bầu dục" }, + Behaviors = new List { "Di chuyển rất nhanh", "Hoạt động ban ngày", "Thường chạy trốn khi gặp người" }, + Habitat = "Đồng ruộng, bụi rậm, vườn nhà" + }, + SymptomsByTime = new List + { + new SymptomTimeline { TimeRange = "Sau khi cắn", Signs = new List { "Chảy máu nhẹ", "Vết xước li ti", "Không sưng nề" }, IsCritical = false } + }, + FirstAidGuidelineOverride = new FirstAidOverride + { + Mode = OverrideMode.Append, + Content = new FirstAidContent + { + Steps = new List + { + new FirstAidStep { Text = "Sát trùng vết thương bằng cồn hoặc nước sạch." } + } + } + } + }, + + new SnakeSpecies + { + Id = 5, + ScientificName = "Bungarus fasciatus", + CommonName = "Rắn Cạp Nong", + Slug = "ran-cap-nong", + Description = "Loài rắn độc thần kinh nguy hiểm, dễ nhận biết với các khoanh vàng đen xen kẽ đều nhau.", + IdentificationSummary = "Kích thước 1.8 - 2.3m. Thân hình tam giác với sống lưng gồ cao, đầu có vệt vàng hình mũi tên.", + PrimaryVenomType = PrimaryVenomType.Neurotoxic, + RiskLevel = 9.0f, + IsVenomous = true, + ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/46/1736402914_0.jpg", + Identification = new IdentificationFeature + { + PhysicalTraits = new List { "Khoanh đen và vàng xen kẽ đều nhau", "Thân hình tam giác, sống lưng gồ", "Vệt vàng hai bên má tạo hình mũi tên" }, + Behaviors = new List { "Săn mồi ban đêm", "Bị thu hút bởi ánh lửa", "Tính tình thường nhút nhát nhưng độc tính rất mạnh" }, + Habitat = "Rừng núi, bụi rậm, ven nguồn nước" + }, + SymptomsByTime = new List + { + new SymptomTimeline { TimeRange = "0 - 30 phút", Signs = new List { "Ngứa nhẹ hoặc tê rát tại vết cắn", "Ít đau khiến nạn nhân chủ quan" }, IsCritical = false }, + new SymptomTimeline { TimeRange = "1 - 2 giờ", Signs = new List { "Mệt mỏi bất thường", "Tức ngực nhẹ", "Sụp mí mắt nhẹ" }, IsCritical = true }, + new SymptomTimeline { TimeRange = "2 - 6 giờ", Signs = new List { "Đau nhức toàn thân", "Yếu liệt cơ tiến triển", "Suy hô hấp cấp" }, IsCritical = true } + }, + FirstAidGuidelineOverride = new FirstAidOverride + { + Mode = OverrideMode.Append, + Content = new FirstAidContent + { + Steps = new List + { + new FirstAidStep { Text = "Cảnh giác cao độ nếu bị cắn khi đang cắm trại hoặc đi rừng ban đêm." } + } + } + } + }, + + new SnakeSpecies + { + Id = 6, + ScientificName = "Bungarus candidus", + CommonName = "Rắn Cạp Nia Nam", + Slug = "ran-cap-nia-nam", + Description = "Loài rắn độc thần kinh cực mạnh ở miền Nam, có tập tính lẻn vào nhà người dân.", + IdentificationSummary = "Khoanh đen và trắng có độ rộng gần bằng nhau, thân tròn bóng, đầu nhỏ.", + PrimaryVenomType = PrimaryVenomType.Neurotoxic, + RiskLevel = 9.8f, + IsVenomous = true, + ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/38/1736401130_0.jpg", + Identification = new IdentificationFeature + { + PhysicalTraits = new List { "Khoanh đen và trắng/vàng nhạt đều nhau", "Thân tròn, vảy trơn bóng", "Đầu nhỏ không phân biệt rõ với cổ" }, + Behaviors = new List { "Hoạt động đêm", "Hay chui vào nhà dân", "Cắn người khi đang ngủ" }, + Habitat = "Vùng đồng bằng, khu dân cư miền Nam Việt Nam" + }, + SymptomsByTime = new List + { + new SymptomTimeline { TimeRange = "0 - 15 phút", Signs = new List { "Vết cắn rất nhẹ, không sưng đau", "Khó thấy dấu răng" }, IsCritical = false }, + new SymptomTimeline { TimeRange = "2 - 3 giờ", Signs = new List { "Sụp mí mắt nặng", "Đồng tử giãn", "Nói ngọng", "Yếu chi" }, IsCritical = true }, + new SymptomTimeline { TimeRange = "3 - 6 giờ", Signs = new List { "Liệt cơ toàn thân", "Suy hô hấp hoàn toàn" }, IsCritical = true } + }, + FirstAidGuidelineOverride = new FirstAidOverride + { + Mode = OverrideMode.Append, + Content = new FirstAidContent + { + Steps = new List + { + new FirstAidStep { Text = "Tuyệt đối không chờ triệu chứng đau mới đi viện vì nọc cạp nia không gây đau." } + } + } + } + }, + + new SnakeSpecies + { + Id = 7, + ScientificName = "Naja kaouthia", + CommonName = "Rắn Hổ Mang Xiêm", + Slug = "ran-ho-mang-xiem", + Description = "Loài rắn hổ mang có nọc độc hỗn hợp, gây hoại tử mô nghiêm trọng và liệt thần kinh.", + IdentificationSummary = "Màu nâu xám hoặc đen, có bành cổ với một hình tròn đơn (hình kính mắt) ở mặt sau.", + PrimaryVenomType = PrimaryVenomType.Neurotoxic, // Lưu ý: Hỗn hợp thần kinh và tế bào + RiskLevel = 9.0f, + IsVenomous = true, + ImageUrl = "https://photo.znews.vn/w660/Uploaded/rotnrz/2023_07_04/ho_meo_1.jpg", + Identification = new IdentificationFeature + { + PhysicalTraits = new List { "Bành cổ rộng", "Hình kính mắt một vòng tròn sau cổ", "Màu nâu hoặc xám đen" }, + Behaviors = new List { "Có thể phun nọc độc xa và chuẩn", "Ngóc đầu cao và phình mang khi tấn công" }, + Habitat = "Ruộng lúa, vườn tược, khu dân cư gần nguồn nước" + }, + SymptomsByTime = new List + { + new SymptomTimeline { TimeRange = "0 - 10 phút", Signs = new List { "Đau rõ rệt", "Chảy máu tại vết cắn" }, IsCritical = false }, + new SymptomTimeline { TimeRange = "10 - 60 phút", Signs = new List { "Sưng nhanh", "Đau tăng dần", "Có thể phồng rộp da" }, IsCritical = false }, + new SymptomTimeline { TimeRange = "1 - 3 giờ", Signs = new List { "Sụp mí mắt", "Yếu cơ", "Khó nuốt" }, IsCritical = true }, + new SymptomTimeline { TimeRange = "6 - 24 giờ", Signs = new List { "Hoại tử mô tại chỗ", "Nhiễm trùng vết thương" }, IsCritical = true } + }, + FirstAidGuidelineOverride = new FirstAidOverride + { + Mode = OverrideMode.Append, + Content = new FirstAidContent + { + Steps = new List + { + new FirstAidStep { Text = "Nếu bị nọc phun vào mắt, phải rửa bằng nước sạch liên tục 15-20 phút." } + } + } + } + }, + + new SnakeSpecies + { + Id = 8, + ScientificName = "Rhabdophis subminiatus", + CommonName = "Rắn Hoa Cỏ Cổ Đỏ", + Slug = "ran-hoa-co-co-do", + Description = "Loài rắn có độc nguy hiểm điều kiện (độc nanh sau và độc da vùng cổ), gây rối loạn đông máu nặng và chưa có huyết thanh đặc hiệu.", + IdentificationSummary = "Cổ màu đỏ rực hoặc cam vàng đặc trưng, thân xanh ô liu, mắt tròn lớn.", + PrimaryVenomType = PrimaryVenomType.Hemotoxic, + RiskLevel = 8.5f, + IsVenomous = true, + ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/93/1737024030_0.jpeg", + Identification = new IdentificationFeature + { + PhysicalTraits = new List { "Vòng cổ màu đỏ rực hoặc vàng cam", "Đầu thuôn không hình tam giác", "Mắt tròn, đồng tử tròn" }, + Behaviors = new List { "Ngóc đầu, tiết độc trắng đục và bẹt cổ giống rắn hổ khi bị đe dọa", "Tính khí không ổn định (lúc hiền lúc dữ)" }, + Habitat = "Rừng, nương rẫy, gần nguồn nước" + }, + SymptomsByTime = new List + { + new SymptomTimeline { + TimeRange = "0 - 1 giờ", + Signs = new List { "Vết cắn đau nhẹ", "Sưng nhẹ cục bộ", "Có thể không có cảm giác bị nhiễm độc ngay" }, + IsCritical = false + }, + new SymptomTimeline { + TimeRange = "1 - 6 giờ", + Signs = new List { "Máu rỉ rả không cầm tại vết cắn", "Bầm tím lan rộng", "Đau bụng, buồn nôn" }, + IsCritical = true + }, + new SymptomTimeline { + TimeRange = "6 - 24 giờ", + Signs = new List { "Chảy máu chân răng, máu cam", "Tiểu ra máu", "Nôn ra máu", "Dấu hiệu suy thận" }, + IsCritical = true + } + }, + FirstAidGuidelineOverride = new FirstAidOverride + { + Mode = OverrideMode.Replace, // Thay thế hoàn toàn vì cách tiếp cận điều trị rất khác + Content = new FirstAidContent + { + Steps = new List { + new FirstAidStep { Text = "Đặt nạn nhân nằm yên, bất động hoàn toàn chi bị cắn.", MediaUrl = "https://assets.snakeaid.vn/aid/immobilize.gif" }, + new FirstAidStep { Text = "Băng ép nhẹ bằng băng vải rộng để bảo vệ vết thương.", MediaUrl = "https://assets.snakeaid.vn/aid/light-bandage.jpg" }, + new FirstAidStep { Text = "Nhanh chóng chuyển nạn nhân đến bệnh viện tuyến tỉnh hoặc trung ương có khả năng lọc máu và truyền máu.", MediaUrl = "" } + }, + Dos = new List { + new FirstAidStep { Text = "Báo cho bác sĩ đây là rắn 'Rhabdophis subminiatus' (Hoa cỏ cổ đỏ).", MediaUrl = "" }, + new FirstAidStep { Text = "Theo dõi sát màu nước tiểu và tình trạng chảy máu.", MediaUrl = "" } + }, + Donts = new List { + new FirstAidStep { Text = "KHÔNG ĐƯỢC CHỦ QUAN nếu thấy vết cắn không sưng đau nhiều lúc đầu.", MediaUrl = "" }, + new FirstAidStep { Text = "KHÔNG dùng ga-rô chặt (làm tăng hoại tử và rối loạn đông máu tại chỗ).", MediaUrl = "" }, + new FirstAidStep { Text = "KHÔNG rạch hoặc hút máu tại vết cắn.", MediaUrl = "" } + }, + Notes = new List { + "Lưu ý quan trọng: Việt Nam chưa có huyết thanh kháng độc cho loài này. Việc điều trị chủ yếu là hỗ trợ, truyền máu và lọc thận.", + "Loài này có răng độc nằm sâu phía sau hàm (Hậu nha), nọc độc chỉ tiết ra khi rắn nhai hoặc cắn sâu." + } + } + } + }, + + new SnakeSpecies + { + Id = 9, + ScientificName = "Coelognathus radiatus", + CommonName = "Rắn Hổ Ngựa", + Slug = "ran-ho-ngua", + Description = "Loài rắn không độc, di chuyển cực nhanh và thường có hành vi tự vệ hung dữ.", + IdentificationSummary = "Màu nâu vàng, có 4 sọc đen chạy dọc phần trước thân, đầu dài.", + PrimaryVenomType = PrimaryVenomType.None, + RiskLevel = 1.0f, + IsVenomous = false, + ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/122/1737364008_0.jpg", + Identification = new IdentificationFeature + { + PhysicalTraits = new List { "Có thể dài đến 2m", "4 sọc đen trên thân trước", "Đầu dài bầu dục", "Mắt lớn" }, + Behaviors = new List { "Ngóc cao thân mình, bẹt cổ để hù dọa giống rắn hổ", " Miệng há rộng, hung hăng, doạ nạt, dữ tợn khi bị đe dọa", "Giả chết nếu cảm thấy nguy hiểm trước đối phương" }, + Habitat = "Đồng ruộng, bụi cây, khu vực nông nghiệp" + }, + SymptomsByTime = new List + { + new SymptomTimeline { TimeRange = "Sau khi cắn", Signs = new List { "Vết xước li ti", "Chảy máu nhẹ", "Không có triệu chứng thần kinh hay sưng nề lớn" }, IsCritical = false } + }, + FirstAidGuidelineOverride = new FirstAidOverride + { + Mode = OverrideMode.Append, + Content = new FirstAidContent + { + Steps = new List + { + new FirstAidStep { Text = "Chỉ cần rửa sạch vết thương bằng xà phòng để tránh nhiễm trùng." } + } + } + } + }, + + new SnakeSpecies + { + Id = 10, + ScientificName = "Elaphe carinata", + CommonName = "Rắn Chuột Vua", + Slug = "ran-chuot-vua", + Description = "Loài rắn không độc nhưng cực kỳ hung dữ, có kích thước lớn và mùi hôi đặc trưng để xua đuổi kẻ thù. Dễ bị nhầm lẫn với rắn hổ mang chúa do kích thước lớn và họa tiết đầu gần giống nhau.", + IdentificationSummary = "Thân màu nâu vàng hoặc xám đen với các vảy có gờ nổi rất mạnh (nhám). Không có nanh độc, không phình mang.", + PrimaryVenomType = PrimaryVenomType.None, + RiskLevel = 2.0f, + IsVenomous = false, + ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/123/1737365069_2.jpeg", + Identification = new IdentificationFeature + { + PhysicalTraits = new List { "Vảy có gờ nổi rất rõ (thân nhám)", "Mắt lớn, đầu thuôn dài", "Kích thước có thể lên tới 2.4m", "Màu sắc pha trộn vàng - đen - nâu ô liu" }, + Behaviors = new List { "Cực kỳ hung dữ, sẵn sàng tấn công khi bị kích động", "Phát ra mùi hôi thối nồng nặc từ tuyến sau", "Ăn thịt các loài rắn khác (kể cả rắn độc)" }, + Habitat = "Vùng đồi núi, bụi rậm, trang trại chăn nuôi" + }, + SymptomsByTime = new List + { + new SymptomTimeline { TimeRange = "Sau khi cắn", Signs = new List { "Vết cắn hình vòng cung", "Chảy máu khá nhiều do răng sắc nhọn", "Đau rát cục bộ" }, IsCritical = false } + }, + FirstAidGuidelineOverride = new FirstAidOverride + { + Mode = OverrideMode.Append, + Content = new FirstAidContent + { + Steps = new List + { + new FirstAidStep { Text = "Sát trùng kỹ vết thương vì miệng loài này chứa nhiều vi khuẩn do ăn chuột và thịt thối." } + } + } + } + }, + + // 11. RẮN LỤC CƯỜM (LỤC GẤM) - Protobothrops mucrosquamatus + new SnakeSpecies + { + Id = 11, + ScientificName = "Protobothrops mucrosquamatus", + CommonName = "Rắn Lục Cườm", + Slug = "ran-luc-cuom", + Description = "Loài rắn độc máu nguy hiểm, đầu hình tam giác lớn, hoa văn đốm sâm so le nhau ở sống lưng.", + IdentificationSummary = "Đầu tam giác rõ rệt, thân có các vệt hoa văn màu nâu đen trên nền xám/vàng đất, vảy nhám.", + PrimaryVenomType = PrimaryVenomType.Hemotoxic, + RiskLevel = 8.5f, + IsVenomous = true, + ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/54/1736524361_0.jpg", + Identification = new IdentificationFeature { + PhysicalTraits = new List { "Đầu tam giác lớn", "Hoa văn vện gấm đốm nâu", "Vảy nhám", "Mắt có con ngươi dọc" }, + Behaviors = new List { "Hoạt động đêm",", Chuyên phục kích săn mồi", "Tính hung dữ, sẵn sàng tấn công", "Thường ở hốc đá, bụi rậm, lá khô" }, + Habitat = "Rừng núi, vùng đồi gò, hang hốc" + }, + SymptomsByTime = new List { + new SymptomTimeline { TimeRange = "0 - 15 phút", Signs = new List { "Đau rát dữ dội", "Sưng nề tức thì" }, IsCritical = false }, + new SymptomTimeline { TimeRange = "1 - 6 giờ", Signs = new List { "Xuất huyết dưới da", "Máu chảy không cầm tại vết cắn", "Bầm tím nặng" }, IsCritical = true } + }, + FirstAidGuidelineOverride = new FirstAidOverride { + Mode = OverrideMode.Replace, Content = new FirstAidContent { Steps = new List { new FirstAidStep { Text = "KHÔNG garô/băng ép." }, new FirstAidStep { Text = "Bất động chi bằng nẹp lỏng." }, new FirstAidStep { Text = "Chuyển viện gấp." } } } } + }, + + // 12. RẮN LỤC NƯA (CHÀM QUẠP) - Calloselasma rhodostoma + new SnakeSpecies + { + Id = 12, + ScientificName = "Calloselasma rhodostoma", + CommonName = "Rắn Lục Nưa", + Slug = "ran-luc-nua", + Description = "Loài rắn cực nguy hiểm ở miền Nam/Tây Nguyên, ngụy trang hoàn hảo dưới lá khô.", + IdentificationSummary = "Thân mập, đầu tam giác, hoa văn hình tam giác sẫm màu dọc hai bên thân.", + PrimaryVenomType = PrimaryVenomType.Hemotoxic, // và có độc tế bào cytotoxic + RiskLevel = 9.5f, + IsVenomous = true, + ImageUrl = "https://cdn.kienthuc.net.vn/images/cf739f51f3276a5be16e9cbb75eb670590e4e1a049c04ce64210426ce976f3c08b805acba731385fd614eebaf46f4c3502d128915c73af35a8698059b85f0f9824f61d459aaa6ca7ad4acb289d18b91958ebfab71a3d1d5ad62d6dd95e9bd598a65c32335617c1b43812b9de6f4caea5/thot-tim-loai-ran-cuc-doc-nam-im-lim-cho-can-nguoi-o-viet-nam-Hinh-9.png", + Identification = new IdentificationFeature { + PhysicalTraits = new List { "Thân mập, ngắn", "Hoa văn hình tam giác đối xứng", "Màu nâu lá khô", "Đầu tam giác rất nhọn" }, + Behaviors = new List { "Nằm bất động dưới lá khô", "Không bỏ chạy khi có người, chủ động cắn", "Tấn công bất ngờ cực nhanh" }, + Habitat = "Rừng cao su, vườn điều, rừng khộp" + }, + SymptomsByTime = new List { + new SymptomTimeline { TimeRange = "0 - 30 phút", Signs = new List { "Sưng nề cực nhanh", "Đau buốt như lửa đốt" }, IsCritical = true }, + new SymptomTimeline { TimeRange = "6 - 12 giờ", Signs = new List { "Hoại tử mô diện rộng", "Xuất huyết toàn thân", "Phồng rộp máu" }, IsCritical = true } + }, + FirstAidGuidelineOverride = new FirstAidOverride { Mode = OverrideMode.Replace, Content = new FirstAidContent { Steps = new List { new FirstAidStep { Text = "Tuyệt đối không rạch vết thương vì nọc gây rối loạn đông máu cực nặng." }, new FirstAidStep { Text = "Băng ép nhẹ bằng băng thun (không chặt)." } } } } + }, + + // 13. RẮN LỤC XANH - Trimeresurus stejnegeri + new SnakeSpecies + { + Id = 13, + ScientificName = "Trimeresurus stejnegeri", + CommonName = "Rắn Lục Xanh (Lục Vẻ)", + Slug = "ran-luc-xanh", + Description = "Thường bị nhầm với lục đuôi đỏ nhưng không có màu đỏ ở đuôi, độc tính tương tự.", + IdentificationSummary = "Toàn thân xanh lá, đầu tam giác, có hố nhiệt giữa mắt và mũi.", + PrimaryVenomType = PrimaryVenomType.Hemotoxic, + RiskLevel = 7.0f, + IsVenomous = true, + ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/34/1736396208_0.jpg", + Identification = new IdentificationFeature { + PhysicalTraits = new List { "Thân xanh mướt", "Đầu tam giác", "Mắt vàng/cam", "Không có đuôi đỏ" }, + Behaviors = new List { "Sống hoàn toàn trên cây", "Ngụy trang trong lá", "Hoạt động ban đêm" }, + Habitat = "Bụi rậm, vườn cây trái, rừng rậm" + }, + SymptomsByTime = new List { + new SymptomTimeline { TimeRange = "0 - 15 phút", Signs = new List { "Sưng đau cục bộ", "Buồn nôn" }, IsCritical = false }, + new SymptomTimeline { TimeRange = "2 - 12 giờ", Signs = new List { "Vết thương thâm đen", "Chảy máu chân răng" }, IsCritical = true } + } + }, + + // 14. RẮN KHIẾM VẠCH - Oligodon fasciolatus + new SnakeSpecies + { + Id = 14, + ScientificName = "Oligodon fasciolatus", + CommonName = "Rắn Khiếm Vạch", + Slug = "ran-khiem-vach", + Description = "Loài rắn không độc nhưng có răng sắc nhọn để ăn trứng chim/bò sát.", + IdentificationSummary = "Kích thước nhỏ, tối đa 45 cm. Màu nâu/xám, có các vạch ngang mờ và hình chữ V trên đỉnh đầu.", + PrimaryVenomType = PrimaryVenomType.None, + RiskLevel = 1.5f, + IsVenomous = false, + ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/154/1737700148_0.jpg", + Identification = new IdentificationFeature { + PhysicalTraits = new List { "Hình chữ V trên đầu", "Vảy trơn bóng", "Đầu bầu dục", "Kích thước nhỏ (tối đa 45 cm)", "Có 2 sọc đen dọc thân" }, + Behaviors = new List { "Săn mồi ban ngày", "Khá nhút nhát", "Thường gặp dưới đống gạch đá" }, + Habitat = "Vườn nhà, khu vực nông nghiệp, rừng thưa" + }, + SymptomsByTime = new List { + new SymptomTimeline { TimeRange = "Sau khi cắn", Signs = new List { "Vết thương lớn", "Chảy máu nhiều", "Không sưng nề" }, IsCritical = false } + }, + FirstAidGuidelineOverride = new FirstAidOverride + { + Mode = OverrideMode.Replace, + Content = new FirstAidContent + { + Steps = new List + { + new FirstAidStep { Text = "Rửa sạch vết thương bằng xà phòng hoặc dung dịch sát khuẩn." }, + new FirstAidStep { Text = "Cầm máu nếu cần thiết." }, + new FirstAidStep { Text = "Theo dõi dấu hiệu nhiễm trùng (sưng, đỏ, mưng mủ)." }, + new FirstAidStep { Text = "Đến cơ sở y tế nếu vết thương không lành hoặc có dấu hiệu nhiễm trùng." } + } + } + } + }, + + // 15. RẮN CƯỜM (HẢI XÀ) - Chrysopelea ornata + new SnakeSpecies + { + Id = 15, + ScientificName = "Chrysopelea ornata", + CommonName = "Rắn Cườm (Rắn Bay)", + Slug = "ran-cuom", + Description = "Loài rắn nước có khả năng 'bay' bằng cách hóp bụng để lượn qua các cành cây.", + IdentificationSummary = "Màu xanh vàng nhạt với các họa tiết viền đen chi tiết trên từng vảy.", + PrimaryVenomType = PrimaryVenomType.None, + RiskLevel = 1.0f, + IsVenomous = false, + ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/3/1737361056_0.jpg", + Identification = new IdentificationFeature { + PhysicalTraits = new List { "Vảy màu vàng chanh viền đen", "Thân thon dài", "Mắt to tròn" }, + Behaviors = new List { "Leo trèo cực giỏi", "Nhảy từ trên cây cao xuống", "Rất hiền lành" }, + Habitat = "Cây cao, vườn nhà, rừng rậm" + } + }, + + // 16. RẮN RÁO TRÂU - Ptyas mucosa + new SnakeSpecies + { + Id = 16, + ScientificName = "Ptyas mucosa", + CommonName = "Rắn Ráo Trâu (Rắn Lãi Lớn)", + Slug = "ran-rao-trau", + Description = "Loài rắn không độc có kích thước lớn, di chuyển tốc độ rất nhanh.", + IdentificationSummary = "Màu nâu/vàng đất, nửa thân sau có các vạch đen ngang rõ rệt như vằn hổ. Mắt rất to. Họa tiet đầu giống rắn hổ mang.", + PrimaryVenomType = PrimaryVenomType.None, + RiskLevel = 2.0f, + IsVenomous = false, + ImageUrl = "https://sgaqua.vn/wp-content/uploads/2026/01/ran-rao-trau-co-doc-khong-1.jpg", + Identification = new IdentificationFeature { + PhysicalTraits = new List { "Kích thước lớn (tới 3m)", "Vằn ngang zig zag trắng nửa thân trước chuyển đen nửa thân sau", "Mắt rất to, tròn", "Vảy trơn, óng ánh, xếp đều", "Họa tiết vảy đầu giống rắn hổ mang" }, + Behaviors = new List { "Chạy trốn cực nhanh", "Hung dữ khi bị dồn vào đường cùng", "Bị đe dọa sẽ mở rộng vùng cổ và tạo âm thanh rít liên tục" }, + Habitat = "Đồng ruộng, bụi rậm, hang hốc" + } + }, + + // 17. RẮN HOA CÂN VÂN ĐỐM - Sinonatrix aequifasciata + new SnakeSpecies + { + Id = 17, + ScientificName = "Sinonatrix aequifasciata", + CommonName = "Rắn Hoa Cân Vân Đốm", + Slug = "ran-hoa-can-van-dom", + Description = "Rắn nước không độc, thường sống gần các khe suối.", + IdentificationSummary = "Kích thước trung bình từ 0.7-1.4m. Thân mập, có các hoa văn hình mắt màu vàng đen chạy dọc thân.", + PrimaryVenomType = PrimaryVenomType.None, + RiskLevel = 1.0f, + IsVenomous = false, + ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/171/trimerodytes-aequifasciatus_1740063874_0.jpg", + Identification = new IdentificationFeature { + PhysicalTraits = new List { "Thân mập hình trụ", "Hoa văn hình mắt màu vàng đen chạy dọc thân", "Đầu bầu dục" }, + Behaviors = new List { "Sống bán thủy sinh", "Ăn cá và ếch nhái" }, + Habitat = "Suối, ao hồ, đầm lầy vùng núi" + } + }, + + // 18. RẮN RI CÁ - Homalopsis buccata + new SnakeSpecies + { + Id = 18, + ScientificName = "Homalopsis buccata", + CommonName = "Rắn Ri Cá", + Slug = "ran-ri-ca", + Description = "Rắn nước phổ biến ở Nam Bộ, thịt ngon nhưng không có độc.", + IdentificationSummary = "Kích thước trung bình khoảng 70cm.Đầu to, có hình mặt nạ trắng trên đầu, thân có nhiều khoanh màu nâu đỏ nhạt.", + PrimaryVenomType = PrimaryVenomType.None, + RiskLevel = 1.0f, + IsVenomous = false, + ImageUrl = "https://upload.wikimedia.org/wikipedia/commons/c/c1/Homalopsis_buccata.png", + Identification = new IdentificationFeature { + PhysicalTraits = new List { "Kích thước trung bình khoảng 70cm", "Đầu to rộng", "Hoa văn mặt nạ trên đỉnh đầu", "Thân chắc, vảy gồ" }, + Behaviors = new List { "Ăn đêm", "Sống dưới nước", "Nhút nhát" }, + Habitat = "Kênh rạch, ao hồ, đầm lầy bùn" + } + }, + + // 19. RẮN ROI - Ahaetulla prasina + new SnakeSpecies + { + Id = 19, + ScientificName = "Ahaetulla prasina", + CommonName = "Rắn Roi (Rắn Sinh Viên)", + Slug = "ran-roi", + Description = "Thân mảnh như sợi dây thừng, đầu rất nhọn, độc nhẹ, không gây nguy hiểm cho người.", + IdentificationSummary = "Màu xanh lá huỳnh quang nổi bật, mõm dài và nhọn, con ngươi nằm ngang.", + PrimaryVenomType = PrimaryVenomType.None, + RiskLevel = 1.0f, + IsVenomous = false, + ImageUrl = "https://cdn-i.vtcnews.vn/files/f2/2015/01/21/nhung-loai-ran-ky-di-nhat-the-gioi-tai-viet-nam-0.jpg", + Identification = new IdentificationFeature { + PhysicalTraits = new List { "Thân cực mảnh", "Mõm nhọn dài", "Con ngươi ngang đặc trưng", "Màu xanh lá hoặc nâu nhạt" }, + Behaviors = new List { "Sống trên cây", "Di chuyển chậm chạp", "Hay thò thụt lưỡi đánh hơi" }, + Habitat = "Vườn cây, rừng thưa, bụi rậm" + } + }, + + // 20. RẮN TRUN - Cylindrophis ruffus + new SnakeSpecies + { + Id = 20, + ScientificName = "Cylindrophis ruffus", + CommonName = "Rắn Trun", + Slug = "ran-trun", + Description = "Loài rắn không độc, thân hình trụ tròn, thường bị nhầm với rắn độc do màu sắc.", + IdentificationSummary = "Thân đen bóng có vạch vàng/trắng, đuôi ngắn tịt và có màu đỏ dưới mặt đuôi.", + PrimaryVenomType = PrimaryVenomType.None, + RiskLevel = 1.0f, + IsVenomous = false, + ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/131/1737365540_0.jpg", + Identification = new IdentificationFeature { + PhysicalTraits = new List { "Thân hình trụ đồng nhất", "Đuôi ngắn giống đầu", "Mặt dưới đuôi màu đỏ" }, + Behaviors = new List { "Chui rúc trong bùn đất", "Khi gặp nguy hiểm sẽ cuộn tròn và giơ đuôi đỏ lên để lừa kẻ thù" }, + Habitat = "Đầm lầy, ruộng lúa, nơi đất ẩm" + } + }, + + // 22. RẮN ĐAI LỚN - Lycodon fasciatus + new SnakeSpecies + { + Id = 21, + ScientificName = "Ptyas major", // Tên khoa học chính xác của Rắn Đại Lớn + CommonName = "Rắn Đai Lớn (Rắn Xanh Lớn)", + Slug = "ran-dai-lon-xanh", + Description = "Loài rắn hoàn toàn không độc, hiền lành, thường bị nhầm với rắn lục do màu xanh lục toàn thân.", + IdentificationSummary = "Kích thước có thể đạt 1,5-2,5m. Không độc, toàn thân màu xanh lá mượt mà, mắt rất to và tròn, đuôi thuôn dài.", + PrimaryVenomType = PrimaryVenomType.None, + RiskLevel = 1.0f, + IsVenomous = false, + ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/101/ptyas-major_1743324730_0.jpg", + Identification = new IdentificationFeature { + PhysicalTraits = new List { + "Thân dài, có thể đạt 1,5-2,5 mét", + "Toàn thân màu xanh lá cây đồng nhất", + "Bụng màu vàng nhạt hoặc trắng xanh", + "Mắt rất to, con ngươi tròn đen", + "Vảy trơn mịn, bóng mượt" + }, + Behaviors = new List { + "Hoạt động chủ yếu ban ngày", + "Rất hiền lành, hiếm khi cắn người kể cả khi bị bắt", + "Di chuyển nhanh nhẹn trên mặt đất và cây cỏ" + }, + Habitat = "Vườn tược, bụi rậm, rừng thưa, thường gặp ở vùng đồi núi và trung du" + }, + SymptomsByTime = new List { + new SymptomTimeline { + TimeRange = "Sau khi cắn", + Signs = new List { "Vết xước rất nhỏ", "Hầu như không đau", "Không sưng nề" }, + IsCritical = false + } + }, + FirstAidGuidelineOverride = new FirstAidOverride { + Mode = OverrideMode.Replace, + Content = new FirstAidContent { + Steps = new List { + new FirstAidStep { Text = "Rửa sạch vết thương bằng nước hoặc xà phòng." }, + new FirstAidStep { Text = "Bình tĩnh vì đây là loài rắn ích lợi, chuyên ăn côn trùng và sâu bọ." } + } + } + } + }, + new SnakeSpecies + { + Id = 22, + ScientificName = "Amphiesma stolatum", + CommonName = "Rắn Sãi Cỏ", + Slug = "ran-sai-co", + Description = "Loài rắn nước không độc, hiền lành và có ích cho nông nghiệp. Chúng thường bị nhầm lẫn với một số loài rắn khác do hoa văn phức tạp.", + IdentificationSummary = "Không độc, dài trung bình 40cm - 80cm. Thân có 2 sọc sáng màu song song, nối với nhau bởi các vạch ngang tối màu trông như chiếc thang.", + PrimaryVenomType = PrimaryVenomType.None, + RiskLevel = 1.0f, + IsVenomous = false, + ImageUrl = "https://vietnamsnakes.com/storage/snakes/species/27/1736340349_0.jpg", + Identification = new IdentificationFeature { + PhysicalTraits = new List { + "Chiều dài trung bình 40 - 80 cm", + "2 sọc sáng màu chạy dọc song song trên lưng", + "Các vạch ngang tối màu nối 2 sọc giống hình chiếc thang", + "Bụng màu kem nhạt với đốm đen nhỏ hai bên thân", + "Mép miệng màu vàng nhạt với vạch đen trước và sau mắt" + }, + Behaviors = new List { + "Hoạt động chủ yếu vào ban ngày (nhật hành)", + "Tính tình nhút nhát, thường bỏ chạy khi gặp người", + "Săn các sinh vật nhỏ như cá, giun đất và tắc kè" + }, + Habitat = "Vùng đồng bằng và đồi núi, thường ở gần nguồn nước (ao, hồ, suối)" + }, + SymptomsByTime = new List { + new SymptomTimeline { + TimeRange = "Sau khi cắn", + Signs = new List { "Vết xước nhỏ hình vòng cung", "Chảy máu nhẹ", "Không sưng nề, không gây độc" }, + IsCritical = false + } + }, + FirstAidGuidelineOverride = new FirstAidOverride { + Mode = OverrideMode.Replace, + Content = new FirstAidContent { + Steps = new List { + new FirstAidStep { Text = "Rửa vết thương bằng xà phòng và nước sạch để tránh nhiễm trùng." }, + new FirstAidStep { Text = "Bình tĩnh vì đây là loài rắn hoàn toàn vô hại." } + } + } + } + } + }; + + context.SnakeSpecies.AddRange(snakes); var speciesVenoms = new List { diff --git a/SnakeAid.Service/Implements/RescueMissionService.cs b/SnakeAid.Service/Implements/RescueMissionService.cs index 22e83f1d..a4e1e617 100644 --- a/SnakeAid.Service/Implements/RescueMissionService.cs +++ b/SnakeAid.Service/Implements/RescueMissionService.cs @@ -67,14 +67,17 @@ public async Task CreateMissionAsync(Guid incidentId, Guid rescue throw new NotFoundException("Rescuer not found."); } - // Check if mission already exists for this incident - var existingMission = await _unitOfWork.GetRepository().FirstOrDefaultAsync( - predicate: m => m.IncidentId == incidentId + // Check for active missions only (allow multiple missions per incident for retry scenarios) + var existingActiveMission = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: m => m.IncidentId == incidentId && + (m.Status == RescueMissionStatus.Preparing || + m.Status == RescueMissionStatus.EnRoute || + m.Status == RescueMissionStatus.RescuerArrived) ); - if (existingMission != null) + if (existingActiveMission != null) { - throw new BadRequestException("Mission already exists for this incident."); + throw new BadRequestException($"Active mission {existingActiveMission.Id} already exists for this incident."); } // Create new mission @@ -230,13 +233,16 @@ await _unitOfWork.ExecuteInTransactionAsync(async () => /// Allowed during Preparing or EnRoute phases public async Task RescuerAbortMissionAsync(Guid missionId, string reason) { + Guid incidentId = Guid.Empty; + try { + // Step 1: Abort mission in transaction await _unitOfWork.ExecuteInTransactionAsync(async () => { + // Query mission WITHOUT Include to avoid navigation property tracking issues var mission = await _unitOfWork.GetRepository().FirstOrDefaultAsync( - predicate: m => m.Id == missionId, - include: q => q.Include(m => m.Incident) + predicate: m => m.Id == missionId ); if (mission == null) @@ -250,37 +256,56 @@ await _unitOfWork.ExecuteInTransactionAsync(async () => throw new BadRequestException($"Cannot abort mission with status: {mission.Status}. Only allowed during Preparing or EnRoute phases."); } + // Query incident SEPARATELY to ensure proper EF tracking + // Using navigation property (mission.Incident) causes Update() to not mark entity as Modified + var incident = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: i => i.Id == mission.IncidentId + ); + + if (incident == null) + { + throw new NotFoundException("Incident not found."); + } + + _logger.LogInformation("Aborting mission {MissionId}: Current incident status before update: {Status}", + missionId, incident.Status); + + // Update mission mission.Status = RescueMissionStatus.MissionAborted; mission.CancellationReason = reason; mission.UpdatedAt = DateTime.UtcNow; // Reset incident to Pending for retry with increased radius - var incident = mission.Incident; incident.Status = SnakebiteIncidentStatus.Pending; incident.AssignedRescuerId = null; incident.AssignedAt = null; _unitOfWork.GetRepository().Update(mission); _unitOfWork.GetRepository().Update(incident); - await _unitOfWork.CommitAsync(); - - _logger.LogInformation("Rescuer aborted mission {MissionId} with reason: {Reason}", missionId, reason); - // Create new session with increased radius after rescuer abort - try - { - await _sessionService.HandleMissionAbortAsync(incident.Id); - _logger.LogInformation("Created new rescue session after rescuer abort for incident {IncidentId}", incident.Id); - } - catch (Exception sessionEx) - { - // Log but don't fail the mission abort - _logger.LogError(sessionEx, "Failed to create new session after rescuer abort for incident {IncidentId}: {Message}", - incident.Id, sessionEx.Message); - } + _logger.LogInformation("Updated incident {IncidentId} to Pending status in transaction", incident.Id); + incidentId = incident.Id; return mission; }); + + _unitOfWork.ClearChangeTracker(); + + _logger.LogInformation("Transaction committed and change tracker cleared for incident {IncidentId}. Tracked entities after clear: {TrackedCount}", + incidentId, _unitOfWork.Context.ChangeTracker.Entries().Count()); + + // Step 2: Create new session AFTER transaction committed + try + { + await _sessionService.HandleMissionAbortAsync(incidentId); + _logger.LogInformation("Created new rescue session after rescuer abort for incident {IncidentId}", incidentId); + } + catch (Exception sessionEx) + { + _logger.LogError(sessionEx, "Failed to create new session after rescuer abort for incident {IncidentId}: {Message}", + incidentId, sessionEx.Message); + throw; + } } catch (Exception ex) { diff --git a/SnakeAid.Service/Implements/RescueRequestSessionService.cs b/SnakeAid.Service/Implements/RescueRequestSessionService.cs index ad4914e0..5878b7f6 100644 --- a/SnakeAid.Service/Implements/RescueRequestSessionService.cs +++ b/SnakeAid.Service/Implements/RescueRequestSessionService.cs @@ -187,17 +187,32 @@ private async Task BroadcastRequestsInternalAsync(RescueRe _logger.LogInformation("Found {Count} rescuers with pending requests for OTHER incidents", rescuersWithPending.Count); + // Query rescuers who have aborted missions for THIS incident + // These rescuers should NOT receive requests again for the same incident + var rescuersWhoAborted = await _unitOfWork.GetRepository() + .CreateBaseQuery() + .Where(m => m.IncidentId == session.IncidentId + && m.Status == RescueMissionStatus.MissionAborted) + .Select(m => m.RescuerId) + .Distinct() + .ToListAsync(); + + _logger.LogInformation("Found {Count} rescuers who previously aborted missions for incident {IncidentId}: {RescuerIds}", + rescuersWhoAborted.Count, session.IncidentId, + string.Join(", ", rescuersWhoAborted)); + var rescuersWithPendingSet = new HashSet(rescuersWithPending); + var rescuersWhoAbortedSet = new HashSet(rescuersWhoAborted); var expiredAt = DateTime.UtcNow.AddSeconds(REQUEST_TIMEOUT_SECONDS); var requests = new List(); - // only for rescuers without any pending request + // Only send to rescuers without any pending request AND who haven't aborted this incident foreach (var rescuer in rescuersInRadius) { var rescuerId = rescuer.AccountId; - // Skip if rescuer already has a pending request (preserve existing session) + // Skip if rescuer already has a pending request for OTHER incidents (preserve existing session) if (rescuersWithPendingSet.Contains(rescuerId)) { _logger.LogDebug( @@ -206,6 +221,14 @@ private async Task BroadcastRequestsInternalAsync(RescueRe continue; } + // Skip if rescuer previously aborted mission for THIS incident + if (rescuersWhoAbortedSet.Contains(rescuerId)) + { + _logger.LogInformation("❌ Excluding rescuer {RescuerId} - previously aborted mission for incident {IncidentId}", + rescuerId, session.IncidentId); + continue; + } + requests.Add(new RescuerRequest { Id = Guid.NewGuid(), @@ -219,8 +242,11 @@ private async Task BroadcastRequestsInternalAsync(RescueRe }); } - _logger.LogInformation("Created {RequestCount} requests for session {SessionId} ({SkippedCount} rescuers skipped due to pending requests for other incidents)", - requests.Count, session.Id, rescuersInRadius.Count - requests.Count); + _logger.LogInformation("Created {RequestCount} requests for session {SessionId}. " + + "Skipped: {SkippedPending} with pending requests + {SkippedAborted} who aborted this incident", + requests.Count, session.Id, + rescuersWithPending.Count, + rescuersWhoAborted.Count); // Bulk insert all requests at once await _unitOfWork.GetRepository().InsertRangeAsync(requests); @@ -437,9 +463,11 @@ public async Task AcceptRequestAsync(Guid requestId, Guid rescuerId) { await _unitOfWork.ExecuteInTransactionAsync(async () => { + // Load request with session only (no circular reference) var request = await _unitOfWork.GetRepository().FirstOrDefaultAsync( predicate: r => r.Id == requestId && r.RescuerId == rescuerId, - include: q => q.Include(r => r.Session).ThenInclude(s => s.Requests) + include: q => q.Include(r => r.Session), + asNoTracking: false ); if (request == null) @@ -475,8 +503,11 @@ await _unitOfWork.ExecuteInTransactionAsync(async () => request.UpdatedAt = DateTime.UtcNow; _unitOfWork.GetRepository().Update(request); - // Mark all other requests in the session as Taken - var otherRequests = request.Session.Requests.Where(r => r.Id != requestId && r.Status == RescueRequestStatus.Pending).ToList(); + // Query other pending requests in the same session separately (no circular reference) + var otherRequests = await _unitOfWork.GetRepository().GetListAsync( + predicate: r => r.SessionId == request.SessionId && r.Id != requestId && r.Status == RescueRequestStatus.Pending, + asNoTracking: false + ); if (otherRequests.Any()) { var updateTime = DateTime.UtcNow; @@ -523,6 +554,19 @@ await _unitOfWork.ExecuteInTransactionAsync(async () => throw new NotFoundException("Rescuer not found."); } + // Check for existing active missions only (allow multiple missions per incident for retry scenarios) + var existingActiveMission = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: m => m.IncidentId == request.IncidentId && + (m.Status == RescueMissionStatus.Preparing || + m.Status == RescueMissionStatus.EnRoute || + m.Status == RescueMissionStatus.RescuerArrived) + ); + + if (existingActiveMission != null) + { + throw new BadRequestException($"Active mission {existingActiveMission.Id} already exists for this incident."); + } + // Create new mission var mission = new RescueMission { @@ -709,15 +753,23 @@ public async Task HandleMissionAbortAsync(Guid incidentId) { try { - var incident = await _unitOfWork.GetRepository().FirstOrDefaultAsync( - predicate: i => i.Id == incidentId - ); + // CRITICAL: Use CreateBaseQuery + AsNoTracking to completely bypass EF cache + // FirstOrDefaultAsync with asNoTracking can still return tracked entities + var incident = await _unitOfWork.GetRepository() + .CreateBaseQuery(asNoTracking: true) + .Where(i => i.Id == incidentId) + .FirstOrDefaultAsync(); if (incident == null) { throw new NotFoundException("Incident not found."); } + // Diagnostic: Check entity state to verify it's truly detached + var entityState = _unitOfWork.Context.Entry(incident).State; + _logger.LogInformation("Incident {IncidentId} status after mission abort: Status={Status} (raw: {StatusInt}), CurrentSession={Session}, EntityState={EntityState}", + incidentId, incident.Status, (int)incident.Status, incident.CurrentSessionNumber, entityState); + // Check if incident is still pending (should be reset by mission abort) if (incident.Status != SnakebiteIncidentStatus.Pending) { diff --git a/SnakeAid.Service/Implements/SnakeAIService.cs b/SnakeAid.Service/Implements/SnakeAIService.cs index d6539514..7cbcff3c 100644 --- a/SnakeAid.Service/Implements/SnakeAIService.cs +++ b/SnakeAid.Service/Implements/SnakeAIService.cs @@ -125,7 +125,7 @@ public async Task DetectAsync(string imageUrl, Guid repo species.FirstAidGuidelineOverride = new FirstAidOverride { Mode = OverrideMode.Append, // Append mode (0) - Steps = venomWithGuide.FirstAidGuideline.Content?.Steps?.Select(s => s.Text).ToList() ?? new List() + Content = venomWithGuide.FirstAidGuideline.Content ?? new FirstAidContent() }; } } @@ -335,7 +335,7 @@ public async Task GetRecognitionResultAsync(Guid recogni species.FirstAidGuidelineOverride = new FirstAidOverride { Mode = OverrideMode.Append, - Steps = venomWithGuide.FirstAidGuideline.Content?.Steps?.Select(s => s.Text).ToList() ?? new List() + Content = venomWithGuide.FirstAidGuideline.Content ?? new FirstAidContent() }; } } diff --git a/SnakeAid.Service/Implements/SnakebiteIncidentService.cs b/SnakeAid.Service/Implements/SnakebiteIncidentService.cs index 25fac5ec..4271ebba 100644 --- a/SnakeAid.Service/Implements/SnakebiteIncidentService.cs +++ b/SnakeAid.Service/Implements/SnakebiteIncidentService.cs @@ -223,7 +223,7 @@ await _unitOfWork.Context.Entry(existingIncident) } } - public async Task GetDetailIncidentAsync(Guid incidentId) + public async Task GetDetailIncidentAsync(Guid incidentId) { try { @@ -237,7 +237,7 @@ public async Task GetDetailIncidentAsync(Guid in .Include(i => i.Sessions) .Include(i => i.AllRequests) .ThenInclude(r => r.Rescuer) - .Include(i => i.RescueMission) + .Include(i => i.Missions) .Include(i => i.Media) ); @@ -246,7 +246,7 @@ public async Task GetDetailIncidentAsync(Guid in throw new NotFoundException("Snakebite incident not found."); } - var responseData = existingIncident.Adapt(); + var responseData = existingIncident.Adapt(); return responseData; }); diff --git a/SnakeAid.Service/Interfaces/ISnakebiteIncidentService.cs b/SnakeAid.Service/Interfaces/ISnakebiteIncidentService.cs index 382666f2..67238cd3 100644 --- a/SnakeAid.Service/Interfaces/ISnakebiteIncidentService.cs +++ b/SnakeAid.Service/Interfaces/ISnakebiteIncidentService.cs @@ -9,7 +9,7 @@ public interface ISnakebiteIncidentService { Task CreateIncidentAsync(CreateIncidentRequest request, Guid userId); - Task GetDetailIncidentAsync(Guid incidentId); + Task GetDetailIncidentAsync(Guid incidentId); Task RaiseSessionRangeAsync(RaiseSessionRangeRequest request); From 205196776e0eb35d73a48de1595e2909a9122ba5 Mon Sep 17 00:00:00 2001 From: NhanNPSE184696 Date: Wed, 11 Feb 2026 15:52:18 +0700 Subject: [PATCH 18/31] feat: add SnakeCatchingRequest functionality with controller, service, and request/response models --- .../SnakeCatchingRequestController.cs | 91 ++++++++ .../Mappings/SnakeCatchingRequestMapper.cs | 18 ++ .../CreateSnakeCatchingRequestRequest.cs | 50 +++++ .../CreateRescueMissionResponse.cs | 2 +- .../ListRescueRequestResponse.cs | 2 +- .../CreateSnakeCatchingMissionResponse.cs | 47 +++++ .../CreateSnakeCatchingRequestResponse.cs | 64 ++++++ .../DetailSnakebiteIncidentReposne.cs | 1 + .../Validators/CoordinateAttribute.cs | 48 +++++ .../Validators/NotInPastAttribute.cs | 39 ++++ .../Implements/SnakeCatchingRequestService.cs | 197 ++++++++++++++++++ .../ISnakeCatchingRequestService.cs | 15 ++ 12 files changed, 572 insertions(+), 2 deletions(-) create mode 100644 SnakeAid.Api/Controllers/SnakeCatchingRequestController.cs create mode 100644 SnakeAid.Core/Mappings/SnakeCatchingRequestMapper.cs create mode 100644 SnakeAid.Core/Requests/SnakeCatchingRequest/CreateSnakeCatchingRequestRequest.cs rename SnakeAid.Core/Responses/{ => RescueMission}/CreateRescueMissionResponse.cs (96%) rename SnakeAid.Core/Responses/{ => RescueMission}/ListRescueRequestResponse.cs (95%) create mode 100644 SnakeAid.Core/Responses/SnakeCatchingMission/CreateSnakeCatchingMissionResponse.cs create mode 100644 SnakeAid.Core/Responses/SnakeCatchingRequest/CreateSnakeCatchingRequestResponse.cs create mode 100644 SnakeAid.Core/Validators/CoordinateAttribute.cs create mode 100644 SnakeAid.Core/Validators/NotInPastAttribute.cs create mode 100644 SnakeAid.Service/Implements/SnakeCatchingRequestService.cs create mode 100644 SnakeAid.Service/Interfaces/ISnakeCatchingRequestService.cs diff --git a/SnakeAid.Api/Controllers/SnakeCatchingRequestController.cs b/SnakeAid.Api/Controllers/SnakeCatchingRequestController.cs new file mode 100644 index 00000000..389ece7a --- /dev/null +++ b/SnakeAid.Api/Controllers/SnakeCatchingRequestController.cs @@ -0,0 +1,91 @@ +using MapsterMapper; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using SnakeAid.Core.Meta; +using SnakeAid.Core.Requests.SnakeCatchingRequest; +using SnakeAid.Core.Responses.SnakeCatchingRequest; +using SnakeAid.Service.Interfaces; +using Swashbuckle.AspNetCore.Annotations; + +namespace SnakeAid.Api.Controllers +{ + [Route("api/snake-catching-requests")] + [ApiController] + [Authorize] + public class SnakeCatchingRequestController : BaseController + { + private readonly ISnakeCatchingRequestService _snakeCatchingRequestService; + + public SnakeCatchingRequestController( + ILogger logger, + IHttpContextAccessor httpContextAccessor, + IMapper mapper, + ISnakeCatchingRequestService snakeCatchingRequestService) + : base(logger, httpContextAccessor, mapper) + { + _snakeCatchingRequestService = snakeCatchingRequestService; + } + + /// + /// Create a new snake catching request + /// + /// + /// Create a request for snake catching service. The request will be assigned to available rescuers based on location and priority. + /// + /// **Requirements:** + /// - User must be authenticated + /// - Valid address and coordinates required + /// - EstimatedPrice must be greater than 0 if provided + /// - MediaURLList can contain uploaded media URLs + /// + /// **Validations:** + /// - Longitude: -180 to 180 + /// - Latitude: -90 to 90 + /// - RequestDate cannot be more than 5 minutes in the past + /// - PreferredTime cannot be in the past + /// - Address max length: 1000 characters + /// - AdditionalDetails max length: 2000 characters + /// - Notes max length: 1000 characters + /// + /// **Example Request:** + /// ```json + /// { + /// "address": "123 Nguyen Hue, District 1, Ho Chi Minh City", + /// "latitude": "10.762622", + /// "longitude": "106.660172", + /// "additionalDetails": "Snake found in the garden, approximately 1 meter long, brown color", + /// "requestDate": "2024-01-20T10:30:00Z", + /// "preferredTime": "2024-01-20T14:00:00Z", + /// "estimatedPrice": 500000, + /// "notes": "Please bring necessary equipment", + /// "mediaURLList": [ + /// "https://cloudinary.com/image1.jpg", + /// "https://cloudinary.com/image2.jpg" + /// ] + /// } + /// ``` + /// + [HttpPost] + [SwaggerOperation( + Summary = "Create Snake Catching Request", + Description = "Submit a request for professional snake catching service")] + [SwaggerResponse(200, "Request created successfully", typeof(ApiResponse))] + [SwaggerResponse(400, "Invalid request data or user not found")] + [SwaggerResponse(401, "User not authenticated")] + [SwaggerResponse(422, "Validation error")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)] + public async Task CreateSnakeCatchingRequest([FromBody] CreateSnakeCatchingRequestRequest request) + { + var userId = GetCurrentUserId(); + + var result = await _snakeCatchingRequestService.CreateSnakeCatchingRequestAsync(userId, request); + + return Ok(ApiResponseBuilder.BuildSuccessResponse( + result, + "Snake catching request created successfully! Our team will review and assign a rescuer soon.")); + } + } +} diff --git a/SnakeAid.Core/Mappings/SnakeCatchingRequestMapper.cs b/SnakeAid.Core/Mappings/SnakeCatchingRequestMapper.cs new file mode 100644 index 00000000..620360f9 --- /dev/null +++ b/SnakeAid.Core/Mappings/SnakeCatchingRequestMapper.cs @@ -0,0 +1,18 @@ +using Mapster; +using SnakeAid.Core.Domains; +using SnakeAid.Core.Responses.SnakeCatchingRequest; + +namespace SnakeAid.Core.Mappings +{ + public class SnakeCatchingRequestMapper : IRegister + { + public void Register(TypeAdapterConfig config) + { + TypeAdapterConfig + .NewConfig() + .Map(dest => dest.LocationCoordinates, src => src.LocationCoordinates) + .Map(dest => dest.Lng, src => src.LocationCoordinates.X) + .Map(dest => dest.Lat, src => src.LocationCoordinates.Y); + } + } +} diff --git a/SnakeAid.Core/Requests/SnakeCatchingRequest/CreateSnakeCatchingRequestRequest.cs b/SnakeAid.Core/Requests/SnakeCatchingRequest/CreateSnakeCatchingRequestRequest.cs new file mode 100644 index 00000000..2bcfd801 --- /dev/null +++ b/SnakeAid.Core/Requests/SnakeCatchingRequest/CreateSnakeCatchingRequestRequest.cs @@ -0,0 +1,50 @@ +using SnakeAid.Core.Domains; +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SnakeAid.Core.Requests.SnakeCatchingRequest +{ + public class CreateSnakeCatchingRequestRequest + { + [Required] + [MaxLength(1000)] + public string Address { get; set; } + + /// + /// Longitude (Kinh độ) - VD: 106.660172 + /// + [Required] + [Range(-180, 180)] + public double Lng { get; set; } + + /// + /// Latitude (Vĩ độ) - VD: 10.762622 + /// + [Required] + [Range(-90, 90)] + public double Lat { get; set; } + + [MaxLength(2000)] + public string AdditionalDetails { get; set; } + + [Required] + public DateTime RequestDate { get; set; } = DateTime.UtcNow; + + public DateTime? PreferredTime { get; set; } + + [Required] + [Column(TypeName = "numeric(18,2)")] + public decimal? EstimatedPrice { get; set; } + + [MaxLength(1000)] + public string? Notes { get; set; } + + + public List MediaURLList { get; set; } = new List(); + } +} diff --git a/SnakeAid.Core/Responses/CreateRescueMissionResponse.cs b/SnakeAid.Core/Responses/RescueMission/CreateRescueMissionResponse.cs similarity index 96% rename from SnakeAid.Core/Responses/CreateRescueMissionResponse.cs rename to SnakeAid.Core/Responses/RescueMission/CreateRescueMissionResponse.cs index faeb10e0..5bfa97d4 100644 --- a/SnakeAid.Core/Responses/CreateRescueMissionResponse.cs +++ b/SnakeAid.Core/Responses/RescueMission/CreateRescueMissionResponse.cs @@ -7,7 +7,7 @@ using System.Text; using System.Threading.Tasks; -namespace SnakeAid.Core.Responses +namespace SnakeAid.Core.Responses.RescueMission { public class CreateRescueMissionResponse { diff --git a/SnakeAid.Core/Responses/ListRescueRequestResponse.cs b/SnakeAid.Core/Responses/RescueMission/ListRescueRequestResponse.cs similarity index 95% rename from SnakeAid.Core/Responses/ListRescueRequestResponse.cs rename to SnakeAid.Core/Responses/RescueMission/ListRescueRequestResponse.cs index bfda5e6b..b8cea1f1 100644 --- a/SnakeAid.Core/Responses/ListRescueRequestResponse.cs +++ b/SnakeAid.Core/Responses/RescueMission/ListRescueRequestResponse.cs @@ -7,7 +7,7 @@ using System.Text; using System.Threading.Tasks; -namespace SnakeAid.Core.Responses +namespace SnakeAid.Core.Responses.RescueMission { public class ListRescueRequestResponse { diff --git a/SnakeAid.Core/Responses/SnakeCatchingMission/CreateSnakeCatchingMissionResponse.cs b/SnakeAid.Core/Responses/SnakeCatchingMission/CreateSnakeCatchingMissionResponse.cs new file mode 100644 index 00000000..d5c355af --- /dev/null +++ b/SnakeAid.Core/Responses/SnakeCatchingMission/CreateSnakeCatchingMissionResponse.cs @@ -0,0 +1,47 @@ +using SnakeAid.Core.Domains; +using SnakeAid.Core.Responses.RescuerProfile; +using SnakeAid.Core.Responses.SnakeCatchingRequest; +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SnakeAid.Core.Responses.SnakeCatchingMission +{ + public class CreateSnakeCatchingMissionResponse + { + public Guid Id { get; set; } + + + public Guid RescuerId { get; set; } + + + public Guid SnakeCatchingRequestId { get; set; } + + public CatchingMissionStatus Status { get; set; } = CatchingMissionStatus.Preparing; + + + public decimal Price { get; set; } + + public DateTime? StartedAt { get; set; } + + public DateTime? ArrivedAt { get; set; } + + public DateTime? CompletedAt { get; set; } + + public string? Notes { get; set; } + + public string? CancellationReason { get; set; } + + public decimal? EstimatedCost { get; set; } + + public decimal? ActualCost { get; set; } + + // Navigation properties + public BriefRescuerProfileResponse Rescuer { get; set; } + public CreateSnakeCatchingRequestResponse SnakeCatchingRequest { get; set; } + } +} diff --git a/SnakeAid.Core/Responses/SnakeCatchingRequest/CreateSnakeCatchingRequestResponse.cs b/SnakeAid.Core/Responses/SnakeCatchingRequest/CreateSnakeCatchingRequestResponse.cs new file mode 100644 index 00000000..8b542f5f --- /dev/null +++ b/SnakeAid.Core/Responses/SnakeCatchingRequest/CreateSnakeCatchingRequestResponse.cs @@ -0,0 +1,64 @@ +using NetTopologySuite.Geometries; +using SnakeAid.Core.Domains; +using SnakeAid.Core.Responses.Media; +using SnakeAid.Core.Responses.MemberProfile; +using SnakeAid.Core.Responses.RescuerProfile; +using SnakeAid.Core.Responses.SnakeCatchingMission; +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SnakeAid.Core.Responses.SnakeCatchingRequest +{ + public class CreateSnakeCatchingRequestResponse + { + public Guid Id { get; set; } + + public Guid UserId { get; set; } + + public string Address { get; set; } + + public Point LocationCoordinates { get; set; } + + /// + /// Longitude for easy client access (extracted from LocationCoordinates) + /// + public double Lng { get; set; } + + /// + /// Latitude for easy client access (extracted from LocationCoordinates) + /// + public double Lat { get; set; } + + public string AdditionalDetails { get; set; } + + public RequestStatus Status { get; set; } + + public RequestPriority Priority { get; set; } + + public DateTime RequestDate { get; set; } + + public DateTime? PreferredTime { get; set; } + + public DateTime? AssignedAt { get; set; } + + public Guid? AssignedRescuerId { get; set; } + + public decimal? EstimatedPrice { get; set; } + + public string? CancellationReason { get; set; } + + public string? Notes { get; set; } + + + // Navigation properties + public BriefMemberProfileRespone User { get; set; } + public BriefRescuerProfileResponse? AssignedRescuer { get; set; } + public CreateSnakeCatchingMissionResponse? Mission { get; set; } + public List Media { get; set; } = new List(); + } +} diff --git a/SnakeAid.Core/Responses/SnakebiteIncident/DetailSnakebiteIncidentReposne.cs b/SnakeAid.Core/Responses/SnakebiteIncident/DetailSnakebiteIncidentReposne.cs index bfd660c5..c4c67482 100644 --- a/SnakeAid.Core/Responses/SnakebiteIncident/DetailSnakebiteIncidentReposne.cs +++ b/SnakeAid.Core/Responses/SnakebiteIncident/DetailSnakebiteIncidentReposne.cs @@ -2,6 +2,7 @@ using SnakeAid.Core.Domains; using SnakeAid.Core.Responses.Media; using SnakeAid.Core.Responses.MemberProfile; +using SnakeAid.Core.Responses.RescueMission; using SnakeAid.Core.Responses.RescueRequestSession; using SnakeAid.Core.Responses.RescuerProfile; using System; diff --git a/SnakeAid.Core/Validators/CoordinateAttribute.cs b/SnakeAid.Core/Validators/CoordinateAttribute.cs new file mode 100644 index 00000000..4ddc3f61 --- /dev/null +++ b/SnakeAid.Core/Validators/CoordinateAttribute.cs @@ -0,0 +1,48 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace SnakeAid.Core.Validators +{ + /// + /// Validates that a string can be parsed as a valid coordinate value + /// + public class CoordinateAttribute : ValidationAttribute + { + public CoordinateType Type { get; set; } + + public CoordinateAttribute(CoordinateType type) + { + Type = type; + } + + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + { + if (value == null || string.IsNullOrWhiteSpace(value.ToString())) + { + return new ValidationResult($"{validationContext.DisplayName} is required."); + } + + var stringValue = value.ToString()!; + + if (!double.TryParse(stringValue, out double coordinate)) + { + return new ValidationResult($"{validationContext.DisplayName} must be a valid number."); + } + + return Type switch + { + CoordinateType.Latitude when coordinate < -90 || coordinate > 90 + => new ValidationResult($"{validationContext.DisplayName} must be between -90 and 90."), + CoordinateType.Longitude when coordinate < -180 || coordinate > 180 + => new ValidationResult($"{validationContext.DisplayName} must be between -180 and 180."), + _ => ValidationResult.Success + }; + } + } + + public enum CoordinateType + { + Latitude, + Longitude + } +} diff --git a/SnakeAid.Core/Validators/NotInPastAttribute.cs b/SnakeAid.Core/Validators/NotInPastAttribute.cs new file mode 100644 index 00000000..0639f228 --- /dev/null +++ b/SnakeAid.Core/Validators/NotInPastAttribute.cs @@ -0,0 +1,39 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace SnakeAid.Core.Validators +{ + /// + /// Validates that a DateTime is not in the past (with optional tolerance in minutes) + /// + public class NotInPastAttribute : ValidationAttribute + { + /// + /// Tolerance in minutes. DateTime within this tolerance from now are considered valid. + /// Default is 5 minutes to account for clock differences. + /// + public int ToleranceMinutes { get; set; } = 5; + + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + { + if (value == null) + { + return ValidationResult.Success; // Null values should be handled by [Required] + } + + if (value is not DateTime dateTime) + { + return new ValidationResult($"{validationContext.DisplayName} must be a valid DateTime."); + } + + var minDateTime = DateTime.UtcNow.AddMinutes(-ToleranceMinutes); + + if (dateTime < minDateTime) + { + return new ValidationResult($"{validationContext.DisplayName} cannot be more than {ToleranceMinutes} minutes in the past."); + } + + return ValidationResult.Success; + } + } +} diff --git a/SnakeAid.Service/Implements/SnakeCatchingRequestService.cs b/SnakeAid.Service/Implements/SnakeCatchingRequestService.cs new file mode 100644 index 00000000..ea367e8b --- /dev/null +++ b/SnakeAid.Service/Implements/SnakeCatchingRequestService.cs @@ -0,0 +1,197 @@ +using Mapster; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using SnakeAid.Core.Domains; +using SnakeAid.Core.Exceptions; +using SnakeAid.Core.Requests.SnakeCatchingRequest; +using SnakeAid.Core.Responses.SnakeCatchingRequest; +using SnakeAid.Repository.Data; +using SnakeAid.Repository.Interfaces; +using SnakeAid.Service.Interfaces; + +namespace SnakeAid.Service.Implements +{ + public class SnakeCatchingRequestService : ISnakeCatchingRequestService + { + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + + public SnakeCatchingRequestService( + IUnitOfWork unitOfWork, + ILogger logger, + IConfiguration configuration) + { + _unitOfWork = unitOfWork; + _logger = logger; + _configuration = configuration; + } + + public async Task CreateSnakeCatchingRequestAsync( + Guid userId, + CreateSnakeCatchingRequestRequest request) + { + try + { + if (request == null) + { + throw new BadRequestException("Request data cannot be null."); + } + + return await _unitOfWork.ExecuteInTransactionAsync(async () => + { + // Validate user exists and has member profile + var existingAccount = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: a => a.Id == userId, + include: m => m.Include(i => i.MemberProfile) + ); + + if (existingAccount == null) + { + throw new NotFoundException("Account not found."); + } + + if (existingAccount.MemberProfile == null) + { + throw new BadRequestException("Member information could not be found for the current account."); + } + + // Validate RequestDate + if (request.RequestDate < DateTime.UtcNow.AddMinutes(-5)) + { + throw new BadRequestException("RequestDate cannot be in the past (more than 5 minutes ago)."); + } + + // Validate PreferredTime if provided + if (request.PreferredTime.HasValue && request.PreferredTime.Value < DateTime.UtcNow) + { + throw new BadRequestException("PreferredTime cannot be in the past."); + } + + // Validate EstimatedPrice + if (request.EstimatedPrice.HasValue && request.EstimatedPrice.Value <= 0) + { + throw new BadRequestException("EstimatedPrice must be greater than 0."); + } + + // Create Point from lng/lat (PostGIS uses SRID 4326 - WGS84) + var geometryFactory = NetTopologySuite.NtsGeometryServices.Instance.CreateGeometryFactory(srid: 4326); + var locationPoint = geometryFactory.CreatePoint( + new NetTopologySuite.Geometries.Coordinate(request.Lng, request.Lat)); + + // Create new SnakeCatchingRequest + var newRequest = new SnakeCatchingRequest + { + Id = Guid.NewGuid(), + UserId = userId, + Address = request.Address, + LocationCoordinates = locationPoint, + AdditionalDetails = request.AdditionalDetails, + Status = RequestStatus.Pending, + Priority = RequestPriority.Normal, + RequestDate = request.RequestDate.ToUniversalTime(), + PreferredTime = request.PreferredTime?.ToUniversalTime(), + EstimatedPrice = request.EstimatedPrice, + Notes = request.Notes + }; + + // Handle media if provided + if (request.MediaURLList != null && request.MediaURLList.Any()) + { + var uploadBatchId = Guid.NewGuid(); + var sequenceOrder = 0; + + foreach (var mediaUrl in request.MediaURLList) + { + if (string.IsNullOrWhiteSpace(mediaUrl)) + { + continue; // Skip empty URLs + } + + // Extract filename from URL or generate one + var filename = ExtractFilenameFromUrl(mediaUrl) ?? $"snake_catching_media_{DateTime.UtcNow:yyyyMMddHHmmss}_{sequenceOrder}"; + + var media = new ReportMedia + { + Id = Guid.NewGuid(), + ReferenceId = newRequest.Id, + ReferenceType = MediaReferenceType.SnakeCatchingRequest, + FileName = filename, + MediaUrl = mediaUrl, + ContentType = DetermineContentType(mediaUrl), + FileSize = 0, // Will be updated later if needed + Purpose = MediaPurpose.SnakeIdentification, + UploadBatchId = uploadBatchId, + SequenceOrder = sequenceOrder++, + RequiresAIProcessing = true, + IsProcessed = false + }; + + await _unitOfWork.GetRepository().InsertAsync(media); + } + } + + // Save the request + await _unitOfWork.GetRepository().InsertAsync(newRequest); + await _unitOfWork.CommitAsync(); + + // Load the created request with navigation properties for response + var createdRequest = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: r => r.Id == newRequest.Id, + include: query => query + .Include(r => r.User) + .Include(r => r.Media) + ); + + if (createdRequest == null) + { + throw new Exception("Failed to retrieve created request."); + } + + var response = createdRequest.Adapt(); + + _logger.LogInformation( + "Snake catching request created successfully. RequestId: {RequestId}, UserId: {UserId}, Location: ({Lat}, {Lng})", + response.Id, userId, request.Lat, request.Lng); + + return response; + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating snake catching request: {Message}", ex.Message); + throw; + } + } + + private string? ExtractFilenameFromUrl(string url) + { + try + { + var uri = new Uri(url); + var segments = uri.Segments; + return segments.Length > 0 ? segments[^1] : null; + } + catch + { + return null; + } + } + + private string DetermineContentType(string url) + { + var extension = System.IO.Path.GetExtension(url).ToLowerInvariant(); + return extension switch + { + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".mp4" => "video/mp4", + ".mov" => "video/quicktime", + _ => "application/octet-stream" + }; + } + } +} diff --git a/SnakeAid.Service/Interfaces/ISnakeCatchingRequestService.cs b/SnakeAid.Service/Interfaces/ISnakeCatchingRequestService.cs new file mode 100644 index 00000000..2bbea9d1 --- /dev/null +++ b/SnakeAid.Service/Interfaces/ISnakeCatchingRequestService.cs @@ -0,0 +1,15 @@ +using SnakeAid.Core.Requests.SnakeCatchingRequest; +using SnakeAid.Core.Responses.SnakeCatchingRequest; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SnakeAid.Service.Interfaces +{ + public interface ISnakeCatchingRequestService + { + Task CreateSnakeCatchingRequestAsync(Guid userId, CreateSnakeCatchingRequestRequest request); + } +} From 6839357d36ddaf133c4c3aa109bc852ef947224c Mon Sep 17 00:00:00 2001 From: NhanNPSE184696 Date: Wed, 11 Feb 2026 17:16:47 +0700 Subject: [PATCH 19/31] feat: add functionality to accept snake catching requests and handle circular references in mappings --- .../SnakeCatchingRequestController.cs | 65 +++++------ SnakeAid.Api/Program.cs | 3 + SnakeAid.Core/Mappings/MapsterConfig.cs | 4 + .../Mappings/SnakeCatchingRequestMapper.cs | 20 ++++ .../ApiExceptionHandlerMiddleware.cs | 14 ++- .../Implements/SnakeCatchingRequestService.cs | 108 ++++++++++++++++++ .../ISnakeCatchingRequestService.cs | 2 + 7 files changed, 176 insertions(+), 40 deletions(-) diff --git a/SnakeAid.Api/Controllers/SnakeCatchingRequestController.cs b/SnakeAid.Api/Controllers/SnakeCatchingRequestController.cs index 389ece7a..5dc3b449 100644 --- a/SnakeAid.Api/Controllers/SnakeCatchingRequestController.cs +++ b/SnakeAid.Api/Controllers/SnakeCatchingRequestController.cs @@ -10,7 +10,7 @@ namespace SnakeAid.Api.Controllers { - [Route("api/snake-catching-requests")] + [Route("api/snake-catching")] [ApiController] [Authorize] public class SnakeCatchingRequestController : BaseController @@ -30,42 +30,6 @@ public SnakeCatchingRequestController( /// /// Create a new snake catching request /// - /// - /// Create a request for snake catching service. The request will be assigned to available rescuers based on location and priority. - /// - /// **Requirements:** - /// - User must be authenticated - /// - Valid address and coordinates required - /// - EstimatedPrice must be greater than 0 if provided - /// - MediaURLList can contain uploaded media URLs - /// - /// **Validations:** - /// - Longitude: -180 to 180 - /// - Latitude: -90 to 90 - /// - RequestDate cannot be more than 5 minutes in the past - /// - PreferredTime cannot be in the past - /// - Address max length: 1000 characters - /// - AdditionalDetails max length: 2000 characters - /// - Notes max length: 1000 characters - /// - /// **Example Request:** - /// ```json - /// { - /// "address": "123 Nguyen Hue, District 1, Ho Chi Minh City", - /// "latitude": "10.762622", - /// "longitude": "106.660172", - /// "additionalDetails": "Snake found in the garden, approximately 1 meter long, brown color", - /// "requestDate": "2024-01-20T10:30:00Z", - /// "preferredTime": "2024-01-20T14:00:00Z", - /// "estimatedPrice": 500000, - /// "notes": "Please bring necessary equipment", - /// "mediaURLList": [ - /// "https://cloudinary.com/image1.jpg", - /// "https://cloudinary.com/image2.jpg" - /// ] - /// } - /// ``` - /// [HttpPost] [SwaggerOperation( Summary = "Create Snake Catching Request", @@ -87,5 +51,32 @@ public async Task CreateSnakeCatchingRequest([FromBody] CreateSna result, "Snake catching request created successfully! Our team will review and assign a rescuer soon.")); } + + /// + /// Accept a snake catching request as a rescuer + /// + /// The ID of the snake catching request to accept + [HttpPost("accept/{requestId:guid}")] + [SwaggerOperation( + Summary = "Accept Snake Catching Request", + Description = "Rescuer accepts a pending snake catching request and creates a new mission")] + [SwaggerResponse(200, "Request accepted successfully", typeof(ApiResponse))] + [SwaggerResponse(400, "Invalid request (already assigned, not pending, or rescuer offline)")] + [SwaggerResponse(401, "User not authenticated")] + [SwaggerResponse(403, "User is not a rescuer")] + [SwaggerResponse(404, "Request not found")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task AcceptSnakeCatchingRequest([FromRoute] Guid requestId) + { + var rescuerId = GetCurrentUserId(); + + var result = await _snakeCatchingRequestService.AcceptSnakeCatchingRequestAsync(rescuerId, requestId); + + return Ok(ApiResponseBuilder.BuildSuccessResponse( + result, + "Snake catching request accepted successfully! Mission created.")); + } } } diff --git a/SnakeAid.Api/Program.cs b/SnakeAid.Api/Program.cs index d76b4fec..2dccf139 100644 --- a/SnakeAid.Api/Program.cs +++ b/SnakeAid.Api/Program.cs @@ -149,6 +149,9 @@ public static async Task Main(string[] args) { options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); options.JsonSerializerOptions.Converters.Add(new SnakeAid.Core.Converters.PointJsonConverter()); + + // Handle circular references in JSON serialization + options.JsonSerializerOptions.ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles; }); builder.Services.AddControllers(); diff --git a/SnakeAid.Core/Mappings/MapsterConfig.cs b/SnakeAid.Core/Mappings/MapsterConfig.cs index 2861a69e..bdb0ccab 100644 --- a/SnakeAid.Core/Mappings/MapsterConfig.cs +++ b/SnakeAid.Core/Mappings/MapsterConfig.cs @@ -11,6 +11,10 @@ public static class MapsterConfig { public static void RegisterMappings() { + // Configure global settings to handle circular references + TypeAdapterConfig.GlobalSettings.Default + .PreserveReference(true) // Enable reference tracking globally + .MaxDepth(3); // Limit mapping depth to prevent stack overflow // Scan and register all mapping configurations in the assembly // This will automatically find all classes implementing IRegister diff --git a/SnakeAid.Core/Mappings/SnakeCatchingRequestMapper.cs b/SnakeAid.Core/Mappings/SnakeCatchingRequestMapper.cs index 620360f9..4244e4dd 100644 --- a/SnakeAid.Core/Mappings/SnakeCatchingRequestMapper.cs +++ b/SnakeAid.Core/Mappings/SnakeCatchingRequestMapper.cs @@ -10,9 +10,29 @@ public void Register(TypeAdapterConfig config) { TypeAdapterConfig .NewConfig() + .PreserveReference(true) // Enable circular reference handling + .MaxDepth(3) // Limit mapping depth to prevent infinite loops .Map(dest => dest.LocationCoordinates, src => src.LocationCoordinates) .Map(dest => dest.Lng, src => src.LocationCoordinates.X) .Map(dest => dest.Lat, src => src.LocationCoordinates.Y); + + // Configure MemberProfile mapping to ignore circular collections + TypeAdapterConfig + .NewConfig() + .PreserveReference(true) + .MaxDepth(2); + + // Configure RescuerProfile mapping to ignore circular collections + TypeAdapterConfig + .NewConfig() + .PreserveReference(true) + .MaxDepth(2); + + // Configure Account mapping to ignore profile back-references + TypeAdapterConfig + .NewConfig() + .PreserveReference(true) + .MaxDepth(2); } } } diff --git a/SnakeAid.Core/Middlewares/ApiExceptionHandlerMiddleware.cs b/SnakeAid.Core/Middlewares/ApiExceptionHandlerMiddleware.cs index ad53913d..dfabaaa6 100644 --- a/SnakeAid.Core/Middlewares/ApiExceptionHandlerMiddleware.cs +++ b/SnakeAid.Core/Middlewares/ApiExceptionHandlerMiddleware.cs @@ -203,11 +203,19 @@ private async Task HandleExceptionAsync(HttpContext context, string errorId, Exc _logger.LogDebug("Development error details: {@DevDetails}", devDetails); } + // Check if response has already started before modifying headers + if (context.Response.HasStarted) + { + _logger.LogWarning( + "Response has already started (Status: {StatusCode}). Cannot send error response for ErrorId: {ErrorId}", + context.Response.StatusCode, + errorId); + return; + } + context.Response.ContentType = "application/json"; context.Response.StatusCode = statusCode; - - if (!context.Response.HasStarted) - await context.Response.WriteAsJsonAsync(apiResponse); + await context.Response.WriteAsJsonAsync(apiResponse); } } diff --git a/SnakeAid.Service/Implements/SnakeCatchingRequestService.cs b/SnakeAid.Service/Implements/SnakeCatchingRequestService.cs index ea367e8b..cbd08306 100644 --- a/SnakeAid.Service/Implements/SnakeCatchingRequestService.cs +++ b/SnakeAid.Service/Implements/SnakeCatchingRequestService.cs @@ -193,5 +193,113 @@ private string DetermineContentType(string url) _ => "application/octet-stream" }; } + + public async Task AcceptSnakeCatchingRequestAsync( + Guid rescuerId, + Guid requestId) + { + try + { + return await _unitOfWork.ExecuteInTransactionAsync(async () => + { + // Validate rescuer exists and has rescuer profile + var existingAccount = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: a => a.Id == rescuerId, + include: r => r.Include(i => i.RescuerProfile) + ); + + if (existingAccount == null) + { + throw new NotFoundException("Account not found."); + } + + if (existingAccount.RescuerProfile == null) + { + throw new BadRequestException("Rescuer profile not found. Only rescuers can accept requests."); + } + + // Check if rescuer is online + if (!existingAccount.RescuerProfile.IsOnline) + { + throw new BadRequestException("Rescuer must be online to accept requests."); + } + + // Get the snake catching request + var request = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: r => r.Id == requestId, + include: query => query + .Include(r => r.User) + .Include(r => r.Media) + ); + + if (request == null) + { + throw new NotFoundException("Snake catching request not found."); + } + + // Validate request status + if (request.Status != RequestStatus.Pending) + { + throw new BadRequestException($"Request cannot be accepted. Current status: {request.Status}"); + } + + // Check if request is already assigned + if (request.AssignedRescuerId.HasValue) + { + throw new BadRequestException("This request has already been assigned to another rescuer."); + } + + // Update the request + request.AssignedRescuerId = rescuerId; + request.AssignedAt = DateTime.UtcNow; + request.Status = RequestStatus.Assigned; + + _unitOfWork.GetRepository().Update(request); + + // Create a new mission for this request + var newMission = new SnakeCatchingMission + { + Id = Guid.NewGuid(), + RescuerId = rescuerId, + SnakeCatchingRequestId = requestId, + Status = CatchingMissionStatus.Preparing, + Price = request.EstimatedPrice ?? 0, // Use estimated price or 0 + EstimatedCost = request.EstimatedPrice + }; + + await _unitOfWork.GetRepository().InsertAsync(newMission); + await _unitOfWork.CommitAsync(); + + // Reload the request with all navigation properties for response + var updatedRequest = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: r => r.Id == requestId, + include: query => query + .Include(r => r.User) + .Include(r => r.AssignedRescuer) + .ThenInclude(ar => ar.Account) + .Include(r => r.Media) + .Include(r => r.Mission) + ); + + if (updatedRequest == null) + { + throw new Exception("Failed to retrieve updated request."); + } + + var response = updatedRequest.Adapt(); + + _logger.LogInformation( + "Snake catching request accepted successfully. RequestId: {RequestId}, RescuerId: {RescuerId}", + requestId, rescuerId); + + return response; + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error accepting snake catching request: {Message}", ex.Message); + throw; + } + } } } diff --git a/SnakeAid.Service/Interfaces/ISnakeCatchingRequestService.cs b/SnakeAid.Service/Interfaces/ISnakeCatchingRequestService.cs index 2bbea9d1..72b664b6 100644 --- a/SnakeAid.Service/Interfaces/ISnakeCatchingRequestService.cs +++ b/SnakeAid.Service/Interfaces/ISnakeCatchingRequestService.cs @@ -11,5 +11,7 @@ namespace SnakeAid.Service.Interfaces public interface ISnakeCatchingRequestService { Task CreateSnakeCatchingRequestAsync(Guid userId, CreateSnakeCatchingRequestRequest request); + + Task AcceptSnakeCatchingRequestAsync(Guid rescuerId, Guid requestId); } } From a899aca29b8ff2f05a7c6fa303c3e1c536ebd2a6 Mon Sep 17 00:00:00 2001 From: NhanNPSE184696 Date: Wed, 11 Feb 2026 17:56:06 +0700 Subject: [PATCH 20/31] feat: enhance snake catching request with detailed species identification and mapping --- SnakeAid.Core/Domains/SnakeCatchingRequest.cs | 1 + .../Mappings/CatchingRequestDetailMapper.cs | 17 ++++++++++ .../CreateSnakeCatchingRequestRequest.cs | 4 +++ .../SnakeSpeciesRequestItem.cs | 14 +++++++++ .../CatchingRequestDetailResponse.cs | 14 +++++++++ .../CreateSnakeCatchingRequestResponse.cs | 1 + .../CatchingRequestDetailConfiguration.cs | 2 +- .../Implements/SnakeCatchingRequestService.cs | 31 +++++++++++++++++++ 8 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 SnakeAid.Core/Mappings/CatchingRequestDetailMapper.cs create mode 100644 SnakeAid.Core/Requests/SnakeCatchingRequest/SnakeSpeciesRequestItem.cs create mode 100644 SnakeAid.Core/Responses/SnakeCatchingRequest/CatchingRequestDetailResponse.cs diff --git a/SnakeAid.Core/Domains/SnakeCatchingRequest.cs b/SnakeAid.Core/Domains/SnakeCatchingRequest.cs index 5d9a6647..4fa08d86 100644 --- a/SnakeAid.Core/Domains/SnakeCatchingRequest.cs +++ b/SnakeAid.Core/Domains/SnakeCatchingRequest.cs @@ -60,6 +60,7 @@ public class SnakeCatchingRequest : BaseEntity public RescuerProfile? AssignedRescuer { get; set; } public SnakeCatchingMission? Mission { get; set; } public ICollection Media { get; set; } = new List(); + public ICollection Details { get; set; } = new List(); } public enum RequestStatus diff --git a/SnakeAid.Core/Mappings/CatchingRequestDetailMapper.cs b/SnakeAid.Core/Mappings/CatchingRequestDetailMapper.cs new file mode 100644 index 00000000..987fe2ad --- /dev/null +++ b/SnakeAid.Core/Mappings/CatchingRequestDetailMapper.cs @@ -0,0 +1,17 @@ +using Mapster; +using SnakeAid.Core.Domains; +using SnakeAid.Core.Responses.SnakeCatchingRequest; + +namespace SnakeAid.Core.Mappings +{ + public class CatchingRequestDetailMapper : IRegister + { + public void Register(TypeAdapterConfig config) + { + TypeAdapterConfig + .NewConfig() + .Map(dest => dest.SnakeSpeciesName, src => src.SnakeSpecies != null ? src.SnakeSpecies.CommonName : null) + .Map(dest => dest.SnakeSpeciesScientificName, src => src.SnakeSpecies != null ? src.SnakeSpecies.ScientificName : null); + } + } +} diff --git a/SnakeAid.Core/Requests/SnakeCatchingRequest/CreateSnakeCatchingRequestRequest.cs b/SnakeAid.Core/Requests/SnakeCatchingRequest/CreateSnakeCatchingRequestRequest.cs index 2bcfd801..a5204d2e 100644 --- a/SnakeAid.Core/Requests/SnakeCatchingRequest/CreateSnakeCatchingRequestRequest.cs +++ b/SnakeAid.Core/Requests/SnakeCatchingRequest/CreateSnakeCatchingRequestRequest.cs @@ -44,6 +44,10 @@ public class CreateSnakeCatchingRequestRequest [MaxLength(1000)] public string? Notes { get; set; } + /// + /// Optional: List of snake species if user identified the snakes + /// + public List SnakeSpeciesList { get; set; } = new List(); public List MediaURLList { get; set; } = new List(); } diff --git a/SnakeAid.Core/Requests/SnakeCatchingRequest/SnakeSpeciesRequestItem.cs b/SnakeAid.Core/Requests/SnakeCatchingRequest/SnakeSpeciesRequestItem.cs new file mode 100644 index 00000000..107de864 --- /dev/null +++ b/SnakeAid.Core/Requests/SnakeCatchingRequest/SnakeSpeciesRequestItem.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace SnakeAid.Core.Requests.SnakeCatchingRequest +{ + public class SnakeSpeciesRequestItem + { + [Required] + public int SnakeSpeciesId { get; set; } + + [Required] + [Range(1, 100)] + public int Quantity { get; set; } = 1; + } +} diff --git a/SnakeAid.Core/Responses/SnakeCatchingRequest/CatchingRequestDetailResponse.cs b/SnakeAid.Core/Responses/SnakeCatchingRequest/CatchingRequestDetailResponse.cs new file mode 100644 index 00000000..bd379dd3 --- /dev/null +++ b/SnakeAid.Core/Responses/SnakeCatchingRequest/CatchingRequestDetailResponse.cs @@ -0,0 +1,14 @@ +using System; + +namespace SnakeAid.Core.Responses.SnakeCatchingRequest +{ + public class CatchingRequestDetailResponse + { + public Guid Id { get; set; } + public Guid SnakeCatchingRequestId { get; set; } + public int SnakeSpeciesId { get; set; } + public int Quantity { get; set; } + public string? SnakeSpeciesName { get; set; } + public string? SnakeSpeciesScientificName { get; set; } + } +} diff --git a/SnakeAid.Core/Responses/SnakeCatchingRequest/CreateSnakeCatchingRequestResponse.cs b/SnakeAid.Core/Responses/SnakeCatchingRequest/CreateSnakeCatchingRequestResponse.cs index 8b542f5f..815cca42 100644 --- a/SnakeAid.Core/Responses/SnakeCatchingRequest/CreateSnakeCatchingRequestResponse.cs +++ b/SnakeAid.Core/Responses/SnakeCatchingRequest/CreateSnakeCatchingRequestResponse.cs @@ -60,5 +60,6 @@ public class CreateSnakeCatchingRequestResponse public BriefRescuerProfileResponse? AssignedRescuer { get; set; } public CreateSnakeCatchingMissionResponse? Mission { get; set; } public List Media { get; set; } = new List(); + public List Details { get; set; } = new List(); } } diff --git a/SnakeAid.Repository/Data/Configurations/CatchingRequestDetailConfiguration.cs b/SnakeAid.Repository/Data/Configurations/CatchingRequestDetailConfiguration.cs index ae542bc0..a7204c01 100644 --- a/SnakeAid.Repository/Data/Configurations/CatchingRequestDetailConfiguration.cs +++ b/SnakeAid.Repository/Data/Configurations/CatchingRequestDetailConfiguration.cs @@ -12,7 +12,7 @@ public void Configure(EntityTypeBuilder builder) // Relationship: Detail -> SnakeCatchingRequest builder.HasOne(d => d.SnakeCatchingRequest) - .WithMany() + .WithMany(sr => sr.Details) .HasForeignKey(d => d.SnakeCatchingRequestId) .OnDelete(DeleteBehavior.Cascade); diff --git a/SnakeAid.Service/Implements/SnakeCatchingRequestService.cs b/SnakeAid.Service/Implements/SnakeCatchingRequestService.cs index cbd08306..bfef06f9 100644 --- a/SnakeAid.Service/Implements/SnakeCatchingRequestService.cs +++ b/SnakeAid.Service/Implements/SnakeCatchingRequestService.cs @@ -96,6 +96,33 @@ public async Task CreateSnakeCatchingRequest Notes = request.Notes }; + // Handle snake species identification if provided + if (request.SnakeSpeciesList != null && request.SnakeSpeciesList.Any()) + { + foreach (var speciesItem in request.SnakeSpeciesList) + { + // Validate snake species exists + var snakeSpecies = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: s => s.Id == speciesItem.SnakeSpeciesId); + + if (snakeSpecies == null) + { + throw new BadRequestException($"Snake species with ID {speciesItem.SnakeSpeciesId} not found."); + } + + // Create CatchingRequestDetail + var requestDetail = new CatchingRequestDetail + { + Id = Guid.NewGuid(), + SnakeCatchingRequestId = newRequest.Id, + SnakeSpeciesId = speciesItem.SnakeSpeciesId, + Quantity = speciesItem.Quantity + }; + + await _unitOfWork.GetRepository().InsertAsync(requestDetail); + } + } + // Handle media if provided if (request.MediaURLList != null && request.MediaURLList.Any()) { @@ -142,6 +169,8 @@ public async Task CreateSnakeCatchingRequest include: query => query .Include(r => r.User) .Include(r => r.Media) + .Include(r => r.Details) + .ThenInclude(d => d.SnakeSpecies) ); if (createdRequest == null) @@ -279,6 +308,8 @@ public async Task AcceptSnakeCatchingRequest .ThenInclude(ar => ar.Account) .Include(r => r.Media) .Include(r => r.Mission) + .Include(r => r.Details) + .ThenInclude(d => d.SnakeSpecies) ); if (updatedRequest == null) From a044469c5d9c87b13718046b9366c4cfaed44d2f Mon Sep 17 00:00:00 2001 From: NhanNPSE184696 Date: Wed, 11 Feb 2026 19:37:02 +0700 Subject: [PATCH 21/31] feat: implement SnakeCatchingMissionController and service for managing mission statuses --- .../SnakeCatchingMissionController.cs | 98 ++++++++++++++ .../UpdateMissionStatusRequest.cs | 9 ++ .../SnakeCatchingMissionDetailResponse.cs | 23 ++++ .../Implements/SnakeCatchingMissionService.cs | 127 ++++++++++++++++++ .../ISnakeCatchingMissionService.cs | 13 ++ 5 files changed, 270 insertions(+) create mode 100644 SnakeAid.Api/Controllers/SnakeCatchingMissionController.cs create mode 100644 SnakeAid.Core/Requests/SnakeCatchingMission/UpdateMissionStatusRequest.cs create mode 100644 SnakeAid.Core/Responses/SnakeCatchingMission/SnakeCatchingMissionDetailResponse.cs create mode 100644 SnakeAid.Service/Implements/SnakeCatchingMissionService.cs create mode 100644 SnakeAid.Service/Interfaces/ISnakeCatchingMissionService.cs diff --git a/SnakeAid.Api/Controllers/SnakeCatchingMissionController.cs b/SnakeAid.Api/Controllers/SnakeCatchingMissionController.cs new file mode 100644 index 00000000..b8f865dd --- /dev/null +++ b/SnakeAid.Api/Controllers/SnakeCatchingMissionController.cs @@ -0,0 +1,98 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using SnakeAid.Core.Meta; +using SnakeAid.Core.Requests.SnakeCatchingMission; +using SnakeAid.Core.Responses.SnakeCatchingMission; +using SnakeAid.Core.Utils; +using SnakeAid.Service.Interfaces; +using Swashbuckle.AspNetCore.Annotations; +using System; +using System.Security.Claims; +using System.Threading.Tasks; + +namespace SnakeAid.Api.Controllers +{ + [Authorize] + [Route("api/snake-catching-missions")] + [ApiController] + public class SnakeCatchingMissionController : ControllerBase + { + private readonly ISnakeCatchingMissionService _missionService; + private readonly ILogger _logger; + + public SnakeCatchingMissionController( + ISnakeCatchingMissionService missionService, + ILogger logger) + { + _missionService = missionService; + _logger = logger; + } + + private Guid GetCurrentUserId() + { + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) + { + throw new UnauthorizedAccessException("User ID not found in token"); + } + return userId; + } + + /// + /// Start mission - Update status to EnRoute + /// + /// The ID of the mission + /// Optional notes + [HttpPut("{missionId:guid}/start")] + [SwaggerOperation( + Summary = "Start Mission (EnRoute)", + Description = "Rescuer starts the mission and updates status from Preparing to EnRoute")] + [SwaggerResponse(200, "Mission started successfully", typeof(ApiResponse))] + [SwaggerResponse(400, "Invalid status transition")] + [SwaggerResponse(401, "User not authenticated")] + [SwaggerResponse(404, "Mission not found")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task StartMission( + [FromRoute] Guid missionId, + [FromBody] UpdateMissionStatusRequest request) + { + var rescuerId = GetCurrentUserId(); + var result = await _missionService.StartMissionAsync(rescuerId, missionId, request); + + return Ok(ApiResponseBuilder.BuildSuccessResponse( + result, + "Mission started successfully. Status updated to EnRoute.")); + } + + /// + /// Mark mission as arrived - Update status to Arrived + /// + /// The ID of the mission + /// Optional notes + [HttpPut("{missionId:guid}/arrived")] + [SwaggerOperation( + Summary = "Mark Mission as Arrived", + Description = "Rescuer marks arrival at location and updates status from EnRoute to Arrived")] + [SwaggerResponse(200, "Mission marked as arrived", typeof(ApiResponse))] + [SwaggerResponse(400, "Invalid status transition")] + [SwaggerResponse(401, "User not authenticated")] + [SwaggerResponse(404, "Mission not found")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task MarkAsArrived( + [FromRoute] Guid missionId, + [FromBody] UpdateMissionStatusRequest request) + { + var rescuerId = GetCurrentUserId(); + var result = await _missionService.MarkAsArrivedAsync(rescuerId, missionId, request); + + return Ok(ApiResponseBuilder.BuildSuccessResponse( + result, + "Mission marked as arrived successfully.")); + } + } +} diff --git a/SnakeAid.Core/Requests/SnakeCatchingMission/UpdateMissionStatusRequest.cs b/SnakeAid.Core/Requests/SnakeCatchingMission/UpdateMissionStatusRequest.cs new file mode 100644 index 00000000..96d0cd81 --- /dev/null +++ b/SnakeAid.Core/Requests/SnakeCatchingMission/UpdateMissionStatusRequest.cs @@ -0,0 +1,9 @@ +using System; + +namespace SnakeAid.Core.Requests.SnakeCatchingMission +{ + public class UpdateMissionStatusRequest + { + public string? Notes { get; set; } + } +} diff --git a/SnakeAid.Core/Responses/SnakeCatchingMission/SnakeCatchingMissionDetailResponse.cs b/SnakeAid.Core/Responses/SnakeCatchingMission/SnakeCatchingMissionDetailResponse.cs new file mode 100644 index 00000000..f509224e --- /dev/null +++ b/SnakeAid.Core/Responses/SnakeCatchingMission/SnakeCatchingMissionDetailResponse.cs @@ -0,0 +1,23 @@ +using System; +using SnakeAid.Core.Domains; + +namespace SnakeAid.Core.Responses.SnakeCatchingMission +{ + public class SnakeCatchingMissionDetailResponse + { + public Guid Id { get; set; } + public Guid RescuerId { get; set; } + public Guid SnakeCatchingRequestId { get; set; } + public CatchingMissionStatus Status { get; set; } + public decimal Price { get; set; } + public DateTime? StartedAt { get; set; } + public DateTime? ArrivedAt { get; set; } + public DateTime? CompletedAt { get; set; } + public string? Notes { get; set; } + public string? CancellationReason { get; set; } + public decimal? EstimatedCost { get; set; } + public decimal? ActualCost { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + } +} diff --git a/SnakeAid.Service/Implements/SnakeCatchingMissionService.cs b/SnakeAid.Service/Implements/SnakeCatchingMissionService.cs new file mode 100644 index 00000000..b310ae93 --- /dev/null +++ b/SnakeAid.Service/Implements/SnakeCatchingMissionService.cs @@ -0,0 +1,127 @@ +using Mapster; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using SnakeAid.Core.Domains; +using SnakeAid.Core.Exceptions; +using SnakeAid.Core.Requests.SnakeCatchingMission; +using SnakeAid.Core.Responses.SnakeCatchingMission; +using SnakeAid.Repository.Data; +using SnakeAid.Repository.Interfaces; +using SnakeAid.Service.Interfaces; +using System; +using System.Threading.Tasks; + +namespace SnakeAid.Service.Implements +{ + public class SnakeCatchingMissionService : ISnakeCatchingMissionService + { + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + + public SnakeCatchingMissionService( + IUnitOfWork unitOfWork, + ILogger logger) + { + _unitOfWork = unitOfWork; + _logger = logger; + } + + public async Task StartMissionAsync( + Guid rescuerId, + Guid missionId, + UpdateMissionStatusRequest request) + { + try + { + return await _unitOfWork.ExecuteInTransactionAsync(async () => + { + // Get mission + var mission = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: m => m.Id == missionId && m.RescuerId == rescuerId); + + if (mission == null) + { + throw new NotFoundException("Mission not found or you don't have permission to access it."); + } + + // Validate current status + if (mission.Status != CatchingMissionStatus.Preparing) + { + throw new BadRequestException($"Cannot start mission. Current status: {mission.Status}. Mission must be in Preparing status."); + } + + // Update to EnRoute + mission.Status = CatchingMissionStatus.EnRoute; + mission.StartedAt = DateTime.UtcNow; + if (!string.IsNullOrWhiteSpace(request.Notes)) + { + mission.Notes = request.Notes; + } + + _unitOfWork.GetRepository().Update(mission); + await _unitOfWork.CommitAsync(); + + _logger.LogInformation( + "Mission started successfully. MissionId: {MissionId}, RescuerId: {RescuerId}", + missionId, rescuerId); + + return mission.Adapt(); + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error starting mission: {Message}", ex.Message); + throw; + } + } + + public async Task MarkAsArrivedAsync( + Guid rescuerId, + Guid missionId, + UpdateMissionStatusRequest request) + { + try + { + return await _unitOfWork.ExecuteInTransactionAsync(async () => + { + // Get mission + var mission = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: m => m.Id == missionId && m.RescuerId == rescuerId); + + if (mission == null) + { + throw new NotFoundException("Mission not found or you don't have permission to access it."); + } + + // Validate current status + if (mission.Status != CatchingMissionStatus.EnRoute) + { + throw new BadRequestException($"Cannot mark as arrived. Current status: {mission.Status}. Mission must be EnRoute."); + } + + // Update to Arrived + mission.Status = CatchingMissionStatus.Arrived; + mission.ArrivedAt = DateTime.UtcNow; + if (!string.IsNullOrWhiteSpace(request.Notes)) + { + mission.Notes = request.Notes; + } + + _unitOfWork.GetRepository().Update(mission); + await _unitOfWork.CommitAsync(); + + _logger.LogInformation( + "Mission marked as arrived. MissionId: {MissionId}, RescuerId: {RescuerId}", + missionId, rescuerId); + + return mission.Adapt(); + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error marking mission as arrived: {Message}", ex.Message); + throw; + } + } + } +} diff --git a/SnakeAid.Service/Interfaces/ISnakeCatchingMissionService.cs b/SnakeAid.Service/Interfaces/ISnakeCatchingMissionService.cs new file mode 100644 index 00000000..bfc5ea6b --- /dev/null +++ b/SnakeAid.Service/Interfaces/ISnakeCatchingMissionService.cs @@ -0,0 +1,13 @@ +using SnakeAid.Core.Requests.SnakeCatchingMission; +using SnakeAid.Core.Responses.SnakeCatchingMission; +using System; +using System.Threading.Tasks; + +namespace SnakeAid.Service.Interfaces +{ + public interface ISnakeCatchingMissionService + { + Task StartMissionAsync(Guid rescuerId, Guid missionId, UpdateMissionStatusRequest request); + Task MarkAsArrivedAsync(Guid rescuerId, Guid missionId, UpdateMissionStatusRequest request); + } +} From f11006afe659d5a4dd7941680db550103417c64a Mon Sep 17 00:00:00 2001 From: NhanNPSE184696 Date: Wed, 11 Feb 2026 19:56:19 +0700 Subject: [PATCH 22/31] feat: implement CatchingMissionDetail functionality with controller, service, and request/response models --- .../CatchingMissionDetailController.cs | 52 ++++++++++ .../SnakeCatchingMissionController.cs | 98 ------------------- .../CreateCatchingMissionDetailRequest.cs | 26 +++++ .../CatchingMissionDetailResponse.cs | 40 ++++++++ .../CatchingMissionDetailService.cs | 89 +++++++++++++++++ .../ICatchingMissionDetailService.cs | 12 +++ 6 files changed, 219 insertions(+), 98 deletions(-) create mode 100644 SnakeAid.Api/Controllers/CatchingMissionDetailController.cs create mode 100644 SnakeAid.Core/Requests/SnakeCatchingMission/CreateCatchingMissionDetailRequest.cs create mode 100644 SnakeAid.Core/Responses/SnakeCatchingMission/CatchingMissionDetailResponse.cs create mode 100644 SnakeAid.Service/Implements/CatchingMissionDetailService.cs create mode 100644 SnakeAid.Service/Interfaces/ICatchingMissionDetailService.cs diff --git a/SnakeAid.Api/Controllers/CatchingMissionDetailController.cs b/SnakeAid.Api/Controllers/CatchingMissionDetailController.cs new file mode 100644 index 00000000..26b40a6d --- /dev/null +++ b/SnakeAid.Api/Controllers/CatchingMissionDetailController.cs @@ -0,0 +1,52 @@ +using MapsterMapper; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SnakeAid.Core.Meta; +using SnakeAid.Core.Requests.SnakeCatchingMission; +using SnakeAid.Core.Responses.SnakeCatchingMission; +using SnakeAid.Core.Validators; +using SnakeAid.Service.Interfaces; +using Swashbuckle.AspNetCore.Annotations; + +namespace SnakeAid.Api.Controllers +{ + [Route("api/catching-mission-details")] + [ApiController] + [Authorize] + public class CatchingMissionDetailController : BaseController + { + private readonly ICatchingMissionDetailService _catchingMissionDetailService; + + public CatchingMissionDetailController( + ILogger logger, + IHttpContextAccessor httpContextAccessor, + IMapper mapper, + ICatchingMissionDetailService catchingMissionDetailService) + : base(logger, httpContextAccessor, mapper) + { + _catchingMissionDetailService = catchingMissionDetailService; + } + + /// + /// Create a new catching mission detail + /// + /// + /// Records the details of snakes caught during a mission, including species and quantity. + /// This should be called when recording the results of a catching mission. + /// + [HttpPost] + [ValidateModel] + [SwaggerOperation( + Summary = "Create Catching Mission Detail", + Description = "Create a new catching mission detail record for snakes caught during a mission")] + [SwaggerResponse(200, "Created successfully", typeof(ApiResponse))] + [SwaggerResponse(400, "Validation error or invalid data")] + [SwaggerResponse(401, "Unauthorized")] + [SwaggerResponse(404, "Mission or snake species not found")] + public async Task CreateCatchingMissionDetail([FromBody] CreateCatchingMissionDetailRequest request) + { + var result = await _catchingMissionDetailService.CreateCatchingMissionDetailAsync(request); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Catching mission detail created successfully!")); + } + } +} diff --git a/SnakeAid.Api/Controllers/SnakeCatchingMissionController.cs b/SnakeAid.Api/Controllers/SnakeCatchingMissionController.cs index b8f865dd..e69de29b 100644 --- a/SnakeAid.Api/Controllers/SnakeCatchingMissionController.cs +++ b/SnakeAid.Api/Controllers/SnakeCatchingMissionController.cs @@ -1,98 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using SnakeAid.Core.Meta; -using SnakeAid.Core.Requests.SnakeCatchingMission; -using SnakeAid.Core.Responses.SnakeCatchingMission; -using SnakeAid.Core.Utils; -using SnakeAid.Service.Interfaces; -using Swashbuckle.AspNetCore.Annotations; -using System; -using System.Security.Claims; -using System.Threading.Tasks; - -namespace SnakeAid.Api.Controllers -{ - [Authorize] - [Route("api/snake-catching-missions")] - [ApiController] - public class SnakeCatchingMissionController : ControllerBase - { - private readonly ISnakeCatchingMissionService _missionService; - private readonly ILogger _logger; - - public SnakeCatchingMissionController( - ISnakeCatchingMissionService missionService, - ILogger logger) - { - _missionService = missionService; - _logger = logger; - } - - private Guid GetCurrentUserId() - { - var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) - { - throw new UnauthorizedAccessException("User ID not found in token"); - } - return userId; - } - - /// - /// Start mission - Update status to EnRoute - /// - /// The ID of the mission - /// Optional notes - [HttpPut("{missionId:guid}/start")] - [SwaggerOperation( - Summary = "Start Mission (EnRoute)", - Description = "Rescuer starts the mission and updates status from Preparing to EnRoute")] - [SwaggerResponse(200, "Mission started successfully", typeof(ApiResponse))] - [SwaggerResponse(400, "Invalid status transition")] - [SwaggerResponse(401, "User not authenticated")] - [SwaggerResponse(404, "Mission not found")] - [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] - [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] - public async Task StartMission( - [FromRoute] Guid missionId, - [FromBody] UpdateMissionStatusRequest request) - { - var rescuerId = GetCurrentUserId(); - var result = await _missionService.StartMissionAsync(rescuerId, missionId, request); - - return Ok(ApiResponseBuilder.BuildSuccessResponse( - result, - "Mission started successfully. Status updated to EnRoute.")); - } - - /// - /// Mark mission as arrived - Update status to Arrived - /// - /// The ID of the mission - /// Optional notes - [HttpPut("{missionId:guid}/arrived")] - [SwaggerOperation( - Summary = "Mark Mission as Arrived", - Description = "Rescuer marks arrival at location and updates status from EnRoute to Arrived")] - [SwaggerResponse(200, "Mission marked as arrived", typeof(ApiResponse))] - [SwaggerResponse(400, "Invalid status transition")] - [SwaggerResponse(401, "User not authenticated")] - [SwaggerResponse(404, "Mission not found")] - [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] - [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] - public async Task MarkAsArrived( - [FromRoute] Guid missionId, - [FromBody] UpdateMissionStatusRequest request) - { - var rescuerId = GetCurrentUserId(); - var result = await _missionService.MarkAsArrivedAsync(rescuerId, missionId, request); - - return Ok(ApiResponseBuilder.BuildSuccessResponse( - result, - "Mission marked as arrived successfully.")); - } - } -} diff --git a/SnakeAid.Core/Requests/SnakeCatchingMission/CreateCatchingMissionDetailRequest.cs b/SnakeAid.Core/Requests/SnakeCatchingMission/CreateCatchingMissionDetailRequest.cs new file mode 100644 index 00000000..9c37278f --- /dev/null +++ b/SnakeAid.Core/Requests/SnakeCatchingMission/CreateCatchingMissionDetailRequest.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; + +namespace SnakeAid.Core.Requests.SnakeCatchingMission +{ + public class CreateCatchingMissionDetailRequest + { + /// + /// ID nhiệm vụ bắt rắn + /// + [Required(ErrorMessage = "SnakeCatchingMissionId is required")] + public Guid SnakeCatchingMissionId { get; set; } + + /// + /// ID loài rắn + /// + [Required(ErrorMessage = "SnakeSpeciesId is required")] + public int SnakeSpeciesId { get; set; } + + /// + /// Số lượng rắn bắt được + /// + [Required(ErrorMessage = "Quantity is required")] + [Range(1, int.MaxValue, ErrorMessage = "Quantity must be at least 1")] + public int Quantity { get; set; } + } +} diff --git a/SnakeAid.Core/Responses/SnakeCatchingMission/CatchingMissionDetailResponse.cs b/SnakeAid.Core/Responses/SnakeCatchingMission/CatchingMissionDetailResponse.cs new file mode 100644 index 00000000..e2c4aaf5 --- /dev/null +++ b/SnakeAid.Core/Responses/SnakeCatchingMission/CatchingMissionDetailResponse.cs @@ -0,0 +1,40 @@ +namespace SnakeAid.Core.Responses.SnakeCatchingMission +{ + public class CatchingMissionDetailResponse + { + /// + /// ID chi tiết nhiệm vụ + /// + public Guid Id { get; set; } + + /// + /// ID nhiệm vụ bắt rắn + /// + public Guid SnakeCatchingMissionId { get; set; } + + /// + /// ID loài rắn + /// + public int SnakeSpeciesId { get; set; } + + /// + /// Tên loài rắn + /// + public string? SnakeSpeciesName { get; set; } + + /// + /// Số lượng rắn bắt được + /// + public int Quantity { get; set; } + + /// + /// Ngày tạo + /// + public DateTime CreatedAt { get; set; } + + /// + /// Ngày cập nhật + /// + public DateTime UpdatedAt { get; set; } + } +} diff --git a/SnakeAid.Service/Implements/CatchingMissionDetailService.cs b/SnakeAid.Service/Implements/CatchingMissionDetailService.cs new file mode 100644 index 00000000..208976ee --- /dev/null +++ b/SnakeAid.Service/Implements/CatchingMissionDetailService.cs @@ -0,0 +1,89 @@ +using Mapster; +using Microsoft.Extensions.Logging; +using SnakeAid.Core.Domains; +using SnakeAid.Core.Exceptions; +using SnakeAid.Core.Requests.SnakeCatchingMission; +using SnakeAid.Core.Responses.SnakeCatchingMission; +using SnakeAid.Repository.Data; +using SnakeAid.Repository.Interfaces; +using SnakeAid.Service.Interfaces; +using System; +using System.Threading.Tasks; + +namespace SnakeAid.Service.Implements +{ + public class CatchingMissionDetailService : ICatchingMissionDetailService + { + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + + public CatchingMissionDetailService( + IUnitOfWork unitOfWork, + ILogger logger) + { + _unitOfWork = unitOfWork; + _logger = logger; + } + + public async Task CreateCatchingMissionDetailAsync(CreateCatchingMissionDetailRequest request) + { + try + { + if (request == null) + { + throw new BadRequestException("Request data cannot be null."); + } + + return await _unitOfWork.ExecuteInTransactionAsync(async () => + { + // Validate SnakeCatchingMission exists + var mission = await _unitOfWork.GetRepository() + .FirstOrDefaultAsync(predicate: m => m.Id == request.SnakeCatchingMissionId); + + if (mission == null) + { + throw new NotFoundException($"Snake catching mission with ID {request.SnakeCatchingMissionId} not found."); + } + + // Validate SnakeSpecies exists + var snakeSpecies = await _unitOfWork.GetRepository() + .FirstOrDefaultAsync(predicate: s => s.Id == request.SnakeSpeciesId); + + if (snakeSpecies == null) + { + throw new NotFoundException($"Snake species with ID {request.SnakeSpeciesId} not found."); + } + + // Create new CatchingMissionDetail + var missionDetail = new CatchingMissionDetail + { + Id = Guid.NewGuid(), + SnakeCatchingMissionId = request.SnakeCatchingMissionId, + SnakeSpeciesId = request.SnakeSpeciesId, + Quantity = request.Quantity, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + await _unitOfWork.GetRepository().InsertAsync(missionDetail); + await _unitOfWork.CommitAsync(); + + _logger.LogInformation( + "Catching mission detail created successfully. DetailId: {DetailId}, MissionId: {MissionId}, SpeciesId: {SpeciesId}", + missionDetail.Id, request.SnakeCatchingMissionId, request.SnakeSpeciesId); + + // Map to response and include snake species name + var response = missionDetail.Adapt(); + response.SnakeSpeciesName = snakeSpecies.CommonName; + + return response; + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating catching mission detail: {Message}", ex.Message); + throw; + } + } + } +} diff --git a/SnakeAid.Service/Interfaces/ICatchingMissionDetailService.cs b/SnakeAid.Service/Interfaces/ICatchingMissionDetailService.cs new file mode 100644 index 00000000..1f7e820b --- /dev/null +++ b/SnakeAid.Service/Interfaces/ICatchingMissionDetailService.cs @@ -0,0 +1,12 @@ +using SnakeAid.Core.Requests.SnakeCatchingMission; +using SnakeAid.Core.Responses.SnakeCatchingMission; +using System; +using System.Threading.Tasks; + +namespace SnakeAid.Service.Interfaces +{ + public interface ICatchingMissionDetailService + { + Task CreateCatchingMissionDetailAsync(CreateCatchingMissionDetailRequest request); + } +} From ab6f2a0b5d23311fb82199aadaa6e4325c3fb80c Mon Sep 17 00:00:00 2001 From: NhanNPSE184696 Date: Wed, 11 Feb 2026 20:24:10 +0700 Subject: [PATCH 23/31] feat: implement SnakeCatchingMissionController and service methods for mission status transitions --- .../SnakeCatchingMissionController.cs | 107 ++++++++++++++++++ SnakeAid.Core/Domains/SnakeCatchingMission.cs | 1 + .../SnakeCatchingMissionDetailResponse.cs | 2 + .../CatchingMissionDetailConfiguration.cs | 2 +- .../Implements/SnakeCatchingMissionService.cs | 93 +++++++++++++++ .../ISnakeCatchingMissionService.cs | 1 + 6 files changed, 205 insertions(+), 1 deletion(-) diff --git a/SnakeAid.Api/Controllers/SnakeCatchingMissionController.cs b/SnakeAid.Api/Controllers/SnakeCatchingMissionController.cs index e69de29b..6bf6c097 100644 --- a/SnakeAid.Api/Controllers/SnakeCatchingMissionController.cs +++ b/SnakeAid.Api/Controllers/SnakeCatchingMissionController.cs @@ -0,0 +1,107 @@ +using MapsterMapper; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using SnakeAid.Core.Meta; +using SnakeAid.Core.Requests.SnakeCatchingMission; +using SnakeAid.Core.Responses.SnakeCatchingMission; +using SnakeAid.Service.Interfaces; +using Swashbuckle.AspNetCore.Annotations; +using System; +using System.Threading.Tasks; + +namespace SnakeAid.Api.Controllers +{ + [Route("api/snake-catching-missions")] + [ApiController] + [Authorize] + public class SnakeCatchingMissionController : BaseController + { + private readonly ISnakeCatchingMissionService _missionService; + + public SnakeCatchingMissionController( + ILogger logger, + IHttpContextAccessor httpContextAccessor, + IMapper mapper, + ISnakeCatchingMissionService missionService) + : base(logger, httpContextAccessor, mapper) + { + _missionService = missionService; + } + + /// + /// Start snake catching mission - transition to EnRoute + /// + [HttpPatch("{missionId}/start")] + [SwaggerOperation( + Summary = "Start Mission", + Description = "Start the snake catching mission (Preparing → EnRoute). Rescuer begins heading to the catching location.")] + [SwaggerResponse(200, "Mission started successfully", typeof(ApiResponse))] + [SwaggerResponse(400, "Invalid status transition")] + [SwaggerResponse(403, "Not authorized")] + [SwaggerResponse(404, "Mission not found")] + public async Task StartMission( + Guid missionId, + [FromBody] UpdateMissionStatusRequest request) + { + var rescuerId = GetCurrentUserId(); + var result = await _missionService.StartMissionAsync(rescuerId, missionId, request); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Mission started! En route to location.")); + } + + /// + /// Mark mission as arrived - transition to Arrived + /// + [HttpPatch("{missionId}/arrived")] + [SwaggerOperation( + Summary = "Mark as Arrived", + Description = "Mark the snake catching mission as arrived (EnRoute → Arrived). Rescuer has reached the location.")] + [SwaggerResponse(200, "Mission marked as arrived successfully", typeof(ApiResponse))] + [SwaggerResponse(400, "Invalid status transition")] + [SwaggerResponse(403, "Not authorized")] + [SwaggerResponse(404, "Mission not found")] + public async Task MarkAsArrived( + Guid missionId, + [FromBody] UpdateMissionStatusRequest request) + { + var rescuerId = GetCurrentUserId(); + var result = await _missionService.MarkAsArrivedAsync(rescuerId, missionId, request); + return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Mission marked as arrived!")); + } + + /// + /// Complete mission - transition to MissionCompleted + /// Requires evidence media in the snake catching request + /// Automatically updates the request status to Finished + /// + [HttpPatch("{missionId}/complete")] + [SwaggerOperation( + Summary = "Complete Mission", + Description = @"Complete the snake catching mission (Arrived → MissionCompleted). + +Requirements: +- Mission must be in Arrived status +- SnakeCatchingRequest must have at least one evidence media uploaded + +When completed successfully: +- Mission status is updated to MissionCompleted +- CompletedAt timestamp is set +- SnakeCatchingRequest status is automatically updated to Finished +- Response includes list of catching mission details (snake species and quantities) if available")] + [SwaggerResponse(200, "Mission completed successfully", typeof(ApiResponse))] + [SwaggerResponse(400, "Invalid status transition or missing evidence media")] + [SwaggerResponse(403, "Not authorized")] + [SwaggerResponse(404, "Mission not found")] + public async Task CompleteMission( + Guid missionId, + [FromBody] UpdateMissionStatusRequest request) + { + var rescuerId = GetCurrentUserId(); + var result = await _missionService.CompleteMissionAsync(rescuerId, missionId, request); + return Ok(ApiResponseBuilder.BuildSuccessResponse( + result, + "Mission completed successfully! Request marked as finished.")); + } + } +} diff --git a/SnakeAid.Core/Domains/SnakeCatchingMission.cs b/SnakeAid.Core/Domains/SnakeCatchingMission.cs index 1cbfe438..53a1a18c 100644 --- a/SnakeAid.Core/Domains/SnakeCatchingMission.cs +++ b/SnakeAid.Core/Domains/SnakeCatchingMission.cs @@ -48,6 +48,7 @@ public class SnakeCatchingMission : BaseEntity // Navigation properties public RescuerProfile Rescuer { get; set; } public SnakeCatchingRequest SnakeCatchingRequest { get; set; } + public ICollection MissionDetails { get; set; } = new List(); } public enum CatchingMissionStatus diff --git a/SnakeAid.Core/Responses/SnakeCatchingMission/SnakeCatchingMissionDetailResponse.cs b/SnakeAid.Core/Responses/SnakeCatchingMission/SnakeCatchingMissionDetailResponse.cs index f509224e..45a85138 100644 --- a/SnakeAid.Core/Responses/SnakeCatchingMission/SnakeCatchingMissionDetailResponse.cs +++ b/SnakeAid.Core/Responses/SnakeCatchingMission/SnakeCatchingMissionDetailResponse.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using SnakeAid.Core.Domains; namespace SnakeAid.Core.Responses.SnakeCatchingMission @@ -19,5 +20,6 @@ public class SnakeCatchingMissionDetailResponse public decimal? ActualCost { get; set; } public DateTime CreatedAt { get; set; } public DateTime? UpdatedAt { get; set; } + public List? MissionDetails { get; set; } } } diff --git a/SnakeAid.Repository/Data/Configurations/CatchingMissionDetailConfiguration.cs b/SnakeAid.Repository/Data/Configurations/CatchingMissionDetailConfiguration.cs index 47bf0b85..a88e8af0 100644 --- a/SnakeAid.Repository/Data/Configurations/CatchingMissionDetailConfiguration.cs +++ b/SnakeAid.Repository/Data/Configurations/CatchingMissionDetailConfiguration.cs @@ -12,7 +12,7 @@ public void Configure(EntityTypeBuilder builder) // Relationship: Detail -> SnakeCatchingMission builder.HasOne(d => d.SnakeCatchingMission) - .WithMany() + .WithMany(m => m.MissionDetails) .HasForeignKey(d => d.SnakeCatchingMissionId) .OnDelete(DeleteBehavior.Cascade); diff --git a/SnakeAid.Service/Implements/SnakeCatchingMissionService.cs b/SnakeAid.Service/Implements/SnakeCatchingMissionService.cs index b310ae93..bba4659f 100644 --- a/SnakeAid.Service/Implements/SnakeCatchingMissionService.cs +++ b/SnakeAid.Service/Implements/SnakeCatchingMissionService.cs @@ -9,6 +9,7 @@ using SnakeAid.Repository.Interfaces; using SnakeAid.Service.Interfaces; using System; +using System.Linq; using System.Threading.Tasks; namespace SnakeAid.Service.Implements @@ -18,6 +19,8 @@ public class SnakeCatchingMissionService : ISnakeCatchingMissionService private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; + private decimal basePrice = 500000; + public SnakeCatchingMissionService( IUnitOfWork unitOfWork, ILogger logger) @@ -123,5 +126,95 @@ public async Task MarkAsArrivedAsync( throw; } } + + public async Task CompleteMissionAsync( + Guid rescuerId, + Guid missionId, + UpdateMissionStatusRequest request) + { + try + { + return await _unitOfWork.ExecuteInTransactionAsync(async () => + { + // Get mission with related data + var mission = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: m => m.Id == missionId && m.RescuerId == rescuerId, + include: q => q + .Include(m => m.SnakeCatchingRequest) + .ThenInclude(r => r.Media) + .Include(m => m.MissionDetails) + .ThenInclude(d => d.SnakeSpecies)); + + if (mission == null) + { + throw new NotFoundException("Mission not found or you don't have permission to access it."); + } + + // Validate current status + if (mission.Status != CatchingMissionStatus.Arrived) + { + throw new BadRequestException($"Cannot complete mission. Current status: {mission.Status}. Mission must be in Arrived status."); + } + + // Validate: Check if SnakeCatchingRequest has evidence media + //var hasEvidence = mission.SnakeCatchingRequest.Media + // .Any(m => m.Purpose == MediaPurpose.Evidence && m.ReferenceType == MediaReferenceType.SnakeCatchingRequest); + + //if (!hasEvidence) + //{ + // throw new BadRequestException("Cannot complete mission. SnakeCatchingRequest must have at least one evidence media."); + //} + + //Update actual cost if provided + decimal additionalCosts = mission.MissionDetails?.Sum(d => d.Quantity * 100000) ?? 0; + mission.ActualCost = basePrice + additionalCosts; + mission.Price = mission.ActualCost.Value + mission.EstimatedCost.Value; + + // Update mission to MissionCompleted + mission.Status = CatchingMissionStatus.MissionCompleted; + mission.CompletedAt = DateTime.UtcNow; + if (!string.IsNullOrWhiteSpace(request.Notes)) + { + mission.Notes = request.Notes; + } + + // Update SnakeCatchingRequest to Finished + mission.SnakeCatchingRequest.Status = RequestStatus.Finished; + + _unitOfWork.GetRepository().Update(mission); + _unitOfWork.GetRepository().Update(mission.SnakeCatchingRequest); + await _unitOfWork.CommitAsync(); + + _logger.LogInformation( + "Mission completed successfully. MissionId: {MissionId}, RescuerId: {RescuerId}, RequestId: {RequestId}", + missionId, rescuerId, mission.SnakeCatchingRequestId); + + // Map to response with mission details + var response = mission.Adapt(); + + // Map mission details if any + if (mission.MissionDetails != null && mission.MissionDetails.Any()) + { + response.MissionDetails = mission.MissionDetails.Select(d => new CatchingMissionDetailResponse + { + Id = d.Id, + SnakeCatchingMissionId = d.SnakeCatchingMissionId, + SnakeSpeciesId = d.SnakeSpeciesId, + SnakeSpeciesName = d.SnakeSpecies?.CommonName, + Quantity = d.Quantity, + CreatedAt = d.CreatedAt, + UpdatedAt = d.UpdatedAt + }).ToList(); + } + + return response; + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error completing mission: {Message}", ex.Message); + throw; + } + } } } diff --git a/SnakeAid.Service/Interfaces/ISnakeCatchingMissionService.cs b/SnakeAid.Service/Interfaces/ISnakeCatchingMissionService.cs index bfc5ea6b..3fc63a0e 100644 --- a/SnakeAid.Service/Interfaces/ISnakeCatchingMissionService.cs +++ b/SnakeAid.Service/Interfaces/ISnakeCatchingMissionService.cs @@ -9,5 +9,6 @@ public interface ISnakeCatchingMissionService { Task StartMissionAsync(Guid rescuerId, Guid missionId, UpdateMissionStatusRequest request); Task MarkAsArrivedAsync(Guid rescuerId, Guid missionId, UpdateMissionStatusRequest request); + Task CompleteMissionAsync(Guid rescuerId, Guid missionId, UpdateMissionStatusRequest request); } } From a78e2975a30a3925f97a940e2a029f2a8b79d1a4 Mon Sep 17 00:00:00 2001 From: NhanNPSE184696 Date: Wed, 11 Feb 2026 20:29:54 +0700 Subject: [PATCH 24/31] feat: add GetDetailAsync method to retrieve snake catching request details and corresponding controller endpoint --- .../SnakeCatchingRequestController.cs | 22 +++++++++++ .../Implements/SnakeCatchingRequestService.cs | 38 +++++++++++++++++++ .../ISnakeCatchingRequestService.cs | 2 + 3 files changed, 62 insertions(+) diff --git a/SnakeAid.Api/Controllers/SnakeCatchingRequestController.cs b/SnakeAid.Api/Controllers/SnakeCatchingRequestController.cs index 5dc3b449..6bdc0285 100644 --- a/SnakeAid.Api/Controllers/SnakeCatchingRequestController.cs +++ b/SnakeAid.Api/Controllers/SnakeCatchingRequestController.cs @@ -78,5 +78,27 @@ public async Task AcceptSnakeCatchingRequest([FromRoute] Guid req result, "Snake catching request accepted successfully! Mission created.")); } + + /// + /// Get snake catching request details by ID + /// + /// The ID of the snake catching request + [HttpGet("{requestId:guid}")] + [SwaggerOperation( + Summary = "Get Snake Catching Request Details", + Description = "Retrieve detailed information about a specific snake catching request including user, rescuer, media, and mission information")] + [SwaggerResponse(200, "Request details retrieved successfully", typeof(ApiResponse))] + [SwaggerResponse(401, "User not authenticated")] + [SwaggerResponse(404, "Request not found")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task GetSnakeCatchingRequestDetail([FromRoute] Guid requestId) + { + var result = await _snakeCatchingRequestService.GetDetailAsync(requestId); + + return Ok(ApiResponseBuilder.BuildSuccessResponse( + result, + "Snake catching request details retrieved successfully.")); + } } } diff --git a/SnakeAid.Service/Implements/SnakeCatchingRequestService.cs b/SnakeAid.Service/Implements/SnakeCatchingRequestService.cs index bfef06f9..7a77d391 100644 --- a/SnakeAid.Service/Implements/SnakeCatchingRequestService.cs +++ b/SnakeAid.Service/Implements/SnakeCatchingRequestService.cs @@ -332,5 +332,43 @@ public async Task AcceptSnakeCatchingRequest throw; } } + + public async Task GetDetailAsync(Guid requestId) + { + try + { + // Get the snake catching request with all related data + var request = await _unitOfWork.GetRepository().FirstOrDefaultAsync( + predicate: r => r.Id == requestId, + include: query => query + .Include(r => r.User) + .ThenInclude(u => u.Account) + .Include(r => r.AssignedRescuer) + .ThenInclude(ar => ar.Account) + .Include(r => r.Media) + .Include(r => r.Mission) + .Include(r => r.Details) + .ThenInclude(d => d.SnakeSpecies) + ); + + if (request == null) + { + throw new NotFoundException($"Snake catching request with ID {requestId} not found."); + } + + var response = request.Adapt(); + + _logger.LogInformation( + "Snake catching request details retrieved successfully. RequestId: {RequestId}", + requestId); + + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting snake catching request detail: {Message}", ex.Message); + throw; + } + } } } diff --git a/SnakeAid.Service/Interfaces/ISnakeCatchingRequestService.cs b/SnakeAid.Service/Interfaces/ISnakeCatchingRequestService.cs index 72b664b6..ef817812 100644 --- a/SnakeAid.Service/Interfaces/ISnakeCatchingRequestService.cs +++ b/SnakeAid.Service/Interfaces/ISnakeCatchingRequestService.cs @@ -13,5 +13,7 @@ public interface ISnakeCatchingRequestService Task CreateSnakeCatchingRequestAsync(Guid userId, CreateSnakeCatchingRequestRequest request); Task AcceptSnakeCatchingRequestAsync(Guid rescuerId, Guid requestId); + + Task GetDetailAsync(Guid requestId); } } From 45b928f4fe2b431e65fcc3739eaeff29fc803bd3 Mon Sep 17 00:00:00 2001 From: DuongNManh Date: Wed, 11 Feb 2026 20:55:38 +0700 Subject: [PATCH 25/31] fix: abort mission Id error --- .../Controllers/RescueDemoController.cs | 10 +- SnakeAid.Api/Pages/Demo/RescueDemo.cshtml | 1628 +++++++++-------- SnakeAid.Api/Program.cs | 12 +- 3 files changed, 835 insertions(+), 815 deletions(-) diff --git a/SnakeAid.Api/Controllers/RescueDemoController.cs b/SnakeAid.Api/Controllers/RescueDemoController.cs index 0af1955c..64729011 100644 --- a/SnakeAid.Api/Controllers/RescueDemoController.cs +++ b/SnakeAid.Api/Controllers/RescueDemoController.cs @@ -533,10 +533,16 @@ public async Task GetIncidentMonitoring() }) .ToListAsync(); - // Query mission if exists + // Query ACTIVE mission only (exclude aborted/cancelled/completed) + var activeMissionStatuses = new[] { + RescueMissionStatus.Preparing, + RescueMissionStatus.EnRoute, + RescueMissionStatus.RescuerArrived + }; var mission = await _unitOfWork.GetRepository() .CreateBaseQuery() - .Where(m => m.IncidentId == _currentDemoIncidentId.Value) + .Where(m => m.IncidentId == _currentDemoIncidentId.Value && activeMissionStatuses.Contains(m.Status)) + .OrderByDescending(m => m.CreatedAt) .Select(m => new { m.Id, diff --git a/SnakeAid.Api/Pages/Demo/RescueDemo.cshtml b/SnakeAid.Api/Pages/Demo/RescueDemo.cshtml index 5292c2e6..b78d2439 100644 --- a/SnakeAid.Api/Pages/Demo/RescueDemo.cshtml +++ b/SnakeAid.Api/Pages/Demo/RescueDemo.cshtml @@ -636,815 +636,829 @@
Rescuer: -
Status: -
-
Price: ₫-
-
- -
- - - -
- - - -

📜 Session History

-
-
- No sessions yet. Create an incident to start. +
Price: ₫-
+
+ +
+ + + +
+
+ + +

📜 Session History

+
+
+ No sessions yet. Create an incident to start. +
+
- - - - - - - \ No newline at end of file + + + + \ No newline at end of file diff --git a/SnakeAid.Api/Program.cs b/SnakeAid.Api/Program.cs index bc47864f..e0053e21 100644 --- a/SnakeAid.Api/Program.cs +++ b/SnakeAid.Api/Program.cs @@ -281,12 +281,12 @@ public static async Task Main(string[] args) // app.ApplyMigrations(); } - // Seed data (mở ra nếu seed lại dữ liệu) - using (var scope = app.Services.CreateScope()) - { - var context = scope.ServiceProvider.GetRequiredService(); - await DataSeeder.SeedAsync(context); - } + // // Seed data (mở ra nếu seed lại dữ liệu) + // using (var scope = app.Services.CreateScope()) + // { + // var context = scope.ServiceProvider.GetRequiredService(); + // await DataSeeder.SeedAsync(context); + // } } app.UseSwagger(); app.UseSwaggerUI(c => From 28f99118a5c8c203ab3802818e2a780ce137495c Mon Sep 17 00:00:00 2001 From: NhanNPSE184696 Date: Wed, 11 Feb 2026 21:08:26 +0700 Subject: [PATCH 26/31] feat: add GetAllSnakeCatchingRequests method and corresponding controller endpoint to retrieve all snake catching requests --- .../SnakeCatchingRequestController.cs | 19 +++++++ .../ListSnakeCatchingRequestResponse.cs | 55 +++++++++++++++++++ .../Implements/SnakeCatchingRequestService.cs | 30 ++++++++++ .../ISnakeCatchingRequestService.cs | 3 + 4 files changed, 107 insertions(+) create mode 100644 SnakeAid.Core/Responses/SnakeCatchingRequest/ListSnakeCatchingRequestResponse.cs diff --git a/SnakeAid.Api/Controllers/SnakeCatchingRequestController.cs b/SnakeAid.Api/Controllers/SnakeCatchingRequestController.cs index 6bdc0285..814fde00 100644 --- a/SnakeAid.Api/Controllers/SnakeCatchingRequestController.cs +++ b/SnakeAid.Api/Controllers/SnakeCatchingRequestController.cs @@ -27,6 +27,25 @@ public SnakeCatchingRequestController( _snakeCatchingRequestService = snakeCatchingRequestService; } + /// + /// Get all snake catching requests + /// + [HttpGet] + [SwaggerOperation( + Summary = "Get All Snake Catching Requests", + Description = "Retrieve all snake catching requests with user information, media, and snake species details. Results are ordered by request date (newest first)")] + [SwaggerResponse(200, "Requests retrieved successfully", typeof(ApiResponse>))] + [SwaggerResponse(401, "User not authenticated")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task GetAllSnakeCatchingRequests() + { + var result = await _snakeCatchingRequestService.GetAllRequestAsync(); + + return Ok(ApiResponseBuilder.BuildSuccessResponse( + result, + $"Retrieved {result.Count} snake catching request(s) successfully.")); + } + /// /// Create a new snake catching request /// diff --git a/SnakeAid.Core/Responses/SnakeCatchingRequest/ListSnakeCatchingRequestResponse.cs b/SnakeAid.Core/Responses/SnakeCatchingRequest/ListSnakeCatchingRequestResponse.cs new file mode 100644 index 00000000..91f405e0 --- /dev/null +++ b/SnakeAid.Core/Responses/SnakeCatchingRequest/ListSnakeCatchingRequestResponse.cs @@ -0,0 +1,55 @@ +using NetTopologySuite.Geometries; +using SnakeAid.Core.Domains; +using SnakeAid.Core.Responses.Media; +using SnakeAid.Core.Responses.MemberProfile; +using SnakeAid.Core.Responses.RescuerProfile; +using SnakeAid.Core.Responses.SnakeCatchingMission; +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SnakeAid.Core.Responses.SnakeCatchingRequest +{ + public class ListSnakeCatchingRequestResponse + { + public Guid Id { get; set; } + + public Guid UserId { get; set; } + + public string Address { get; set; } + + public Point LocationCoordinates { get; set; } + + /// + /// Longitude for easy client access (extracted from LocationCoordinates) + /// + public double Lng { get; set; } + + /// + /// Latitude for easy client access (extracted from LocationCoordinates) + /// + public double Lat { get; set; } + + public string AdditionalDetails { get; set; } + + public RequestStatus Status { get; set; } + + public RequestPriority Priority { get; set; } + + public DateTime RequestDate { get; set; } + + public DateTime? PreferredTime { get; set; } + + public string? Notes { get; set; } + + + // Navigation properties + public BriefMemberProfileRespone User { get; set; } + public List Media { get; set; } = new List(); + public List Details { get; set; } = new List(); + } +} diff --git a/SnakeAid.Service/Implements/SnakeCatchingRequestService.cs b/SnakeAid.Service/Implements/SnakeCatchingRequestService.cs index 7a77d391..542b2da6 100644 --- a/SnakeAid.Service/Implements/SnakeCatchingRequestService.cs +++ b/SnakeAid.Service/Implements/SnakeCatchingRequestService.cs @@ -370,5 +370,35 @@ public async Task GetDetailAsync(Guid reques throw; } } + + public async Task> GetAllRequestAsync() + { + try + { + // Get all snake catching requests with related data + var requests = await _unitOfWork.GetRepository().GetListAsync( + include: query => query + .Include(r => r.User) + .ThenInclude(u => u.Account) + .Include(r => r.Media) + .Include(r => r.Details) + .ThenInclude(d => d.SnakeSpecies), + orderBy: q => q.OrderByDescending(r => r.RequestDate) + ); + + var response = requests.Adapt>(); + + _logger.LogInformation( + "Retrieved {Count} snake catching requests successfully.", + response.Count); + + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting all snake catching requests: {Message}", ex.Message); + throw; + } + } } } diff --git a/SnakeAid.Service/Interfaces/ISnakeCatchingRequestService.cs b/SnakeAid.Service/Interfaces/ISnakeCatchingRequestService.cs index ef817812..703c70de 100644 --- a/SnakeAid.Service/Interfaces/ISnakeCatchingRequestService.cs +++ b/SnakeAid.Service/Interfaces/ISnakeCatchingRequestService.cs @@ -15,5 +15,8 @@ public interface ISnakeCatchingRequestService Task AcceptSnakeCatchingRequestAsync(Guid rescuerId, Guid requestId); Task GetDetailAsync(Guid requestId); + + Task> GetAllRequestAsync(); + } } From 2ddd9cc7e11f4acb7a76385878f9727fef0275c1 Mon Sep 17 00:00:00 2001 From: DuongNManh Date: Wed, 11 Feb 2026 21:33:07 +0700 Subject: [PATCH 27/31] fix: uow method --- .../Implements/RescueRequestSessionService.cs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/SnakeAid.Service/Implements/RescueRequestSessionService.cs b/SnakeAid.Service/Implements/RescueRequestSessionService.cs index 5878b7f6..54781f9e 100644 --- a/SnakeAid.Service/Implements/RescueRequestSessionService.cs +++ b/SnakeAid.Service/Implements/RescueRequestSessionService.cs @@ -556,9 +556,9 @@ await _unitOfWork.ExecuteInTransactionAsync(async () => // Check for existing active missions only (allow multiple missions per incident for retry scenarios) var existingActiveMission = await _unitOfWork.GetRepository().FirstOrDefaultAsync( - predicate: m => m.IncidentId == request.IncidentId && - (m.Status == RescueMissionStatus.Preparing || - m.Status == RescueMissionStatus.EnRoute || + predicate: m => m.IncidentId == request.IncidentId && + (m.Status == RescueMissionStatus.Preparing || + m.Status == RescueMissionStatus.EnRoute || m.Status == RescueMissionStatus.RescuerArrived) ); @@ -753,12 +753,11 @@ public async Task HandleMissionAbortAsync(Guid incidentId) { try { - // CRITICAL: Use CreateBaseQuery + AsNoTracking to completely bypass EF cache - // FirstOrDefaultAsync with asNoTracking can still return tracked entities var incident = await _unitOfWork.GetRepository() - .CreateBaseQuery(asNoTracking: true) - .Where(i => i.Id == incidentId) - .FirstOrDefaultAsync(); + .FirstOrDefaultAsync( + predicate: i => i.Id == incidentId, + asNoTracking: false + ); if (incident == null) { From 4423ad026b6e939150641438833c7ae56b9ef95b Mon Sep 17 00:00:00 2001 From: NhanNPSE184696 Date: Wed, 11 Feb 2026 22:54:10 +0700 Subject: [PATCH 28/31] refactor: update API routes for catching missions and requests; improve status handling in mission service --- .../Controllers/CatchingMissionDetailController.cs | 2 +- .../Controllers/SnakeCatchingMissionController.cs | 6 +++--- .../Controllers/SnakeCatchingRequestController.cs | 2 +- .../Implements/SnakeCatchingMissionService.cs | 14 +++++++++++--- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/SnakeAid.Api/Controllers/CatchingMissionDetailController.cs b/SnakeAid.Api/Controllers/CatchingMissionDetailController.cs index 26b40a6d..6fdf42cf 100644 --- a/SnakeAid.Api/Controllers/CatchingMissionDetailController.cs +++ b/SnakeAid.Api/Controllers/CatchingMissionDetailController.cs @@ -10,7 +10,7 @@ namespace SnakeAid.Api.Controllers { - [Route("api/catching-mission-details")] + [Route("api/catchingmission/details")] [ApiController] [Authorize] public class CatchingMissionDetailController : BaseController diff --git a/SnakeAid.Api/Controllers/SnakeCatchingMissionController.cs b/SnakeAid.Api/Controllers/SnakeCatchingMissionController.cs index 6bf6c097..08921586 100644 --- a/SnakeAid.Api/Controllers/SnakeCatchingMissionController.cs +++ b/SnakeAid.Api/Controllers/SnakeCatchingMissionController.cs @@ -13,7 +13,7 @@ namespace SnakeAid.Api.Controllers { - [Route("api/snake-catching-missions")] + [Route("api/snakecatching/missions")] [ApiController] [Authorize] public class SnakeCatchingMissionController : BaseController @@ -87,7 +87,7 @@ public async Task MarkAsArrived( When completed successfully: - Mission status is updated to MissionCompleted - CompletedAt timestamp is set -- SnakeCatchingRequest status is automatically updated to Finished +- SnakeCatchingRequest status is automatically updated to Completed - Response includes list of catching mission details (snake species and quantities) if available")] [SwaggerResponse(200, "Mission completed successfully", typeof(ApiResponse))] [SwaggerResponse(400, "Invalid status transition or missing evidence media")] @@ -101,7 +101,7 @@ public async Task CompleteMission( var result = await _missionService.CompleteMissionAsync(rescuerId, missionId, request); return Ok(ApiResponseBuilder.BuildSuccessResponse( result, - "Mission completed successfully! Request marked as finished.")); + "Mission completed successfully! Request marked as completed.")); } } } diff --git a/SnakeAid.Api/Controllers/SnakeCatchingRequestController.cs b/SnakeAid.Api/Controllers/SnakeCatchingRequestController.cs index 814fde00..01c6a7c4 100644 --- a/SnakeAid.Api/Controllers/SnakeCatchingRequestController.cs +++ b/SnakeAid.Api/Controllers/SnakeCatchingRequestController.cs @@ -10,7 +10,7 @@ namespace SnakeAid.Api.Controllers { - [Route("api/snake-catching")] + [Route("api/snakecatching/requests")] [ApiController] [Authorize] public class SnakeCatchingRequestController : BaseController diff --git a/SnakeAid.Service/Implements/SnakeCatchingMissionService.cs b/SnakeAid.Service/Implements/SnakeCatchingMissionService.cs index bba4659f..05268a7c 100644 --- a/SnakeAid.Service/Implements/SnakeCatchingMissionService.cs +++ b/SnakeAid.Service/Implements/SnakeCatchingMissionService.cs @@ -178,11 +178,19 @@ public async Task CompleteMissionAsync( mission.Notes = request.Notes; } + // Update mission first + _unitOfWork.GetRepository().Update(mission); + // Update SnakeCatchingRequest to Finished - mission.SnakeCatchingRequest.Status = RequestStatus.Finished; + var catchingRequest = await _unitOfWork.GetRepository() + .FirstOrDefaultAsync(predicate: r => r.Id == mission.SnakeCatchingRequestId); + + if (catchingRequest != null) + { + catchingRequest.Status = RequestStatus.Finished; + _unitOfWork.GetRepository().Update(catchingRequest); + } - _unitOfWork.GetRepository().Update(mission); - _unitOfWork.GetRepository().Update(mission.SnakeCatchingRequest); await _unitOfWork.CommitAsync(); _logger.LogInformation( From be72b4d3ff8f68d892af42e1e6ce9f1bb3280287 Mon Sep 17 00:00:00 2001 From: NhanNPSE184696 Date: Wed, 11 Feb 2026 23:09:18 +0700 Subject: [PATCH 29/31] refactor: uncomment and update RescueMissionController methods and documentation for mission status handling --- .../Controllers/RescueMissionController.cs | 328 +++++++++--------- 1 file changed, 155 insertions(+), 173 deletions(-) diff --git a/SnakeAid.Api/Controllers/RescueMissionController.cs b/SnakeAid.Api/Controllers/RescueMissionController.cs index 0e5ebded..3510bc06 100644 --- a/SnakeAid.Api/Controllers/RescueMissionController.cs +++ b/SnakeAid.Api/Controllers/RescueMissionController.cs @@ -1,186 +1,168 @@ -// using MapsterMapper; -// using Microsoft.AspNetCore.Authorization; -// using Microsoft.AspNetCore.Http; -// using Microsoft.AspNetCore.Mvc; -// using Microsoft.Extensions.Logging; -// using SnakeAid.Core.Domains; -// using SnakeAid.Core.Meta; -// using SnakeAid.Core.Requests.RescueMission; -// using SnakeAid.Core.Responses.RescueMission; -// using SnakeAid.Service.Interfaces; -// using Swashbuckle.AspNetCore.Annotations; -// using System; -// using System.Threading.Tasks; +using MapsterMapper; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using SnakeAid.Core.Domains; +using SnakeAid.Core.Meta; +using SnakeAid.Core.Requests.RescueMission; +using SnakeAid.Core.Responses.RescueMission; +using SnakeAid.Service.Interfaces; +using Swashbuckle.AspNetCore.Annotations; +using System; +using System.Threading.Tasks; -// namespace SnakeAid.Api.Controllers -// { -// [Route("api/rescue-missions")] -// [ApiController] -// [Authorize] -// public class RescueMissionController : BaseController -// { -// private readonly IRescueMissionService _missionService; +namespace SnakeAid.Api.Controllers +{ + [Route("api/rescue-missions")] + [ApiController] + [Authorize] + public class RescueMissionController : BaseController + { + private readonly IRescueMissionService _missionService; -// public RescueMissionController( -// ILogger logger, -// IHttpContextAccessor httpContextAccessor, -// IMapper mapper, -// IRescueMissionService missionService) -// : base(logger, httpContextAccessor, mapper) -// { -// _missionService = missionService; -// } + public RescueMissionController( + ILogger logger, + IHttpContextAccessor httpContextAccessor, + IMapper mapper, + IRescueMissionService missionService) + : base(logger, httpContextAccessor, mapper) + { + _missionService = missionService; + } -// /// -// /// Get rescue mission details -// /// -// [HttpGet("{missionId}")] -// [SwaggerOperation(Summary = "Get Mission Details", Description = "Retrieve detailed information about a rescue mission")] -// [SwaggerResponse(200, "Mission details retrieved successfully", typeof(ApiResponse))] -// [SwaggerResponse(404, "Mission not found")] -// public async Task GetMissionDetails(Guid missionId) -// { -// var result = await _missionService.GetMissionDetailsAsync(missionId); -// return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Mission details retrieved successfully!")); -// } + // Note: GetMissionDetailsAsync is not defined in IRescueMissionService interface + // Commenting out until it's added to the interface + // /// + // /// Get rescue mission details + // /// + // [HttpGet("{missionId}")] + // [SwaggerOperation(Summary = "Get Mission Details", Description = "Retrieve detailed information about a rescue mission")] + // [SwaggerResponse(200, "Mission details retrieved successfully", typeof(ApiResponse))] + // [SwaggerResponse(404, "Mission not found")] + // public async Task GetMissionDetails(Guid missionId) + // { + // var result = await _missionService.GetMissionDetailsAsync(missionId); + // return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Mission details retrieved successfully!")); + // } -// /// -// /// Update rescue mission status -// /// When status is set to MissionCompleted, the corresponding incident is automatically updated to Finished -// /// -// [HttpPatch("{missionId}/status")] -// [SwaggerOperation( -// Summary = "Update Mission Status", -// Description = @"Update the status of a rescue mission. + /// + /// Update rescue mission status + /// When status is set to MissionCompleted, the corresponding incident is automatically updated to Finished + /// + [HttpPatch("{missionId}/status")] + [SwaggerOperation( + Summary = "Update Mission Status", + Description = @"Update the status of a rescue mission. -// Valid status transitions: -// - Preparing → EnRoute, Cancelled -// - EnRoute → RescuerArrived, MissionAborted -// - RescuerArrived → MissionCompleted, MissionUncompleted, MissionAborted + Valid status transitions: + - Preparing → EnRoute, Cancelled + - EnRoute → RescuerArrived, MissionAborted + - RescuerArrived → MissionCompleted, MissionUncompleted, MissionAborted -// When transitioning to MissionCompleted: -// - At least one verification image is required -// - The corresponding SnakebiteIncident status is automatically updated to Finished")] -// [SwaggerResponse(200, "Mission status updated successfully", typeof(ApiResponse))] -// [SwaggerResponse(400, "Invalid status transition or missing verification images")] -// [SwaggerResponse(403, "Not authorized to update this mission")] -// [SwaggerResponse(404, "Mission not found")] -// [SwaggerResponse(422, "Validation error")] -// public async Task UpdateMissionStatus( -// Guid missionId, -// [FromBody] UpdateRescueMissionStatusRequest request) -// { -// var rescuerId = GetCurrentUserId(); -// var result = await _missionService.UpdateMissionStatusAsync(missionId, request, rescuerId); + When transitioning to MissionCompleted: + - The corresponding SnakebiteIncident status is automatically updated to Finished")] + [SwaggerResponse(200, "Mission status updated successfully")] + [SwaggerResponse(400, "Invalid status transition")] + [SwaggerResponse(404, "Mission not found")] + public async Task UpdateMissionStatus( + Guid missionId, + [FromBody] UpdateRescueMissionStatusRequest request) + { + await _missionService.UpdateMissionStatusAsync(missionId, request.Status); -// var message = request.Status == RescueMissionStatus.MissionCompleted -// ? "Mission completed successfully! Incident marked as finished." -// : $"Mission status updated to {request.Status} successfully!"; + var message = request.Status == RescueMissionStatus.MissionCompleted + ? "Mission completed successfully! Incident marked as finished." + : $"Mission status updated to {request.Status} successfully!"; -// return Ok(ApiResponseBuilder.BuildSuccessResponse(result, message)); -// } + return Ok(ApiResponseBuilder.BuildSuccessResponse(null, message)); + } -// /// -// /// Start mission - transition to EnRoute -// /// -// [HttpPatch("{missionId}/start")] -// [SwaggerOperation( -// Summary = "Start Mission", -// Description = "Start the rescue mission (Preparing → EnRoute). Rescuer begins heading to the incident location.")] -// [SwaggerResponse(200, "Mission started successfully", typeof(ApiResponse))] -// [SwaggerResponse(400, "Invalid status transition")] -// [SwaggerResponse(403, "Not authorized")] -// [SwaggerResponse(404, "Mission not found")] -// public async Task StartMission(Guid missionId) -// { -// var rescuerId = GetCurrentUserId(); -// var request = new UpdateRescueMissionStatusRequest { Status = RescueMissionStatus.EnRoute }; -// var result = await _missionService.UpdateMissionStatusAsync(missionId, request, rescuerId); -// return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Mission started! En route to location.")); -// } + /// + /// Start mission - transition to EnRoute + /// + [HttpPatch("{missionId}/start")] + [SwaggerOperation( + Summary = "Start Mission", + Description = "Start the rescue mission (Preparing → EnRoute). Rescuer begins heading to the incident location.")] + [SwaggerResponse(200, "Mission started successfully")] + [SwaggerResponse(400, "Invalid status transition")] + [SwaggerResponse(404, "Mission not found")] + public async Task StartMission(Guid missionId) + { + await _missionService.UpdateMissionStatusAsync(missionId, RescueMissionStatus.EnRoute); + return Ok(ApiResponseBuilder.BuildSuccessResponse(null, "Mission started! En route to location.")); + } -// /// -// /// Mark arrival at location - transition to RescuerArrived -// /// -// [HttpPatch("{missionId}/arrive")] -// [SwaggerOperation( -// Summary = "Arrive at Location", -// Description = "Mark rescuer's arrival at the incident location (EnRoute → RescuerArrived).")] -// [SwaggerResponse(200, "Arrival marked successfully", typeof(ApiResponse))] -// [SwaggerResponse(400, "Invalid status transition")] -// [SwaggerResponse(403, "Not authorized")] -// [SwaggerResponse(404, "Mission not found")] -// public async Task ArriveAtLocation(Guid missionId) -// { -// var rescuerId = GetCurrentUserId(); -// var request = new UpdateRescueMissionStatusRequest { Status = RescueMissionStatus.RescuerArrived }; -// var result = await _missionService.UpdateMissionStatusAsync(missionId, request, rescuerId); -// return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Arrival marked successfully!")); -// } + /// + /// Mark arrival at location - transition to RescuerArrived + /// + [HttpPatch("{missionId}/arrive")] + [SwaggerOperation( + Summary = "Arrive at Location", + Description = "Mark rescuer's arrival at the incident location (EnRoute → RescuerArrived).")] + [SwaggerResponse(200, "Arrival marked successfully")] + [SwaggerResponse(400, "Invalid status transition")] + [SwaggerResponse(404, "Mission not found")] + public async Task ArriveAtLocation(Guid missionId) + { + await _missionService.UpdateMissionStatusAsync(missionId, RescueMissionStatus.RescuerArrived); + return Ok(ApiResponseBuilder.BuildSuccessResponse(null, "Arrival marked successfully!")); + } -// /// -// /// Complete mission - transition to MissionCompleted -// /// Requires verification images and updates incident to Finished -// /// -// [HttpPatch("{missionId}/complete")] -// [SwaggerOperation( -// Summary = "Complete Mission", -// Description = "Complete the rescue mission with verification images (RescuerArrived → MissionCompleted). Automatically updates incident status to Finished. Requires at least one verification image.")] -// [SwaggerResponse(200, "Mission completed successfully", typeof(ApiResponse))] -// [SwaggerResponse(400, "Invalid status transition or missing verification images")] -// [SwaggerResponse(403, "Not authorized")] -// [SwaggerResponse(404, "Mission not found")] -// [SwaggerResponse(422, "Validation error")] -// public async Task CompleteMission( -// Guid missionId, -// [FromBody] UpdateRescueMissionStatusRequest request) -// { -// var rescuerId = GetCurrentUserId(); -// request.Status = RescueMissionStatus.MissionCompleted; -// var result = await _missionService.UpdateMissionStatusAsync(missionId, request, rescuerId); -// return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Mission completed successfully! Incident marked as finished.")); -// } + /// + /// Complete mission - transition to MissionCompleted + /// Updates incident to Finished + /// + [HttpPatch("{missionId}/complete")] + [SwaggerOperation( + Summary = "Complete Mission", + Description = "Complete the rescue mission (RescuerArrived → MissionCompleted). Automatically updates incident status to Finished.")] + [SwaggerResponse(200, "Mission completed successfully")] + [SwaggerResponse(400, "Invalid status transition")] + [SwaggerResponse(404, "Mission not found")] + public async Task CompleteMission(Guid missionId) + { + await _missionService.UpdateMissionStatusAsync(missionId, RescueMissionStatus.MissionCompleted); + return Ok(ApiResponseBuilder.BuildSuccessResponse(null, "Mission completed successfully! Incident marked as finished.")); + } -// /// -// /// Abort mission - transition to MissionAborted -// /// -// [HttpPatch("{missionId}/abort")] -// [SwaggerOperation( -// Summary = "Abort Mission", -// Description = "Abort the mission with a reason (EnRoute/RescuerArrived → MissionAborted).")] -// [SwaggerResponse(200, "Mission aborted", typeof(ApiResponse))] -// [SwaggerResponse(400, "Invalid status transition")] -// [SwaggerResponse(403, "Not authorized")] -// [SwaggerResponse(404, "Mission not found")] -// public async Task AbortMission( -// Guid missionId, -// [FromBody] UpdateRescueMissionStatusRequest request) -// { -// var rescuerId = GetCurrentUserId(); -// request.Status = RescueMissionStatus.MissionAborted; -// var result = await _missionService.UpdateMissionStatusAsync(missionId, request, rescuerId); -// return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Mission aborted.")); -// } + /// + /// Abort mission - rescuer cannot complete + /// Creates a new session with increased radius for finding another rescuer + /// + [HttpPatch("{missionId}/abort")] + [SwaggerOperation( + Summary = "Abort Mission (Rescuer)", + Description = "Rescuer aborts the mission with a reason (Preparing/EnRoute → MissionAborted). Incident is reset to Pending and a new rescue session is created with increased radius.")] + [SwaggerResponse(200, "Mission aborted, new session created")] + [SwaggerResponse(400, "Invalid status transition")] + [SwaggerResponse(404, "Mission not found")] + public async Task AbortMission( + Guid missionId, + [FromBody] UpdateRescueMissionStatusRequest request) + { + await _missionService.RescuerAbortMissionAsync(missionId, request.CancellationReason ?? "No reason provided"); + return Ok(ApiResponseBuilder.BuildSuccessResponse(null, "Mission aborted. New rescue session created with increased radius.")); + } -// /// -// /// Cancel mission - transition to Cancelled -// /// -// [HttpPatch("{missionId}/cancel")] -// [SwaggerOperation( -// Summary = "Cancel Mission", -// Description = "Cancel the mission before it starts (Preparing → Cancelled).")] -// [SwaggerResponse(200, "Mission cancelled", typeof(ApiResponse))] -// [SwaggerResponse(400, "Invalid status transition")] -// [SwaggerResponse(403, "Not authorized")] -// [SwaggerResponse(404, "Mission not found")] -// public async Task CancelMission( -// Guid missionId, -// [FromBody] UpdateRescueMissionStatusRequest request) -// { -// var rescuerId = GetCurrentUserId(); -// request.Status = RescueMissionStatus.Cancelled; -// var result = await _missionService.UpdateMissionStatusAsync(missionId, request, rescuerId); -// return Ok(ApiResponseBuilder.BuildSuccessResponse(result, "Mission cancelled.")); -// } -// } -// } + /// + /// Cancel mission - user cancels before rescuer starts + /// No new session is created + /// + [HttpPatch("{missionId}/cancel")] + [SwaggerOperation( + Summary = "Cancel Mission (User)", + Description = "User cancels the mission before rescuer goes en route (Preparing → Cancelled). Incident is set to Cancelled. No new session is created.")] + [SwaggerResponse(200, "Mission cancelled")] + [SwaggerResponse(400, "Invalid status transition - can only cancel during Preparing phase")] + [SwaggerResponse(404, "Mission not found")] + public async Task CancelMission( + Guid missionId, + [FromBody] UpdateRescueMissionStatusRequest request) + { + await _missionService.UserCancelMissionAsync(missionId, request.CancellationReason ?? "No reason provided"); + return Ok(ApiResponseBuilder.BuildSuccessResponse(null, "Mission cancelled by user.")); + } + } +} From 2a403a6dabbd35e6aab047d9e2673dbe788f430d Mon Sep 17 00:00:00 2001 From: NhanNPSE184696 Date: Wed, 11 Feb 2026 23:37:05 +0700 Subject: [PATCH 30/31] feat: enhance FirstAidGuidelineService to support guideline content overrides for snake species --- .../Implements/FirstAidGuidelineService.cs | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/SnakeAid.Service/Implements/FirstAidGuidelineService.cs b/SnakeAid.Service/Implements/FirstAidGuidelineService.cs index 59cc3a8b..5469a1cd 100644 --- a/SnakeAid.Service/Implements/FirstAidGuidelineService.cs +++ b/SnakeAid.Service/Implements/FirstAidGuidelineService.cs @@ -196,7 +196,55 @@ public async Task> GetFirstAidGuidelinesBySnakeS .Distinct() .ToList(); - return guidelines.Adapt>(); + // Map to response + var guidelineResponses = guidelines.Adapt>(); + + // Apply override if exists + if (snakeSpecies.FirstAidGuidelineOverride != null) + { + var overrideData = snakeSpecies.FirstAidGuidelineOverride; + + foreach (var response in guidelineResponses) + { + if (overrideData.Mode == OverrideMode.Append) + { + // Append mode: Add override content to existing fields + if (overrideData.Content?.Steps != null && overrideData.Content.Steps.Count > 0) + { + response.Content.Steps = response.Content.Steps ?? new List(); + response.Content.Steps.AddRange(overrideData.Content.Steps); + } + + if (overrideData.Content?.Dos != null && overrideData.Content.Dos.Count > 0) + { + response.Content.Dos = response.Content.Dos ?? new List(); + response.Content.Dos.AddRange(overrideData.Content.Dos); + } + + if (overrideData.Content?.Donts != null && overrideData.Content.Donts.Count > 0) + { + response.Content.Donts = response.Content.Donts ?? new List(); + response.Content.Donts.AddRange(overrideData.Content.Donts); + } + + if (overrideData.Content?.Notes != null && overrideData.Content.Notes.Count > 0) + { + response.Content.Notes = response.Content.Notes ?? new List(); + response.Content.Notes.AddRange(overrideData.Content.Notes); + } + } + else if (overrideData.Mode == OverrideMode.Replace) + { + // Replace mode: Replace entire content with override + if (overrideData.Content != null) + { + response.Content = overrideData.Content; + } + } + } + } + + return guidelineResponses; } } } From 6a0620ae4718b1bafc953a04aaadc8e7fd872f06 Mon Sep 17 00:00:00 2001 From: the-khiem7 Date: Thu, 12 Feb 2026 01:00:26 +0700 Subject: [PATCH 31/31] refactor: update ports and configurations for consistent HTTP/HTTPS handling across environments --- .vscode/launch.json | 6 ++--- SnakeAid.Api/Program.cs | 26 ++++++--------------- SnakeAid.Api/Properties/launchSettings.json | 4 ++-- SnakeAid.Docs | 2 +- docker-compose.yml | 1 - 5 files changed, 13 insertions(+), 26 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index da579d08..278f00c5 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -33,7 +33,7 @@ "cwd": "${workspaceFolder}/SnakeAid.Api", "stopAtEntry": false, "serverReadyAction": { - "pattern": "Now listening on:\\s+(https://[^\\s]+:7026)", + "pattern": "Now listening on:\\s+(https://[^\\s]+:8081)", "uriFormat": "%s", "action": "openExternally" }, @@ -58,7 +58,7 @@ "cwd": "${workspaceFolder}/SnakeAid.Api", "stopAtEntry": false, "serverReadyAction": { - "pattern": "Now listening on:\\s+(http://[^\\s]+:5009)", + "pattern": "Now listening on:\\s+(http://[^\\s]+:8080)", "uriFormat": "%s", "action": "openExternally" }, @@ -83,7 +83,7 @@ "cwd": "${workspaceFolder}/SnakeAid.Api", "stopAtEntry": false, "serverReadyAction": { - "pattern": "Now listening on:\\s+(https://[^\\s]+:7026)", + "pattern": "Now listening on:\\s+(https://[^\\s]+:8081)", "uriFormat": "%s", "action": "openExternally" }, diff --git a/SnakeAid.Api/Program.cs b/SnakeAid.Api/Program.cs index c91d7e09..2aac7d12 100644 --- a/SnakeAid.Api/Program.cs +++ b/SnakeAid.Api/Program.cs @@ -206,27 +206,15 @@ public static async Task Main(string[] args) // Bind Kestrel to all network interfaces builder.WebHost.ConfigureKestrel((context, options) => { - if (context.HostingEnvironment.IsDevelopment()) - { - // Check if running in container - var isContainer = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true"; + // Always listen on port 8080 (HTTP) + // This creates consistency across Local, Docker, and Production environments + options.ListenAnyIP(8080); - if (isContainer) - { - // Docker container: HTTP only - options.ListenAnyIP(8080); - } - else - { - // Local dev: both HTTP and HTTPS - options.ListenAnyIP(5009); - options.ListenLocalhost(7026, listenOptions => listenOptions.UseHttps()); - } - } - else + // For Local Development, also listen on port 8081 (HTTPS) + // This allows debugging secure features (Cookies, OAuth, etc.) locally + if (context.HostingEnvironment.IsDevelopment()) { - // Production: HTTP only (HTTPS termination at reverse proxy) - options.ListenAnyIP(8080); + options.ListenLocalhost(8081, listenOptions => listenOptions.UseHttps()); } }); diff --git a/SnakeAid.Api/Properties/launchSettings.json b/SnakeAid.Api/Properties/launchSettings.json index 97da9ec0..b21bb656 100644 --- a/SnakeAid.Api/Properties/launchSettings.json +++ b/SnakeAid.Api/Properties/launchSettings.json @@ -13,7 +13,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "", + "applicationUrl": "http://localhost:8080", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -22,7 +22,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "", + "applicationUrl": "https://localhost:8081;http://localhost:8080", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/SnakeAid.Docs b/SnakeAid.Docs index 1976c6f4..13a83208 160000 --- a/SnakeAid.Docs +++ b/SnakeAid.Docs @@ -1 +1 @@ -Subproject commit 1976c6f4e921976550de5fba46f33197db52923c +Subproject commit 13a83208ec40201b8f5ab8cae171ef7e4bd87be7 diff --git a/docker-compose.yml b/docker-compose.yml index c73167b3..8ff27da9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,5 @@ services: # Settings for container - ASPNETCORE_HTTPS_PORT= - ASPNETCORE_Kestrel__Certificates__Default__Path= - - DOTNET_RUNNING_IN_CONTAINER=true restart: unless-stopped \ No newline at end of file